diff --git a/alembic/versions/0019_add_request_cost_to_requests.py b/alembic/versions/0019_add_request_cost_to_requests.py new file mode 100644 index 0000000..d58dd77 --- /dev/null +++ b/alembic/versions/0019_add_request_cost_to_requests.py @@ -0,0 +1,27 @@ +"""add request_cost field to requests + +Revision ID: 0019_request_cost_on_requests +Revises: 0018_status_groups +Create Date: 2026-02-26 00:15:00.000000 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0019_request_cost_on_requests" +down_revision = "0018_status_groups" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("requests", sa.Column("request_cost", sa.Numeric(14, 2), nullable=True)) + + +def downgrade() -> None: + op.drop_column("requests", "request_cost") + diff --git a/alembic/versions/0020_add_phone_to_admin_users.py b/alembic/versions/0020_add_phone_to_admin_users.py new file mode 100644 index 0000000..6ec0c9f --- /dev/null +++ b/alembic/versions/0020_add_phone_to_admin_users.py @@ -0,0 +1,28 @@ +"""add phone to admin_users + +Revision ID: 0020_admin_users_phone +Revises: 0019_request_cost_on_requests +Create Date: 2026-02-26 22:10:00.000000 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0020_admin_users_phone" +down_revision = "0019_request_cost_on_requests" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("admin_users", sa.Column("phone", sa.String(length=30), nullable=True)) + op.create_index(op.f("ix_admin_users_phone"), "admin_users", ["phone"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_admin_users_phone"), table_name="admin_users") + op.drop_column("admin_users", "phone") diff --git a/alembic/versions/0021_request_data_chat_fields.py b/alembic/versions/0021_request_data_chat_fields.py new file mode 100644 index 0000000..59d0fe5 --- /dev/null +++ b/alembic/versions/0021_request_data_chat_fields.py @@ -0,0 +1,50 @@ +"""extend request data templates and request data requirements for chat requests + +Revision ID: 0021_request_data_chat_fields +Revises: 0020_admin_users_phone +Create Date: 2026-02-26 12:10:00.000000 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0021_request_data_chat_fields" +down_revision = "0020_admin_users_phone" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("topic_data_templates", sa.Column("value_type", sa.String(length=20), nullable=False, server_default="text")) + op.add_column("topic_data_templates", sa.Column("document_name", sa.String(length=200), nullable=True)) + op.create_index(op.f("ix_topic_data_templates_document_name"), "topic_data_templates", ["document_name"], unique=False) + + op.add_column("request_data_requirements", sa.Column("request_message_id", sa.UUID(), nullable=True)) + op.add_column("request_data_requirements", sa.Column("field_type", sa.String(length=20), nullable=False, server_default="text")) + op.add_column("request_data_requirements", sa.Column("document_name", sa.String(length=200), nullable=True)) + op.add_column("request_data_requirements", sa.Column("value_text", sa.String(length=500), nullable=True)) + op.add_column("request_data_requirements", sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0")) + op.create_index(op.f("ix_request_data_requirements_request_message_id"), "request_data_requirements", ["request_message_id"], unique=False) + op.create_index(op.f("ix_request_data_requirements_document_name"), "request_data_requirements", ["document_name"], unique=False) + + op.alter_column("topic_data_templates", "value_type", server_default=None) + op.alter_column("request_data_requirements", "field_type", server_default=None) + op.alter_column("request_data_requirements", "sort_order", server_default=None) + + +def downgrade() -> None: + op.drop_index(op.f("ix_request_data_requirements_document_name"), table_name="request_data_requirements") + op.drop_index(op.f("ix_request_data_requirements_request_message_id"), table_name="request_data_requirements") + op.drop_column("request_data_requirements", "sort_order") + op.drop_column("request_data_requirements", "value_text") + op.drop_column("request_data_requirements", "document_name") + op.drop_column("request_data_requirements", "field_type") + op.drop_column("request_data_requirements", "request_message_id") + + op.drop_index(op.f("ix_topic_data_templates_document_name"), table_name="topic_data_templates") + op.drop_column("topic_data_templates", "document_name") + op.drop_column("topic_data_templates", "value_type") diff --git a/alembic/versions/0022_request_data_templates_tables.py b/alembic/versions/0022_request_data_templates_tables.py new file mode 100644 index 0000000..74378fb --- /dev/null +++ b/alembic/versions/0022_request_data_templates_tables.py @@ -0,0 +1,76 @@ +"""add request data templates and template items tables + +Revision ID: 0022_req_data_templates +Revises: 0021_request_data_chat_fields +Create Date: 2026-02-26 13:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = "0022_req_data_templates" +down_revision = "0021_request_data_chat_fields" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "request_data_templates", + sa.Column("topic_code", sa.String(length=50), nullable=False), + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("created_by_admin_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"), + sa.UniqueConstraint("topic_code", "name", name="uq_request_data_templates_topic_name"), + ) + op.create_index(op.f("ix_request_data_templates_topic_code"), "request_data_templates", ["topic_code"], unique=False) + op.create_index(op.f("ix_request_data_templates_name"), "request_data_templates", ["name"], unique=False) + op.create_index(op.f("ix_request_data_templates_created_by_admin_id"), "request_data_templates", ["created_by_admin_id"], unique=False) + + op.create_table( + "request_data_template_items", + sa.Column("request_data_template_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("topic_data_template_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("key", sa.String(length=80), nullable=False), + sa.Column("label", sa.String(length=200), nullable=False), + sa.Column("value_type", sa.String(length=20), nullable=False, server_default="string"), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"), + sa.UniqueConstraint("request_data_template_id", "key", name="uq_request_data_template_items_template_key"), + ) + op.create_index(op.f("ix_request_data_template_items_request_data_template_id"), "request_data_template_items", ["request_data_template_id"], unique=False) + op.create_index(op.f("ix_request_data_template_items_topic_data_template_id"), "request_data_template_items", ["topic_data_template_id"], unique=False) + op.create_index(op.f("ix_request_data_template_items_key"), "request_data_template_items", ["key"], unique=False) + + op.alter_column("request_data_templates", "enabled", server_default=None) + op.alter_column("request_data_templates", "sort_order", server_default=None) + op.alter_column("request_data_templates", "responsible", server_default=None) + op.alter_column("request_data_template_items", "value_type", server_default=None) + op.alter_column("request_data_template_items", "sort_order", server_default=None) + op.alter_column("request_data_template_items", "responsible", server_default=None) + + +def downgrade() -> None: + op.drop_index(op.f("ix_request_data_template_items_key"), table_name="request_data_template_items") + op.drop_index(op.f("ix_request_data_template_items_topic_data_template_id"), table_name="request_data_template_items") + op.drop_index(op.f("ix_request_data_template_items_request_data_template_id"), table_name="request_data_template_items") + op.drop_table("request_data_template_items") + + op.drop_index(op.f("ix_request_data_templates_created_by_admin_id"), table_name="request_data_templates") + op.drop_index(op.f("ix_request_data_templates_name"), table_name="request_data_templates") + op.drop_index(op.f("ix_request_data_templates_topic_code"), table_name="request_data_templates") + op.drop_table("request_data_templates") diff --git a/alembic/versions/0023_status_important_date.py b/alembic/versions/0023_status_important_date.py new file mode 100644 index 0000000..58b68c3 --- /dev/null +++ b/alembic/versions/0023_status_important_date.py @@ -0,0 +1,35 @@ +"""add important date to requests and status history + +Revision ID: 0023_status_important_date +Revises: 0022_req_data_templates +Create Date: 2026-02-26 15:20:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "0023_status_important_date" +down_revision = "0022_req_data_templates" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("requests", sa.Column("important_date_at", sa.DateTime(timezone=True), nullable=True)) + op.create_index(op.f("ix_requests_important_date_at"), "requests", ["important_date_at"], unique=False) + + op.add_column("status_history", sa.Column("important_date_at", sa.DateTime(timezone=True), nullable=True)) + op.create_index(op.f("ix_status_history_important_date_at"), "status_history", ["important_date_at"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_status_history_important_date_at"), table_name="status_history") + op.drop_column("status_history", "important_date_at") + + op.drop_index(op.f("ix_requests_important_date_at"), table_name="requests") + op.drop_column("requests", "important_date_at") + diff --git a/alembic/versions/0024_featured_staff_carousel.py b/alembic/versions/0024_featured_staff_carousel.py new file mode 100644 index 0000000..46ea166 --- /dev/null +++ b/alembic/versions/0024_featured_staff_carousel.py @@ -0,0 +1,72 @@ +"""add landing featured staff carousel table + +Revision ID: 0024_featured_staff_carousel +Revises: 0023_status_important_date +Create Date: 2026-02-26 23:45:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = "0024_featured_staff_carousel" +down_revision = "0023_status_important_date" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "landing_featured_staff", + sa.Column("admin_user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("caption", sa.String(length=300), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"), + sa.UniqueConstraint("admin_user_id", name="uq_landing_featured_staff_admin_user"), + ) + op.create_index( + op.f("ix_landing_featured_staff_admin_user_id"), + "landing_featured_staff", + ["admin_user_id"], + unique=False, + ) + op.create_index( + op.f("ix_landing_featured_staff_sort_order"), + "landing_featured_staff", + ["sort_order"], + unique=False, + ) + op.create_index( + op.f("ix_landing_featured_staff_pinned"), + "landing_featured_staff", + ["pinned"], + unique=False, + ) + op.create_index( + op.f("ix_landing_featured_staff_enabled"), + "landing_featured_staff", + ["enabled"], + unique=False, + ) + + op.alter_column("landing_featured_staff", "sort_order", server_default=None) + op.alter_column("landing_featured_staff", "pinned", server_default=None) + op.alter_column("landing_featured_staff", "enabled", server_default=None) + op.alter_column("landing_featured_staff", "responsible", server_default=None) + + +def downgrade() -> None: + op.drop_index(op.f("ix_landing_featured_staff_enabled"), table_name="landing_featured_staff") + op.drop_index(op.f("ix_landing_featured_staff_pinned"), table_name="landing_featured_staff") + op.drop_index(op.f("ix_landing_featured_staff_sort_order"), table_name="landing_featured_staff") + op.drop_index(op.f("ix_landing_featured_staff_admin_user_id"), table_name="landing_featured_staff") + op.drop_table("landing_featured_staff") diff --git a/app/api/admin/chat.py b/app/api/admin/chat.py index c60c32d..d0124ee 100644 --- a/app/api/admin/chat.py +++ b/app/api/admin/chat.py @@ -8,10 +8,22 @@ from sqlalchemy.orm import Session from app.core.deps import require_role from app.db.session import get_db from app.models.admin_user import AdminUser +from app.models.attachment import Attachment +from app.models.message import Message from app.models.request import Request -from app.services.chat_service import create_admin_or_lawyer_message, list_messages_for_request, serialize_message +from app.models.request_data_requirement import RequestDataRequirement +from app.models.request_data_template import RequestDataTemplate +from app.models.request_data_template_item import RequestDataTemplateItem +from app.models.topic_data_template import TopicDataTemplate +from app.services.chat_service import ( + create_admin_or_lawyer_message, + list_messages_for_request, + serialize_message, + serialize_messages_for_request, +) router = APIRouter() +ALLOWED_VALUE_TYPES = {"string", "text", "date", "number", "file"} def _request_uuid_or_400(request_id: str) -> UUID: @@ -52,6 +64,121 @@ def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None: raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками") +def _parse_uuid_or_400(raw: str, field_name: str) -> UUID: + try: + return UUID(str(raw)) + except ValueError: + raise HTTPException(status_code=400, detail=f'Некорректное поле "{field_name}"') + + +def _slugify_key(raw: str) -> str: + text = str(raw or "").strip().lower() + out = [] + dash = False + for ch in text: + if ch.isalnum(): + out.append(ch) + dash = False + continue + if not dash: + out.append("-") + dash = True + slug = "".join(out).strip("-") + return (slug or "data-field")[:80] + + +def _normalize_value_type(raw: str | None) -> str: + value = str(raw or "text").strip().lower() + if value not in ALLOWED_VALUE_TYPES: + raise HTTPException(status_code=400, detail='Тип поля должен быть одним из: string, text, date, number, file') + return value + + +def _serialize_template(row: TopicDataTemplate) -> dict: + return { + "id": str(row.id), + "topic_code": row.topic_code, + "key": row.key, + "label": row.label, + "value_type": str(row.value_type or "text"), + "document_name": row.document_name, + "description": row.description, + "sort_order": int(row.sort_order or 0), + "enabled": bool(row.enabled), + } + + +def _serialize_request_data_template(row: RequestDataTemplate) -> dict: + return { + "id": str(row.id), + "topic_code": row.topic_code, + "name": row.name, + "description": row.description, + "enabled": bool(row.enabled), + "sort_order": int(row.sort_order or 0), + "created_by_admin_id": str(row.created_by_admin_id) if row.created_by_admin_id else None, + } + + +def _serialize_request_data_template_item(row: RequestDataTemplateItem) -> dict: + return { + "id": str(row.id), + "request_data_template_id": str(row.request_data_template_id), + "topic_data_template_id": str(row.topic_data_template_id) if row.topic_data_template_id else None, + "key": row.key, + "label": row.label, + "value_type": str(row.value_type or "string"), + "sort_order": int(row.sort_order or 0), + } + + +def _serialize_data_request_items(db: Session, rows: list[RequestDataRequirement]) -> list[dict]: + attachment_ids: list[UUID] = [] + for row in rows: + if str(row.field_type or "").lower() != "file": + continue + try: + attachment_ids.append(UUID(str(row.value_text or "").strip())) + except Exception: + continue + attachment_map = {} + if attachment_ids: + attachment_rows = db.query(Attachment).filter(Attachment.id.in_(attachment_ids)).all() # type: ignore[name-defined] + attachment_map = {str(item.id): item for item in attachment_rows} + out = [] + for index, row in enumerate(rows, start=1): + value_text = str(row.value_text or "").strip() + value_file = None + if str(row.field_type or "").lower() == "file" and value_text: + attachment = attachment_map.get(value_text) + if attachment is not None: + value_file = { + "attachment_id": str(attachment.id), + "file_name": attachment.file_name, + "mime_type": attachment.mime_type, + "size_bytes": int(attachment.size_bytes or 0), + "download_url": f"/api/admin/uploads/object/{attachment.id}", + } + out.append( + { + "id": str(row.id), + "request_message_id": str(row.request_message_id) if row.request_message_id else None, + "topic_template_id": str(row.topic_template_id) if row.topic_template_id else None, + "key": row.key, + "label": row.label, + "field_type": str(row.field_type or "text"), + "document_name": row.document_name, + "description": row.description, + "value_text": row.value_text, + "value_file": value_file, + "is_filled": bool(value_text), + "sort_order": int(row.sort_order or 0), + "index": index, + } + ) + return out + + @router.get("/requests/{request_id}/messages") def list_request_messages( request_id: str, @@ -61,7 +188,7 @@ def list_request_messages( req = _request_for_id_or_404(db, request_id) _ensure_lawyer_can_view_request_or_403(admin, req) rows = list_messages_for_request(db, req.id) - return {"rows": [serialize_message(row) for row in rows], "total": len(rows)} + return {"rows": serialize_messages_for_request(db, req.id, rows), "total": len(rows)} @router.post("/requests/{request_id}/messages", status_code=201) @@ -95,3 +222,464 @@ def create_request_message( actor_admin_user_id=actor_admin_user_id, ) return serialize_message(row) + + +@router.get("/requests/{request_id}/data-request-templates") +def list_data_request_templates( + request_id: str, + document: str | None = None, + db: Session = Depends(get_db), + admin: dict = Depends(require_role("ADMIN", "LAWYER")), +): + req = _request_for_id_or_404(db, request_id) + _ensure_lawyer_can_manage_request_or_403(admin, req) + topic_code = str(req.topic_code or "").strip() + if not topic_code: + return {"rows": [], "documents": [], "templates": []} + query = db.query(TopicDataTemplate).filter(TopicDataTemplate.topic_code == topic_code) + document_name = str(document or "").strip() + if document_name: + query = query.filter(TopicDataTemplate.document_name == document_name) + rows = ( + query.order_by( + TopicDataTemplate.document_name.asc().nullsfirst(), + TopicDataTemplate.sort_order.asc(), + TopicDataTemplate.label.asc(), + TopicDataTemplate.key.asc(), + ).all() + ) + all_docs_rows = ( + db.query(TopicDataTemplate.document_name) + .filter(TopicDataTemplate.topic_code == topic_code, TopicDataTemplate.enabled.is_(True)) + .distinct() + .all() + ) + documents = sorted({str(row[0]).strip() for row in all_docs_rows if row[0]}, key=lambda x: x.lower()) + template_rows = ( + db.query(RequestDataTemplate) + .filter(RequestDataTemplate.topic_code == topic_code, RequestDataTemplate.enabled.is_(True)) + .order_by(RequestDataTemplate.sort_order.asc(), RequestDataTemplate.name.asc()) + .all() + ) + return { + "rows": [_serialize_template(row) for row in rows if row.enabled], # legacy catalog payload + "documents": documents, # legacy + "templates": [_serialize_request_data_template(row) for row in template_rows], + } + + +@router.get("/requests/{request_id}/data-requests/{message_id}") +def get_data_request_batch( + request_id: str, + message_id: str, + db: Session = Depends(get_db), + admin: dict = Depends(require_role("ADMIN", "LAWYER")), +): + req = _request_for_id_or_404(db, request_id) + _ensure_lawyer_can_view_request_or_403(admin, req) + msg_uuid = _parse_uuid_or_400(message_id, "message_id") + message = db.get(Message, msg_uuid) + if message is None or message.request_id != req.id: + raise HTTPException(status_code=404, detail="Сообщение запроса не найдено") + rows = ( + db.query(RequestDataRequirement) + .filter( + RequestDataRequirement.request_id == req.id, + RequestDataRequirement.request_message_id == msg_uuid, + ) + .order_by(RequestDataRequirement.sort_order.asc(), RequestDataRequirement.created_at.asc(), RequestDataRequirement.id.asc()) + .all() + ) + if not rows: + raise HTTPException(status_code=404, detail="Набор данных для сообщения не найден") + return { + "message_id": str(message.id), + "request_id": str(req.id), + "track_number": req.track_number, + "document_name": rows[0].document_name if rows else None, + "items": _serialize_data_request_items(db, rows), + } + + +@router.get("/requests/{request_id}/data-request-templates/{template_id}") +def get_data_request_template( + request_id: str, + template_id: str, + db: Session = Depends(get_db), + admin: dict = Depends(require_role("ADMIN", "LAWYER")), +): + req = _request_for_id_or_404(db, request_id) + _ensure_lawyer_can_manage_request_or_403(admin, req) + template_uuid = _parse_uuid_or_400(template_id, "template_id") + template = db.get(RequestDataTemplate, template_uuid) + if template is None: + raise HTTPException(status_code=404, detail="Шаблон не найден") + if str(template.topic_code or "").strip() != str(req.topic_code or "").strip(): + raise HTTPException(status_code=400, detail="Шаблон не соответствует теме заявки") + rows = ( + db.query(RequestDataTemplateItem) + .filter(RequestDataTemplateItem.request_data_template_id == template.id) + .order_by(RequestDataTemplateItem.sort_order.asc(), RequestDataTemplateItem.created_at.asc(), RequestDataTemplateItem.id.asc()) + .all() + ) + return { + "template": _serialize_request_data_template(template), + "items": [_serialize_request_data_template_item(row) for row in rows], + } + + +@router.post("/requests/{request_id}/data-request-templates", status_code=201) +def save_data_request_template( + request_id: str, + payload: dict, + db: Session = Depends(get_db), + admin: dict = Depends(require_role("ADMIN", "LAWYER")), +): + req = _request_for_id_or_404(db, request_id) + _ensure_lawyer_can_manage_request_or_403(admin, req) + topic_code = str(req.topic_code or "").strip() + if not topic_code: + raise HTTPException(status_code=400, detail="У заявки не указана тема") + + body = payload or {} + name = str(body.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="Укажите название шаблона") + raw_items = body.get("items") + if not isinstance(raw_items, list) or not raw_items: + raise HTTPException(status_code=400, detail="Шаблон должен содержать хотя бы одно поле") + + actor_uuid = None + raw_actor = str(admin.get("sub") or "").strip() + if raw_actor: + try: + actor_uuid = UUID(raw_actor) + except ValueError: + actor_uuid = None + responsible = str(admin.get("email") or "").strip() or "Администратор системы" + + template = None + template_id_raw = str(body.get("template_id") or "").strip() + if template_id_raw: + template = db.get(RequestDataTemplate, _parse_uuid_or_400(template_id_raw, "template_id")) + if template is None: + raise HTTPException(status_code=404, detail="Шаблон не найден") + if str(template.topic_code or "").strip() != topic_code: + raise HTTPException(status_code=400, detail="Шаблон не соответствует теме заявки") + else: + template = ( + db.query(RequestDataTemplate) + .filter(RequestDataTemplate.topic_code == topic_code, RequestDataTemplate.name == name) + .first() + ) + + if template is None: + template = RequestDataTemplate( + topic_code=topic_code, + name=name, + enabled=True, + sort_order=0, + created_by_admin_id=actor_uuid, + responsible=responsible, + ) + db.add(template) + db.flush() + else: + actor_role = str(admin.get("role") or "").upper() + if actor_role == "LAWYER": + owner_id = str(template.created_by_admin_id or "").strip() + actor_id = str(actor_uuid or "").strip() + if owner_id and actor_id and owner_id != actor_id: + raise HTTPException(status_code=403, detail="Юрист может изменять только свои шаблоны") + template.name = name + template.responsible = responsible + db.add(template) + db.flush() + + touched_keys: set[str] = set() + normalized_items: list[tuple[int, TopicDataTemplate | None, str, str, str]] = [] + for index, item in enumerate(raw_items): + if not isinstance(item, dict): + raise HTTPException(status_code=400, detail="Элемент шаблона должен быть объектом") + catalog = None + catalog_id_raw = str(item.get("topic_data_template_id") or item.get("topic_template_id") or "").strip() + if catalog_id_raw: + catalog = db.get(TopicDataTemplate, _parse_uuid_or_400(catalog_id_raw, "topic_data_template_id")) + if catalog is None: + raise HTTPException(status_code=400, detail="Поле справочника не найдено") + label = str(item.get("label") or (catalog.label if catalog else "")).strip() + if not label: + raise HTTPException(status_code=400, detail="Укажите наименование поля") + value_type = _normalize_value_type(item.get("value_type") or item.get("field_type") or (catalog.value_type if catalog else None)) + key = str(item.get("key") or (catalog.key if catalog else "")).strip() + if not key: + key = _slugify_key(label) + key = key[:80] + if key in touched_keys: + raise HTTPException(status_code=400, detail=f'Поле "{label}" добавлено дважды') + touched_keys.add(key) + + if catalog is None: + catalog = ( + db.query(TopicDataTemplate) + .filter(TopicDataTemplate.topic_code == topic_code, TopicDataTemplate.key == key) + .first() + ) + if catalog is None: + catalog = TopicDataTemplate( + topic_code=topic_code, + key=key, + label=label, + value_type=value_type, + enabled=True, + required=True, + sort_order=index, + responsible=responsible, + ) + db.add(catalog) + db.flush() + else: + changed = False + if str(catalog.label or "") != label: + catalog.label = label + changed = True + if str(catalog.value_type or "string") != value_type: + catalog.value_type = value_type + changed = True + if changed: + catalog.responsible = responsible + db.add(catalog) + db.flush() + normalized_items.append((index, catalog, key, label, value_type)) + + existing_items = ( + db.query(RequestDataTemplateItem) + .filter(RequestDataTemplateItem.request_data_template_id == template.id) + .all() + ) + by_key = {str(row.key): row for row in existing_items} + for index, catalog, key, label, value_type in normalized_items: + row = by_key.get(key) + if row is None: + row = RequestDataTemplateItem( + request_data_template_id=template.id, + topic_data_template_id=catalog.id if catalog else None, + key=key, + label=label, + value_type=value_type, + sort_order=index, + responsible=responsible, + ) + else: + row.topic_data_template_id = catalog.id if catalog else None + row.label = label + row.value_type = value_type + row.sort_order = index + row.responsible = responsible + db.add(row) + for row in existing_items: + if str(row.key) not in touched_keys: + db.delete(row) + + db.commit() + db.refresh(template) + items = ( + db.query(RequestDataTemplateItem) + .filter(RequestDataTemplateItem.request_data_template_id == template.id) + .order_by(RequestDataTemplateItem.sort_order.asc(), RequestDataTemplateItem.created_at.asc(), RequestDataTemplateItem.id.asc()) + .all() + ) + return {"template": _serialize_request_data_template(template), "items": [_serialize_request_data_template_item(row) for row in items]} + + +@router.post("/requests/{request_id}/data-requests", status_code=201) +def upsert_data_request_batch( + request_id: str, + payload: dict, + db: Session = Depends(get_db), + admin: dict = Depends(require_role("ADMIN", "LAWYER")), +): + req = _request_for_id_or_404(db, request_id) + _ensure_lawyer_can_manage_request_or_403(admin, req) + actor_role = str(admin.get("role") or "").strip().upper() + + body = payload or {} + raw_items = body.get("items") + if not isinstance(raw_items, list) or not raw_items: + raise HTTPException(status_code=400, detail="Нужно передать список полей запроса") + + message_id_raw = str(body.get("message_id") or "").strip() + existing_message = None + existing_message_rows: list[RequestDataRequirement] = [] + if message_id_raw: + msg_uuid = _parse_uuid_or_400(message_id_raw, "message_id") + existing_message = db.get(Message, msg_uuid) + if existing_message is None or existing_message.request_id != req.id: + raise HTTPException(status_code=404, detail="Сообщение запроса не найдено") + existing_message_rows = ( + db.query(RequestDataRequirement) + .filter( + RequestDataRequirement.request_id == req.id, + RequestDataRequirement.request_message_id == existing_message.id, + ) + .all() + ) + else: + role = actor_role + actor_name = str(admin.get("email") or "").strip() or ("Юрист" if role == "LAWYER" else "Администратор") + actor_admin_user_id = str(admin.get("sub") or "").strip() or None + if actor_admin_user_id: + try: + actor_uuid = UUID(actor_admin_user_id) + except ValueError: + actor_uuid = None + if actor_uuid is not None: + actor_user = db.get(AdminUser, actor_uuid) + if actor_user is not None: + actor_name = str(actor_user.name or actor_user.email or actor_name) + existing_message = create_admin_or_lawyer_message( + db, + request=req, + body="Запрос", + actor_role=role, + actor_name=actor_name, + actor_admin_user_id=actor_admin_user_id, + ) + + message_uuid = existing_message.id + topic_code = str(req.topic_code or "").strip() + document_name_default = str(body.get("document_name") or "").strip() or None + actor_uuid = None + raw_actor = str(admin.get("sub") or "").strip() + if raw_actor: + try: + actor_uuid = UUID(raw_actor) + except ValueError: + actor_uuid = None + + normalized_rows: list[RequestDataRequirement] = [] + touched_keys: set[str] = set() + for index, item in enumerate(raw_items): + if not isinstance(item, dict): + raise HTTPException(status_code=400, detail="Элемент списка полей должен быть объектом") + template = None + template_id_raw = str(item.get("topic_template_id") or "").strip() + if template_id_raw: + template = db.get(TopicDataTemplate, _parse_uuid_or_400(template_id_raw, "topic_template_id")) + if template is None: + raise HTTPException(status_code=400, detail="Шаблон дополнительного поля не найден") + + label = str(item.get("label") or (template.label if template else "")).strip() + if not label: + raise HTTPException(status_code=400, detail="Укажите наименование поля") + field_type = _normalize_value_type(item.get("field_type") or (template.value_type if template else None)) + doc_name = str(item.get("document_name") or (template.document_name if template else "") or (document_name_default or "")).strip() or None + key = str(item.get("key") or (template.key if template else "")).strip() + if not key: + key = _slugify_key(label) + key = key[:80] + if key in touched_keys: + raise HTTPException(status_code=400, detail=f'Поле "{label}" добавлено дважды') + touched_keys.add(key) + + topic_template_id = None + if template is not None: + topic_template_id = template.id + elif topic_code: + existing_template = ( + db.query(TopicDataTemplate) + .filter(TopicDataTemplate.topic_code == topic_code, TopicDataTemplate.key == key) + .first() + ) + if existing_template is None: + existing_template = TopicDataTemplate( + topic_code=topic_code, + key=key, + label=label, + value_type=field_type, + document_name=doc_name, + enabled=True, + required=True, + sort_order=index, + responsible=str(admin.get("email") or "").strip() or "Администратор системы", + ) + db.add(existing_template) + db.flush() + else: + changed = False + if str(existing_template.label or "") != label: + existing_template.label = label + changed = True + if str(existing_template.value_type or "text") != field_type: + existing_template.value_type = field_type + changed = True + if str(existing_template.document_name or "") != str(doc_name or ""): + existing_template.document_name = doc_name + changed = True + if changed: + db.add(existing_template) + db.flush() + topic_template_id = existing_template.id + + req_row = ( + db.query(RequestDataRequirement) + .filter(RequestDataRequirement.request_id == req.id, RequestDataRequirement.key == key) + .first() + ) + if req_row is None: + req_row = RequestDataRequirement( + request_id=req.id, + request_message_id=message_uuid, + topic_template_id=topic_template_id, + key=key, + label=label, + field_type=field_type, + document_name=doc_name, + required=True, + sort_order=index, + created_by_admin_id=actor_uuid, + responsible=str(admin.get("email") or "").strip() or "Администратор системы", + ) + else: + if actor_role == "LAWYER" and str(req_row.value_text or "").strip(): + current_message_id = str(req_row.request_message_id or "") + incoming_message_id = str(message_uuid or "") + current_topic_template_id = str(req_row.topic_template_id or "") + incoming_topic_template_id = str(topic_template_id or "") + current_doc_name = str(req_row.document_name or "") if req_row.document_name is not None else "" + incoming_doc_name = str(doc_name or "") + if ( + str(req_row.label or "") != label + or str(req_row.field_type or "text") != field_type + or current_doc_name != incoming_doc_name + or current_topic_template_id != incoming_topic_template_id + or current_message_id != incoming_message_id + or int(req_row.sort_order or 0) != int(index) + ): + raise HTTPException(status_code=403, detail="Юрист не может изменять заполненные клиентом данные") + req_row.request_message_id = message_uuid + req_row.topic_template_id = topic_template_id + req_row.label = label + req_row.field_type = field_type + req_row.document_name = doc_name + req_row.sort_order = index + req_row.responsible = str(admin.get("email") or "").strip() or "Администратор системы" + db.add(req_row) + normalized_rows.append(req_row) + + if message_id_raw: + if actor_role == "LAWYER": + for row in existing_message_rows: + if row.key not in touched_keys and str(row.value_text or "").strip(): + raise HTTPException(status_code=403, detail="Юрист не может удалять заполненные клиентом данные") + for row in existing_message_rows: + if row.key not in touched_keys: + db.delete(row) + + db.commit() + fresh_messages = list_messages_for_request(db, req.id) + serialized = serialize_messages_for_request(db, req.id, fresh_messages) + payload_row = next((item for item in serialized if str(item.get("id")) == str(message_uuid)), None) + if payload_row is None: + raise HTTPException(status_code=500, detail="Не удалось сформировать сообщение запроса") + return payload_row diff --git a/app/api/admin/crud.py b/app/api/admin/crud.py index c579e18..47cc80d 100644 --- a/app/api/admin/crud.py +++ b/app/api/admin/crud.py @@ -4,7 +4,7 @@ import importlib import json import pkgutil import uuid -from datetime import date, datetime, timezone +from datetime import date, datetime, timedelta, timezone from decimal import Decimal from functools import lru_cache from typing import Any @@ -27,6 +27,8 @@ from app.models.form_field import FormField from app.models.client import Client from app.models.table_availability import TableAvailability from app.models.request_data_requirement import RequestDataRequirement +from app.models.request_data_template import RequestDataTemplate +from app.models.request_data_template_item import RequestDataTemplateItem from app.models.attachment import Attachment from app.models.message import Message from app.models.request import Request @@ -75,6 +77,7 @@ REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid REQUEST_CALCULATED_FIELDS = {"invoice_amount", "paid_at", "paid_by_admin_id", "total_attachments_bytes"} INVOICE_CALCULATED_FIELDS = {"issued_by_admin_user_id", "issued_by_role", "issued_at", "paid_at"} ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"} +ALLOWED_REQUEST_DATA_VALUE_TYPES = {"string", "text", "date", "number", "file"} # Per-table RBAC: table -> role -> actions. # If a table is missing here, fallback rules are used. @@ -103,9 +106,12 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = { "otp_sessions": {"ADMIN": {"query", "read"}}, "admin_users": {"ADMIN": set(CRUD_ACTIONS)}, "admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)}, + "landing_featured_staff": {"ADMIN": set(CRUD_ACTIONS)}, "topic_status_transitions": {"ADMIN": set(CRUD_ACTIONS)}, "topic_required_fields": {"ADMIN": set(CRUD_ACTIONS)}, "topic_data_templates": {"ADMIN": set(CRUD_ACTIONS)}, + "request_data_templates": {"ADMIN": set(CRUD_ACTIONS)}, + "request_data_template_items": {"ADMIN": set(CRUD_ACTIONS)}, "request_data_requirements": {"ADMIN": set(CRUD_ACTIONS)}, "notifications": {"ADMIN": {"query", "read", "update"}}, } @@ -264,10 +270,13 @@ def _table_label(table_name: str) -> str: "clients": "Клиенты", "table_availability": "Доступность таблиц", "topic_required_fields": "Обязательные поля темы", - "topic_data_templates": "Шаблоны данных темы", + "topic_data_templates": "Дополнительные данные", + "request_data_templates": "Шаблоны доп. данных", + "request_data_template_items": "Набор данных шаблона", "topic_status_transitions": "Переходы статусов темы", "admin_users": "Пользователи", "admin_user_topics": "Дополнительные темы юристов", + "landing_featured_staff": "Карусель сотрудников лендинга", "attachments": "Вложения", "messages": "Сообщения", "audit_log": "Журнал аудита", @@ -355,8 +364,16 @@ def _column_label(table_name: str, column_name: str) -> str: "key": "Ключ", "name": "Название", "label": "Метка", + "caption": "Подпись", + "value_type": "Тип значения", + "document_name": "Документ", + "request_data_template_id": "Шаблон", + "request_data_template_item_id": "Элемент шаблона", "text": "Текст", "description": "Описание", + "request_message_id": "ID сообщения запроса", + "field_type": "Тип поля", + "value_text": "Данные", "author": "Автор", "source": "Источник", "email": "Email", @@ -384,6 +401,7 @@ def _column_label(table_name: str, column_name: str) -> str: "updated_at": "Дата обновления", "responsible": "Ответственный", "sort_order": "Порядок", + "pinned": "Закреплен", "is_active": "Активен", "enabled": "Активен", "required": "Обязательное", @@ -396,6 +414,7 @@ def _column_label(table_name: str, column_name: str) -> str: "primary_topic_code": "Профильная тема", "default_rate": "Ставка по умолчанию", "effective_rate": "Ставка (фикс.)", + "request_cost": "Стоимость заявки", "salary_percent": "Процент зарплаты", "invoice_amount": "Сумма счета", "paid_by_admin_id": "Оплату подтвердил", @@ -468,12 +487,17 @@ def _reference_override(table_name: str, column_name: str) -> tuple[str, str] | ("topic_required_fields", "topic_code"): ("topics", "code"), ("topic_required_fields", "field_key"): ("form_fields", "key"), ("topic_data_templates", "topic_code"): ("topics", "code"), + ("request_data_templates", "topic_code"): ("topics", "code"), + ("request_data_templates", "created_by_admin_id"): ("admin_users", "id"), + ("request_data_template_items", "request_data_template_id"): ("request_data_templates", "id"), + ("request_data_template_items", "topic_data_template_id"): ("topic_data_templates", "id"), ("topic_status_transitions", "topic_code"): ("topics", "code"), ("topic_status_transitions", "from_status"): ("statuses", "code"), ("topic_status_transitions", "to_status"): ("statuses", "code"), ("admin_users", "primary_topic_code"): ("topics", "code"), ("admin_user_topics", "admin_user_id"): ("admin_users", "id"), ("admin_user_topics", "topic_code"): ("topics", "code"), + ("landing_featured_staff", "admin_user_id"): ("admin_users", "id"), ("request_data_requirements", "request_id"): ("requests", "id"), ("request_data_requirements", "topic_template_id"): ("topic_data_templates", "id"), ("request_data_requirements", "created_by_admin_id"): ("admin_users", "id"), @@ -530,6 +554,8 @@ def _reference_label_field(table_name: str, value_field: str) -> str: "status_groups": "name", "form_fields": "label", "topic_data_templates": "label", + "request_data_templates": "name", + "request_data_template_items": "label", "invoices": "invoice_number", "messages": "body", "attachments": "file_name", @@ -784,6 +810,8 @@ def _apply_admin_user_fields_for_create(payload: dict[str, Any]) -> dict[str, An raise HTTPException(status_code=400, detail="Email обязателен") data["email"] = email data["role"] = role + if "phone" in data: + data["phone"] = _normalize_optional_string(_normalize_client_phone(data.get("phone"))) data["avatar_url"] = _normalize_optional_string(data.get("avatar_url")) data["primary_topic_code"] = _normalize_optional_string(data.get("primary_topic_code")) data["password_hash"] = hash_password(raw_password) @@ -809,6 +837,8 @@ def _apply_admin_user_fields_for_update(payload: dict[str, Any]) -> dict[str, An if not email: raise HTTPException(status_code=400, detail="Email не может быть пустым") data["email"] = email + if "phone" in data: + data["phone"] = _normalize_optional_string(_normalize_client_phone(data.get("phone"))) if "avatar_url" in data: data["avatar_url"] = _normalize_optional_string(data.get("avatar_url")) if "primary_topic_code" in data: @@ -936,6 +966,88 @@ def _apply_topic_data_templates_fields(db: Session, payload: dict[str, Any]) -> if not key: raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым') data["key"] = key + if "value_type" in data: + value_type = str(data.get("value_type") or "").strip().lower() + if value_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES: + raise HTTPException(status_code=400, detail='Поле "value_type" должно быть одним из: string, text, date, number, file') + data["value_type"] = value_type + if "document_name" in data: + data["document_name"] = _normalize_optional_string(data.get("document_name")) + return data + + +def _apply_request_data_templates_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]: + data = dict(payload) + if "topic_code" in data: + topic_code = str(data.get("topic_code") or "").strip() + if not topic_code: + raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым') + _ensure_topic_exists_or_400(db, topic_code) + data["topic_code"] = topic_code + if "name" in data: + name = str(data.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail='Поле "name" не может быть пустым') + data["name"] = name + if "description" in data: + data["description"] = _normalize_optional_string(data.get("description")) + if "created_by_admin_id" in data and data.get("created_by_admin_id") is not None: + admin_id = _parse_uuid_or_400(data.get("created_by_admin_id"), "created_by_admin_id") + admin_user = db.get(AdminUser, admin_id) + if admin_user is None: + raise HTTPException(status_code=400, detail="Пользователь не найден") + data["created_by_admin_id"] = admin_id + return data + + +def _apply_request_data_template_items_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]: + data = dict(payload) + template = None + if "request_data_template_id" in data: + template_id = _parse_uuid_or_400(data.get("request_data_template_id"), "request_data_template_id") + template = db.get(RequestDataTemplate, template_id) + if template is None: + raise HTTPException(status_code=400, detail="Шаблон не найден") + data["request_data_template_id"] = template_id + if "topic_data_template_id" in data and data.get("topic_data_template_id") is not None: + catalog_id = _parse_uuid_or_400(data.get("topic_data_template_id"), "topic_data_template_id") + catalog = db.get(TopicDataTemplate, catalog_id) + if catalog is None: + raise HTTPException(status_code=400, detail="Поле доп. данных не найдено") + data["topic_data_template_id"] = catalog_id + if "key" not in data or not str(data.get("key") or "").strip(): + data["key"] = str(catalog.key or "").strip() + if "label" not in data or not str(data.get("label") or "").strip(): + data["label"] = str(catalog.label or catalog.key or "").strip() + if "value_type" not in data or not str(data.get("value_type") or "").strip(): + data["value_type"] = str(catalog.value_type or "string") + if template is not None and str(template.topic_code or "").strip() and str(catalog.topic_code or "").strip(): + if str(template.topic_code) != str(catalog.topic_code): + raise HTTPException(status_code=400, detail="Поле не соответствует теме шаблона") + if "key" in data: + key = str(data.get("key") or "").strip() + if not key: + raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым') + data["key"] = key[:80] + if "label" in data: + label = str(data.get("label") or "").strip() + if not label: + raise HTTPException(status_code=400, detail='Поле "label" не может быть пустым') + data["label"] = label + if "value_type" in data: + value_type = str(data.get("value_type") or "").strip().lower() + if value_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES: + raise HTTPException(status_code=400, detail='Поле "value_type" должно быть одним из: string, text, date, number, file') + data["value_type"] = value_type + if "sort_order" in data: + raw = data.get("sort_order") + if raw is None or str(raw).strip() == "": + data["sort_order"] = 0 + else: + try: + data["sort_order"] = int(raw) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail='Поле "sort_order" должно быть целым числом') return data @@ -953,11 +1065,31 @@ def _apply_request_data_requirements_fields(db: Session, payload: dict[str, Any] if template is None: raise HTTPException(status_code=400, detail="Шаблон темы не найден") data["topic_template_id"] = template_id + if "request_message_id" in data and data.get("request_message_id") is not None: + data["request_message_id"] = _parse_uuid_or_400(data.get("request_message_id"), "request_message_id") if "key" in data: key = str(data.get("key") or "").strip() if not key: raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым') data["key"] = key + if "field_type" in data: + field_type = str(data.get("field_type") or "").strip().lower() + if field_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES: + raise HTTPException(status_code=400, detail='Поле "field_type" должно быть одним из: string, text, date, number, file') + data["field_type"] = field_type + if "document_name" in data: + data["document_name"] = _normalize_optional_string(data.get("document_name")) + if "value_text" in data: + data["value_text"] = _normalize_optional_string(data.get("value_text")) + if "sort_order" in data: + raw_sort = data.get("sort_order") + if raw_sort is None or str(raw_sort).strip() == "": + data["sort_order"] = 0 + else: + try: + data["sort_order"] = int(raw_sort) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail='Поле "sort_order" должно быть целым числом') return data @@ -1416,7 +1548,20 @@ def get_row( if normalized == "attachments" and isinstance(row, Attachment): req = _request_for_related_row_or_404(db, row) _ensure_lawyer_can_view_request_or_403(admin, req) - return _strip_hidden_fields(normalized, _row_to_dict(row)) + payload = _strip_hidden_fields(normalized, _row_to_dict(row)) + if normalized == "requests" and isinstance(row, Request): + assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip() + if assigned_lawyer_id: + try: + lawyer_uuid = uuid.UUID(assigned_lawyer_id) + except ValueError: + lawyer_uuid = None + if lawyer_uuid is not None: + lawyer = db.get(AdminUser, lawyer_uuid) + if lawyer is not None: + payload["assigned_lawyer_name"] = lawyer.name or lawyer.email or assigned_lawyer_id + payload["assigned_lawyer_phone"] = _serialize_value(getattr(lawyer, "phone", None)) + return payload @router.post("/{table_name}", status_code=201) @@ -1490,6 +1635,10 @@ def create_row( clean_payload = _apply_topic_required_fields_fields(db, clean_payload) if normalized == "topic_data_templates": clean_payload = _apply_topic_data_templates_fields(db, clean_payload) + if normalized == "request_data_templates": + clean_payload = _apply_request_data_templates_fields(db, clean_payload) + if normalized == "request_data_template_items": + clean_payload = _apply_request_data_template_items_fields(db, clean_payload) if normalized == "request_data_requirements": clean_payload = _apply_request_data_requirements_fields(db, clean_payload) if normalized == "topic_status_transitions": @@ -1557,6 +1706,10 @@ def update_row( clean_payload = _apply_topic_required_fields_fields(db, clean_payload) if normalized == "topic_data_templates": clean_payload = _apply_topic_data_templates_fields(db, clean_payload) + if normalized == "request_data_templates": + clean_payload = _apply_request_data_templates_fields(db, clean_payload) + if normalized == "request_data_template_items": + clean_payload = _apply_request_data_template_items_fields(db, clean_payload) if normalized == "request_data_requirements": clean_payload = _apply_request_data_requirements_fields(db, clean_payload) if normalized == "topic_status_transitions": @@ -1603,23 +1756,9 @@ def update_row( if normalized == "requests" and "status_code" in clean_payload: before_status = str(before.get("status_code") or "") after_status = str(clean_payload.get("status_code") or "") - topic_code = str(before.get("topic_code") or "").strip() or None - if not transition_allowed_for_topic(db, topic_code, before_status, after_status): - raise HTTPException( - status_code=400, - detail="Переход статуса не разрешен для выбранной темы", - ) if before_status != after_status and isinstance(row, Request): - extra_fields_override = clean_payload.get("extra_fields") - if not isinstance(extra_fields_override, dict): - extra_fields_override = row.extra_fields if isinstance(row.extra_fields, dict) else None - validate_transition_requirements_or_400( - db, - row, - from_status=before_status, - to_status=after_status, - extra_fields_override=extra_fields_override, - ) + if "important_date_at" not in clean_payload or clean_payload.get("important_date_at") is None: + clean_payload["important_date_at"] = datetime.now(timezone.utc) + timedelta(days=3) billing_note = apply_billing_transition_effects( db, req=row, @@ -1635,6 +1774,7 @@ def update_row( from_status=before_status, to_status=after_status, admin=admin, + important_date_at=clean_payload.get("important_date_at"), responsible=responsible, ) notify_request_event( @@ -1643,7 +1783,15 @@ def update_row( event_type=NOTIFICATION_EVENT_STATUS, actor_role=_actor_role(admin), actor_admin_user_id=admin.get("sub"), - body=(f"{before_status} -> {after_status}" + (f"\n{billing_note}" if billing_note else "")), + body=( + f"{before_status} -> {after_status}" + + ( + f"\nВажная дата: {clean_payload.get('important_date_at').isoformat()}" + if isinstance(clean_payload.get("important_date_at"), datetime) + else "" + ) + + (f"\n{billing_note}" if billing_note else "") + ), responsible=responsible, ) for key, value in clean_payload.items(): diff --git a/app/api/admin/metrics.py b/app/api/admin/metrics.py index 7483b97..a957c08 100644 --- a/app/api/admin/metrics.py +++ b/app/api/admin/metrics.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from decimal import Decimal from uuid import UUID @@ -11,6 +11,7 @@ from sqlalchemy.orm import Session from app.core.deps import require_role from app.db.session import get_db from app.models.admin_user import AdminUser +from app.models.audit_log import AuditLog from app.models.request import Request from app.models.status import Status from app.models.status_history import StatusHistory @@ -59,6 +60,33 @@ def _uuid_or_none(value: str | None) -> UUID | None: return None +def _extract_assigned_lawyer_from_audit(diff: dict | None, action: str | None) -> str | None: + if not isinstance(diff, dict): + return None + action_code = str(action or "").upper() + if action_code == "MANUAL_CLAIM": + value = diff.get("assigned_lawyer_id") + return str(value).strip() if value else None + if action_code == "MANUAL_REASSIGN": + value = diff.get("to_lawyer_id") + return str(value).strip() if value else None + if action_code in {"CREATE", "UPDATE"}: + after = diff.get("after") + before = diff.get("before") + if action_code == "UPDATE": + if not isinstance(after, dict) or not isinstance(before, dict): + return None + prev_value = str(before.get("assigned_lawyer_id") or "").strip() + next_value = str(after.get("assigned_lawyer_id") or "").strip() + if not next_value or next_value == prev_value: + return None + return next_value + if isinstance(after, dict): + value = str(after.get("assigned_lawyer_id") or "").strip() + return value or None + return None + + @router.get("/overview") def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))): role = str(admin.get("role") or "").upper() @@ -123,6 +151,32 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", paid_events_map = {str(lawyer_id): int(events) for lawyer_id, events, _ in paid_rows if lawyer_id} monthly_gross_map = {str(lawyer_id): _to_float(gross) for lawyer_id, _, gross in paid_rows if lawyer_id} + monthly_completed_rows = ( + db.query(Request.assigned_lawyer_id, func.count(func.distinct(StatusHistory.request_id))) + .join(StatusHistory, StatusHistory.request_id == Request.id) + .filter(Request.assigned_lawyer_id.is_not(None)) + .filter(StatusHistory.created_at >= month_start, StatusHistory.created_at < next_month_start) + .filter(func.upper(StatusHistory.to_status).in_({str(code).upper() for code in terminal_codes})) + .group_by(Request.assigned_lawyer_id) + .all() + ) + monthly_completed_map = {str(lawyer_id): int(count) for lawyer_id, count in monthly_completed_rows if lawyer_id} + + monthly_assigned_map: dict[str, int] = {} + audit_rows = ( + db.query(AuditLog.action, AuditLog.diff) + .filter(AuditLog.entity == "requests") + .filter(AuditLog.created_at >= month_start, AuditLog.created_at < next_month_start) + .all() + ) + for action, diff in audit_rows: + assigned_to = _extract_assigned_lawyer_from_audit(diff, action) + if not assigned_to: + continue + monthly_assigned_map[assigned_to] = int(monthly_assigned_map.get(assigned_to, 0)) + 1 + + monthly_revenue = round(sum(monthly_gross_map.values()), 2) + lawyers = ( db.query(AdminUser) .filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True)) @@ -146,6 +200,8 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "active_load": active_load_map.get(lawyer_id, 0), "total_assigned": total_load_map.get(lawyer_id, 0), "active_amount": round(active_amount_map.get(lawyer_id, 0.0), 2), + "monthly_assigned_count": monthly_assigned_map.get(lawyer_id, 0), + "monthly_completed_count": monthly_completed_map.get(lawyer_id, 0), "monthly_paid_events": paid_events_map.get(lawyer_id, 0), "monthly_paid_gross": round(monthly_paid_gross, 2), "monthly_salary": round(monthly_salary, 2), @@ -221,6 +277,18 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", scoped_lawyer_loads = lawyer_loads sla_snapshot = compute_sla_snapshot(db) + next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1) + deadline_alert_query = ( + db.query(func.count(Request.id)) + .filter(Request.important_date_at.is_not(None)) + .filter(Request.important_date_at < next_day_start) + .filter(Request.status_code.notin_(terminal_codes)) + ) + if role == "LAWYER" and actor_uuid is not None: + deadline_alert_query = deadline_alert_query.filter(Request.assigned_lawyer_id == str(actor_uuid)) + elif role == "LAWYER": + deadline_alert_query = deadline_alert_query.filter(Request.id.is_(None)) + deadline_alert_total = int(deadline_alert_query.scalar() or 0) return { "scope": role if role in {"ADMIN", "LAWYER"} else "ADMIN", "new": int(by_status.get("NEW", 0)), @@ -230,6 +298,11 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "unassigned_total": unassigned_total, "my_unread_updates": my_unread_updates, "my_unread_by_event": my_unread_by_event, + "deadline_alert_total": deadline_alert_total, + "month_revenue": monthly_revenue, + "month_expenses": round(sum(_to_float(row.get("monthly_salary")) for row in scoped_lawyer_loads), 2) + if role == "LAWYER" + else round(sum(_to_float(row.get("monthly_salary")) for row in lawyer_loads), 2), "frt_avg_minutes": sla_snapshot.get("frt_avg_minutes"), "sla_overdue": sla_snapshot.get("overdue_total", 0), "overdue_by_status": sla_snapshot.get("overdue_by_status", {}), @@ -239,3 +312,79 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "unread_for_lawyers": int(unread_for_lawyers), "lawyer_loads": scoped_lawyer_loads, } + + +@router.get("/lawyers/{lawyer_id}/active-requests") +def lawyer_active_requests( + lawyer_id: str, + db: Session = Depends(get_db), + admin=Depends(require_role("ADMIN", "LAWYER")), +): + actor_role = str(admin.get("role") or "").upper() + actor_id = str(admin.get("sub") or "").strip() + if actor_role == "LAWYER" and str(lawyer_id) != actor_id: + return {"rows": [], "total": 0, "totals": {"amount": 0.0, "salary": 0.0}} + + lawyer = db.query(AdminUser).filter(AdminUser.id == _uuid_or_none(lawyer_id), AdminUser.role == "LAWYER").first() + if lawyer is None: + return {"rows": [], "total": 0, "totals": {"amount": 0.0, "salary": 0.0}} + + terminal_codes = _terminal_status_codes(db) + paid_codes = _paid_status_codes() + now_utc = datetime.now(timezone.utc) + month_start, next_month_start = _month_bounds(now_utc) + + salary_percent = _to_float(lawyer.salary_percent) + paid_by_request_rows = ( + db.query(StatusHistory.request_id, func.count(StatusHistory.id)) + .join(Request, Request.id == StatusHistory.request_id) + .filter(Request.assigned_lawyer_id == str(lawyer.id)) + .filter(StatusHistory.created_at >= month_start, StatusHistory.created_at < next_month_start) + .filter(func.upper(StatusHistory.to_status).in_(paid_codes)) + .group_by(StatusHistory.request_id) + .all() + ) + paid_events_per_request = {str(req_id): int(count) for req_id, count in paid_by_request_rows if req_id} + + request_rows = ( + db.query(Request) + .filter(Request.assigned_lawyer_id == str(lawyer.id)) + .filter(Request.status_code.notin_(terminal_codes)) + .order_by(Request.created_at.desc(), Request.track_number.asc()) + .all() + ) + + rows = [] + total_amount = 0.0 + total_salary = 0.0 + for req in request_rows: + req_id = str(req.id) + invoice_amount = _to_float(req.invoice_amount) + paid_events = int(paid_events_per_request.get(req_id, 0)) + month_paid_amount = round(invoice_amount * paid_events, 2) + month_salary_amount = round(month_paid_amount * salary_percent / 100.0, 2) + total_amount += month_paid_amount + total_salary += month_salary_amount + rows.append( + { + "id": req_id, + "track_number": req.track_number, + "status_code": req.status_code, + "client_name": req.client_name, + "invoice_amount": round(invoice_amount, 2), + "month_paid_events": paid_events, + "month_paid_amount": month_paid_amount, + "month_salary_amount": month_salary_amount, + "created_at": req.created_at.isoformat() if req.created_at else None, + "paid_at": req.paid_at.isoformat() if req.paid_at else None, + } + ) + + return { + "rows": rows, + "total": len(rows), + "totals": { + "amount": round(total_amount, 2), + "salary": round(total_salary, 2), + }, + } diff --git a/app/api/admin/requests.py b/app/api/admin/requests.py index db84d7a..ec09a44 100644 --- a/app/api/admin/requests.py +++ b/app/api/admin/requests.py @@ -16,16 +16,17 @@ from app.schemas.admin import ( RequestDataRequirementCreate, RequestDataRequirementPatch, RequestReassign, + RequestStatusChange, ) from app.models.admin_user import AdminUser from app.models.audit_log import AuditLog +from app.models.client import Client from app.models.request_data_requirement import RequestDataRequirement from app.models.request import Request from app.models.status import Status from app.models.status_group import StatusGroup from app.models.status_history import StatusHistory from app.models.topic_data_template import TopicDataTemplate -from app.models.topic_status_transition import TopicStatusTransition from app.services.notifications import ( EVENT_STATUS as NOTIFICATION_EVENT_STATUS, mark_admin_notifications_read, @@ -33,9 +34,8 @@ from app.services.notifications import ( ) from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_lawyer, mark_unread_for_client from app.services.request_status import actor_admin_uuid, apply_status_change_effects -from app.services.status_flow import transition_allowed_for_topic from app.services.request_templates import validate_required_topic_fields_or_400 -from app.services.status_transition_requirements import normalize_string_list, validate_transition_requirements_or_400 +from app.services.status_transition_requirements import normalize_string_list from app.services.billing_flow import apply_billing_transition_effects from app.services.universal_query import apply_universal_query @@ -106,6 +106,149 @@ def _parse_datetime_safe(value: object) -> datetime | None: return parsed +def _normalize_important_date_or_default(raw: object, *, default_days: int = 3) -> datetime: + parsed = _parse_datetime_safe(raw) + if parsed: + return parsed + return datetime.now(timezone.utc) + timedelta(days=default_days) + + +def _terminal_status_codes(db: Session) -> set[str]: + rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all() + codes = {str(code or "").strip() for (code,) in rows if str(code or "").strip()} + return codes or {"RESOLVED", "CLOSED", "REJECTED"} + + +def _coerce_request_bool_filter_or_400(value: object) -> bool: + if isinstance(value, bool): + return value + text = str(value or "").strip().lower() + if text in {"1", "true", "yes", "y", "да"}: + return True + if text in {"0", "false", "no", "n", "нет"}: + return False + raise HTTPException(status_code=400, detail="Значение фильтра должно быть boolean") + + +def _split_request_special_filters(uq: UniversalQuery) -> tuple[UniversalQuery, list[FilterClause]]: + filters = list(uq.filters or []) + special: list[FilterClause] = [] + regular: list[FilterClause] = [] + for clause in filters: + field = str(getattr(clause, "field", "") or "").strip() + if field in {"has_unread_updates", "deadline_alert"}: + special.append(clause) + else: + regular.append(clause) + return UniversalQuery(filters=regular, sort=list(uq.sort or []), page=uq.page), special + + +def _apply_request_special_filters( + base_query, + *, + db: Session, + role: str, + actor_id: str, + special_filters: list[FilterClause], +): + if not special_filters: + return base_query + terminal_codes_cache: set[str] | None = None + for clause in special_filters: + field = str(clause.field or "").strip() + op = str(clause.op or "").strip() + if op not in {"=", "!="}: + raise HTTPException(status_code=400, detail=f'Оператор "{op}" не поддерживается для фильтра "{field}"') + expected = _coerce_request_bool_filter_or_400(clause.value) + if field == "has_unread_updates": + if role == "LAWYER": + expr = Request.lawyer_has_unread_updates.is_(True) + else: + expr = or_( + Request.lawyer_has_unread_updates.is_(True), + Request.client_has_unread_updates.is_(True), + ) + elif field == "deadline_alert": + now_utc = datetime.now(timezone.utc) + next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1) + if terminal_codes_cache is None: + terminal_codes_cache = _terminal_status_codes(db) + expr = ( + Request.important_date_at.is_not(None) + & (Request.important_date_at < next_day_start) + & (Request.status_code.notin_(terminal_codes_cache)) + ) + if role == "LAWYER": + expr = expr & (Request.assigned_lawyer_id == actor_id) + else: + continue + base_query = base_query.filter(expr if expected else ~expr) + return base_query + + +def _normalize_client_phone(value: object) -> str: + text = "".join(ch for ch in str(value or "") if ch.isdigit() or ch == "+") + if not text: + return "" + if text.startswith("8") and len(text) == 11: + text = "+7" + text[1:] + if not text.startswith("+") and text.isdigit(): + text = "+" + text + return text + + +def _client_uuid_or_none(value: object) -> UUID | None: + raw = str(value or "").strip() + if not raw: + return None + try: + return UUID(raw) + except ValueError: + raise HTTPException(status_code=400, detail='Некорректный "client_id"') + + +def _client_for_request_payload_or_400( + db: Session, + *, + client_id: object, + client_name: object, + client_phone: object, + responsible: str, +) -> Client: + client_uuid = _client_uuid_or_none(client_id) + if client_uuid is not None: + row = db.get(Client, client_uuid) + if row is None: + raise HTTPException(status_code=404, detail="Клиент не найден") + return row + + normalized_phone = _normalize_client_phone(client_phone) + if not normalized_phone: + raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно') + normalized_name = str(client_name or "").strip() or "Клиент" + + row = db.query(Client).filter(Client.phone == normalized_phone).first() + if row is None: + row = Client( + full_name=normalized_name, + phone=normalized_phone, + responsible=responsible, + ) + db.add(row) + db.flush() + return row + + changed = False + if normalized_name and row.full_name != normalized_name: + row.full_name = normalized_name + changed = True + if changed: + row.responsible = responsible + db.add(row) + db.flush() + return row + + def _extract_case_deadline(extra_fields: object) -> datetime | None: if not isinstance(extra_fields, dict): return None @@ -281,8 +424,8 @@ def _request_data_requirement_row(row: RequestDataRequirement) -> dict: def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))): base_query = db.query(Request) role = str(admin.get("role") or "").upper() + actor = str(admin.get("sub") or "").strip() if role == "LAWYER": - actor = str(admin.get("sub") or "").strip() if not actor: raise HTTPException(status_code=401, detail="Некорректный токен") base_query = base_query.filter( @@ -292,7 +435,15 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe ) ) - q = apply_universal_query(base_query, Request, uq) + regular_uq, special_filters = _split_request_special_filters(uq) + base_query = _apply_request_special_filters( + base_query, + db=db, + role=role, + actor_id=actor, + special_filters=special_filters, + ) + q = apply_universal_query(base_query, Request, regular_uq) total = q.count() rows = q.offset(uq.page.offset).limit(uq.page.limit).all() return { @@ -300,11 +451,14 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe { "id": str(r.id), "track_number": r.track_number, + "client_id": str(r.client_id) if r.client_id else None, "status_code": r.status_code, "client_name": r.client_name, "client_phone": r.client_phone, "topic_code": r.topic_code, + "important_date_at": r.important_date_at.isoformat() if r.important_date_at else None, "effective_rate": float(r.effective_rate) if r.effective_rate is not None else None, + "request_cost": float(r.request_cost) if r.request_cost is not None else None, "invoice_amount": float(r.invoice_amount) if r.invoice_amount is not None else None, "paid_at": r.paid_at.isoformat() if r.paid_at else None, "paid_by_admin_id": r.paid_by_admin_id, @@ -360,33 +514,8 @@ def get_requests_kanban( request_id_to_row = {str(row.id): row for row in request_rows} request_ids = [row.id for row in request_rows] - topic_codes = {str(row.topic_code or "").strip() for row in request_rows if str(row.topic_code or "").strip()} status_codes = {str(row.status_code or "").strip() for row in request_rows if str(row.status_code or "").strip()} - transition_rows: list[TopicStatusTransition] = [] - if topic_codes: - transition_rows = ( - db.query(TopicStatusTransition) - .filter( - TopicStatusTransition.enabled.is_(True), - TopicStatusTransition.topic_code.in_(list(topic_codes)), - ) - .order_by( - TopicStatusTransition.topic_code.asc(), - TopicStatusTransition.from_status.asc(), - TopicStatusTransition.sort_order.asc(), - TopicStatusTransition.to_status.asc(), - ) - .all() - ) - for row in transition_rows: - from_code = str(row.from_status or "").strip() - to_code = str(row.to_status or "").strip() - if from_code: - status_codes.add(from_code) - if to_code: - status_codes.add(to_code) - status_meta_map: dict[str, dict[str, object]] = {} if status_codes: status_rows = ( @@ -448,16 +577,30 @@ def get_requests_kanban( current_status_changed_at[request_id] = row.created_at previous_status_by_request[request_id] = str(row.from_status or "").strip() - transitions_by_key: dict[tuple[str, str], list[TopicStatusTransition]] = {} - transitions_to_key: dict[tuple[str, str], list[TopicStatusTransition]] = {} - for row in transition_rows: - topic_code = str(row.topic_code or "").strip() - from_status = str(row.from_status or "").strip() - to_status = str(row.to_status or "").strip() - if not topic_code or not from_status or not to_status: + all_enabled_status_rows = ( + db.query(Status, StatusGroup) + .outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id) + .filter(Status.enabled.is_(True)) + .order_by(Status.sort_order.asc(), Status.name.asc(), Status.code.asc()) + .all() + ) + all_enabled_statuses: list[dict[str, object]] = [] + for status_row, group_row in all_enabled_status_rows: + code = str(status_row.code or "").strip() + if not code: continue - transitions_by_key.setdefault((topic_code, from_status), []).append(row) - transitions_to_key.setdefault((topic_code, to_status), []).append(row) + meta = { + "code": code, + "name": str(status_row.name or code), + "kind": str(status_row.kind or "DEFAULT"), + "is_terminal": bool(status_row.is_terminal), + "status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None, + "status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None), + "status_group_order": (int(group_row.sort_order or 0) if group_row is not None else None), + "sort_order": int(status_row.sort_order or 0), + } + status_meta_map.setdefault(code, meta) + all_enabled_statuses.append(meta) status_groups_rows = db.query(StatusGroup).order_by(StatusGroup.sort_order.asc(), StatusGroup.name.asc()).all() columns_catalog = [ @@ -474,7 +617,6 @@ def get_requests_kanban( group_totals: dict[str, int] = {row["key"]: 0 for row in columns_catalog} for row in request_rows: request_id = str(row.id) - topic_code = str(row.topic_code or "").strip() status_code = str(row.status_code or "").strip() status_meta = _status_meta_or_default(status_meta_map, status_code) status_group = str(status_meta.get("status_group_id") or "").strip() @@ -496,9 +638,9 @@ def get_requests_kanban( } columns_catalog.append(columns_by_key[status_group]) available_transitions = [] - for transition in transitions_by_key.get((topic_code, status_code), []): - to_status = str(transition.to_status or "").strip() - if not to_status: + for status_def in all_enabled_statuses: + to_status = str(status_def.get("code") or "").strip() + if not to_status or to_status == status_code: continue to_meta = _status_meta_or_default(status_meta_map, to_status) target_group = str(to_meta.get("status_group_id") or "").strip() @@ -514,26 +656,12 @@ def get_requests_kanban( "to_status": to_status, "to_status_name": str(to_meta.get("name") or to_status), "target_group": target_group, - "sla_hours": transition.sla_hours, + "is_terminal": bool(to_meta.get("is_terminal")), } ) - case_deadline = _extract_case_deadline(row.extra_fields) - entered_at = current_status_changed_at.get(request_id) or row.created_at - previous_status = previous_status_by_request.get(request_id) - transition_candidates = transitions_to_key.get((topic_code, status_code), []) - matched_transition = None - if previous_status: - for transition in transition_candidates: - if str(transition.from_status or "").strip() == previous_status: - matched_transition = transition - break - if matched_transition is None and transition_candidates: - matched_transition = transition_candidates[0] - + case_deadline = row.important_date_at or _extract_case_deadline(row.extra_fields) sla_deadline = None - if entered_at and matched_transition and matched_transition.sla_hours is not None: - sla_deadline = entered_at + timedelta(hours=int(matched_transition.sla_hours)) assigned_id = str(row.assigned_lawyer_id or "").strip() or None items.append( @@ -544,6 +672,7 @@ def get_requests_kanban( "client_phone": row.client_phone, "topic_code": row.topic_code, "status_code": status_code, + "important_date_at": row.important_date_at.isoformat() if row.important_date_at else None, "status_name": str(status_meta.get("name") or status_code), "status_group": status_group, "status_group_name": status_group_name or None, @@ -618,6 +747,13 @@ def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), a validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields) track = payload.track_number or f"TRK-{uuid4().hex[:10].upper()}" responsible = str(admin.get("email") or "").strip() or "Администратор системы" + client = _client_for_request_payload_or_400( + db, + client_id=payload.client_id, + client_name=payload.client_name, + client_phone=payload.client_phone, + responsible=responsible, + ) assigned_lawyer_id = str(payload.assigned_lawyer_id or "").strip() or None effective_rate = payload.effective_rate if assigned_lawyer_id: @@ -627,14 +763,17 @@ def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), a effective_rate = assigned_lawyer.default_rate row = Request( track_number=track, - client_name=payload.client_name, - client_phone=payload.client_phone, + client_id=client.id, + client_name=client.full_name, + client_phone=client.phone, topic_code=payload.topic_code, status_code=payload.status_code, + important_date_at=payload.important_date_at, description=payload.description, extra_fields=payload.extra_fields, assigned_lawyer_id=assigned_lawyer_id, effective_rate=effective_rate, + request_cost=payload.request_cost, invoice_amount=payload.invoice_amount, paid_at=payload.paid_at, paid_by_admin_id=payload.paid_by_admin_id, @@ -682,30 +821,32 @@ def update_request( changes["effective_rate"] = assigned_lawyer.default_rate old_status = str(row.status_code or "") responsible = str(admin.get("email") or "").strip() or "Администратор системы" + if {"client_id", "client_name", "client_phone"}.intersection(set(changes.keys())): + client = _client_for_request_payload_or_400( + db, + client_id=changes.get("client_id", row.client_id), + client_name=changes.get("client_name", row.client_name), + client_phone=changes.get("client_phone", row.client_phone), + responsible=responsible, + ) + changes["client_id"] = client.id + changes["client_name"] = client.full_name + changes["client_phone"] = client.phone + status_changed = "status_code" in changes and str(changes.get("status_code") or "") != old_status + if status_changed and ("important_date_at" not in changes or changes.get("important_date_at") is None): + changes["important_date_at"] = _normalize_important_date_or_default(None) for key, value in changes.items(): setattr(row, key, value) - if "status_code" in changes and str(changes.get("status_code") or "") != old_status: + if status_changed: next_status = str(changes.get("status_code") or "") - if not transition_allowed_for_topic( - db, - str(row.topic_code or "").strip() or None, - old_status, - next_status, - ): - raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы") - validate_transition_requirements_or_400( - db, - row, - from_status=old_status, - to_status=next_status, - extra_fields_override=row.extra_fields if isinstance(row.extra_fields, dict) else None, - ) + important_date_at = row.important_date_at billing_note = apply_billing_transition_effects( db, req=row, from_status=old_status, to_status=next_status, admin=admin, + important_date_at=important_date_at, responsible=responsible, ) mark_unread_for_client(row, EVENT_STATUS) @@ -723,7 +864,11 @@ def update_request( event_type=NOTIFICATION_EVENT_STATUS, actor_role=str(admin.get("role") or "").upper() or "ADMIN", actor_admin_user_id=admin.get("sub"), - body=(f"{old_status} -> {next_status}" + (f"\n{billing_note}" if billing_note else "")), + body=( + f"{old_status} -> {next_status}" + + (f"\nВажная дата: {important_date_at.isoformat()}" if important_date_at else "") + + (f"\n{billing_note}" if billing_note else "") + ), responsible=responsible, ) try: @@ -772,14 +917,17 @@ def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(re return { "id": str(req.id), "track_number": req.track_number, + "client_id": str(req.client_id) if req.client_id else None, "client_name": req.client_name, "client_phone": req.client_phone, "topic_code": req.topic_code, "status_code": req.status_code, + "important_date_at": req.important_date_at.isoformat() if req.important_date_at else None, "description": req.description, "extra_fields": req.extra_fields, "assigned_lawyer_id": req.assigned_lawyer_id, "effective_rate": float(req.effective_rate) if req.effective_rate is not None else None, + "request_cost": float(req.request_cost) if req.request_cost is not None else None, "invoice_amount": float(req.invoice_amount) if req.invoice_amount is not None else None, "paid_at": req.paid_at.isoformat() if req.paid_at else None, "paid_by_admin_id": req.paid_by_admin_id, @@ -793,6 +941,86 @@ def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(re } +@router.post("/{request_id}/status-change") +def change_request_status( + request_id: str, + payload: RequestStatusChange, + db: Session = Depends(get_db), + admin=Depends(require_role("ADMIN", "LAWYER")), +): + request_uuid = _request_uuid_or_400(request_id) + req = db.get(Request, request_uuid) + if not req: + raise HTTPException(status_code=404, detail="Заявка не найдена") + _ensure_lawyer_can_manage_request_or_403(admin, req) + + next_status = str(payload.status_code or "").strip() + if not next_status: + raise HTTPException(status_code=400, detail='Поле "status_code" обязательно') + + status_row = db.query(Status).filter(Status.code == next_status, Status.enabled.is_(True)).first() + if status_row is None: + raise HTTPException(status_code=400, detail="Указан несуществующий или неактивный статус") + + old_status = str(req.status_code or "").strip() + if old_status == next_status: + raise HTTPException(status_code=400, detail="Выберите новый статус") + + important_date_at = _normalize_important_date_or_default(payload.important_date_at) + comment = str(payload.comment or "").strip() or None + responsible = str(admin.get("email") or "").strip() or "Администратор системы" + + req.status_code = next_status + req.important_date_at = important_date_at + req.responsible = responsible + + billing_note = apply_billing_transition_effects( + db, + req=req, + from_status=old_status, + to_status=next_status, + admin=admin, + responsible=responsible, + ) + mark_unread_for_client(req, EVENT_STATUS) + apply_status_change_effects( + db, + req, + from_status=old_status, + to_status=next_status, + admin=admin, + comment=comment, + important_date_at=important_date_at, + responsible=responsible, + ) + notify_request_event( + db, + request=req, + event_type=NOTIFICATION_EVENT_STATUS, + actor_role=str(admin.get("role") or "").upper() or "ADMIN", + actor_admin_user_id=admin.get("sub"), + body=( + f"{old_status} -> {next_status}" + + f"\nВажная дата: {important_date_at.isoformat()}" + + (f"\n{comment}" if comment else "") + + (f"\n{billing_note}" if billing_note else "") + ), + responsible=responsible, + ) + + db.add(req) + db.commit() + db.refresh(req) + return { + "status": "ok", + "request_id": str(req.id), + "track_number": req.track_number, + "from_status": old_status or None, + "to_status": next_status, + "important_date_at": req.important_date_at.isoformat() if req.important_date_at else None, + } + + @router.get("/{request_id}/status-route") def get_request_status_route( request_id: str, @@ -808,22 +1036,6 @@ def get_request_status_route( topic_code = str(req.topic_code or "").strip() current_status = str(req.status_code or "").strip() - transitions: list[TopicStatusTransition] = [] - if topic_code: - transitions = ( - db.query(TopicStatusTransition) - .filter( - TopicStatusTransition.topic_code == topic_code, - TopicStatusTransition.enabled.is_(True), - ) - .order_by( - TopicStatusTransition.sort_order.asc(), - TopicStatusTransition.from_status.asc(), - TopicStatusTransition.to_status.asc(), - ) - .all() - ) - history_rows = ( db.query(StatusHistory) .filter(StatusHistory.request_id == req.id) @@ -841,23 +1053,28 @@ def get_request_status_route( known_codes.add(from_code) if to_code: known_codes.add(to_code) - for row in transitions: - from_code = str(row.from_status or "").strip() - to_code = str(row.to_status or "").strip() - if from_code: - known_codes.add(from_code) - if to_code: - known_codes.add(to_code) - statuses_map: dict[str, dict[str, str]] = {} + all_enabled_status_rows = db.query(Status, StatusGroup).outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id).filter(Status.enabled.is_(True)).all() + for status_row, _group_row in all_enabled_status_rows: + code = str(status_row.code or "").strip() + if code: + known_codes.add(code) if known_codes: - status_rows = db.query(Status).filter(Status.code.in_(list(known_codes))).all() + status_rows = ( + db.query(Status, StatusGroup) + .outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id) + .filter(Status.code.in_(list(known_codes))) + .all() + ) statuses_map = { - str(row.code): { - "name": str(row.name or row.code), - "kind": str(row.kind or "DEFAULT"), + str(status_row.code): { + "name": str(status_row.name or status_row.code), + "kind": str(status_row.kind or "DEFAULT"), + "is_terminal": bool(status_row.is_terminal), + "status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None, + "status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None), } - for row in status_rows + for status_row, group_row in status_rows } sequence_from_history: list[str] = [] @@ -885,27 +1102,8 @@ def get_request_status_route( for code in sequence_from_history: add_code(code) - for row in transitions: - add_code(str(row.from_status or "")) - add_code(str(row.to_status or "")) - add_code(current_status) - transition_by_to_status: dict[str, dict[str, object]] = {} - for row in transitions: - to_code = str(row.to_status or "").strip() - if not to_code: - continue - current = transition_by_to_status.get(to_code) - if current is None or int(row.sort_order or 0) < int(current.get("sort_order") or 0): - transition_by_to_status[to_code] = { - "from_status": str(row.from_status or "").strip() or None, - "sla_hours": row.sla_hours, - "required_data_keys": normalize_string_list(row.required_data_keys), - "required_mime_types": normalize_string_list(row.required_mime_types), - "sort_order": int(row.sort_order or 0), - } - changed_at_by_status: dict[str, str] = {} for row in history_rows: to_code = str(row.to_status or "").strip() @@ -922,7 +1120,6 @@ def get_request_status_route( nodes: list[dict[str, str | int | None]] = [] for index, code in enumerate(ordered_codes): meta = statuses_map.get(code) or {} - transition_meta = transition_by_to_status.get(code) or {} state = "pending" if code == current_status: state = "current" @@ -930,18 +1127,6 @@ def get_request_status_route( state = "completed" note_parts: list[str] = [] - from_status = transition_meta.get("from_status") - if from_status: - note_parts.append(f"Переход из статуса «{status_name(str(from_status))}»") - sla_hours = transition_meta.get("sla_hours") - if sla_hours is not None: - note_parts.append(f"SLA: {sla_hours} ч") - required_data_keys = transition_meta.get("required_data_keys") or [] - if required_data_keys: - note_parts.append("Данные: " + ", ".join(str(item) for item in required_data_keys)) - required_mime_types = transition_meta.get("required_mime_types") or [] - if required_mime_types: - note_parts.append("Файлы: " + ", ".join(str(item) for item in required_mime_types)) kind = str(meta.get("kind") or "DEFAULT") if kind == "INVOICE": note_parts.append("Этап выставления счета") @@ -954,19 +1139,88 @@ def get_request_status_route( "name": status_name(code), "kind": kind, "state": state, - "sla_hours": sla_hours, - "required_data_keys": required_data_keys, - "required_mime_types": required_mime_types, "changed_at": changed_at_by_status.get(code), "note": " • ".join(note_parts), } ) + history_entries: list[dict[str, object]] = [] + timeline: list[dict[str, object]] = [] + for row in history_rows: + timeline.append( + { + "id": str(row.id), + "from_status": str(row.from_status or "").strip() or None, + "to_status": str(row.to_status or "").strip() or None, + "to_status_name": status_name(str(row.to_status or "").strip()) if str(row.to_status or "").strip() else None, + "created_at": row.created_at, + "important_date_at": row.important_date_at, + "comment": row.comment, + } + ) + if not timeline: + timeline.append( + { + "id": "current", + "from_status": None, + "to_status": current_status or None, + "to_status_name": status_name(current_status) if current_status else None, + "created_at": req.updated_at or req.created_at, + "important_date_at": req.important_date_at, + "comment": None, + } + ) + for index, item in enumerate(timeline): + current_at = item.get("created_at") + next_at = timeline[index + 1].get("created_at") if index + 1 < len(timeline) else datetime.now(timezone.utc) + duration_seconds = None + if isinstance(current_at, datetime) and isinstance(next_at, datetime): + delta = next_at - current_at + duration_seconds = max(0, int(delta.total_seconds())) + history_entries.append( + { + "id": item.get("id"), + "from_status": item.get("from_status"), + "to_status": item.get("to_status"), + "to_status_name": item.get("to_status_name"), + "changed_at": current_at.isoformat() if isinstance(current_at, datetime) else None, + "important_date_at": item.get("important_date_at").isoformat() if isinstance(item.get("important_date_at"), datetime) else None, + "comment": item.get("comment"), + "duration_seconds": duration_seconds, + } + ) + + available_statuses: list[dict[str, object]] = [] + for status_row, group_row in sorted( + all_enabled_status_rows, + key=lambda pair: ( + int(pair[1].sort_order or 0) if pair[1] is not None else 999, + int(pair[0].sort_order or 0), + str(pair[0].name or pair[0].code).lower(), + ), + ): + code = str(status_row.code or "").strip() + if not code: + continue + available_statuses.append( + { + "code": code, + "name": str(status_row.name or code), + "kind": str(status_row.kind or "DEFAULT"), + "is_terminal": bool(status_row.is_terminal), + "status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None, + "status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None), + } + ) + return { "request_id": str(req.id), "track_number": req.track_number, "topic_code": req.topic_code, "current_status": current_status or None, + "current_important_date_at": req.important_date_at.isoformat() if req.important_date_at else None, + "available_statuses": available_statuses, + "history": list(reversed(history_entries)), "nodes": nodes, } diff --git a/app/api/admin/router.py b/app/api/admin/router.py index 12f4089..4e1ea7f 100644 --- a/app/api/admin/router.py +++ b/app/api/admin/router.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, chat +from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, chat, test_utils router = APIRouter() router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"]) @@ -13,3 +13,4 @@ router.include_router(notifications.router, prefix="/notifications", tags=["Admi router.include_router(invoices.router, prefix="/invoices", tags=["AdminInvoices"]) router.include_router(chat.router, prefix="/chat", tags=["AdminChat"]) router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"]) +router.include_router(test_utils.router, prefix="/test-utils", tags=["AdminTestUtils"]) diff --git a/app/api/admin/test_utils.py b/app/api/admin/test_utils.py new file mode 100644 index 0000000..8247f0f --- /dev/null +++ b/app/api/admin/test_utils.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.deps import require_role +from app.db.session import get_db +from app.services.test_data_cleanup import CleanupSpec, cleanup_test_data + + +router = APIRouter() + + +class TestDataCleanupPayload(BaseModel): + track_numbers: list[str] = Field(default_factory=list) + phones: list[str] = Field(default_factory=list) + emails: list[str] = Field(default_factory=list) + include_default_e2e_patterns: bool = True + + +def _guard_local_only() -> None: + env = str(settings.APP_ENV or "").strip().lower() + if env in {"prod", "production"}: + raise HTTPException(status_code=404, detail="Not found") + + +@router.post("/cleanup-test-data") +def cleanup_test_data_endpoint( + payload: TestDataCleanupPayload, + db: Session = Depends(get_db), + admin: dict[str, Any] = Depends(require_role("ADMIN")), +): + _guard_local_only() + counts = cleanup_test_data( + db, + CleanupSpec( + track_numbers=payload.track_numbers, + phones=payload.phones, + emails=payload.emails, + include_default_e2e_patterns=bool(payload.include_default_e2e_patterns), + ), + ) + return { + "status": "ok", + "environment": settings.APP_ENV, + "requested_by": str(admin.get("email") or admin.get("sub") or ""), + "deleted": counts, + } diff --git a/app/api/public/chat.py b/app/api/public/chat.py index a4ae402..68d8367 100644 --- a/app/api/public/chat.py +++ b/app/api/public/chat.py @@ -1,17 +1,42 @@ from __future__ import annotations +from uuid import UUID from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from app.core.deps import get_public_session from app.db.session import get_db +from app.models.attachment import Attachment +from app.models.message import Message from app.models.request import Request +from app.models.request_data_requirement import RequestDataRequirement from app.schemas.public import PublicMessageCreate -from app.services.chat_service import create_client_message, list_messages_for_request, serialize_message +from app.services.chat_service import create_client_message, list_messages_for_request, serialize_message, serialize_messages_for_request +from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_lawyer router = APIRouter() +def _attachment_meta_for_public(req: Request, value_text: str | None, db: Session) -> dict | None: + raw = str(value_text or "").strip() + if not raw: + return None + try: + attachment_uuid = UUID(raw) + except ValueError: + return None + attachment = db.get(Attachment, attachment_uuid) + if attachment is None or attachment.request_id != req.id: + return None + return { + "attachment_id": str(attachment.id), + "file_name": attachment.file_name, + "mime_type": attachment.mime_type, + "size_bytes": int(attachment.size_bytes or 0), + "download_url": f"/api/public/uploads/object/{attachment.id}", + } + + def _normalize_phone(raw: str | None) -> str: value = str(raw or "").strip() if not value: @@ -60,7 +85,7 @@ def list_messages_by_track( req = _request_for_track_or_404(db, track_number) _ensure_view_access_or_403(session, req) rows = list_messages_for_request(db, req.id) - return [serialize_message(row) for row in rows] + return serialize_messages_for_request(db, req.id, rows) @router.post("/requests/{track_number}/messages", status_code=201) @@ -74,3 +99,131 @@ def create_message_by_track( _ensure_view_access_or_403(session, req) row = create_client_message(db, request=req, body=payload.body) return serialize_message(row) + + +@router.get("/requests/{track_number}/data-requests/{message_id}") +def get_data_request_by_message( + track_number: str, + message_id: str, + db: Session = Depends(get_db), + session: dict = Depends(get_public_session), +): + req = _request_for_track_or_404(db, track_number) + _ensure_view_access_or_403(session, req) + try: + message_uuid = UUID(str(message_id)) + except ValueError: + raise HTTPException(status_code=400, detail="Некорректный идентификатор сообщения") + message = db.get(Message, message_uuid) + if message is None or message.request_id != req.id: + raise HTTPException(status_code=404, detail="Сообщение запроса не найдено") + rows = ( + db.query(RequestDataRequirement) + .filter( + RequestDataRequirement.request_id == req.id, + RequestDataRequirement.request_message_id == message_uuid, + ) + .order_by(RequestDataRequirement.sort_order.asc(), RequestDataRequirement.created_at.asc(), RequestDataRequirement.id.asc()) + .all() + ) + if not rows: + raise HTTPException(status_code=404, detail="Запрос данных не найден") + return { + "message_id": str(message.id), + "request_id": str(req.id), + "track_number": req.track_number, + "items": [ + { + "id": str(row.id), + "key": row.key, + "label": row.label, + "field_type": str(row.field_type or "text"), + "value_text": row.value_text, + "value_file": _attachment_meta_for_public(req, row.value_text, db) if str(row.field_type or "").lower() == "file" else None, + "is_filled": bool(str(row.value_text or "").strip()), + "sort_order": int(row.sort_order or 0), + } + for row in rows + ], + } + + +@router.post("/requests/{track_number}/data-requests/{message_id}") +def save_data_request_values( + track_number: str, + message_id: str, + payload: dict, + db: Session = Depends(get_db), + session: dict = Depends(get_public_session), +): + req = _request_for_track_or_404(db, track_number) + _ensure_view_access_or_403(session, req) + try: + message_uuid = UUID(str(message_id)) + except ValueError: + raise HTTPException(status_code=400, detail="Некорректный идентификатор сообщения") + message = db.get(Message, message_uuid) + if message is None or message.request_id != req.id: + raise HTTPException(status_code=404, detail="Сообщение запроса не найдено") + + raw_items = (payload or {}).get("items") + if not isinstance(raw_items, list): + raise HTTPException(status_code=400, detail="Ожидается список items") + + rows = ( + db.query(RequestDataRequirement) + .filter( + RequestDataRequirement.request_id == req.id, + RequestDataRequirement.request_message_id == message_uuid, + ) + .all() + ) + by_id = {str(row.id): row for row in rows} + by_key = {str(row.key): row for row in rows} + updated = 0 + for item in raw_items: + if not isinstance(item, dict): + continue + target = None + raw_id = str(item.get("id") or "").strip() + if raw_id: + target = by_id.get(raw_id) + if target is None: + raw_key = str(item.get("key") or "").strip() + if raw_key: + target = by_key.get(raw_key) + if target is None: + continue + if str(target.field_type or "").lower() == "file": + attachment_id_raw = str(item.get("attachment_id") or item.get("value_text") or "").strip() + if attachment_id_raw: + try: + attachment_uuid = UUID(attachment_id_raw) + except ValueError: + raise HTTPException(status_code=400, detail="Некорректный attachment_id для файла") + attachment = db.get(Attachment, attachment_uuid) + if attachment is None or attachment.request_id != req.id: + raise HTTPException(status_code=400, detail="Файл для поля не найден или недоступен") + normalized = str(attachment.id) + else: + normalized = "" + else: + value_text = item.get("value_text") + normalized = str(value_text).strip() if value_text is not None else "" + target.value_text = normalized or None + target.responsible = "Клиент" + db.add(target) + updated += 1 + + if updated: + mark_unread_for_lawyer(req, EVENT_MESSAGE) + req.responsible = "Клиент" + db.add(req) + db.commit() + else: + db.rollback() + + messages = list_messages_for_request(db, req.id) + serialized = serialize_messages_for_request(db, req.id, messages) + current = next((item for item in serialized if str(item.get("id")) == str(message_uuid)), None) + return {"updated": updated, "message": current} diff --git a/app/api/public/featured_staff.py b/app/api/public/featured_staff.py new file mode 100644 index 0000000..24d533e --- /dev/null +++ b/app/api/public/featured_staff.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import and_ +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.admin_user import AdminUser +from app.models.landing_featured_staff import LandingFeaturedStaff +from app.models.topic import Topic + +router = APIRouter() + + +@router.get("") +def list_featured_staff( + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), +): + topic_names = { + str(row.code): str(row.name) + for row in db.query(Topic).filter(Topic.enabled.is_(True)).all() + } + + rows = ( + db.query(LandingFeaturedStaff, AdminUser) + .join(AdminUser, AdminUser.id == LandingFeaturedStaff.admin_user_id) + .filter( + LandingFeaturedStaff.enabled.is_(True), + AdminUser.is_active.is_(True), + AdminUser.role.in_(("ADMIN", "LAWYER")), + AdminUser.avatar_url.is_not(None), + and_(AdminUser.avatar_url != ""), + ) + .order_by( + LandingFeaturedStaff.pinned.desc(), + LandingFeaturedStaff.sort_order.asc(), + LandingFeaturedStaff.created_at.asc(), + ) + .limit(limit) + .all() + ) + + result = [] + for slot, user in rows: + role_code = str(user.role or "").upper() + role_label = "Администратор" if role_code == "ADMIN" else "Юрист" + primary_topic_code = str(user.primary_topic_code or "").strip() or None + result.append( + { + "id": str(slot.id), + "admin_user_id": str(user.id), + "name": user.name, + "role": role_code, + "role_label": role_label, + "avatar_url": user.avatar_url, + "caption": str(slot.caption or "").strip() or None, + "pinned": bool(slot.pinned), + "sort_order": int(slot.sort_order or 0), + "primary_topic_code": primary_topic_code, + "primary_topic_name": topic_names.get(primary_topic_code or "", primary_topic_code), + } + ) + return {"items": result, "total": len(result)} diff --git a/app/api/public/router.py b/app/api/public/router.py index 60f48e0..f038e03 100644 --- a/app/api/public/router.py +++ b/app/api/public/router.py @@ -1,9 +1,10 @@ from fastapi import APIRouter -from app.api.public import requests, otp, quotes, uploads, chat +from app.api.public import requests, otp, quotes, uploads, chat, featured_staff router = APIRouter() router.include_router(requests.router, prefix="/requests", tags=["Public"]) router.include_router(otp.router, prefix="/otp", tags=["Public"]) router.include_router(quotes.router, prefix="/quotes", tags=["Public"]) +router.include_router(featured_staff.router, prefix="/featured-staff", tags=["Public"]) router.include_router(uploads.router, prefix="/uploads", tags=["PublicFiles"]) router.include_router(chat.router, prefix="/chat", tags=["PublicChat"]) diff --git a/app/core/http_hardening.py b/app/core/http_hardening.py index 7f45274..4a82cf0 100644 --- a/app/core/http_hardening.py +++ b/app/core/http_hardening.py @@ -23,6 +23,19 @@ SECURITY_HEADERS = { "Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", } +FRAMEABLE_FILE_SECURITY_HEADERS = { + **SECURITY_HEADERS, + "X-Frame-Options": "SAMEORIGIN", + "Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'", +} + +_FRAMEABLE_PATH_PATTERNS = ( + re.compile(r"^/api/public/uploads/object/"), + re.compile(r"^/api/admin/uploads/object/"), + re.compile(r"^/api/public/requests/[^/]+/invoices/[^/]+/pdf$"), + re.compile(r"^/api/admin/invoices/[^/]+/pdf$"), +) + def _request_id_from_header(raw: str | None) -> str: value = str(raw or "").strip() @@ -33,6 +46,14 @@ def _request_id_from_header(raw: str | None) -> str: return value +def _response_security_headers(request: Request) -> dict[str, str]: + method = str(request.method or "").upper() + path = str(request.url.path or "") + if method in {"GET", "HEAD"} and any(pattern.search(path) for pattern in _FRAMEABLE_PATH_PATTERNS): + return FRAMEABLE_FILE_SECURITY_HEADERS + return SECURITY_HEADERS + + def install_http_hardening(app: FastAPI) -> None: @app.middleware("http") async def _http_hardening_middleware(request: Request, call_next): @@ -42,7 +63,7 @@ def install_http_hardening(app: FastAPI) -> None: response = await call_next(request) - for key, value in SECURITY_HEADERS.items(): + for key, value in _response_security_headers(request).items(): response.headers[key] = value # Backend serves application data and operational endpoints only. # Keep responses non-cacheable to avoid stale or sensitive data reuse. diff --git a/app/data/cleanup_test_artifacts.py b/app/data/cleanup_test_artifacts.py new file mode 100644 index 0000000..656197d --- /dev/null +++ b/app/data/cleanup_test_artifacts.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import argparse + +from app.db.session import SessionLocal +from app.services.test_data_cleanup import CleanupSpec, cleanup_test_data + + +def main() -> None: + parser = argparse.ArgumentParser(description="Cleanup E2E / test artifacts from development DB") + parser.add_argument("--track", action="append", dest="tracks", default=[], help="Track number to cleanup (repeatable)") + parser.add_argument("--phone", action="append", dest="phones", default=[], help="Phone to cleanup (repeatable)") + parser.add_argument("--email", action="append", dest="emails", default=[], help="Email to cleanup (repeatable)") + parser.add_argument( + "--no-default-patterns", + action="store_true", + help="Disable cleanup by default E2E patterns (use only explicit track/phone/email values)", + ) + args = parser.parse_args() + + db = SessionLocal() + try: + result = cleanup_test_data( + db, + CleanupSpec( + track_numbers=args.tracks, + phones=args.phones, + emails=args.emails, + include_default_e2e_patterns=not bool(args.no_default_patterns), + ), + ) + print(result) + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/app/data/manual_test_seed.py b/app/data/manual_test_seed.py new file mode 100644 index 0000000..bc026e3 --- /dev/null +++ b/app/data/manual_test_seed.py @@ -0,0 +1,694 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from decimal import Decimal +from pathlib import Path +from typing import Iterable +from uuid import uuid4 + +from sqlalchemy import or_ + +from app.core.config import settings +from app.core.security import hash_password +from app.db.session import SessionLocal +from app.models.admin_user import AdminUser +from app.models.admin_user_topic import AdminUserTopic +from app.models.attachment import Attachment +from app.models.audit_log import AuditLog +from app.models.client import Client +from app.models.invoice import Invoice +from app.models.message import Message +from app.models.notification import Notification +from app.models.request import Request +from app.models.request_data_requirement import RequestDataRequirement +from app.models.security_audit_log import SecurityAuditLog +from app.models.status import Status +from app.models.status_group import StatusGroup +from app.models.status_history import StatusHistory +from app.models.topic import Topic +from app.models.topic_data_template import TopicDataTemplate +from app.services.admin_bootstrap import ensure_bootstrap_admin_for_login +from app.services.s3_storage import get_s3_storage + + +UTC = timezone.utc +NOW = datetime.now(UTC).replace(second=0, microsecond=0) +REQUEST_PREFIX = "TRK-MAN-" +CLIENT_PHONE_PREFIX = "+79001000" +LAWYER_PHONE_PREFIX = "+79002000" +LAWYER_EMAIL_DOMAIN = "example.com" +LAWYER_PASSWORD = "LawyerManual-123!" +PROJECT_ROOT = Path(__file__).resolve().parents[2] +ACCESS_FILE_PATH = PROJECT_ROOT / "context" / "15_manual_test_access.md" + + +STATUS_GROUPS = [ + ("Новые", 10), + ("В работе", 20), + ("Ожидание", 30), + ("Завершены", 40), +] + +STATUSES = [ + {"code": "NEW", "name": "Новая", "group": "Новые", "sort_order": 10, "is_terminal": False}, + {"code": "ASSIGNED", "name": "Назначена", "group": "Новые", "sort_order": 20, "is_terminal": False}, + {"code": "IN_PROGRESS", "name": "В работе", "group": "В работе", "sort_order": 30, "is_terminal": False}, + {"code": "WAITING_CLIENT", "name": "Ожидание клиента", "group": "Ожидание", "sort_order": 40, "is_terminal": False}, + {"code": "WAITING_DOCUMENTS", "name": "Ожидание документов", "group": "Ожидание", "sort_order": 50, "is_terminal": False}, + {"code": "PAUSED", "name": "Пауза", "group": "Ожидание", "sort_order": 60, "is_terminal": False}, + {"code": "RESOLVED", "name": "Решена", "group": "Завершены", "sort_order": 70, "is_terminal": True}, + {"code": "CLOSED", "name": "Закрыта", "group": "Завершены", "sort_order": 80, "is_terminal": True}, +] + +TOPICS = [ + ("manual-civil", "Гражданские споры", 10), + ("manual-family", "Семейное право", 20), + ("manual-labor", "Трудовые споры", 30), + ("manual-tax", "Налоговые вопросы", 40), + ("manual-contract", "Договорная работа", 50), +] + +LAWYERS = [ + { + "email": f"lawyer1.manual@{LAWYER_EMAIL_DOMAIN}", + "name": "Иван Волков", + "phone": f"{LAWYER_PHONE_PREFIX}01", + "primary_topic_code": "manual-civil", + "extra_topics": ["manual-contract"], + "default_rate": Decimal("5000.00"), + "salary_percent": Decimal("35.00"), + }, + { + "email": f"lawyer2.manual@{LAWYER_EMAIL_DOMAIN}", + "name": "Мария Егорова", + "phone": f"{LAWYER_PHONE_PREFIX}02", + "primary_topic_code": "manual-family", + "extra_topics": ["manual-labor"], + "default_rate": Decimal("4500.00"), + "salary_percent": Decimal("32.00"), + }, + { + "email": f"lawyer3.manual@{LAWYER_EMAIL_DOMAIN}", + "name": "Павел Климов", + "phone": f"{LAWYER_PHONE_PREFIX}03", + "primary_topic_code": "manual-tax", + "extra_topics": ["manual-contract"], + "default_rate": Decimal("6500.00"), + "salary_percent": Decimal("40.00"), + }, + { + "email": f"lawyer4.manual@{LAWYER_EMAIL_DOMAIN}", + "name": "Ольга Смирнова", + "phone": f"{LAWYER_PHONE_PREFIX}04", + "primary_topic_code": "manual-contract", + "extra_topics": ["manual-civil", "manual-tax"], + "default_rate": Decimal("5500.00"), + "salary_percent": Decimal("38.00"), + }, +] + +CLIENTS = [ + ("Ручной Клиент 01", f"{CLIENT_PHONE_PREFIX}01"), + ("Ручной Клиент 02", f"{CLIENT_PHONE_PREFIX}02"), + ("Ручной Клиент 03", f"{CLIENT_PHONE_PREFIX}03"), + ("Ручной Клиент 04", f"{CLIENT_PHONE_PREFIX}04"), + ("Ручной Клиент 05", f"{CLIENT_PHONE_PREFIX}05"), + ("Ручной Клиент 06", f"{CLIENT_PHONE_PREFIX}06"), + ("Ручной Клиент 07", f"{CLIENT_PHONE_PREFIX}07"), + ("Ручной Клиент 08", f"{CLIENT_PHONE_PREFIX}08"), + ("Ручной Клиент 09", f"{CLIENT_PHONE_PREFIX}09"), + ("Ручной Клиент 10", f"{CLIENT_PHONE_PREFIX}10"), +] + + +@dataclass +class SeedRequestSpec: + idx: int + client_phone: str + topic_code: str + status_code: str + assigned_lawyer_email: str | None + created_days_ago: int + last_update_hours_ago: int + important_in_days: int | None + request_cost: Decimal + invoice_amount: Decimal | None + invoice_status: str | None + paid_days_ago: int | None + chat_pairs: int + client_unread: bool + lawyer_unread: bool + extra_note: str + + +REQUEST_SPECS: list[SeedRequestSpec] = [ + SeedRequestSpec(1, CLIENTS[0][1], "manual-civil", "NEW", None, 1, 2, 2, Decimal("15000"), None, None, None, 2, False, False, "Первичная консультация"), + SeedRequestSpec(2, CLIENTS[0][1], "manual-contract", "IN_PROGRESS", LAWYERS[0]["email"], 9, 4, 1, Decimal("80000"), Decimal("30000"), "PAID", 3, 4, True, False, "Договор и претензия"), + SeedRequestSpec(3, CLIENTS[0][1], "manual-tax", "WAITING_CLIENT", LAWYERS[2]["email"], 16, 8, -1, Decimal("120000"), Decimal("50000"), "WAITING_PAYMENT", None, 3, False, True, "Запрошены акты"), + SeedRequestSpec(4, CLIENTS[0][1], "manual-tax", "RESOLVED", LAWYERS[2]["email"], 34, 72, None, Decimal("95000"), Decimal("95000"), "PAID", 10, 2, False, False, "Решено через досудебное"), + SeedRequestSpec(5, CLIENTS[0][1], "manual-family", "CLOSED", LAWYERS[1]["email"], 64, 240, None, Decimal("60000"), Decimal("60000"), "PAID", 28, 2, False, False, "Завершено мировым соглашением"), + SeedRequestSpec(6, CLIENTS[1][1], "manual-family", "IN_PROGRESS", LAWYERS[1]["email"], 4, 3, 0, Decimal("45000"), Decimal("20000"), "WAITING_PAYMENT", None, 4, False, True, "Подготовка иска"), + SeedRequestSpec(7, CLIENTS[1][1], "manual-labor", "WAITING_DOCUMENTS", LAWYERS[1]["email"], 7, 12, 3, Decimal("38000"), None, None, None, 3, True, False, "Ожидаем трудовой договор"), + SeedRequestSpec(8, CLIENTS[1][1], "manual-labor", "NEW", None, 0, 1, 3, Decimal("25000"), None, None, None, 1, False, False, "Новая заявка без назначения"), + SeedRequestSpec(9, CLIENTS[1][1], "manual-contract", "ASSIGNED", LAWYERS[3]["email"], 2, 5, 2, Decimal("70000"), None, None, None, 2, True, False, "Назначена, ожидает старта"), + SeedRequestSpec(10, CLIENTS[2][1], "manual-civil", "IN_PROGRESS", LAWYERS[0]["email"], 11, 6, 4, Decimal("110000"), Decimal("40000"), "PAID", 5, 5, False, True, "Судебное представительство"), + SeedRequestSpec(11, CLIENTS[2][1], "manual-contract", "WAITING_CLIENT", LAWYERS[3]["email"], 14, 20, 1, Decimal("52000"), Decimal("15000"), "WAITING_PAYMENT", None, 3, False, True, "Дозапрос доверенности"), + SeedRequestSpec(12, CLIENTS[2][1], "manual-tax", "PAUSED", LAWYERS[2]["email"], 21, 48, 7, Decimal("135000"), None, None, None, 2, True, False, "Пауза до ответа инспекции"), + SeedRequestSpec(13, CLIENTS[3][1], "manual-civil", "WAITING_DOCUMENTS", LAWYERS[0]["email"], 6, 9, -2, Decimal("50000"), None, None, None, 3, True, False, "Просрочен дедлайн по документам"), + SeedRequestSpec(14, CLIENTS[3][1], "manual-family", "RESOLVED", LAWYERS[1]["email"], 27, 96, None, Decimal("42000"), Decimal("42000"), "PAID", 12, 2, False, False, "Закрытие по соглашению"), + SeedRequestSpec(15, CLIENTS[4][1], "manual-tax", "IN_PROGRESS", LAWYERS[2]["email"], 3, 2, 5, Decimal("210000"), Decimal("90000"), "PAID", 1, 4, False, True, "Налоговая проверка"), + SeedRequestSpec(16, CLIENTS[4][1], "manual-contract", "WAITING_CLIENT", LAWYERS[3]["email"], 5, 10, 2, Decimal("65000"), None, None, None, 3, True, False, "Ждем подписанный акт"), + SeedRequestSpec(17, CLIENTS[5][1], "manual-labor", "NEW", None, 1, 1, 3, Decimal("18000"), None, None, None, 1, False, False, "Консультация по увольнению"), + SeedRequestSpec(18, CLIENTS[6][1], "manual-civil", "IN_PROGRESS", LAWYERS[0]["email"], 8, 7, 2, Decimal("92000"), Decimal("30000"), "WAITING_PAYMENT", None, 4, False, True, "Исполнительное производство"), + SeedRequestSpec(19, CLIENTS[7][1], "manual-family", "ASSIGNED", LAWYERS[1]["email"], 2, 6, 3, Decimal("55000"), None, None, None, 2, True, False, "Раздел имущества"), + SeedRequestSpec(20, CLIENTS[8][1], "manual-tax", "WAITING_DOCUMENTS", LAWYERS[2]["email"], 10, 18, 1, Decimal("175000"), None, None, None, 3, True, False, "Требуются книги учета"), + SeedRequestSpec(21, CLIENTS[9][1], "manual-contract", "NEW", None, 0, 0, 3, Decimal("30000"), None, None, None, 1, False, False, "Проверка оферты"), +] + + +def _money(value: Decimal | None) -> Decimal | None: + if value is None: + return None + return Decimal(value).quantize(Decimal("0.01")) + + +def _track_number(idx: int) -> str: + return f"{REQUEST_PREFIX}{idx:04d}" + + +def _ensure_status_groups(db) -> dict[str, StatusGroup]: + out: dict[str, StatusGroup] = {} + for name, sort_order in STATUS_GROUPS: + row = db.query(StatusGroup).filter(StatusGroup.name == name).first() + if row is None: + row = StatusGroup(name=name, sort_order=sort_order, responsible="Сид ручных тестов") + db.add(row) + db.flush() + else: + row.sort_order = sort_order + row.responsible = "Сид ручных тестов" + out[name] = row + return out + + +def _ensure_statuses(db, groups: dict[str, StatusGroup]) -> dict[str, Status]: + out: dict[str, Status] = {} + for item in STATUSES: + row = db.query(Status).filter(Status.code == item["code"]).first() + if row is None: + row = Status( + code=item["code"], + name=item["name"], + status_group_id=groups[item["group"]].id, + enabled=True, + sort_order=int(item["sort_order"]), + is_terminal=bool(item["is_terminal"]), + responsible="Сид ручных тестов", + ) + db.add(row) + else: + row.name = str(item["name"]) + row.status_group_id = groups[item["group"]].id + row.enabled = True + row.sort_order = int(item["sort_order"]) + row.is_terminal = bool(item["is_terminal"]) + row.responsible = "Сид ручных тестов" + out[item["code"]] = row + return out + + +def _ensure_topics(db) -> dict[str, Topic]: + out: dict[str, Topic] = {} + for code, name, sort_order in TOPICS: + row = db.query(Topic).filter(Topic.code == code).first() + if row is None: + row = Topic(code=code, name=name, enabled=True, sort_order=sort_order, responsible="Сид ручных тестов") + db.add(row) + else: + row.name = name + row.enabled = True + row.sort_order = sort_order + row.responsible = "Сид ручных тестов" + out[code] = row + return out + + +def _ensure_bootstrap_admin(db) -> AdminUser: + admin = ensure_bootstrap_admin_for_login(db, settings.ADMIN_BOOTSTRAP_EMAIL, settings.ADMIN_BOOTSTRAP_PASSWORD) + if admin is None: + row = db.query(AdminUser).filter(AdminUser.email == settings.ADMIN_BOOTSTRAP_EMAIL).first() + if row: + return row + raise RuntimeError("Не удалось обеспечить bootstrap-администратора") + if not str(admin.phone or "").strip(): + admin.phone = "+79009999999" + admin.responsible = "Сид ручных тестов" + db.add(admin) + db.commit() + db.refresh(admin) + return admin + + +def _ensure_lawyers(db) -> dict[str, AdminUser]: + out: dict[str, AdminUser] = {} + for idx, item in enumerate(LAWYERS, start=1): + row = db.query(AdminUser).filter(AdminUser.email == item["email"]).first() + if row is None: + row = AdminUser( + role="LAWYER", + name=item["name"], + email=item["email"], + phone=item["phone"], + password_hash=hash_password(LAWYER_PASSWORD), + primary_topic_code=item["primary_topic_code"], + default_rate=item["default_rate"], + salary_percent=item["salary_percent"], + is_active=True, + responsible="Сид ручных тестов", + ) + db.add(row) + db.flush() + else: + row.role = "LAWYER" + row.name = item["name"] + row.phone = item["phone"] + row.primary_topic_code = item["primary_topic_code"] + row.default_rate = item["default_rate"] + row.salary_percent = item["salary_percent"] + row.is_active = True + row.responsible = "Сид ручных тестов" + if not str(row.password_hash or "").strip(): + row.password_hash = hash_password(LAWYER_PASSWORD) + out[item["email"]] = row + + db.flush() + for item in LAWYERS: + row = out[item["email"]] + db.query(AdminUserTopic).filter(AdminUserTopic.admin_user_id == row.id).delete(synchronize_session=False) + for topic_code in item["extra_topics"]: + db.add( + AdminUserTopic( + admin_user_id=row.id, + topic_code=topic_code, + responsible="Сид ручных тестов", + ) + ) + return out + + +def _ensure_clients(db) -> dict[str, Client]: + out: dict[str, Client] = {} + for full_name, phone in CLIENTS: + row = db.query(Client).filter(Client.phone == phone).first() + if row is None: + row = Client(full_name=full_name, phone=phone, responsible="Сид ручных тестов") + db.add(row) + else: + row.full_name = full_name + row.responsible = "Сид ручных тестов" + out[phone] = row + return out + + +def _seed_request_cleanup(db, request_ids: list, request_tracks: list[str]) -> dict[str, int]: + counts = { + "attachments": 0, + "messages": 0, + "status_history": 0, + "invoices": 0, + "notifications": 0, + "request_data_requirements": 0, + "audit_log": 0, + "security_audit_log": 0, + "requests": 0, + } + if not request_ids: + return counts + + attachment_rows = db.query(Attachment).filter(Attachment.request_id.in_(request_ids)).all() + attachment_ids = [row.id for row in attachment_rows] + try: + storage = get_s3_storage() + storage.ensure_bucket() + for row in attachment_rows: + try: + storage.client.delete_object(Bucket=storage.bucket, Key=row.s3_key) + except Exception: + pass + except Exception: + pass + + counts["notifications"] += db.query(Notification).filter(Notification.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + counts["request_data_requirements"] += ( + db.query(RequestDataRequirement).filter(RequestDataRequirement.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + ) + counts["status_history"] += db.query(StatusHistory).filter(StatusHistory.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + counts["invoices"] += db.query(Invoice).filter(Invoice.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + counts["messages"] += db.query(Message).filter(Message.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + counts["attachments"] += db.query(Attachment).filter(Attachment.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + counts["security_audit_log"] += ( + db.query(SecurityAuditLog).filter(SecurityAuditLog.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + ) + if attachment_ids: + counts["security_audit_log"] += ( + db.query(SecurityAuditLog).filter(SecurityAuditLog.attachment_id.in_(attachment_ids)).delete(synchronize_session=False) or 0 + ) + counts["audit_log"] += ( + db.query(AuditLog) + .filter(AuditLog.entity == "requests", AuditLog.entity_id.in_([str(v) for v in request_ids])) + .delete(synchronize_session=False) + or 0 + ) + if request_tracks: + counts["notifications"] += ( + db.query(Notification) + .filter(Notification.recipient_track_number.in_(request_tracks)) + .delete(synchronize_session=False) + or 0 + ) + counts["requests"] += db.query(Request).filter(Request.id.in_(request_ids)).delete(synchronize_session=False) or 0 + return counts + + +def _rebuild_manual_requests(db, clients_by_phone: dict[str, Client], lawyers_by_email: dict[str, AdminUser]) -> list[Request]: + manual_existing = db.query(Request).filter(Request.track_number.like(f"{REQUEST_PREFIX}%")).all() + cleanup_counts = _seed_request_cleanup( + db, + [row.id for row in manual_existing], + [str(row.track_number or "") for row in manual_existing], + ) + + created_requests: list[Request] = [] + for spec in REQUEST_SPECS: + client = clients_by_phone[spec.client_phone] + lawyer = lawyers_by_email.get(spec.assigned_lawyer_email or "") if spec.assigned_lawyer_email else None + + created_at = NOW - timedelta(days=spec.created_days_ago) + updated_at = NOW - timedelta(hours=spec.last_update_hours_ago) + important_date = None if spec.important_in_days is None else (NOW + timedelta(days=spec.important_in_days)) + status_timeline = _build_status_timeline(spec.status_code, created_at, important_date) + + row = Request( + track_number=_track_number(spec.idx), + client_id=client.id, + client_name=client.full_name, + client_phone=client.phone, + topic_code=spec.topic_code, + status_code=spec.status_code, + important_date_at=important_date, + description=( + f"Тестовая заявка для ручной проверки платформы. {spec.extra_note}. " + f"Клиент: {client.full_name}. Стадия: {spec.status_code}." + ), + extra_fields={ + "номер_договора": f"MAN-{spec.idx:04d}", + "канал": "manual_seed", + "приоритет": "средний" if spec.idx % 3 else "высокий", + }, + assigned_lawyer_id=str(lawyer.id) if lawyer else None, + effective_rate=_money(lawyer.default_rate if lawyer else Decimal("0.00")) if lawyer else None, + request_cost=_money(spec.request_cost), + invoice_amount=_money(spec.invoice_amount), + paid_at=(NOW - timedelta(days=spec.paid_days_ago)) if spec.paid_days_ago is not None else None, + paid_by_admin_id=None, + total_attachments_bytes=0, + client_has_unread_updates=spec.client_unread, + client_unread_event_type="MESSAGE" if spec.client_unread else None, + lawyer_has_unread_updates=spec.lawyer_unread, + lawyer_unread_event_type="MESSAGE" if spec.lawyer_unread else None, + responsible=(lawyer.name if lawyer else "Не назначено"), + created_at=created_at, + updated_at=updated_at, + ) + db.add(row) + db.flush() + created_requests.append(row) + + _seed_status_history(db, row, status_timeline, lawyers_by_email, lawyer) + _seed_chat_messages(db, row, lawyer, spec.chat_pairs) + _seed_notifications(db, row, lawyer, spec.client_unread, spec.lawyer_unread) + if spec.invoice_status and spec.invoice_amount is not None: + _seed_invoice(db, row, client, lawyer, spec.invoice_status, spec.invoice_amount, spec.paid_days_ago) + + print( + "manual seed cleanup:", + ", ".join(f"{k}={v}" for k, v in cleanup_counts.items() if v), + ) + return created_requests + + +def _build_status_timeline(final_status: str, created_at: datetime, important_date: datetime | None): + chain_by_final = { + "NEW": ["NEW"], + "ASSIGNED": ["NEW", "ASSIGNED"], + "IN_PROGRESS": ["NEW", "ASSIGNED", "IN_PROGRESS"], + "WAITING_CLIENT": ["NEW", "ASSIGNED", "IN_PROGRESS", "WAITING_CLIENT"], + "WAITING_DOCUMENTS": ["NEW", "ASSIGNED", "IN_PROGRESS", "WAITING_DOCUMENTS"], + "PAUSED": ["NEW", "ASSIGNED", "IN_PROGRESS", "PAUSED"], + "RESOLVED": ["NEW", "ASSIGNED", "IN_PROGRESS", "RESOLVED"], + "CLOSED": ["NEW", "ASSIGNED", "IN_PROGRESS", "WAITING_CLIENT", "CLOSED"], + } + chain = chain_by_final.get(final_status, ["NEW", final_status]) + steps = [] + base = created_at + for i, status_code in enumerate(chain): + step_at = base + timedelta(hours=max(1, i * 18)) + if i == len(chain) - 1: + imp = important_date + else: + imp = step_at + timedelta(days=3) + steps.append({"to_status": status_code, "at": step_at, "important_date_at": imp}) + return steps + + +def _seed_status_history(db, request: Request, timeline: list[dict], lawyers_by_email: dict[str, AdminUser], assigned_lawyer: AdminUser | None): + actor = assigned_lawyer + for idx, item in enumerate(timeline): + from_status = timeline[idx - 1]["to_status"] if idx > 0 else None + db.add( + StatusHistory( + request_id=request.id, + from_status=from_status, + to_status=item["to_status"], + changed_by_admin_id=actor.id if actor else None, + comment=( + "Создание заявки" if idx == 0 else f"Переход в статус {item['to_status']} (сценарий ручного теста)" + ), + important_date_at=item["important_date_at"], + responsible=(actor.name if actor else "Сид ручных тестов"), + created_at=item["at"], + updated_at=item["at"], + ) + ) + + +def _seed_chat_messages(db, request: Request, lawyer: AdminUser | None, chat_pairs: int): + start_time = (request.created_at or NOW) + timedelta(hours=2) + for i in range(chat_pairs): + client_time = start_time + timedelta(hours=i * 8) + db.add( + Message( + request_id=request.id, + author_type="CLIENT", + author_name=request.client_name, + body=f"Клиент: сообщение #{i + 1} по заявке {request.track_number}", + immutable=False, + responsible="Клиент", + created_at=client_time, + updated_at=client_time, + ) + ) + if lawyer: + lawyer_time = client_time + timedelta(hours=1) + db.add( + Message( + request_id=request.id, + author_type="LAWYER", + author_name=lawyer.name, + body=f"Юрист: ответ #{i + 1} по заявке {request.track_number}", + immutable=False, + responsible=lawyer.name, + created_at=lawyer_time, + updated_at=lawyer_time, + ) + ) + + +def _seed_notifications(db, request: Request, lawyer: AdminUser | None, client_unread: bool, lawyer_unread: bool): + base_payload = { + "request_id": str(request.id), + "track_number": request.track_number, + "status_code": request.status_code, + "topic_code": request.topic_code, + } + if client_unread: + db.add( + Notification( + request_id=request.id, + recipient_type="CLIENT", + recipient_track_number=request.track_number, + event_type="MESSAGE", + title=f"Новое сообщение по заявке {request.track_number}", + body="Есть непрочитанный ответ юриста", + payload=base_payload, + is_read=False, + responsible="Сид ручных тестов", + dedupe_key=f"manual-seed:client:{request.track_number}", + created_at=(request.updated_at or NOW) - timedelta(minutes=5), + updated_at=(request.updated_at or NOW) - timedelta(minutes=5), + ) + ) + if lawyer_unread and lawyer: + db.add( + Notification( + request_id=request.id, + recipient_type="ADMIN_USER", + recipient_admin_user_id=lawyer.id, + event_type="MESSAGE", + title=f"Новое сообщение по заявке {request.track_number}", + body="Есть непрочитанное сообщение клиента", + payload=base_payload, + is_read=False, + responsible="Сид ручных тестов", + dedupe_key=f"manual-seed:lawyer:{lawyer.id}:{request.track_number}", + created_at=(request.updated_at or NOW) - timedelta(minutes=4), + updated_at=(request.updated_at or NOW) - timedelta(minutes=4), + ) + ) + + +def _seed_invoice( + db, + request: Request, + client: Client, + lawyer: AdminUser | None, + status: str, + amount: Decimal, + paid_days_ago: int | None, +): + issued_at = (request.created_at or NOW) + timedelta(days=1) + paid_at = (NOW - timedelta(days=paid_days_ago)) if (status == "PAID" and paid_days_ago is not None) else None + db.add( + Invoice( + request_id=request.id, + client_id=client.id, + invoice_number=f"INV-MAN-{request.track_number[-4:]}", + status=status, + amount=_money(amount) or Decimal("0.00"), + currency="RUB", + payer_display_name=client.full_name, + payer_details_encrypted=None, + issued_by_admin_user_id=lawyer.id if lawyer else None, + issued_by_role=("LAWYER" if lawyer else "ADMIN"), + issued_at=issued_at, + paid_at=paid_at, + responsible=(lawyer.name if lawyer else "Администратор системы"), + created_at=issued_at, + updated_at=(paid_at or issued_at), + ) + ) + + +def _write_access_file( + admin: AdminUser, + lawyers_by_email: dict[str, AdminUser], + clients_by_phone: dict[str, Client], + requests: list[Request], +) -> None: + requests_by_phone: dict[str, list[Request]] = {} + for row in requests: + requests_by_phone.setdefault(str(row.client_phone), []).append(row) + for rows in requests_by_phone.values(): + rows.sort(key=lambda r: str(r.track_number)) + + topic_name_by_code = {code: name for code, name, _ in TOPICS} + lawyer_by_id = {str(row.id): row for row in lawyers_by_email.values()} + + lines: list[str] = [] + lines.append("# Тестовые доступы для ручной проверки") + lines.append("") + lines.append("Сид: `app/data/manual_test_seed.py` (идемпотентный, пересоздает заявки `TRK-MAN-*`).") + lines.append(f"Обновлено: `{datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S %Z')}`") + lines.append("") + lines.append("## Администратор") + lines.append(f"- Email: `{admin.email}`") + lines.append(f"- Пароль: `{settings.ADMIN_BOOTSTRAP_PASSWORD}`") + lines.append(f"- Телефон: `{admin.phone or '-'}`") + lines.append("") + lines.append("## Юристы (4)") + for item in LAWYERS: + row = lawyers_by_email[item["email"]] + lines.append( + f"- {row.name}: `{row.email}` / `{LAWYER_PASSWORD}` | тел.: `{row.phone or '-'}` | " + f"основная тема: `{topic_name_by_code.get(str(row.primary_topic_code or ''), row.primary_topic_code or '-')}`" + ) + lines.append("") + lines.append("## Клиенты (10) и заявки") + lines.append("Для клиента вход через OTP (код выводится в backend-консоль в mock-режиме).") + for full_name, phone in CLIENTS: + client_rows = requests_by_phone.get(phone, []) + lines.append(f"- {full_name} | тел.: `{phone}` | заявок: `{len(client_rows)}`") + for req in client_rows: + lawyer_name = "-" + if req.assigned_lawyer_id and str(req.assigned_lawyer_id) in lawyer_by_id: + lawyer_name = lawyer_by_id[str(req.assigned_lawyer_id)].name + important = req.important_date_at.astimezone(UTC).strftime("%d.%m.%y %H:%M") if req.important_date_at else "-" + lines.append( + f" - `{req.track_number}` | статус: `{req.status_code}` | тема: `{topic_name_by_code.get(str(req.topic_code or ''), req.topic_code or '-')}` " + f"| юрист: `{lawyer_name}` | важная дата: `{important}`" + ) + lines.append("") + lines.append("## Примечания") + lines.append("- В выборке есть неназначенные заявки, активные, ожидающие и терминальные статусы.") + lines.append("- Есть заявки с оплаченными и ожидающими оплату счетами для проверки dashboard/финансов.") + lines.append("- В активных заявках есть переписка и непрочитанные уведомления.") + ACCESS_FILE_PATH.parent.mkdir(parents=True, exist_ok=True) + ACCESS_FILE_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def seed_manual_test_data() -> dict[str, int]: + db = SessionLocal() + try: + _ensure_bootstrap_admin(db) + _ensure_topics(db) + groups = _ensure_status_groups(db) + _ensure_statuses(db, groups) + lawyers_by_email = _ensure_lawyers(db) + clients_by_phone = _ensure_clients(db) + db.flush() + + created_requests = _rebuild_manual_requests(db, clients_by_phone, lawyers_by_email) + db.commit() + + admin = db.query(AdminUser).filter(AdminUser.email == settings.ADMIN_BOOTSTRAP_EMAIL).first() + if admin is None: + raise RuntimeError("Bootstrap admin not found after seed") + + # refresh rows for final access file output + final_requests = ( + db.query(Request) + .filter(Request.track_number.like(f"{REQUEST_PREFIX}%")) + .order_by(Request.track_number.asc()) + .all() + ) + _write_access_file(admin, lawyers_by_email, clients_by_phone, final_requests) + + summary = { + "clients": db.query(Client).filter(Client.phone.like(f"{CLIENT_PHONE_PREFIX}%")).count(), + "lawyers": db.query(AdminUser).filter(AdminUser.email.like("lawyer%.manual@%")).count(), + "requests": len(final_requests), + "messages": db.query(Message).join(Request, Message.request_id == Request.id).filter(Request.track_number.like(f"{REQUEST_PREFIX}%")).count(), + "status_history": db.query(StatusHistory).join(Request, StatusHistory.request_id == Request.id).filter(Request.track_number.like(f"{REQUEST_PREFIX}%")).count(), + "invoices": db.query(Invoice).join(Request, Invoice.request_id == Request.id).filter(Request.track_number.like(f"{REQUEST_PREFIX}%")).count(), + "notifications": db.query(Notification).join(Request, Notification.request_id == Request.id).filter(Request.track_number.like(f"{REQUEST_PREFIX}%")).count(), + } + print("manual seed summary:", summary) + print("manual access file:", str(ACCESS_FILE_PATH)) + return summary + finally: + db.close() + + +if __name__ == "__main__": + seed_manual_test_data() diff --git a/app/models/admin_user.py b/app/models/admin_user.py index 8f6246b..b4e8a2d 100644 --- a/app/models/admin_user.py +++ b/app/models/admin_user.py @@ -8,6 +8,7 @@ class AdminUser(Base, UUIDMixin, TimestampMixin): role: Mapped[str] = mapped_column(String(20), nullable=False) # ADMIN|LAWYER name: Mapped[str] = mapped_column(String(200), nullable=False) email: Mapped[str] = mapped_column(String(200), unique=True, nullable=False) + phone: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True) primary_topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) diff --git a/app/models/landing_featured_staff.py b/app/models/landing_featured_staff.py new file mode 100644 index 0000000..e2ca9f0 --- /dev/null +++ b/app/models/landing_featured_staff.py @@ -0,0 +1,18 @@ +import uuid + +from sqlalchemy import Boolean, Integer, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base +from app.models.common import TimestampMixin, UUIDMixin + + +class LandingFeaturedStaff(Base, UUIDMixin, TimestampMixin): + __tablename__ = "landing_featured_staff" + + admin_user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, unique=True, index=True) + caption: Mapped[str | None] = mapped_column(String(300), nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True) + pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) diff --git a/app/models/request.py b/app/models/request.py index a25a667..7f433e1 100644 --- a/app/models/request.py +++ b/app/models/request.py @@ -15,10 +15,12 @@ class Request(Base, UUIDMixin, TimestampMixin): client_phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True) topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) status_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True, default="NEW") + important_date_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) description: Mapped[str | None] = mapped_column(Text, nullable=True) extra_fields: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False) assigned_lawyer_id: Mapped[str | None] = mapped_column(String(64), nullable=True) effective_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True) + request_cost: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True) invoice_amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True) paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) paid_by_admin_id: Mapped[str | None] = mapped_column(String(64), nullable=True) diff --git a/app/models/request_data_requirement.py b/app/models/request_data_requirement.py index 62fb814..f3912a0 100644 --- a/app/models/request_data_requirement.py +++ b/app/models/request_data_requirement.py @@ -1,6 +1,6 @@ import uuid -from sqlalchemy import String, Boolean, Text, UniqueConstraint +from sqlalchemy import String, Boolean, Text, UniqueConstraint, Integer from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.dialects.postgresql import UUID @@ -19,9 +19,14 @@ class RequestDataRequirement(Base, UUIDMixin, TimestampMixin): ) request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True) + request_message_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True) topic_template_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True) key: Mapped[str] = mapped_column(String(80), nullable=False, index=True) label: Mapped[str] = mapped_column(String(200), nullable=False) + field_type: Mapped[str] = mapped_column(String(20), nullable=False, default="text") + document_name: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True) description: Mapped[str | None] = mapped_column(Text, nullable=True) + value_text: Mapped[str | None] = mapped_column(String(500), nullable=True) required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) created_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) diff --git a/app/models/request_data_template.py b/app/models/request_data_template.py new file mode 100644 index 0000000..d859c55 --- /dev/null +++ b/app/models/request_data_template.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base +from app.models.common import TimestampMixin, UUIDMixin + + +class RequestDataTemplate(Base, UUIDMixin, TimestampMixin): + __tablename__ = "request_data_templates" + __table_args__ = ( + UniqueConstraint("topic_code", "name", name="uq_request_data_templates_topic_name"), + ) + + topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + created_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True) diff --git a/app/models/request_data_template_item.py b/app/models/request_data_template_item.py new file mode 100644 index 0000000..0050f92 --- /dev/null +++ b/app/models/request_data_template_item.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import Integer, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base +from app.models.common import TimestampMixin, UUIDMixin + + +class RequestDataTemplateItem(Base, UUIDMixin, TimestampMixin): + __tablename__ = "request_data_template_items" + __table_args__ = ( + UniqueConstraint("request_data_template_id", "key", name="uq_request_data_template_items_template_key"), + ) + + request_data_template_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True) + topic_data_template_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True) + key: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + label: Mapped[str] = mapped_column(String(200), nullable=False) + value_type: Mapped[str] = mapped_column(String(20), nullable=False, default="string") + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) diff --git a/app/models/status_history.py b/app/models/status_history.py index 94af80c..8e2fca5 100644 --- a/app/models/status_history.py +++ b/app/models/status_history.py @@ -1,5 +1,6 @@ import uuid -from sqlalchemy import String +from datetime import datetime +from sqlalchemy import DateTime, String from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.dialects.postgresql import UUID from app.db.session import Base @@ -12,3 +13,4 @@ class StatusHistory(Base, UUIDMixin, TimestampMixin): to_status: Mapped[str] = mapped_column(String(50), nullable=False) changed_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) comment: Mapped[str | None] = mapped_column(String(400), nullable=True) + important_date_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) diff --git a/app/models/topic_data_template.py b/app/models/topic_data_template.py index e8cebf2..8388a59 100644 --- a/app/models/topic_data_template.py +++ b/app/models/topic_data_template.py @@ -18,6 +18,8 @@ class TopicDataTemplate(Base, UUIDMixin, TimestampMixin): topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) key: Mapped[str] = mapped_column(String(80), nullable=False, index=True) label: Mapped[str] = mapped_column(String(200), nullable=False) + value_type: Mapped[str] = mapped_column(String(20), nullable=False, default="text") + document_name: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True) description: Mapped[str | None] = mapped_column(Text, nullable=True) required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) diff --git a/app/schemas/admin.py b/app/schemas/admin.py index d2eaf8a..a6837f9 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -63,14 +63,17 @@ class FormFieldUpsert(BaseModel): class RequestAdminCreate(BaseModel): track_number: Optional[str] = None + client_id: Optional[str] = None client_name: str client_phone: str topic_code: Optional[str] = None status_code: str = "NEW" + important_date_at: Optional[datetime] = None description: Optional[str] = None extra_fields: dict = Field(default_factory=dict) assigned_lawyer_id: Optional[str] = None effective_rate: Optional[float] = None + request_cost: Optional[float] = None invoice_amount: Optional[float] = None paid_at: Optional[datetime] = None paid_by_admin_id: Optional[str] = None @@ -79,14 +82,17 @@ class RequestAdminCreate(BaseModel): class RequestAdminPatch(BaseModel): track_number: Optional[str] = None + client_id: Optional[str] = None client_name: Optional[str] = None client_phone: Optional[str] = None topic_code: Optional[str] = None status_code: Optional[str] = None + important_date_at: Optional[datetime] = None description: Optional[str] = None extra_fields: Optional[dict] = None assigned_lawyer_id: Optional[str] = None effective_rate: Optional[float] = None + request_cost: Optional[float] = None invoice_amount: Optional[float] = None paid_at: Optional[datetime] = None paid_by_admin_id: Optional[str] = None @@ -97,6 +103,12 @@ class RequestReassign(BaseModel): lawyer_id: str +class RequestStatusChange(BaseModel): + status_code: str + important_date_at: Optional[datetime] = None + comment: Optional[str] = None + + class RequestDataRequirementCreate(BaseModel): key: str label: str diff --git a/app/services/chat_service.py b/app/services/chat_service.py index b8ebd76..974a0e5 100644 --- a/app/services/chat_service.py +++ b/app/services/chat_service.py @@ -6,7 +6,9 @@ from fastapi import HTTPException from sqlalchemy.orm import Session from app.models.message import Message +from app.models.attachment import Attachment from app.models.request import Request +from app.models.request_data_requirement import RequestDataRequirement from app.services.notifications import EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, notify_request_event from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_client, mark_unread_for_lawyer @@ -32,6 +34,113 @@ def serialize_message(row: Message) -> dict[str, Any]: } +def _truncate_request_data_label(label: str, limit: int = 18) -> str: + text = str(label or "").strip() + if len(text) <= limit: + return text + return text[: max(3, limit - 3)].rstrip() + "..." + + +def serialize_messages_for_request(db: Session, request_id: Any, rows: list[Message]) -> list[dict[str, Any]]: + message_ids = [] + for row in rows: + try: + message_ids.append(row.id) + except Exception: + continue + requirements = ( + db.query(RequestDataRequirement) + .filter( + RequestDataRequirement.request_id == request_id, + RequestDataRequirement.request_message_id.in_(message_ids) if message_ids else False, + ) + .order_by( + RequestDataRequirement.request_message_id.asc(), + RequestDataRequirement.sort_order.asc(), + RequestDataRequirement.created_at.asc(), + RequestDataRequirement.id.asc(), + ) + .all() + if message_ids + else [] + ) + by_message_id: dict[str, list[RequestDataRequirement]] = {} + for item in requirements: + mid = str(item.request_message_id or "").strip() + if not mid: + continue + by_message_id.setdefault(mid, []).append(item) + file_attachment_ids = [] + for item in requirements: + if str(item.field_type or "").lower() != "file": + continue + raw = str(item.value_text or "").strip() + if not raw: + continue + try: + file_attachment_ids.append(raw) + except Exception: + continue + attachment_map: dict[str, Attachment] = {} + if file_attachment_ids: + attachment_rows = db.query(Attachment).filter(Attachment.id.in_(file_attachment_ids)).all() + attachment_map = {str(row.id): row for row in attachment_rows} + + out: list[dict[str, Any]] = [] + for row in rows: + payload = serialize_message(row) + linked = by_message_id.get(str(row.id), []) + if linked: + linked_sorted = sorted( + linked, + key=lambda req: ( + 1 if str(req.value_text or "").strip() else 0, + int(req.sort_order or 0), + req.created_at.timestamp() if getattr(req, "created_at", None) else 0, + str(req.id), + ), + ) + items = [] + all_filled = True + for idx, req in enumerate(linked_sorted, start=1): + value_text = str(req.value_text or "").strip() + is_filled = bool(value_text) + if not is_filled: + all_filled = False + items.append( + { + "id": str(req.id), + "index": idx, + "key": req.key, + "label": req.label, + "label_short": _truncate_request_data_label(str(req.label or "")), + "field_type": str(req.field_type or "text"), + "document_name": req.document_name, + "value_text": req.value_text, + "value_file": ( + { + "attachment_id": str(attachment_map[value_text].id), + "file_name": attachment_map[value_text].file_name, + "mime_type": attachment_map[value_text].mime_type, + "size_bytes": int(attachment_map[value_text].size_bytes or 0), + "download_url": None, + } + if str(req.field_type or "").lower() == "file" and value_text in attachment_map + else None + ), + "is_filled": is_filled, + } + ) + payload["message_kind"] = "REQUEST_DATA" + payload["request_data_items"] = items + payload["request_data_all_filled"] = all_filled and bool(items) + payload["body"] = "Запрос" + else: + payload["message_kind"] = "TEXT" + out.append(payload) + return out + + def create_client_message(db: Session, *, request: Request, body: str) -> Message: message_body = str(body or "").strip() if not message_body: diff --git a/app/services/request_status.py b/app/services/request_status.py index 5156578..2750e43 100644 --- a/app/services/request_status.py +++ b/app/services/request_status.py @@ -2,6 +2,7 @@ from __future__ import annotations import uuid from typing import Any +from datetime import datetime from sqlalchemy.orm import Session @@ -42,6 +43,7 @@ def register_status_history( *, admin: dict[str, Any] | None = None, comment: str | None = None, + important_date_at: datetime | None = None, responsible: str = "Администратор системы", ) -> None: db.add( @@ -51,6 +53,7 @@ def register_status_history( to_status=str(to_status or "").strip(), changed_by_admin_id=actor_admin_uuid(admin), comment=comment, + important_date_at=important_date_at, responsible=responsible, ) ) @@ -64,6 +67,7 @@ def apply_status_change_effects( to_status: str, admin: dict[str, Any] | None = None, comment: str | None = None, + important_date_at: datetime | None = None, responsible: str = "Администратор системы", ) -> None: old_code = str(from_status or "").strip() @@ -78,5 +82,6 @@ def apply_status_change_effects( new_code, admin=admin, comment=comment, + important_date_at=important_date_at, responsible=responsible, ) diff --git a/app/services/test_data_cleanup.py b/app/services/test_data_cleanup.py new file mode 100644 index 0000000..57c96b9 --- /dev/null +++ b/app/services/test_data_cleanup.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Iterable +from uuid import UUID + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.models.admin_user import AdminUser +from app.models.admin_user_topic import AdminUserTopic +from app.models.attachment import Attachment +from app.models.audit_log import AuditLog +from app.models.client import Client +from app.models.invoice import Invoice +from app.models.message import Message +from app.models.notification import Notification +from app.models.otp_session import OtpSession +from app.models.request import Request +from app.models.request_data_requirement import RequestDataRequirement +from app.models.request_data_template import RequestDataTemplate +from app.models.request_data_template_item import RequestDataTemplateItem +from app.models.security_audit_log import SecurityAuditLog +from app.models.status_history import StatusHistory +from app.models.topic import Topic +from app.models.topic_data_template import TopicDataTemplate +from app.services.s3_storage import get_s3_storage + + +@dataclass +class CleanupSpec: + track_numbers: list[str] = field(default_factory=list) + phones: list[str] = field(default_factory=list) + emails: list[str] = field(default_factory=list) + include_default_e2e_patterns: bool = True + + +def _normalize_list(values: Iterable[object] | None) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for raw in values or []: + text = str(raw or "").strip() + if not text or text in seen: + continue + seen.add(text) + out.append(text) + return out + + +def _safe_delete_s3_objects(rows: list[Attachment]) -> int: + if not rows: + return 0 + deleted = 0 + try: + storage = get_s3_storage() + for row in rows: + key = str(row.s3_key or "").strip() + if not key: + continue + try: + storage.ensure_bucket() + storage.client.delete_object(Bucket=storage.bucket, Key=key) + deleted += 1 + except Exception: + continue + except Exception: + return 0 + return deleted + + +def cleanup_test_data(db: Session, spec: CleanupSpec | None = None) -> dict[str, int]: + payload = spec or CleanupSpec() + track_numbers = _normalize_list(payload.track_numbers) + phones = _normalize_list(payload.phones) + emails = [item.lower() for item in _normalize_list(payload.emails)] + + request_query = db.query(Request) + request_filters = [] + if track_numbers: + request_filters.append(Request.track_number.in_(track_numbers)) + if phones: + request_filters.append(Request.client_phone.in_(phones)) + if payload.include_default_e2e_patterns: + request_filters.extend( + [ + Request.client_name.ilike("Клиент E2E %"), + Request.description.ilike("%e2e%"), + Request.description.ilike("%E2E%"), + ] + ) + request_rows = request_query.filter(or_(*request_filters)).all() if request_filters else [] + request_ids = [row.id for row in request_rows] + request_tracks = [str(row.track_number or "") for row in request_rows] + client_ids_from_requests = [row.client_id for row in request_rows if row.client_id] + + attachment_rows = db.query(Attachment).filter(Attachment.request_id.in_(request_ids)).all() if request_ids else [] + attachment_ids = [row.id for row in attachment_rows] + s3_deleted = _safe_delete_s3_objects(attachment_rows) + + deleted_counts: dict[str, int] = { + "requests": 0, + "messages": 0, + "attachments": 0, + "status_history": 0, + "invoices": 0, + "notifications": 0, + "request_data_requirements": 0, + "security_audit_log": 0, + "audit_log": 0, + "otp_sessions": 0, + "clients": 0, + "admin_users": 0, + "admin_user_topics": 0, + "topics": 0, + "topic_data_templates": 0, + "request_data_templates": 0, + "request_data_template_items": 0, + "s3_objects": s3_deleted, + } + + if request_ids: + deleted_counts["notifications"] += ( + db.query(Notification).filter(Notification.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + ) + deleted_counts["request_data_requirements"] += ( + db.query(RequestDataRequirement).filter(RequestDataRequirement.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + ) + deleted_counts["status_history"] += ( + db.query(StatusHistory).filter(StatusHistory.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + ) + deleted_counts["invoices"] += ( + db.query(Invoice).filter(Invoice.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + ) + deleted_counts["messages"] += ( + db.query(Message).filter(Message.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + ) + deleted_counts["attachments"] += ( + db.query(Attachment).filter(Attachment.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + ) + deleted_counts["security_audit_log"] += ( + db.query(SecurityAuditLog).filter(SecurityAuditLog.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + ) + if attachment_ids: + deleted_counts["security_audit_log"] += ( + db.query(SecurityAuditLog).filter(SecurityAuditLog.attachment_id.in_(attachment_ids)).delete(synchronize_session=False) or 0 + ) + request_id_strs = {str(item) for item in request_ids} + deleted_counts["audit_log"] += ( + db.query(AuditLog) + .filter(AuditLog.entity == "requests", AuditLog.entity_id.in_(list(request_id_strs))) + .delete(synchronize_session=False) + or 0 + ) + deleted_counts["requests"] += ( + db.query(Request).filter(Request.id.in_(request_ids)).delete(synchronize_session=False) or 0 + ) + + otp_filters = [] + if phones: + otp_filters.append(OtpSession.phone.in_(phones)) + if request_tracks: + otp_filters.append(OtpSession.track_number.in_(request_tracks)) + if otp_filters: + deleted_counts["otp_sessions"] += db.query(OtpSession).filter(or_(*otp_filters)).delete(synchronize_session=False) or 0 + + client_filters = [] + if client_ids_from_requests: + client_filters.append(Client.id.in_(client_ids_from_requests)) + if phones: + client_filters.append(Client.phone.in_(phones)) + if payload.include_default_e2e_patterns: + client_filters.append(Client.full_name.ilike("Клиент E2E %")) + if client_filters: + deleted_counts["clients"] += db.query(Client).filter(or_(*client_filters)).delete(synchronize_session=False) or 0 + + user_rows: list[AdminUser] = [] + user_filters = [] + if emails: + user_filters.append(AdminUser.email.in_(emails)) + if payload.include_default_e2e_patterns: + user_filters.extend([AdminUser.email.ilike("ui-lawyer-%@example.com"), AdminUser.name.ilike("Юрист UI %")]) + if user_filters: + user_rows = db.query(AdminUser).filter(or_(*user_filters)).all() + user_ids = [row.id for row in user_rows] + if user_ids: + deleted_counts["admin_user_topics"] += ( + db.query(AdminUserTopic).filter(AdminUserTopic.admin_user_id.in_(user_ids)).delete(synchronize_session=False) or 0 + ) + deleted_counts["notifications"] += ( + db.query(Notification).filter(Notification.recipient_admin_user_id.in_(user_ids)).delete(synchronize_session=False) or 0 + ) + deleted_counts["audit_log"] += ( + db.query(AuditLog).filter(AuditLog.actor_admin_id.in_(user_ids)).delete(synchronize_session=False) or 0 + ) + deleted_counts["admin_users"] += ( + db.query(AdminUser).filter(AdminUser.id.in_(user_ids)).delete(synchronize_session=False) or 0 + ) + + topic_rows: list[Topic] = [] + topic_filters = [] + if payload.include_default_e2e_patterns: + topic_filters.append(Topic.name.ilike("Тема UI %")) + if topic_filters: + topic_rows = db.query(Topic).filter(or_(*topic_filters)).all() + topic_codes = [str(row.code or "").strip() for row in topic_rows if str(row.code or "").strip()] + if topic_codes: + deleted_counts["topic_data_templates"] += ( + db.query(TopicDataTemplate).filter(TopicDataTemplate.topic_code.in_(topic_codes)).delete(synchronize_session=False) or 0 + ) + template_rows = db.query(RequestDataTemplate).filter(RequestDataTemplate.topic_code.in_(topic_codes)).all() + template_ids = [row.id for row in template_rows] + if template_ids: + deleted_counts["request_data_template_items"] += ( + db.query(RequestDataTemplateItem) + .filter(RequestDataTemplateItem.request_data_template_id.in_(template_ids)) + .delete(synchronize_session=False) + or 0 + ) + deleted_counts["request_data_templates"] += ( + db.query(RequestDataTemplate).filter(RequestDataTemplate.id.in_(template_ids)).delete(synchronize_session=False) or 0 + ) + deleted_counts["topics"] += db.query(Topic).filter(Topic.code.in_(topic_codes)).delete(synchronize_session=False) or 0 + + if payload.include_default_e2e_patterns: + deleted_counts["topic_data_templates"] += ( + db.query(TopicDataTemplate) + .filter(or_(TopicDataTemplate.label.ilike("%E2E%"), TopicDataTemplate.label.ilike("%e2e%"))) + .delete(synchronize_session=False) + or 0 + ) + template_rows = ( + db.query(RequestDataTemplate) + .filter(or_(RequestDataTemplate.name.ilike("%E2E%"), RequestDataTemplate.name.ilike("%e2e%"))) + .all() + ) + template_ids = [row.id for row in template_rows] + if template_ids: + deleted_counts["request_data_template_items"] += ( + db.query(RequestDataTemplateItem) + .filter(RequestDataTemplateItem.request_data_template_id.in_(template_ids)) + .delete(synchronize_session=False) + or 0 + ) + deleted_counts["request_data_templates"] += ( + db.query(RequestDataTemplate).filter(RequestDataTemplate.id.in_(template_ids)).delete(synchronize_session=False) or 0 + ) + + db.commit() + return deleted_counts diff --git a/app/services/universal_query.py b/app/services/universal_query.py index 423b703..1123774 100644 --- a/app/services/universal_query.py +++ b/app/services/universal_query.py @@ -1,31 +1,146 @@ import uuid +from datetime import date, datetime, timezone +from datetime import timedelta +from decimal import Decimal, InvalidOperation from fastapi import HTTPException -from sqlalchemy.orm import Query from sqlalchemy import asc, desc +from sqlalchemy.orm import Query from app.schemas.universal import UniversalQuery +def _bad_filter_value(column_key: str, kind: str) -> HTTPException: + return HTTPException(status_code=400, detail=f'Некорректное значение фильтра для поля "{column_key}" ({kind})') + + +def _coerce_bool_filter_value(column_key: str, value): + if isinstance(value, bool): + return value + text = str(value or "").strip().lower() + if text in {"1", "true", "yes", "y", "да"}: + return True + if text in {"0", "false", "no", "n", "нет"}: + return False + raise _bad_filter_value(column_key, "boolean") + + +def _coerce_number_filter_value(column_key: str, value, python_type): + if value is None: + return None + if python_type in {int, float} and isinstance(value, (int, float)): + return python_type(value) + if python_type is Decimal and isinstance(value, Decimal): + return value + text = str(value).strip() + if not text: + raise _bad_filter_value(column_key, "number") + normalized = text.replace(",", ".") + try: + if python_type is int: + return int(normalized) + if python_type is float: + return float(normalized) + if python_type is Decimal: + return Decimal(normalized) + return python_type(normalized) + except (ValueError, TypeError, InvalidOperation): + raise _bad_filter_value(column_key, "number") + + +def _coerce_date_filter_value(column_key: str, value): + if isinstance(value, date) and not isinstance(value, datetime): + return value + text = str(value or "").strip() + if not text: + raise _bad_filter_value(column_key, "date") + try: + # Accept either YYYY-MM-DD or full ISO datetime and take its date part. + if "T" in text or " " in text: + return datetime.fromisoformat(text.replace("Z", "+00:00")).date() + return date.fromisoformat(text) + except ValueError: + raise _bad_filter_value(column_key, "date") + + +def _coerce_datetime_filter_value(column_key: str, value): + if isinstance(value, datetime): + parsed = value + else: + text = str(value or "").strip() + if not text: + raise _bad_filter_value(column_key, "datetime") + try: + if "T" not in text and " " not in text and len(text) == 10: + # Date-only filter value for timestamp columns -> start of the day. + parsed = datetime.combine(date.fromisoformat(text), datetime.min.time()) + else: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + raise _bad_filter_value(column_key, "datetime") + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + def _coerce_filter_value(column, value): try: python_type = column.property.columns[0].type.python_type except Exception: return value - if python_type is uuid.UUID and isinstance(value, str): + if python_type is uuid.UUID: + if isinstance(value, uuid.UUID): + return value try: - return uuid.UUID(value) + return uuid.UUID(str(value or "").strip()) except ValueError: raise HTTPException(status_code=400, detail=f'Некорректный UUID в фильтре поля "{column.key}"') + if python_type is bool: + return _coerce_bool_filter_value(column.key, value) + if python_type in {int, float, Decimal}: + return _coerce_number_filter_value(column.key, value, python_type) + if python_type is date: + return _coerce_date_filter_value(column.key, value) + if python_type is datetime: + return _coerce_datetime_filter_value(column.key, value) return value +def _column_python_type(column): + try: + return column.property.columns[0].type.python_type + except Exception: + return None + + +def _is_date_only_filter_literal(raw_value) -> bool: + if isinstance(raw_value, date) and not isinstance(raw_value, datetime): + return True + if not isinstance(raw_value, str): + return False + text = raw_value.strip() + if not text or "T" in text or " " in text: + return False + try: + date.fromisoformat(text) + return True + except ValueError: + return False + + def apply_universal_query(q: Query, model, uq: UniversalQuery) -> Query: for f in uq.filters: col = getattr(model, f.field, None) if col is None: continue + col_python_type = _column_python_type(col) value = _coerce_filter_value(col, f.value) + if col_python_type is datetime and f.op in {"=", "!="} and _is_date_only_filter_literal(f.value): + day_start = value + day_end = day_start + timedelta(days=1) + day_expr = (col >= day_start) & (col < day_end) + q = q.filter(day_expr if f.op == "=" else ~day_expr) + continue if f.op == "=": q = q.filter(col == value) elif f.op == "!=": diff --git a/app/web/admin.css b/app/web/admin.css index 7c2717b..2b4892f 100644 --- a/app/web/admin.css +++ b/app/web/admin.css @@ -122,6 +122,8 @@ .main { padding: 1.2rem; + min-width: 0; + overflow-x: hidden; } .topbar { @@ -156,6 +158,65 @@ letter-spacing: 0.06em; } + .topbar-actions { + display: inline-flex; + align-items: center; + gap: 0.45rem; + flex-shrink: 0; + } + + .topbar-alert-btn { + width: 38px; + height: 38px; + border-radius: 50%; + position: relative; + color: #cbd9ee; + } + + .topbar-alert-btn.has-alert.alert-success { + border-color: rgba(62, 214, 146, 0.52); + background: rgba(62, 214, 146, 0.14); + color: #bff7de; + box-shadow: 0 0 0 3px rgba(62, 214, 146, 0.12); + } + + .topbar-alert-btn.has-alert.alert-danger { + border-color: rgba(255, 98, 98, 0.58); + background: rgba(255, 98, 98, 0.14); + color: #ffd7d7; + box-shadow: 0 0 0 3px rgba(255, 98, 98, 0.12); + } + + .topbar-alert-dot { + position: absolute; + right: 2px; + top: 2px; + width: 9px; + height: 9px; + border-radius: 50%; + border: 2px solid rgba(8, 16, 24, 0.96); + background: #6f839e; + opacity: 0; + transform: scale(0.85); + transition: opacity 0.15s ease, transform 0.15s ease; + pointer-events: none; + } + + .topbar-alert-btn.has-alert .topbar-alert-dot { + opacity: 1; + transform: scale(1); + } + + .topbar-alert-btn.has-alert.alert-success .topbar-alert-dot { + background: #3ed692; + box-shadow: 0 0 0 3px rgba(62, 214, 146, 0.18); + } + + .topbar-alert-btn.has-alert.alert-danger .topbar-alert-dot { + background: #ff6767; + box-shadow: 0 0 0 3px rgba(255, 103, 103, 0.16); + } + .btn { border: 1px solid transparent; border-radius: 999px; @@ -194,6 +255,7 @@ background: linear-gradient(160deg, rgba(20, 30, 40, 0.93), rgba(14, 22, 30, 0.96)); box-shadow: var(--shadow); padding: 1rem; + min-width: 0; } .section.active { display: block; } @@ -258,24 +320,223 @@ color: #f6dab0; } + .lawyer-dashboard-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; + align-items: stretch; + } + + .lawyer-dashboard-card { + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(255, 255, 255, 0.025); + padding: 0.8rem; + color: inherit; + text-align: left; + cursor: pointer; + display: grid; + grid-template-columns: 120px 1fr; + gap: 0.8rem; + transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease; + } + + .lawyer-dashboard-card:hover, + .lawyer-dashboard-card:focus-visible { + border-color: rgba(212, 168, 106, 0.45); + background: rgba(212, 168, 106, 0.04); + transform: translateY(-1px); + outline: none; + } + + .lawyer-dashboard-left { + display: flex; + flex-direction: column; + gap: 0.35rem; + align-items: center; + text-align: center; + } + + .lawyer-dashboard-avatar { + width: 84px; + height: 84px; + border-radius: 50%; + display: grid; + place-items: center; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.03); + } + + .lawyer-dashboard-name { + color: #eaf2ff; + line-height: 1.25; + font-size: 0.95rem; + margin-top: 0.1rem; + } + + .lawyer-dashboard-topic { + color: var(--muted); + font-size: 0.8rem; + line-height: 1.3; + } + + .lawyer-dashboard-right { + display: grid; + grid-template-columns: 1fr; + gap: 0.38rem; + align-content: start; + } + + .lawyer-metric-pair { + display: flex; + justify-content: space-between; + gap: 0.5rem; + align-items: baseline; + border-bottom: 1px dashed rgba(255, 255, 255, 0.08); + padding-bottom: 0.22rem; + } + + .lawyer-metric-pair span { + color: var(--muted); + font-size: 0.78rem; + } + + .lawyer-metric-pair b { + color: #f6dab0; + font-size: 0.88rem; + text-align: right; + } + + .lawyer-dashboard-modal { + width: min(1180px, 100%); + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .lawyer-dashboard-modal .modal-head { + flex: 0 0 auto; + position: sticky; + top: 0; + z-index: 2; + padding-bottom: 0.45rem; + background: linear-gradient(160deg, rgba(21, 31, 42, 0.96), rgba(14, 21, 28, 0.98)); + } + + .lawyer-dashboard-modal-summary { + display: grid; + grid-template-columns: 120px 1fr; + gap: 0.85rem; + align-items: center; + margin-bottom: 0.65rem; + flex: 0 0 auto; + } + + .lawyer-dashboard-modal-avatar { + border: none; + border-radius: 0; + min-height: 110px; + background: transparent; + display: grid; + place-items: center; + align-self: center; + } + + .lawyer-dashboard-modal-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.4rem 0.75rem; + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.65rem 0.75rem; + background: rgba(255, 255, 255, 0.02); + } + + .lawyer-dashboard-modal-scroll { + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.015); + padding: 0.55rem; + overflow: hidden; + min-height: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 0.45rem; + } + + .lawyer-dashboard-modal-table-area { + min-height: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + } + + .lawyer-dashboard-modal-table-area .table-wrap { + min-height: 220px; + max-height: min(42vh, 420px); + overflow: auto; + border: none; + border-radius: 0; + background: transparent; + } + + .lawyer-dashboard-modal-table-area thead th { + position: sticky; + top: 0; + z-index: 2; + background: linear-gradient(160deg, rgba(24, 36, 48, 0.98), rgba(16, 24, 32, 0.98)); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04); + } + + .lawyer-dashboard-modal-footer { + margin-top: 0.55rem; + flex: 0 0 auto; + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + justify-content: flex-end; + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding-top: 0.55rem; + background: linear-gradient(180deg, rgba(13, 20, 27, 0), rgba(13, 20, 27, 0.42)); + } + + .lawyer-dashboard-total-chip { + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.32rem 0.65rem; + background: rgba(255, 255, 255, 0.03); + color: var(--muted); + font-size: 0.82rem; + } + + .lawyer-dashboard-total-chip b { + color: #f6dab0; + margin-left: 0.25rem; + } + .kanban-wrap { display: flex; flex-direction: column; gap: 0.75rem; + min-width: 0; } .kanban-board { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; gap: 0.75rem; - overflow-x: hidden; + overflow-x: auto; + overflow-y: hidden; + max-width: 100%; padding-bottom: 0.25rem; align-items: stretch; - align-content: flex-start; + align-content: stretch; } .kanban-column { - flex: 1 1 300px; + flex: 0 0 320px; min-width: 260px; border: 1px solid var(--line); border-radius: 14px; @@ -535,7 +796,7 @@ .filter-chip { display: inline-flex; align-items: center; - gap: 0.35rem; + gap: 0; padding: 0.25rem 0.5rem; border: 1px solid rgba(212, 168, 106, 0.3); border-radius: 999px; @@ -544,12 +805,15 @@ font-size: 0.76rem; line-height: 1.2; cursor: pointer; + transition: padding-right 0.18s ease, border-color 0.18s ease, background 0.18s ease; } .filter-chip button { border: none; - width: 18px; + width: 0; height: 18px; + min-width: 0; + margin-left: 0; border-radius: 50%; background: rgba(255, 255, 255, 0.1); color: #fce3bd; @@ -557,6 +821,35 @@ font-size: 0.82rem; line-height: 1; padding: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; + transform: scale(0.85); + transition: + width 0.18s ease, + margin-left 0.18s ease, + opacity 0.14s ease, + transform 0.18s ease, + background 0.18s ease; + } + + .filter-chip:hover, + .filter-chip:focus-within { + padding-right: 0.35rem; + border-color: rgba(212, 168, 106, 0.44); + } + + .filter-chip:hover button, + .filter-chip:focus-within button { + width: 18px; + margin-left: 0.35rem; + opacity: 1; + pointer-events: auto; + transform: scale(1); + } + + .filter-chip button:hover { + background: rgba(255, 255, 255, 0.18); } .chip-placeholder { @@ -606,6 +899,15 @@ min-height: 38px; } + input:focus, + textarea:focus, + select:focus { + outline: none; + border-color: rgba(120, 163, 235, 0.72); + box-shadow: 0 0 0 3px rgba(89, 133, 210, 0.18); + background: rgba(255, 255, 255, 0.045); + } + textarea { min-height: 108px; resize: vertical; @@ -624,6 +926,30 @@ min-width: 840px; } + .request-track-link { + border: none; + background: transparent; + padding: 0; + margin: 0; + cursor: pointer; + color: inherit; + font: inherit; + text-align: left; + } + + .request-track-link code { + color: #f5dbb5; + font-weight: 700; + text-decoration: underline; + text-decoration-color: rgba(245, 219, 181, 0.35); + text-underline-offset: 2px; + } + + .request-track-link:hover code, + .request-track-link:focus-visible code { + text-decoration-color: rgba(245, 219, 181, 0.9); + } + th, td { padding: 0.63rem 0.65rem; border-bottom: 1px solid var(--line); @@ -779,29 +1105,36 @@ color: #ffd9d9; } - .icon-btn::after { - content: attr(data-tooltip); - position: absolute; - left: 50%; - bottom: calc(100% + 7px); - transform: translate(-50%, 2px); + .icon-btn::after, + .has-tooltip::after { + content: none; + display: none; + } + + .global-tooltip-layer { + position: fixed; + z-index: 2000; + transform: translate(-50%, -100%); background: #081018; border: 1px solid var(--line); color: #dce6f5; font-size: 0.72rem; + line-height: 1.3; + border-radius: 8px; + padding: 0.28rem 0.45rem; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.32); white-space: nowrap; - border-radius: 7px; - padding: 0.24rem 0.4rem; + overflow: hidden; + text-overflow: ellipsis; opacity: 0; pointer-events: none; - transition: opacity 0.16s ease, transform 0.16s ease; - z-index: 3; + visibility: hidden; + transition: opacity 0.12s ease; } - .icon-btn:hover::after, - .icon-btn:focus-visible::after { + .global-tooltip-layer.open { opacity: 1; - transform: translate(-50%, 0); + visibility: visible; } .pager { @@ -1036,12 +1369,54 @@ gap: 0.75rem; } + .request-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.45rem; + } + + .request-card-head h3 { + margin: 0; + } + + .request-card-head-actions { + display: inline-flex; + align-items: center; + gap: 0.35rem; + } + + .request-card-finance-btn { + width: 34px; + height: 34px; + border-radius: 10px; + font-weight: 800; + font-size: 0.95rem; + color: #f2d39d; + border-color: rgba(228, 184, 104, 0.35); + background: rgba(219, 162, 67, 0.09); + } + + .request-card-data-btn { + width: 34px; + height: 34px; + border-radius: 10px; + color: #b8d6ff; + border-color: rgba(110, 160, 230, 0.28); + background: rgba(92, 139, 214, 0.08); + } + .request-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.55rem; } + .request-card-grid-compact { + align-items: start; + } + .request-field { border: 1px solid var(--line); border-radius: 10px; @@ -1051,6 +1426,13 @@ gap: 0.2rem; } + .request-field-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.4rem; + } + .request-field-label { font-size: 0.72rem; letter-spacing: 0.06em; @@ -1066,6 +1448,43 @@ line-height: 1.35; } + .request-contact-value { + position: relative; + display: inline-flex; + align-items: center; + gap: 0.28rem; + width: fit-content; + max-width: 100%; + cursor: help; + border-bottom: 1px dashed rgba(159, 176, 197, 0.45); + padding-bottom: 1px; + } + + .request-contact-value:hover { + border-bottom-color: rgba(229, 196, 132, 0.5); + color: #eef5ff; + } + + .request-field-span-2 { + grid-column: 1 / -1; + } + + .request-field-description .request-field-value { + white-space: pre-wrap; + display: block; + min-height: calc(1.35em * 4); + max-height: calc(1.35em * 4); + overflow: auto; + padding-right: 0.1rem; + } + + .request-field-expand-btn { + width: 28px; + height: 28px; + border-radius: 8px; + flex: 0 0 auto; + } + .request-description-block, .request-extra-block { margin-top: 0.7rem; @@ -1090,6 +1509,516 @@ overflow: auto; } + .request-finance-modal { + width: min(560px, 100%); + } + + .request-finance-subtitle { + margin: 0.2rem 0 0; + } + + .request-finance-grid { + margin-top: 0.2rem; + } + + .request-data-modal { + width: min(860px, 100%); + } + + .request-data-summary-modal { + width: min(760px, 100%); + max-height: min(78vh, 760px); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + } + + .request-data-summary-list { + min-height: 0; + overflow-y: auto; + display: grid; + gap: 0.45rem; + padding-right: 0.1rem; + } + + .request-data-summary-row { + display: grid; + grid-template-columns: minmax(180px, 0.9fr) minmax(0, 1.6fr); + gap: 0.6rem; + align-items: start; + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.5rem 0.55rem; + background: rgba(255, 255, 255, 0.02); + } + + .request-data-summary-label { + color: #a9bad0; + font-size: 0.82rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .request-data-summary-value { + color: #e5eefb; + word-break: break-word; + white-space: pre-wrap; + } + + .request-data-summary-file .chat-message-file-chip { + margin-top: 0; + } + + .request-status-change-modal { + width: min(920px, 100%); + max-height: 88vh; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .request-status-change-modal form.stack { + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .request-status-change-modal input, + .request-status-change-modal textarea, + .request-status-change-modal select { + color-scheme: dark; + } + + .request-status-change-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, 0.7fr); + gap: 0.6rem; + align-items: end; + } + + .request-status-change-files { + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + padding: 0.55rem; + display: grid; + gap: 0.45rem; + } + + .request-status-change-files-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + } + + .request-status-history-block { + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + padding: 0.55rem; + min-height: 0; + display: flex; + flex-direction: column; + gap: 0.45rem; + } + + .request-status-history-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + } + + .request-status-history-list { + border: 1px solid rgba(120, 144, 175, 0.18); + border-radius: 10px; + background: rgba(255, 255, 255, 0.01); + padding: 0.5rem 0.55rem 0.35rem; + max-height: 260px; + overflow-y: auto; + margin: 0; + list-style: none; + } + + .request-status-history-route-item { + padding-left: 2rem; + padding-bottom: 0.7rem; + } + + .request-status-history-route-item .route-dot { + top: 0.32rem; + width: 1.04rem; + height: 1.04rem; + } + + .request-status-history-route-item::before { + left: 0.52rem; + top: 0.56rem; + bottom: -0.16rem; + } + + .request-status-history-route-item .route-body { + border: 1px solid rgba(120, 144, 175, 0.14); + border-radius: 10px; + padding: 0.45rem 0.5rem; + background: rgba(255, 255, 255, 0.015); + display: grid; + gap: 0.2rem; + } + + .request-status-history-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.45rem; + } + + .request-status-history-chip { + border-radius: 999px; + border: 1px solid rgba(200, 138, 87, 0.35); + background: rgba(200, 138, 87, 0.13); + color: #f4cfaa; + padding: 0.14rem 0.45rem; + font-size: 0.7rem; + font-weight: 700; + white-space: nowrap; + } + + .request-status-history-meta { + display: flex; + justify-content: space-between; + gap: 0.55rem; + color: #a4b5cb; + font-size: 0.78rem; + flex-wrap: wrap; + } + + .request-status-history-list .route-body b { + font-size: 0.88rem; + text-transform: none; + letter-spacing: normal; + } + + .request-status-history-list .route-time { + margin-top: 0; + font-size: 0.74rem; + } + + .request-status-history-comment { + color: #dfe9f8; + font-size: 0.84rem; + white-space: pre-wrap; + word-break: break-word; + margin-top: 0.1rem; + } + + .request-data-modal-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.55rem; + align-items: end; + } + + .request-data-manual-row { + grid-template-columns: minmax(0, 1fr) 180px auto; + } + + .request-data-modal-actions-inline { + display: flex; + align-items: flex-end; + gap: 0.45rem; + flex-wrap: wrap; + padding-bottom: 0.05rem; + } + + .request-data-combobox { + position: relative; + z-index: 5; + } + + .request-data-template-badge { + position: absolute; + top: 50%; + right: 0.45rem; + transform: translateY(-50%); + border-radius: 999px; + padding: 0.14rem 0.5rem; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.02em; + border: 1px solid rgba(120, 149, 186, 0.35); + background: rgba(47, 66, 96, 0.3); + color: #d9e7fb; + pointer-events: none; + max-width: 52%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .request-data-template-badge.create { + color: #cdeecf; + border-color: rgba(95, 179, 106, 0.35); + background: rgba(65, 137, 77, 0.16); + } + + .request-data-template-badge.existing { + color: #d7e8ff; + border-color: rgba(109, 150, 210, 0.35); + background: rgba(70, 110, 170, 0.16); + } + + .request-data-template-badge.readonly { + color: #ffd6a8; + border-color: rgba(214, 153, 83, 0.35); + background: rgba(175, 114, 54, 0.16); + } + + .request-data-suggest-list { + position: absolute; + left: 0; + right: 0; + top: calc(100% + 0.3rem); + z-index: 80; + border: 1px solid rgba(95, 124, 163, 0.55); + background: linear-gradient(180deg, rgba(18, 28, 41, 0.98), rgba(13, 20, 31, 0.98)); + border-radius: 12px; + box-shadow: 0 14px 30px rgba(0, 0, 0, 0.32); + max-height: 240px; + overflow: auto; + padding: 0.25rem; + display: grid; + gap: 0.18rem; + } + + .request-data-suggest-item { + width: 100%; + border: 1px solid transparent; + background: rgba(255, 255, 255, 0.02); + color: #e7effc; + border-radius: 10px; + padding: 0.4rem 0.5rem; + text-align: left; + cursor: pointer; + display: grid; + gap: 0.1rem; + font: inherit; + } + + .request-data-suggest-item small { + color: #9ab0cc; + font-size: 0.72rem; + } + + .request-data-suggest-item:hover { + border-color: rgba(123, 159, 205, 0.45); + background: rgba(75, 111, 163, 0.18); + } + + .request-data-rows { + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.55rem; + background: rgba(255, 255, 255, 0.02); + max-height: 260px; + overflow: auto; + display: grid; + gap: 0.45rem; + } + + .request-data-row { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) 170px 38px; + gap: 0.45rem; + align-items: end; + border-bottom: 1px solid rgba(130, 151, 180, 0.1); + padding-bottom: 0.45rem; + transition: background-color 0.16s ease, border-color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease; + } + + .request-data-row:last-child { + border-bottom: none; + padding-bottom: 0; + } + + .request-data-row.drag-over { + background: rgba(77, 126, 199, 0.1); + border-color: rgba(112, 161, 228, 0.28); + border-radius: 10px; + box-shadow: inset 0 0 0 1px rgba(112, 161, 228, 0.22); + } + + .request-data-row.dragging { + opacity: 0.82; + transform: scale(0.995); + } + + .request-data-row.row-locked { + opacity: 0.96; + } + + .request-data-row-controls { + display: flex; + gap: 0.35rem; + align-items: center; + justify-content: flex-end; + padding-bottom: 0.02rem; + } + + .modal-actions-right { + justify-content: flex-end; + } + + .request-data-row-index-handle, + .request-data-row-action-btn { + width: 38px; + height: 38px; + border-radius: 10px; + } + + .request-data-row-index-handle { + align-self: end; + margin-bottom: 0.02rem; + color: #bfd0e4; + font-weight: 700; + font-size: 0.8rem; + cursor: grab; + } + + .request-data-row-index-handle:active { + cursor: grabbing; + } + + .request-data-row-index-handle span { + display: inline-block; + transform: translateY(0.5px); + } + + .request-data-row.row-locked .request-data-row-index-handle { + cursor: not-allowed; + color: #879bb3; + } + + .request-data-submit-btn { + min-height: 38px; + padding: 0.48rem 0.9rem; + align-self: flex-end; + } + + .request-data-modal .modal-actions { + margin-top: 0.35rem; + } + + .request-description-modal { + width: min(980px, 100%); + height: min(86vh, 900px); + max-height: 86vh; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .request-description-modal-headline { + margin-top: 0.25rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + flex-wrap: wrap; + } + + .request-description-modal-headline .request-finance-subtitle { + margin: 0; + min-width: 0; + } + + .request-description-status-chip { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid rgba(121, 152, 197, 0.38); + background: rgba(76, 110, 165, 0.2); + color: #deebff; + padding: 0.18rem 0.55rem; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.03em; + white-space: nowrap; + } + + .request-description-modal-body { + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + gap: 0.55rem; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + } + + .request-description-modal-main { + min-height: 0; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + padding: 0.65rem; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 0.45rem; + min-height: 0; + } + + .request-description-modal-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + } + + .request-description-modal-text { + padding: 0.5rem 0.2rem 0.55rem 0.1rem; + color: #dfe9f8; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + overflow: auto; + font-size: 0.95rem; + } + + .request-description-modal-meta-wrap { + border: 1px solid rgba(130, 151, 180, 0.14); + border-radius: 12px; + background: rgba(255, 255, 255, 0.015); + padding: 0.55rem 0.65rem; + } + + .request-description-modal-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.45rem 0.8rem; + align-content: start; + } + + .request-description-meta-item { + min-width: 0; + display: grid; + gap: 0.18rem; + align-content: start; + padding: 0.05rem 0; + } + + .request-description-meta-item.align-right { + justify-items: end; + text-align: right; + } + + .request-description-meta-item .request-field-value { + font-size: 0.92rem; + } + .request-status-route { margin-top: 0.85rem; padding-top: 0.8rem; @@ -1339,6 +2268,90 @@ text-align: right; } + .chat-request-data-bubble { + cursor: pointer; + border-color: rgba(221, 168, 87, 0.42); + background: linear-gradient(165deg, rgba(96, 69, 20, 0.92), rgba(58, 41, 14, 0.96)); + } + + .chat-message.outgoing .chat-request-data-bubble { + border-color: rgba(228, 180, 96, 0.52); + background: linear-gradient(165deg, rgba(123, 86, 26, 0.95), rgba(85, 59, 18, 0.98)); + } + + .chat-request-data-bubble.all-filled { + border-color: rgba(87, 177, 113, 0.42); + background: linear-gradient(165deg, rgba(36, 96, 52, 0.92), rgba(24, 66, 35, 0.96)); + } + + .chat-request-data-head { + font-size: 0.84rem; + font-weight: 800; + color: #ffe0a6; + margin-bottom: 0.2rem; + } + + .chat-request-data-bubble.all-filled .chat-request-data-head { + color: #d3f4dc; + margin-bottom: 0.08rem; + } + + .chat-request-data-list { + display: grid; + gap: 0.16rem; + max-height: 11.6rem; + overflow: hidden; + } + + .chat-request-data-item { + display: flex; + align-items: baseline; + gap: 0.3rem; + font-size: 0.8rem; + color: #edf1f8; + } + + .chat-request-data-item.filled .chat-request-data-label { + text-decoration: line-through; + color: #c4cfde; + } + + .chat-request-data-index { + display: inline-flex; + align-items: center; + gap: 0.15rem; + min-width: 2rem; + color: #ffe1af; + font-weight: 700; + } + + .chat-request-data-check { + color: #66db87; + font-weight: 900; + } + + .chat-request-data-collapsed { + margin: 0; + color: #d3f5dc; + font-weight: 800; + font-size: 0.86rem; + } + + .chat-request-data-more { + color: #c2cfde; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.01em; + padding-left: 2.02rem; + } + + @media (max-width: 900px) { + .request-data-summary-row { + grid-template-columns: 1fr; + gap: 0.25rem; + } + } + .chat-message-files { margin-top: 0.35rem; display: flex; @@ -1527,6 +2540,21 @@ border: none; } + .request-preview-text { + width: 100%; + min-height: min(60vh, 520px); + margin: 0; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(10, 16, 24, 0.88); + color: #dbe7f8; + padding: 0.7rem; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font: 500 0.86rem/1.45 "JetBrains Mono", "Fira Code", monospace; + } + .request-preview-image { max-width: 100%; max-height: 72vh; @@ -1555,6 +2583,12 @@ font-size: 0.96rem; } + .workspace-head-icon-glyph { + display: inline-block; + transform: rotate(180deg); + line-height: 1; + } + .overlay { position: fixed; inset: 0; @@ -1633,6 +2667,7 @@ @media (max-width: 1160px) { .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .lawyer-dashboard-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .kanban-column { flex-basis: calc(50% - 0.375rem); min-width: 240px; @@ -1643,6 +2678,12 @@ .request-modal-grid { grid-template-columns: 1fr; } .request-workspace-layout { grid-template-columns: 1fr; } .request-card-grid { grid-template-columns: 1fr; } + .request-description-modal-body { grid-template-rows: minmax(0, 1fr) auto; } + .request-description-modal-meta { grid-template-columns: 1fr; } + .request-description-meta-item.align-right { + justify-items: start; + text-align: left; + } } @media (max-width: 920px) { @@ -1658,6 +2699,20 @@ .filters { grid-template-columns: 1fr; } + .lawyer-dashboard-grid { grid-template-columns: 1fr; } + .lawyer-dashboard-card { + grid-template-columns: 1fr; + } + .lawyer-dashboard-left { + align-items: flex-start; + text-align: left; + } + .lawyer-dashboard-modal-summary { + grid-template-columns: 1fr; + } + .lawyer-dashboard-modal-metrics { + grid-template-columns: 1fr; + } .kanban-column { flex-basis: 100%; min-width: 0; diff --git a/app/web/admin.jsx b/app/web/admin.jsx index f7a6198..95acffe 100644 --- a/app/web/admin.jsx +++ b/app/web/admin.jsx @@ -1,522 +1,69 @@ +import { + DEFAULT_FORM_FIELD_TYPES, + INVOICE_STATUS_LABELS, + LS_TOKEN, + OPERATOR_LABELS, + ROLE_LABELS, + STATUS_LABELS, + STATUS_KIND_LABELS, + TABLE_KEY_ALIASES, + TABLE_MUTATION_CONFIG, + TABLE_SERVER_CONFIG, + TABLE_UNALIASES, + PAGE_SIZE, +} from "./admin/shared/constants.js"; +import { createTableState } from "./admin/shared/state.js"; +import { KanbanBoard } from "./admin/features/kanban/KanbanBoard.jsx"; +import { ConfigSection } from "./admin/features/config/ConfigSection.jsx"; +import { DashboardSection } from "./admin/features/dashboard/DashboardSection.jsx"; +import { InvoicesSection } from "./admin/features/invoices/InvoicesSection.jsx"; +import { RequestsSection } from "./admin/features/requests/RequestsSection.jsx"; +import { QuotesSection } from "./admin/features/quotes/QuotesSection.jsx"; +import { RequestWorkspace } from "./admin/features/requests/RequestWorkspace.jsx"; +import { AvailableTablesSection } from "./admin/features/tables/AvailableTablesSection.jsx"; +import { useAdminApi } from "./admin/hooks/useAdminApi.js"; +import { useAdminCatalogLoaders } from "./admin/hooks/useAdminCatalogLoaders.js"; +import { useKanban } from "./admin/hooks/useKanban.js"; +import { useRequestWorkspace } from "./admin/hooks/useRequestWorkspace.js"; +import { useTableActions } from "./admin/hooks/useTableActions.js"; +import { useTableFilterActions } from "./admin/hooks/useTableFilterActions.js"; +import { useTablesState } from "./admin/hooks/useTablesState.js"; +import { + avatarColor, + boolFilterLabel, + buildUniversalQuery, + canAccessSection, + decodeJwtPayload, + detectAttachmentPreviewKind, + fallbackStatusGroup, + fmtAmount, + fmtBytes, + fmtDateOnly, + fmtKanbanDate, + fmtTimeOnly, + getOperatorsForType, + humanizeKey, + localizeMeta, + localizeRequestDetails, + metaKindToFilterType, + metaKindToRecordType, + normalizeReferenceMeta, + normalizeStringList, + resolveAdminObjectSrc, + resolveAdminRoute, + resolveAvatarSrc, + resolveDeadlineTone, + roleLabel, + sortByName, + statusLabel, + translateApiError, + userInitials, +} from "./admin/shared/utils.js"; + (function () { - const { useCallback, useEffect, useMemo, useRef, useState } = React; - - const LS_TOKEN = "admin_access_token"; - const PAGE_SIZE = 50; - const DEFAULT_FORM_FIELD_TYPES = ["string", "text", "number", "boolean", "date"]; - const ALL_OPERATORS = ["=", "!=", ">", "<", ">=", "<=", "~"]; - const OPERATOR_LABELS = { - "=": "=", - "!=": "!=", - ">": ">", - "<": "<", - ">=": ">=", - "<=": "<=", - "~": "~", - }; - - const ROLE_LABELS = { - ADMIN: "Администратор", - LAWYER: "Юрист", - }; - - const STATUS_LABELS = { - NEW: "Новая", - IN_PROGRESS: "В работе", - WAITING_CLIENT: "Ожидание клиента", - WAITING_COURT: "Ожидание суда", - RESOLVED: "Решена", - CLOSED: "Закрыта", - REJECTED: "Отклонена", - }; - const INVOICE_STATUS_LABELS = { - WAITING_PAYMENT: "Ожидает оплату", - PAID: "Оплачен", - CANCELED: "Отменен", - }; - const STATUS_KIND_LABELS = { - DEFAULT: "Обычный", - INVOICE: "Выставление счета", - PAID: "Оплачено", - }; - - const REQUEST_UPDATE_EVENT_LABELS = { - MESSAGE: "сообщение", - ATTACHMENT: "файл", - STATUS: "статус", - }; - const KANBAN_GROUPS = [ - { key: "NEW", label: "Новые" }, - { key: "IN_PROGRESS", label: "В работе" }, - { key: "WAITING", label: "Ожидание" }, - { key: "DONE", label: "Завершены" }, - ]; - - const TABLE_SERVER_CONFIG = { - requests: { - table: "requests", - endpoint: "/api/admin/crud/requests/query", - sort: [{ field: "created_at", dir: "desc" }], - }, - invoices: { - table: "invoices", - endpoint: "/api/admin/invoices/query", - sort: [{ field: "issued_at", dir: "desc" }], - }, - quotes: { - table: "quotes", - endpoint: "/api/admin/crud/quotes/query", - sort: [{ field: "sort_order", dir: "asc" }], - }, - topics: { - table: "topics", - endpoint: "/api/admin/crud/topics/query", - sort: [{ field: "sort_order", dir: "asc" }], - }, - statuses: { - table: "statuses", - endpoint: "/api/admin/crud/statuses/query", - sort: [{ field: "sort_order", dir: "asc" }], - }, - formFields: { - table: "form_fields", - endpoint: "/api/admin/crud/form_fields/query", - sort: [{ field: "sort_order", dir: "asc" }], - }, - topicRequiredFields: { - table: "topic_required_fields", - endpoint: "/api/admin/crud/topic_required_fields/query", - sort: [{ field: "sort_order", dir: "asc" }], - }, - topicDataTemplates: { - table: "topic_data_templates", - endpoint: "/api/admin/crud/topic_data_templates/query", - sort: [{ field: "sort_order", dir: "asc" }], - }, - statusTransitions: { - table: "topic_status_transitions", - endpoint: "/api/admin/crud/topic_status_transitions/query", - sort: [{ field: "sort_order", dir: "asc" }], - }, - users: { - table: "admin_users", - endpoint: "/api/admin/crud/admin_users/query", - sort: [{ field: "created_at", dir: "desc" }], - }, - userTopics: { - table: "admin_user_topics", - endpoint: "/api/admin/crud/admin_user_topics/query", - sort: [{ field: "created_at", dir: "desc" }], - }, - }; - - const TABLE_MUTATION_CONFIG = Object.fromEntries( - Object.entries(TABLE_SERVER_CONFIG).map(([tableKey, config]) => [ - tableKey, - { - create: "/api/admin/crud/" + config.table, - update: (id) => "/api/admin/crud/" + config.table + "/" + id, - delete: (id) => "/api/admin/crud/" + config.table + "/" + id, - }, - ]) - ); - TABLE_MUTATION_CONFIG.invoices = { - create: "/api/admin/invoices", - update: (id) => "/api/admin/invoices/" + id, - delete: (id) => "/api/admin/invoices/" + id, - }; - const TABLE_KEY_ALIASES = { - form_fields: "formFields", - status_groups: "statusGroups", - topic_required_fields: "topicRequiredFields", - topic_data_templates: "topicDataTemplates", - topic_status_transitions: "statusTransitions", - admin_users: "users", - admin_user_topics: "userTopics", - }; - const TABLE_UNALIASES = Object.fromEntries(Object.entries(TABLE_KEY_ALIASES).map(([table, alias]) => [alias, table])); - const KNOWN_CONFIG_TABLE_KEYS = new Set([ - "quotes", - "topics", - "statuses", - "formFields", - "topicRequiredFields", - "topicDataTemplates", - "statusTransitions", - "users", - "userTopics", - ]); - - function createTableState() { - return { - filters: [], - sort: null, - offset: 0, - total: 0, - showAll: false, - rows: [], - }; - } - - function createRequestModalState() { - return { - loading: false, - requestId: null, - trackNumber: "", - requestData: null, - statusRouteNodes: [], - messages: [], - attachments: [], - messageDraft: "", - selectedFiles: [], - fileUploading: false, - }; - } - - function resolveAdminRoute(search) { - const params = new URLSearchParams(String(search || "")); - const section = String(params.get("section") || "").trim(); - const view = String(params.get("view") || "").trim(); - const requestId = String(params.get("requestId") || "").trim(); - return { section, view, requestId }; - } - - function humanizeKey(value) { - const text = String(value || "") - .replace(/[_-]+/g, " ") - .replace(/\s+/g, " ") - .trim(); - if (!text) return "-"; - return text.charAt(0).toUpperCase() + text.slice(1); - } - - function metaKindToFilterType(kind) { - if (kind === "boolean") return "boolean"; - if (kind === "number") return "number"; - if (kind === "date" || kind === "datetime") return "date"; - return "text"; - } - - function metaKindToRecordType(kind) { - if (kind === "boolean") return "boolean"; - if (kind === "number") return "number"; - if (kind === "json") return "json"; - return "text"; - } - - function decodeJwtPayload(token) { - try { - const payload = token.split(".")[1] || ""; - const base64 = payload.replace(/-/g, "+").replace(/_/g, "/"); - const json = decodeURIComponent( - atob(base64) - .split("") - .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) - .join("") - ); - return JSON.parse(json); - } catch (_) { - return null; - } - } - - function sortByName(items) { - return [...items].sort((a, b) => String(a.name || a.code || "").localeCompare(String(b.name || b.code || ""), "ru")); - } - - function roleLabel(role) { - return ROLE_LABELS[role] || role || "-"; - } - - function statusLabel(code) { - return STATUS_LABELS[code] || code || "-"; - } - - function invoiceStatusLabel(code) { - return INVOICE_STATUS_LABELS[code] || code || "-"; - } - - function statusKindLabel(code) { - return STATUS_KIND_LABELS[code] || code || "-"; - } - - function fallbackStatusGroup(statusCode) { - const code = String(statusCode || "").toUpperCase(); - if (!code) return "NEW"; - if (code.startsWith("NEW")) return "NEW"; - if (code.includes("WAIT") || code.includes("PEND") || code.includes("HOLD")) return "WAITING"; - if (code.includes("CLOSE") || code.includes("RESOLV") || code.includes("REJECT") || code.includes("DONE") || code.includes("PAID")) return "DONE"; - return "IN_PROGRESS"; - } - - function boolLabel(value) { - return value ? "Да" : "Нет"; - } - - function boolFilterLabel(value) { - return value ? "True" : "False"; - } - - function fmtDate(value) { - if (!value) return "-"; - const date = new Date(value); - return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("ru-RU"); - } - - function fmtDateOnly(value) { - if (!value) return "-"; - const date = new Date(value); - return Number.isNaN(date.getTime()) - ? String(value) - : date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" }); - } - - function fmtTimeOnly(value) { - if (!value) return "-"; - const date = new Date(value); - return Number.isNaN(date.getTime()) - ? String(value) - : date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); - } - - function fmtKanbanDate(value) { - if (!value) return "-"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return String(value); - const dd = String(date.getDate()).padStart(2, "0"); - const mm = String(date.getMonth() + 1).padStart(2, "0"); - const yy = String(date.getFullYear()).slice(-2); - const hh = String(date.getHours()).padStart(2, "0"); - const mi = String(date.getMinutes()).padStart(2, "0"); - return dd + ":" + mm + ":" + yy + " " + hh + ":" + mi; - } - - function resolveDeadlineTone(value) { - if (!value) return "ok"; - const time = new Date(value).getTime(); - if (!Number.isFinite(time)) return "ok"; - const delta = time - Date.now(); - const fourDaysMs = 4 * 24 * 60 * 60 * 1000; - const oneDayMs = 24 * 60 * 60 * 1000; - if (delta > fourDaysMs) return "ok"; - if (delta > oneDayMs) return "warn"; - return "danger"; - } - - function fmtAmount(value) { - if (value === null || value === undefined || value === "") return "-"; - const numeric = Number(value); - if (!Number.isFinite(numeric)) return String(value); - return numeric.toLocaleString("ru-RU"); - } - - function fmtBytes(value) { - const size = Number(value || 0); - if (!Number.isFinite(size) || size <= 0) return "0 Б"; - const units = ["Б", "КБ", "МБ", "ГБ"]; - let index = 0; - let normalized = size; - while (normalized >= 1024 && index < units.length - 1) { - normalized /= 1024; - index += 1; - } - return normalized.toLocaleString("ru-RU", { maximumFractionDigits: index === 0 ? 0 : 1 }) + " " + units[index]; - } - - function normalizeStringList(value) { - if (!Array.isArray(value)) return []; - const out = []; - const seen = new Set(); - value.forEach((item) => { - const text = String(item || "").trim(); - if (!text) return; - const key = text.toLowerCase(); - if (seen.has(key)) return; - seen.add(key); - out.push(text); - }); - return out; - } - - function listPreview(value, emptyLabel) { - const items = normalizeStringList(value); - return items.length ? items.join(", ") : emptyLabel; - } - - function normalizeReferenceMeta(raw) { - if (!raw || typeof raw !== "object") return null; - const table = String(raw.table || "").trim(); - const valueField = String(raw.value_field || "id").trim() || "id"; - const labelField = String(raw.label_field || valueField).trim() || valueField; - if (!table) return null; - return { table, value_field: valueField, label_field: labelField }; - } - - function userInitials(name, email) { - const source = String(name || "").trim(); - if (source) { - const parts = source.split(/\s+/).filter(Boolean); - if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); - return source.slice(0, 2).toUpperCase(); - } - const mail = String(email || "").trim(); - return (mail.slice(0, 2) || "U").toUpperCase(); - } - - function avatarColor(seed) { - const palette = ["#6f8fa9", "#568f7d", "#a07a5c", "#7d6ea9", "#8f6f8f", "#7f8c5a"]; - const text = String(seed || ""); - let hash = 0; - for (let i = 0; i < text.length; i += 1) hash = (hash * 31 + text.charCodeAt(i)) >>> 0; - return palette[hash % palette.length]; - } - - function resolveAvatarSrc(avatarUrl, accessToken) { - const raw = String(avatarUrl || "").trim(); - if (!raw) return ""; - if (raw.startsWith("s3://")) { - const key = raw.slice("s3://".length); - if (!key || !accessToken) return ""; - return "/api/admin/uploads/object/" + encodeURIComponent(key) + "?token=" + encodeURIComponent(accessToken); - } - return raw; - } - - function resolveAdminObjectSrc(s3Key, accessToken) { - const key = String(s3Key || "").trim(); - if (!key || !accessToken) return ""; - return "/api/admin/uploads/object/" + encodeURIComponent(key) + "?token=" + encodeURIComponent(accessToken); - } - - function detectAttachmentPreviewKind(fileName, mimeType) { - const name = String(fileName || "").toLowerCase(); - const mime = String(mimeType || "").toLowerCase(); - if (mime.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name)) return "image"; - if (mime.startsWith("video/") || /\.(mp4|webm|ogg|mov|m4v)$/.test(name)) return "video"; - if (mime === "application/pdf" || /\.pdf$/.test(name)) return "pdf"; - return "none"; - } - - function buildUniversalQuery(filters, sort, limit, offset) { - return { - filters: filters || [], - sort: sort || [], - page: { limit: limit ?? PAGE_SIZE, offset: offset ?? 0 }, - }; - } - - function canAccessSection(role, section) { - const allowed = new Set(["dashboard", "kanban", "requests", "requestWorkspace", "invoices", "meta", "quotes", "config", "availableTables"]); - if (!allowed.has(section)) return false; - if (section === "quotes" || section === "config" || section === "availableTables") return role === "ADMIN"; - return true; - } - - function translateApiError(message) { - const direct = { - "Missing auth token": "Отсутствует токен авторизации", - "Missing bearer token": "Отсутствует токен авторизации", - "Invalid token": "Некорректный токен", - Forbidden: "Недостаточно прав", - "Invalid credentials": "Неверный логин или пароль", - "Request not found": "Заявка не найдена", - "Quote not found": "Цитата не найдена", - not_found: "Запись не найдена", - }; - if (direct[message]) return direct[message]; - if (String(message).startsWith("HTTP ")) return "Ошибка сервера (" + message + ")"; - return message; - } - - function getOperatorsForType(type) { - if (type === "number" || type === "date" || type === "datetime") return ["=", "!=", ">", "<", ">=", "<="]; - if (type === "boolean" || type === "reference" || type === "enum") return ["=", "!="]; - return [...ALL_OPERATORS]; - } - - function localizeRequestDetails(row) { - return { - ID: row.id || null, - "Номер заявки": row.track_number || null, - Клиент: row.client_name || null, - Телефон: row.client_phone || null, - "Тема (код)": row.topic_code || null, - Статус: statusLabel(row.status_code), - Описание: row.description || null, - "Дополнительные поля": row.extra_fields || {}, - "Назначенный юрист (ID)": row.assigned_lawyer_id || null, - "Ставка (фикс.)": row.effective_rate ?? null, - "Сумма счета": row.invoice_amount ?? null, - "Оплачено": row.paid_at ? fmtDate(row.paid_at) : null, - "Оплату подтвердил (ID)": row.paid_by_admin_id || null, - "Непрочитано клиентом": boolLabel(Boolean(row.client_has_unread_updates)), - "Тип обновления для клиента": row.client_unread_event_type ? (REQUEST_UPDATE_EVENT_LABELS[row.client_unread_event_type] || row.client_unread_event_type) : null, - "Непрочитано юристом": boolLabel(Boolean(row.lawyer_has_unread_updates)), - "Тип обновления для юриста": row.lawyer_unread_event_type ? (REQUEST_UPDATE_EVENT_LABELS[row.lawyer_unread_event_type] || row.lawyer_unread_event_type) : null, - "Общий размер вложений (байт)": row.total_attachments_bytes ?? 0, - Создано: fmtDate(row.created_at), - Обновлено: fmtDate(row.updated_at), - }; - } - - function renderRequestUpdatesCell(row, role) { - if (role === "LAWYER") { - const has = Boolean(row.lawyer_has_unread_updates); - const eventType = String(row.lawyer_unread_event_type || "").toUpperCase(); - return has ? ( - - - {REQUEST_UPDATE_EVENT_LABELS[eventType] || "обновление"} - - ) : ( - нет - ); - } - - const clientHas = Boolean(row.client_has_unread_updates); - const clientType = String(row.client_unread_event_type || "").toUpperCase(); - const lawyerHas = Boolean(row.lawyer_has_unread_updates); - const lawyerType = String(row.lawyer_unread_event_type || "").toUpperCase(); - - if (!clientHas && !lawyerHas) return нет; - return ( - - {clientHas ? ( - - - {"Клиент: " + (REQUEST_UPDATE_EVENT_LABELS[clientType] || "обновление")} - - ) : null} - {lawyerHas ? ( - - - {"Юрист: " + (REQUEST_UPDATE_EVENT_LABELS[lawyerType] || "обновление")} - - ) : null} - - ); - } - - function localizeMeta(data) { - const fieldTypeMap = { - string: "строка", - text: "текст", - boolean: "булево", - number: "число", - date: "дата", - }; - return { - Сущность: data.entity, - Поля: (data.fields || []).map((field) => ({ - "Код поля": field.field_name, - Название: field.label, - Тип: fieldTypeMap[field.type] || field.type, - Обязательное: boolLabel(field.required), - "Только чтение": boolLabel(field.read_only), - "Редактируемые роли": (field.editable_roles || []).map(roleLabel), - })), - }; - } +const { useCallback, useEffect, useMemo, useRef, useState } = React; +const LEGACY_HIDDEN_DICTIONARY_TABLES = new Set(["formFields", "topicRequiredFields", "statusTransitions"]); +const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; function StatusLine({ status }) { return

{status?.message || ""}

; @@ -946,17 +493,41 @@ function AttachmentPreviewModal({ open, title, url, fileName, mimeType, onClose }) { const [resolvedUrl, setResolvedUrl] = useState(""); + const [resolvedText, setResolvedText] = useState(""); + const [resolvedKind, setResolvedKind] = useState(""); + const [hint, setHint] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const decodeTextPreview = (arrayBuffer) => { + const bytes = new Uint8Array(arrayBuffer || new ArrayBuffer(0)); + const sampleLength = Math.min(bytes.length, 4096); + let suspicious = 0; + for (let i = 0; i < sampleLength; i += 1) { + const byte = bytes[i]; + if (byte === 0) suspicious += 4; + else if (byte < 9 || (byte > 13 && byte < 32)) suspicious += 1; + } + if (sampleLength && suspicious / sampleLength > 0.08) return null; + const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes).replace(/\u0000/g, ""); + const normalized = text.length > 200000 ? text.slice(0, 200000) + "\n\n[Текст обрезан для предпросмотра]" : text; + return normalized; + }; + useEffect(() => { if (!open || !url) { setResolvedUrl(""); + setResolvedText(""); + setResolvedKind(""); + setHint(""); setLoading(false); setError(""); return; } const kind = detectAttachmentPreviewKind(fileName, mimeType); + setResolvedKind(kind); + setResolvedText(""); + setHint(""); if (kind === "none") { setResolvedUrl(""); setLoading(false); @@ -974,13 +545,54 @@ try { const response = await fetch(url, { credentials: "same-origin" }); if (!response.ok) throw new Error("Не удалось загрузить файл для предпросмотра"); - const blob = await response.blob(); + const buffer = await response.arrayBuffer(); + if (cancelled) return; + + if (kind === "pdf") { + const header = new Uint8Array(buffer.slice(0, 5)); + const isPdf = + header.length >= 5 && + header[0] === 0x25 && + header[1] === 0x50 && + header[2] === 0x44 && + header[3] === 0x46 && + header[4] === 0x2d; + if (isPdf) { + setResolvedUrl(String(url)); + setResolvedKind("pdf"); + setLoading(false); + return; + } + const textPreview = decodeTextPreview(buffer); + if (textPreview != null) { + setResolvedUrl(""); + setResolvedText(textPreview); + setResolvedKind("text"); + setHint("Файл помечен как PDF, но не является валидным PDF. Показан текстовый предпросмотр."); + setLoading(false); + return; + } + throw new Error("Файл помечен как PDF, но не является валидным PDF-документом."); + } + + if (kind === "text") { + const textPreview = decodeTextPreview(buffer); + if (textPreview == null) throw new Error("Не удалось распознать текстовый файл для предпросмотра."); + setResolvedUrl(""); + setResolvedText(textPreview); + setResolvedKind("text"); + setLoading(false); + return; + } + + const blob = new Blob([buffer], { type: response.headers.get("content-type") || mimeType || "application/octet-stream" }); objectUrl = URL.createObjectURL(blob); if (cancelled) { URL.revokeObjectURL(objectUrl); return; } setResolvedUrl(objectUrl); + setResolvedKind(kind); setLoading(false); } catch (err) { if (cancelled) return; @@ -996,7 +608,7 @@ }, [fileName, mimeType, open, url]); if (!open || !url) return null; - const kind = detectAttachmentPreviewKind(fileName, mimeType); + const kind = resolvedKind || detectAttachmentPreviewKind(fileName, mimeType); return ( event.target.id === "request-file-preview-overlay" && onClose()}>
event.stopPropagation()}> @@ -1025,6 +637,7 @@
{loading ?

Загрузка предпросмотра...

: null} + {!loading && !error && hint ?

{hint}

: null} {error ?

{error}

: null} {!loading && !error && kind === "image" && resolvedUrl ? ( {fileName @@ -1035,6 +648,9 @@ {!loading && !error && kind === "pdf" && resolvedUrl ? (