Test-5 commit

This commit is contained in:
TronoSfera 2026-02-25 22:14:34 +03:00
parent 7754a6fedf
commit 4d87cefcee
16 changed files with 1520 additions and 187 deletions

View file

@ -0,0 +1,136 @@
"""add status groups dictionary and status.status_group_id
Revision ID: 0018_status_groups
Revises: 0017_transition_requirements
Create Date: 2026-02-25 20:05:00.000000
"""
from __future__ import annotations
import uuid
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "0018_status_groups"
down_revision = "0017_transition_requirements"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"status_groups",
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
)
op.create_index("ix_status_groups_name", "status_groups", ["name"], unique=True)
op.add_column("statuses", sa.Column("status_group_id", postgresql.UUID(as_uuid=True), nullable=True))
op.create_index("ix_statuses_status_group_id", "statuses", ["status_group_id"])
conn = op.get_bind()
groups = [
("Новые", 10),
("В работе", 20),
("Ожидание", 30),
("Завершены", 40),
]
group_ids: dict[str, str] = {}
for name, sort_order in groups:
group_id = str(uuid.uuid4())
group_ids[name] = group_id
conn.execute(
sa.text(
"""
INSERT INTO status_groups (id, name, sort_order, responsible)
VALUES (:id, :name, :sort_order, :responsible)
"""
),
{
"id": group_id,
"name": name,
"sort_order": sort_order,
"responsible": "Администратор системы",
},
)
conn.execute(
sa.text(
"""
UPDATE statuses
SET status_group_id = :group_done
WHERE
COALESCE(is_terminal, false) = true
OR UPPER(COALESCE(kind, 'DEFAULT')) = 'PAID'
OR UPPER(COALESCE(code, '')) LIKE '%CLOSE%'
OR UPPER(COALESCE(code, '')) LIKE '%RESOLV%'
OR UPPER(COALESCE(code, '')) LIKE '%REJECT%'
OR UPPER(COALESCE(code, '')) LIKE '%DONE%'
OR UPPER(COALESCE(code, '')) LIKE '%PAID%'
"""
),
{"group_done": group_ids["Завершены"]},
)
conn.execute(
sa.text(
"""
UPDATE statuses
SET status_group_id = :group_waiting
WHERE
status_group_id IS NULL
AND (
UPPER(COALESCE(kind, 'DEFAULT')) = 'INVOICE'
OR UPPER(COALESCE(code, '')) LIKE '%WAIT%'
OR UPPER(COALESCE(code, '')) LIKE '%PEND%'
OR UPPER(COALESCE(code, '')) LIKE '%HOLD%'
OR UPPER(COALESCE(code, '')) LIKE '%SUSPEND%'
OR UPPER(COALESCE(code, '')) LIKE '%BLOCK%'
)
"""
),
{"group_waiting": group_ids["Ожидание"]},
)
conn.execute(
sa.text(
"""
UPDATE statuses
SET status_group_id = :group_new
WHERE
status_group_id IS NULL
AND (
UPPER(COALESCE(code, '')) LIKE 'NEW%'
OR UPPER(COALESCE(code, '')) LIKE '%_NEW'
)
"""
),
{"group_new": group_ids["Новые"]},
)
conn.execute(
sa.text(
"""
UPDATE statuses
SET status_group_id = :group_in_progress
WHERE status_group_id IS NULL
"""
),
{"group_in_progress": group_ids["В работе"]},
)
op.alter_column("status_groups", "sort_order", server_default=None)
op.alter_column("status_groups", "created_at", server_default=None)
op.alter_column("status_groups", "updated_at", server_default=None)
op.alter_column("status_groups", "responsible", server_default=None)
def downgrade() -> None:
op.drop_index("ix_statuses_status_group_id", table_name="statuses")
op.drop_column("statuses", "status_group_id")
op.drop_index("ix_status_groups_name", table_name="status_groups")
op.drop_table("status_groups")

View file

@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from app.core.deps import require_role from app.core.deps import require_role
from app.db.session import get_db from app.db.session import get_db
from app.models.admin_user import AdminUser
from app.models.request import Request from app.models.request import Request
from app.services.chat_service import create_admin_or_lawyer_message, list_messages_for_request, serialize_message from app.services.chat_service import create_admin_or_lawyer_message, list_messages_for_request, serialize_message
@ -75,12 +76,22 @@ def create_request_message(
body = str((payload or {}).get("body") or "").strip() body = str((payload or {}).get("body") or "").strip()
role = str(admin.get("role") or "").upper() role = str(admin.get("role") or "").upper()
actor_name = str(admin.get("email") or "").strip() or ("Юрист" if role == "LAWYER" else "Администратор") 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)
row = create_admin_or_lawyer_message( row = create_admin_or_lawyer_message(
db, db,
request=req, request=req,
body=body, body=body,
actor_role=role, actor_role=role,
actor_name=actor_name, actor_name=actor_name,
actor_admin_user_id=str(admin.get("sub") or "").strip() or None, actor_admin_user_id=actor_admin_user_id,
) )
return serialize_message(row) return serialize_message(row)

View file

@ -1,12 +1,14 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from uuid import UUID
from app.db.session import get_db from app.db.session import get_db
from app.core.deps import require_role from app.core.deps import require_role
from app.schemas.universal import UniversalQuery from app.schemas.universal import UniversalQuery
from app.schemas.admin import TopicUpsert, StatusUpsert, FormFieldUpsert from app.schemas.admin import TopicUpsert, StatusUpsert, FormFieldUpsert
from app.models.topic import Topic from app.models.topic import Topic
from app.models.status import Status from app.models.status import Status
from app.models.status_group import StatusGroup
from app.models.form_field import FormField from app.models.form_field import FormField
from app.services.universal_query import apply_universal_query from app.services.universal_query import apply_universal_query
@ -22,6 +24,7 @@ def _status_row(row: Status):
"id": str(row.id), "id": str(row.id),
"code": row.code, "code": row.code,
"name": row.name, "name": row.name,
"status_group_id": str(row.status_group_id) if row.status_group_id else None,
"enabled": row.enabled, "enabled": row.enabled,
"sort_order": row.sort_order, "sort_order": row.sort_order,
"is_terminal": row.is_terminal, "is_terminal": row.is_terminal,
@ -100,8 +103,20 @@ def query_statuses(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
@router.post("/statuses", status_code=201) @router.post("/statuses", status_code=201)
def create_status(payload: StatusUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))): def create_status(payload: StatusUpsert, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN"))):
data = payload.model_dump()
raw_group = data.get("status_group_id")
if raw_group:
try:
group_id = UUID(str(raw_group))
except ValueError:
raise HTTPException(status_code=400, detail="Некорректная группа статусов")
if db.get(StatusGroup, group_id) is None:
raise HTTPException(status_code=400, detail="Группа статусов не найдена")
data["status_group_id"] = group_id
else:
data["status_group_id"] = None
responsible = str(admin.get("email") or "").strip() or "Администратор системы" responsible = str(admin.get("email") or "").strip() or "Администратор системы"
row = Status(**payload.model_dump(), responsible=responsible) row = Status(**data, responsible=responsible)
try: try:
db.add(row) db.add(row)
db.commit() db.commit()
@ -117,7 +132,19 @@ def update_status(id: str, payload: StatusUpsert, db: Session = Depends(get_db),
row = db.query(Status).filter(Status.id == id).first() row = db.query(Status).filter(Status.id == id).first()
if not row: if not row:
raise HTTPException(status_code=404, detail="Статус не найден") raise HTTPException(status_code=404, detail="Статус не найден")
for k, v in payload.model_dump().items(): data = payload.model_dump()
raw_group = data.get("status_group_id")
if raw_group:
try:
group_id = UUID(str(raw_group))
except ValueError:
raise HTTPException(status_code=400, detail="Некорректная группа статусов")
if db.get(StatusGroup, group_id) is None:
raise HTTPException(status_code=400, detail="Группа статусов не найдена")
data["status_group_id"] = group_id
else:
data["status_group_id"] = None
for k, v in data.items():
setattr(row, k, v) setattr(row, k, v)
try: try:
db.add(row) db.add(row)

View file

@ -31,6 +31,7 @@ from app.models.attachment import Attachment
from app.models.message import Message from app.models.message import Message
from app.models.request import Request from app.models.request import Request
from app.models.status import Status from app.models.status import Status
from app.models.status_group import StatusGroup
from app.models.topic_data_template import TopicDataTemplate from app.models.topic_data_template import TopicDataTemplate
from app.models.topic_required_field import TopicRequiredField from app.models.topic_required_field import TopicRequiredField
from app.models.topic import Topic from app.models.topic import Topic
@ -93,6 +94,7 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
"quotes": {"ADMIN": set(CRUD_ACTIONS)}, "quotes": {"ADMIN": set(CRUD_ACTIONS)},
"topics": {"ADMIN": set(CRUD_ACTIONS)}, "topics": {"ADMIN": set(CRUD_ACTIONS)},
"statuses": {"ADMIN": set(CRUD_ACTIONS)}, "statuses": {"ADMIN": set(CRUD_ACTIONS)},
"status_groups": {"ADMIN": set(CRUD_ACTIONS)},
"form_fields": {"ADMIN": set(CRUD_ACTIONS)}, "form_fields": {"ADMIN": set(CRUD_ACTIONS)},
"clients": {"ADMIN": set(CRUD_ACTIONS)}, "clients": {"ADMIN": set(CRUD_ACTIONS)},
"table_availability": {"ADMIN": set(CRUD_ACTIONS)}, "table_availability": {"ADMIN": set(CRUD_ACTIONS)},
@ -257,6 +259,7 @@ def _table_label(table_name: str) -> str:
"quotes": "Цитаты", "quotes": "Цитаты",
"topics": "Темы", "topics": "Темы",
"statuses": "Статусы", "statuses": "Статусы",
"status_groups": "Группы статусов",
"form_fields": "Поля формы", "form_fields": "Поля формы",
"clients": "Клиенты", "clients": "Клиенты",
"table_availability": "Доступность таблиц", "table_availability": "Доступность таблиц",
@ -359,6 +362,7 @@ def _column_label(table_name: str, column_name: str) -> str:
"email": "Email", "email": "Email",
"role": "Роль", "role": "Роль",
"kind": "Тип", "kind": "Тип",
"status_group_id": "Группа",
"status": "Статус", "status": "Статус",
"status_code": "Статус", "status_code": "Статус",
"topic_code": "Тема", "topic_code": "Тема",
@ -441,6 +445,126 @@ def _column_label(table_name: str, column_name: str) -> str:
return _humanize_identifier_ru(normalized_column) return _humanize_identifier_ru(normalized_column)
def _pluralize_identifier(base: str) -> list[str]:
token = _normalize_table_name(base)
if not token:
return []
candidates = [token]
if token.endswith("y"):
candidates.append(token[:-1] + "ies")
candidates.append(token + "s")
return list(dict.fromkeys(candidates))
def _reference_override(table_name: str, column_name: str) -> tuple[str, str] | None:
normalized_table = _normalize_table_name(table_name)
normalized_column = _normalize_table_name(column_name)
explicit: dict[tuple[str, str], tuple[str, str]] = {
("requests", "assigned_lawyer_id"): ("admin_users", "id"),
("requests", "paid_by_admin_id"): ("admin_users", "id"),
("requests", "topic_code"): ("topics", "code"),
("requests", "status_code"): ("statuses", "code"),
("statuses", "status_group_id"): ("status_groups", "id"),
("topic_required_fields", "topic_code"): ("topics", "code"),
("topic_required_fields", "field_key"): ("form_fields", "key"),
("topic_data_templates", "topic_code"): ("topics", "code"),
("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"),
("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"),
("messages", "request_id"): ("requests", "id"),
("attachments", "request_id"): ("requests", "id"),
("attachments", "message_id"): ("messages", "id"),
("invoices", "request_id"): ("requests", "id"),
("invoices", "client_id"): ("clients", "id"),
("invoices", "issued_by_admin_user_id"): ("admin_users", "id"),
("notifications", "recipient_admin_user_id"): ("admin_users", "id"),
("status_history", "request_id"): ("requests", "id"),
("status_history", "changed_by_admin_id"): ("admin_users", "id"),
("audit_log", "actor_admin_id"): ("admin_users", "id"),
}
if (normalized_table, normalized_column) in explicit:
return explicit[(normalized_table, normalized_column)]
return None
def _detect_reference_for_column(table_name: str, column_name: str) -> tuple[str, str] | None:
override = _reference_override(table_name, column_name)
if override is not None:
return override
normalized = _normalize_table_name(column_name)
table_models = _table_model_map()
if normalized.endswith("_id") and normalized not in {"id"}:
base = normalized[:-3]
for candidate in _pluralize_identifier(base):
if candidate in table_models:
return candidate, "id"
if base.endswith("_admin_user"):
return "admin_users", "id"
if base.endswith("_lawyer"):
return "admin_users", "id"
if normalized.endswith("_code"):
base = normalized[:-5]
for candidate in _pluralize_identifier(base):
if candidate in table_models:
return candidate, "code"
return None
def _reference_label_field(table_name: str, value_field: str) -> str:
explicit = {
"admin_users": "name",
"clients": "full_name",
"requests": "track_number",
"topics": "name",
"statuses": "name",
"status_groups": "name",
"form_fields": "label",
"topic_data_templates": "label",
"invoices": "invoice_number",
"messages": "body",
"attachments": "file_name",
}
if table_name in explicit:
return explicit[table_name]
_, model = _resolve_table_model(table_name)
mapper = sa_inspect(model)
hidden = _hidden_response_fields(table_name)
blocked = {"id", value_field, "created_at", "updated_at", "responsible"}
for column in mapper.columns:
name = str(column.key)
if name in hidden or name in blocked:
continue
return name
return value_field
def _reference_meta_for_column(table_name: str, column_name: str) -> dict[str, str] | None:
detected = _detect_reference_for_column(table_name, column_name)
if detected is None:
return None
ref_table, value_field = detected
try:
label_field = _reference_label_field(ref_table, value_field)
except HTTPException:
return None
return {
"table": ref_table,
"value_field": value_field,
"label_field": label_field,
}
def _default_sort_for_table(model: type) -> list[dict[str, str]]: def _default_sort_for_table(model: type) -> list[dict[str, str]]:
columns = _columns_map(model) columns = _columns_map(model)
if "sort_order" in columns: if "sort_order" in columns:
@ -466,20 +590,22 @@ def _table_columns_meta(table_name: str, model: type) -> list[dict[str, Any]]:
kind = _column_kind(column) kind = _column_kind(column)
has_default = column.default is not None or column.server_default is not None or name in primary_keys has_default = column.default is not None or column.server_default is not None or name in primary_keys
editable = name not in SYSTEM_FIELDS and name not in protected and name not in primary_keys editable = name not in SYSTEM_FIELDS and name not in protected and name not in primary_keys
out.append( item = {
{ "name": name,
"name": name, "label": _column_label(table_name, name),
"label": _column_label(table_name, name), "kind": kind,
"kind": kind, "nullable": bool(column.nullable),
"nullable": bool(column.nullable), "editable": bool(editable),
"editable": bool(editable), "sortable": True,
"sortable": True, "filterable": kind != "json",
"filterable": kind != "json", "required_on_create": not bool(column.nullable) and not bool(has_default) and bool(editable),
"required_on_create": not bool(column.nullable) and not bool(has_default) and bool(editable), "has_default": bool(has_default),
"has_default": bool(has_default), "is_primary_key": name in primary_keys,
"is_primary_key": name in primary_keys, }
} reference = _reference_meta_for_column(table_name, name)
) if reference is not None:
item["reference"] = reference
out.append(item)
return out return out
@ -877,10 +1003,20 @@ def _apply_topic_status_transitions_fields(db: Session, payload: dict[str, Any])
return data return data
def _apply_status_fields(payload: dict[str, Any]) -> dict[str, Any]: def _apply_status_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload) data = dict(payload)
if "kind" in data: if "kind" in data:
data["kind"] = normalize_status_kind_or_400(data.get("kind")) data["kind"] = normalize_status_kind_or_400(data.get("kind"))
if "status_group_id" in data:
raw_group = data.get("status_group_id")
if raw_group is None or str(raw_group).strip() == "":
data["status_group_id"] = None
else:
group_id = _parse_uuid_or_400(raw_group, "status_group_id")
group = db.get(StatusGroup, group_id)
if group is None:
raise HTTPException(status_code=400, detail="Группа статусов не найдена")
data["status_group_id"] = group_id
if "invoice_template" in data: if "invoice_template" in data:
text = str(data.get("invoice_template") or "").strip() text = str(data.get("invoice_template") or "").strip()
data["invoice_template"] = text or None data["invoice_template"] = text or None
@ -1359,7 +1495,7 @@ def create_row(
if normalized == "topic_status_transitions": if normalized == "topic_status_transitions":
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload) clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
if normalized == "statuses": if normalized == "statuses":
clean_payload = _apply_status_fields(clean_payload) clean_payload = _apply_status_fields(db, clean_payload)
if normalized == "requests": if normalized == "requests":
clean_payload["client_id"] = resolved_request_client_id clean_payload["client_id"] = resolved_request_client_id
if normalized == "invoices": if normalized == "invoices":
@ -1426,7 +1562,7 @@ def update_row(
if normalized == "topic_status_transitions": if normalized == "topic_status_transitions":
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload) clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
if normalized == "statuses": if normalized == "statuses":
clean_payload = _apply_status_fields(clean_payload) clean_payload = _apply_status_fields(db, clean_payload)
if normalized == "requests" and isinstance(row, Request): if normalized == "requests" and isinstance(row, Request):
if {"client_name", "client_phone"}.intersection(set(clean_payload.keys())) or row.client_id is None: if {"client_name", "client_phone"}.intersection(set(clean_payload.keys())) or row.client_id is None:
client = _upsert_client_or_400( client = _upsert_client_or_400(

View file

@ -1,3 +1,4 @@
import json
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@ -8,7 +9,7 @@ from sqlalchemy import case, or_, update
from app.db.session import get_db from app.db.session import get_db
from app.core.deps import require_role from app.core.deps import require_role
from app.schemas.universal import UniversalQuery from app.schemas.universal import FilterClause, Page, UniversalQuery
from app.schemas.admin import ( from app.schemas.admin import (
RequestAdminCreate, RequestAdminCreate,
RequestAdminPatch, RequestAdminPatch,
@ -21,6 +22,7 @@ from app.models.audit_log import AuditLog
from app.models.request_data_requirement import RequestDataRequirement from app.models.request_data_requirement import RequestDataRequirement
from app.models.request import Request from app.models.request import Request
from app.models.status import Status from app.models.status import Status
from app.models.status_group import StatusGroup
from app.models.status_history import StatusHistory from app.models.status_history import StatusHistory
from app.models.topic_data_template import TopicDataTemplate from app.models.topic_data_template import TopicDataTemplate
from app.models.topic_status_transition import TopicStatusTransition from app.models.topic_status_transition import TopicStatusTransition
@ -39,41 +41,50 @@ from app.services.universal_query import apply_universal_query
router = APIRouter() router = APIRouter()
REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"} REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"}
KANBAN_GROUP_LABELS = { ALLOWED_KANBAN_FILTER_FIELDS = {"assigned_lawyer_id", "client_name", "status_code", "created_at", "topic_code", "overdue"}
"NEW": "Новые", ALLOWED_KANBAN_SORT_MODES = {"created_newest", "lawyer", "deadline"}
"IN_PROGRESS": "В работе", FALLBACK_KANBAN_GROUPS = [
"WAITING": "Ожидание", ("fallback_new", "Новые", 10),
"DONE": "Завершены", ("fallback_in_progress", "В работе", 20),
} ("fallback_waiting", "Ожидание", 30),
("fallback_done", "Завершены", 40),
]
def _status_meta_or_default(meta_map: dict[str, dict[str, object]], status_code: str) -> dict[str, object]: def _status_meta_or_default(meta_map: dict[str, dict[str, object]], status_code: str) -> dict[str, object]:
return meta_map.get(status_code) or {"name": status_code, "kind": "DEFAULT", "is_terminal": False} return meta_map.get(status_code) or {
"name": status_code,
"kind": "DEFAULT",
"is_terminal": False,
"status_group_id": None,
"status_group_name": None,
"status_group_order": None,
}
def _kanban_group_for_status(status_code: str, status_meta: dict[str, object]) -> str: def _fallback_group_for_status(status_code: str, status_meta: dict[str, object]) -> tuple[str, str, int]:
code = str(status_code or "").strip().upper() code = str(status_code or "").strip().upper()
kind = str(status_meta.get("kind") or "DEFAULT").upper() kind = str(status_meta.get("kind") or "DEFAULT").upper()
name = str(status_meta.get("name") or "").upper() name = str(status_meta.get("name") or "").upper()
is_terminal = bool(status_meta.get("is_terminal")) is_terminal = bool(status_meta.get("is_terminal"))
if is_terminal: if is_terminal:
return "DONE" return FALLBACK_KANBAN_GROUPS[3]
if kind == "PAID": if kind == "PAID":
return "DONE" return FALLBACK_KANBAN_GROUPS[3]
if code.startswith("NEW") or "НОВ" in name: if code.startswith("NEW") or "НОВ" in name:
return "NEW" return FALLBACK_KANBAN_GROUPS[0]
waiting_tokens = ("WAIT", "PEND", "HOLD", "SUSPEND", "BLOCK") waiting_tokens = ("WAIT", "PEND", "HOLD", "SUSPEND", "BLOCK")
waiting_ru_tokens = ("ОЖИД", "ПАУЗ", "СОГЛАС", "ОПЛАТ", "СУД") waiting_ru_tokens = ("ОЖИД", "ПАУЗ", "СОГЛАС", "ОПЛАТ", "СУД")
if kind == "INVOICE": if kind == "INVOICE":
return "WAITING" return FALLBACK_KANBAN_GROUPS[2]
if any(token in code for token in waiting_tokens) or any(token in name for token in waiting_ru_tokens): if any(token in code for token in waiting_tokens) or any(token in name for token in waiting_ru_tokens):
return "WAITING" return FALLBACK_KANBAN_GROUPS[2]
done_tokens = ("CLOSE", "RESOLV", "REJECT", "DONE", "PAID") done_tokens = ("CLOSE", "RESOLV", "REJECT", "DONE", "PAID")
done_ru_tokens = ("ЗАВЕРШ", "ЗАКРЫ", "РЕШЕН", "ОТКЛОН", "ОПЛАЧ") done_ru_tokens = ("ЗАВЕРШ", "ЗАКРЫ", "РЕШЕН", "ОТКЛОН", "ОПЛАЧ")
if any(token in code for token in done_tokens) or any(token in name for token in done_ru_tokens): if any(token in code for token in done_tokens) or any(token in name for token in done_ru_tokens):
return "DONE" return FALLBACK_KANBAN_GROUPS[3]
return "IN_PROGRESS" return FALLBACK_KANBAN_GROUPS[1]
def _parse_datetime_safe(value: object) -> datetime | None: def _parse_datetime_safe(value: object) -> datetime | None:
@ -115,6 +126,101 @@ def _extract_case_deadline(extra_fields: object) -> datetime | None:
return None return None
def _coerce_kanban_bool(value: object) -> bool:
if isinstance(value, bool):
return value
text = str(value or "").strip().lower()
if text in {"1", "true", "yes", "y", "on"}:
return True
if text in {"0", "false", "no", "n", "off"}:
return False
raise HTTPException(status_code=400, detail='Поле "overdue" должно быть boolean')
def _parse_kanban_filters_or_400(raw_filters: str | None) -> tuple[list[FilterClause], list[tuple[str, bool]]]:
if not raw_filters:
return [], []
try:
parsed = json.loads(raw_filters)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail="Некорректный JSON фильтров канбана") from exc
if not isinstance(parsed, list):
raise HTTPException(status_code=400, detail="Фильтры канбана должны быть массивом")
universal_filters: list[FilterClause] = []
overdue_filters: list[tuple[str, bool]] = []
for index, item in enumerate(parsed):
if not isinstance(item, dict):
raise HTTPException(status_code=400, detail=f"Фильтр #{index + 1} должен быть объектом")
field = str(item.get("field") or "").strip()
op = str(item.get("op") or "").strip()
value = item.get("value")
if field not in ALLOWED_KANBAN_FILTER_FIELDS:
raise HTTPException(status_code=400, detail=f'Недоступное поле фильтра: "{field}"')
if op not in {"=", "!=", ">", "<", ">=", "<=", "~"}:
raise HTTPException(status_code=400, detail=f'Недопустимый оператор фильтра: "{op}"')
if field == "overdue":
if op not in {"=", "!="}:
raise HTTPException(status_code=400, detail='Для поля "overdue" доступны только операторы "=" и "!="')
overdue_filters.append((op, _coerce_kanban_bool(value)))
continue
universal_filters.append(FilterClause(field=field, op=op, value=value))
return universal_filters, overdue_filters
def _apply_overdue_filters(items: list[dict[str, object]], overdue_filters: list[tuple[str, bool]]) -> list[dict[str, object]]:
if not overdue_filters:
return items
now = datetime.now(timezone.utc)
out: list[dict[str, object]] = []
for item in items:
raw_deadline = item.get("sla_deadline_at") or item.get("case_deadline_at")
deadline_at = _parse_datetime_safe(raw_deadline)
is_overdue = bool(deadline_at and deadline_at <= now)
ok = True
for op, expected in overdue_filters:
if op == "=":
ok = ok and (is_overdue == expected)
elif op == "!=":
ok = ok and (is_overdue != expected)
if not ok:
break
if ok:
out.append(item)
return out
def _sort_kanban_items(items: list[dict[str, object]], sort_mode: str) -> list[dict[str, object]]:
mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest"
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
if mode == "lawyer":
return sorted(
items,
key=lambda row: (
1 if not str(row.get("assigned_lawyer_name") or "").strip() else 0,
str(row.get("assigned_lawyer_name") or "").lower(),
-int((_parse_datetime_safe(row.get("created_at")) or epoch).timestamp()),
),
)
if mode == "deadline":
far_future = datetime(9999, 12, 31, tzinfo=timezone.utc)
return sorted(
items,
key=lambda row: (
_parse_datetime_safe(row.get("sla_deadline_at") or row.get("case_deadline_at")) or far_future,
-int((_parse_datetime_safe(row.get("created_at")) or epoch).timestamp()),
),
)
return sorted(
items,
key=lambda row: _parse_datetime_safe(row.get("created_at")) or epoch,
reverse=True,
)
def _request_uuid_or_400(request_id: str) -> UUID: def _request_uuid_or_400(request_id: str) -> UUID:
try: try:
return UUID(str(request_id)) return UUID(str(request_id))
@ -220,6 +326,8 @@ def get_requests_kanban(
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin=Depends(require_role("ADMIN", "LAWYER")), admin=Depends(require_role("ADMIN", "LAWYER")),
limit: int = Query(default=400, ge=1, le=1000), limit: int = Query(default=400, ge=1, le=1000),
filters: str | None = Query(default=None),
sort_mode: str = Query(default="created_newest"),
): ):
role = str(admin.get("role") or "").upper() role = str(admin.get("role") or "").upper()
actor = str(admin.get("sub") or "").strip() actor = str(admin.get("sub") or "").strip()
@ -235,18 +343,23 @@ def get_requests_kanban(
) )
) )
request_rows: list[Request] = ( normalized_sort_mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest"
base_query query_filters, overdue_filters = _parse_kanban_filters_or_400(filters)
.order_by(Request.created_at.desc()) if query_filters:
.limit(limit) base_query = apply_universal_query(
.all() base_query,
) Request,
total = int(base_query.count() or 0) UniversalQuery(
filters=query_filters,
sort=[],
page=Page(limit=limit, offset=0),
),
)
request_rows: list[Request] = base_query.all()
request_id_to_row = {str(row.id): row for row in request_rows} request_id_to_row = {str(row.id): row for row in request_rows}
request_ids = [row.id for row in request_rows] request_ids = [row.id for row in request_rows]
request_ids_str = list(request_id_to_row.keys())
topic_codes = {str(row.topic_code or "").strip() for row in request_rows if str(row.topic_code or "").strip()} topic_codes = {str(row.topic_code or "").strip() for row in request_rows if str(row.topic_code or "").strip()}
status_codes = {str(row.status_code or "").strip() for row in request_rows if str(row.status_code or "").strip()} status_codes = {str(row.status_code or "").strip() for row in request_rows if str(row.status_code or "").strip()}
@ -276,15 +389,23 @@ def get_requests_kanban(
status_meta_map: dict[str, dict[str, object]] = {} status_meta_map: dict[str, dict[str, object]] = {}
if status_codes: if status_codes:
status_rows = db.query(Status).filter(Status.code.in_(list(status_codes))).all() status_rows = (
db.query(Status, StatusGroup)
.outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id)
.filter(Status.code.in_(list(status_codes)))
.all()
)
status_meta_map = { status_meta_map = {
str(row.code): { str(status_row.code): {
"name": str(row.name or row.code), "name": str(status_row.name or status_row.code),
"kind": str(row.kind or "DEFAULT"), "kind": str(status_row.kind or "DEFAULT"),
"is_terminal": bool(row.is_terminal), "is_terminal": bool(status_row.is_terminal),
"sort_order": int(row.sort_order or 0), "sort_order": int(status_row.sort_order or 0),
"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),
} }
for row in status_rows for status_row, group_row in status_rows
} }
assigned_ids = {str(row.assigned_lawyer_id or "").strip() for row in request_rows if str(row.assigned_lawyer_id or "").strip()} assigned_ids = {str(row.assigned_lawyer_id or "").strip() for row in request_rows if str(row.assigned_lawyer_id or "").strip()}
@ -338,27 +459,61 @@ def get_requests_kanban(
transitions_by_key.setdefault((topic_code, from_status), []).append(row) transitions_by_key.setdefault((topic_code, from_status), []).append(row)
transitions_to_key.setdefault((topic_code, to_status), []).append(row) transitions_to_key.setdefault((topic_code, to_status), []).append(row)
status_groups_rows = db.query(StatusGroup).order_by(StatusGroup.sort_order.asc(), StatusGroup.name.asc()).all()
columns_catalog = [
{
"key": str(group.id),
"label": str(group.name),
"sort_order": int(group.sort_order or 0),
}
for group in status_groups_rows
]
columns_by_key = {row["key"]: row for row in columns_catalog}
items: list[dict[str, object]] = [] items: list[dict[str, object]] = []
group_totals = {key: 0 for key in KANBAN_GROUP_LABELS.keys()} group_totals: dict[str, int] = {row["key"]: 0 for row in columns_catalog}
for row in request_rows: for row in request_rows:
request_id = str(row.id) request_id = str(row.id)
topic_code = str(row.topic_code or "").strip() topic_code = str(row.topic_code or "").strip()
status_code = str(row.status_code or "").strip() status_code = str(row.status_code or "").strip()
status_meta = _status_meta_or_default(status_meta_map, status_code) status_meta = _status_meta_or_default(status_meta_map, status_code)
status_group = _kanban_group_for_status(status_code, status_meta) status_group = str(status_meta.get("status_group_id") or "").strip()
group_totals[status_group] = int(group_totals.get(status_group, 0)) + 1 status_group_name = str(status_meta.get("status_group_name") or "").strip()
status_group_order = status_meta.get("status_group_order")
if not status_group:
fallback_key, fallback_label, fallback_order = _fallback_group_for_status(status_code, status_meta)
status_group = fallback_key
status_group_name = fallback_label
status_group_order = fallback_order
if fallback_key not in columns_by_key:
columns_by_key[fallback_key] = {"key": fallback_key, "label": fallback_label, "sort_order": fallback_order}
columns_catalog.append(columns_by_key[fallback_key])
elif status_group not in columns_by_key:
columns_by_key[status_group] = {
"key": status_group,
"label": status_group_name or status_group,
"sort_order": int(status_group_order or 999),
}
columns_catalog.append(columns_by_key[status_group])
available_transitions = [] available_transitions = []
for transition in transitions_by_key.get((topic_code, status_code), []): for transition in transitions_by_key.get((topic_code, status_code), []):
to_status = str(transition.to_status or "").strip() to_status = str(transition.to_status or "").strip()
if not to_status: if not to_status:
continue continue
to_meta = _status_meta_or_default(status_meta_map, to_status) to_meta = _status_meta_or_default(status_meta_map, to_status)
target_group = str(to_meta.get("status_group_id") or "").strip()
if not target_group:
target_group, fallback_label, fallback_order = _fallback_group_for_status(to_status, to_meta)
if target_group not in columns_by_key:
columns_by_key[target_group] = {"key": target_group, "label": fallback_label, "sort_order": fallback_order}
columns_catalog.append(columns_by_key[target_group])
if target_group not in group_totals:
group_totals[target_group] = 0
available_transitions.append( available_transitions.append(
{ {
"to_status": to_status, "to_status": to_status,
"to_status_name": str(to_meta.get("name") or to_status), "to_status_name": str(to_meta.get("name") or to_status),
"target_group": _kanban_group_for_status(to_status, to_meta), "target_group": target_group,
"sla_hours": transition.sla_hours, "sla_hours": transition.sla_hours,
} }
) )
@ -391,6 +546,8 @@ def get_requests_kanban(
"status_code": status_code, "status_code": status_code,
"status_name": str(status_meta.get("name") or status_code), "status_name": str(status_meta.get("name") or status_code),
"status_group": status_group, "status_group": status_group,
"status_group_name": status_group_name or None,
"status_group_order": int(status_group_order or 0) if status_group_order is not None else None,
"assigned_lawyer_id": assigned_id, "assigned_lawyer_id": assigned_id,
"assigned_lawyer_name": lawyer_name_map.get(assigned_id or "", assigned_id), "assigned_lawyer_name": lawyer_name_map.get(assigned_id or "", assigned_id),
"description": row.description, "description": row.description,
@ -406,14 +563,37 @@ def get_requests_kanban(
} }
) )
columns = [ items = _apply_overdue_filters(items, overdue_filters)
{ items = _sort_kanban_items(items, normalized_sort_mode)
"key": key, total = len(items)
"label": label, if total > limit:
"total": int(group_totals.get(key, 0)), items = items[:limit]
}
for key, label in KANBAN_GROUP_LABELS.items() for row in items:
] key = str(row.get("status_group") or "").strip()
if not key:
continue
group_totals[key] = int(group_totals.get(key, 0)) + 1
columns = []
for item in sorted(
columns_catalog,
key=lambda row: (
int(row.get("sort_order") or 0),
str(row.get("label") or "").lower(),
),
):
key = str(item.get("key") or "")
if not key:
continue
columns.append(
{
"key": key,
"label": str(item.get("label") or key),
"sort_order": int(item.get("sort_order") or 0),
"total": int(group_totals.get(key, 0)),
}
)
return { return {
"scope": role, "scope": role,
@ -421,6 +601,7 @@ def get_requests_kanban(
"columns": columns, "columns": columns,
"total": total, "total": total,
"limit": int(limit), "limit": int(limit),
"sort_mode": normalized_sort_mode,
"truncated": bool(total > len(items)), "truncated": bool(total > len(items)),
} }

View file

@ -1,4 +1,7 @@
import uuid
from sqlalchemy import String, Boolean, Integer, Text from sqlalchemy import String, Boolean, Integer, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base from app.db.session import Base
from app.models.common import UUIDMixin, TimestampMixin from app.models.common import UUIDMixin, TimestampMixin
@ -7,6 +10,7 @@ class Status(Base, UUIDMixin, TimestampMixin):
__tablename__ = "statuses" __tablename__ = "statuses"
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False) name: Mapped[str] = mapped_column(String(200), nullable=False)
status_group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
is_terminal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_terminal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)

View file

@ -0,0 +1,12 @@
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.common import TimestampMixin, UUIDMixin
class StatusGroup(Base, UUIDMixin, TimestampMixin):
__tablename__ = "status_groups"
name: Mapped[str] = mapped_column(String(200), nullable=False, unique=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)

View file

@ -29,6 +29,7 @@ class TopicUpsert(BaseModel):
class StatusUpsert(BaseModel): class StatusUpsert(BaseModel):
code: str code: str
name: str name: str
status_group_id: Optional[str] = None
enabled: bool = True enabled: bool = True
sort_order: int = 0 sort_order: int = 0
is_terminal: bool = False is_terminal: bool = False

View file

@ -175,6 +175,12 @@
color: #dbe6f5; color: #dbe6f5;
} }
.btn.secondary.active-success {
border-color: rgba(77, 190, 147, 0.48);
background: rgba(77, 190, 147, 0.22);
color: #c8f5e4;
}
.btn.danger { .btn.danger {
border-color: rgba(255, 127, 127, 0.3); border-color: rgba(255, 127, 127, 0.3);
background: rgba(255, 127, 127, 0.13); background: rgba(255, 127, 127, 0.13);
@ -259,14 +265,18 @@
} }
.kanban-board { .kanban-board {
display: grid; display: flex;
grid-template-columns: repeat(4, minmax(260px, 1fr)); flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
overflow-x: auto; overflow-x: hidden;
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
align-items: stretch;
align-content: flex-start;
} }
.kanban-column { .kanban-column {
flex: 1 1 300px;
min-width: 260px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 14px; border-radius: 14px;
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
@ -321,13 +331,22 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.45rem; gap: 0.45rem;
cursor: pointer;
}
.kanban-card.draggable {
cursor: grab; cursor: grab;
} }
.kanban-card:active { .kanban-card.draggable:active {
cursor: grabbing; cursor: grabbing;
} }
.kanban-card:focus-visible {
outline: 2px solid rgba(137, 178, 255, 0.55);
outline-offset: 1px;
}
.kanban-card-head { .kanban-card-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -403,11 +422,73 @@
margin-top: 0.1rem; margin-top: 0.1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-start;
gap: 0.45rem; gap: 0.45rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.kanban-update-icons {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.kanban-update-icon {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid rgba(122, 139, 163, 0.38);
background: rgba(111, 133, 160, 0.14);
color: #9daec4;
font-size: 0.76rem;
line-height: 1;
}
.kanban-update-icon.is-unread {
border-color: rgba(74, 197, 143, 0.52);
background: rgba(74, 197, 143, 0.2);
color: #b9f3dd;
box-shadow: 0 0 0 2px rgba(74, 197, 143, 0.14);
}
.kanban-deadline-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 120px;
padding: 0.18rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.05);
color: #dde9fa;
font-size: 0.75rem;
line-height: 1.2;
font-weight: 700;
letter-spacing: 0.01em;
white-space: nowrap;
}
.kanban-deadline-chip.tone-ok {
border-color: rgba(76, 197, 145, 0.5);
background: rgba(76, 197, 145, 0.2);
color: #c5f8e3;
}
.kanban-deadline-chip.tone-warn {
border-color: rgba(228, 182, 92, 0.52);
background: rgba(228, 182, 92, 0.22);
color: #f9e0ac;
}
.kanban-deadline-chip.tone-danger {
border-color: rgba(255, 98, 98, 0.58);
background: rgba(255, 98, 98, 0.24);
color: #ffd4d4;
}
.kanban-transition-select { .kanban-transition-select {
flex: 1; flex: 1;
min-width: 140px; min-width: 140px;
@ -1258,6 +1339,45 @@
text-align: right; text-align: right;
} }
.chat-message-files {
margin-top: 0.35rem;
display: flex;
flex-wrap: wrap;
gap: 0.32rem;
}
.chat-message-file-chip {
border: 1px solid rgba(130, 153, 183, 0.45);
background: rgba(31, 45, 63, 0.58);
color: #d8e6f8;
border-radius: 999px;
padding: 0.16rem 0.46rem 0.16rem 0.36rem;
display: inline-flex;
align-items: center;
gap: 0.28rem;
cursor: pointer;
max-width: 100%;
font-size: 0.75rem;
line-height: 1.2;
}
.chat-message-file-chip:hover {
border-color: rgba(170, 198, 236, 0.65);
background: rgba(52, 74, 104, 0.66);
}
.chat-message-file-icon {
color: #a9c1df;
flex-shrink: 0;
}
.chat-message-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 220px;
}
.request-chat-list li.chat-date-divider { .request-chat-list li.chat-date-divider {
margin: 0.32rem 0 0.24rem; margin: 0.32rem 0 0.24rem;
padding: 0; padding: 0;
@ -1381,6 +1501,12 @@
width: min(980px, 100%); width: min(980px, 100%);
} }
.request-preview-head-actions {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.request-preview-body { .request-preview-body {
width: 100%; width: 100%;
min-height: 280px; min-height: 280px;
@ -1418,10 +1544,17 @@
margin: 0; margin: 0;
} }
.request-preview-download { .request-preview-download-icon {
text-decoration: none; text-decoration: none;
} }
.workspace-head-icon {
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 0.96rem;
}
.overlay { .overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@ -1500,7 +1633,10 @@
@media (max-width: 1160px) { @media (max-width: 1160px) {
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kanban-board { grid-template-columns: repeat(2, minmax(240px, 1fr)); } .kanban-column {
flex-basis: calc(50% - 0.375rem);
min-width: 240px;
}
.filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.triple { grid-template-columns: 1fr; } .triple { grid-template-columns: 1fr; }
.config-layout { grid-template-columns: 1fr; } .config-layout { grid-template-columns: 1fr; }
@ -1522,8 +1658,9 @@
.filters { .filters {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.kanban-board { .kanban-column {
grid-template-columns: 1fr; flex-basis: 100%;
min-width: 0;
} }
.filter-toolbar { .filter-toolbar {
flex-direction: column; flex-direction: column;

View file

@ -4,12 +4,12 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Административная панель • Правовой трекер</title> <title>Административная панель • Правовой трекер</title>
<link rel="stylesheet" href="/admin.css?v=20260225-7"> <link rel="stylesheet" href="/admin.css?v=20260225-12">
</head> </head>
<body> <body>
<div id="admin-root"></div> <div id="admin-root"></div>
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script> <script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script> <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
<script src="/admin.js?v=20260225-7"></script> <script src="/admin.js?v=20260225-12"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -23,6 +23,21 @@ test("kanban flow via UI: lawyer sees unassigned card, claims and opens request
await page.locator("aside .menu button[data-section='kanban']").click(); await page.locator("aside .menu button[data-section='kanban']").click();
await expect(page.locator("#section-kanban h2")).toHaveText("Канбан заявок"); await expect(page.locator("#section-kanban h2")).toHaveText("Канбан заявок");
await page.locator("#section-kanban .filter-toolbar").getByRole("button", { name: "Фильтр" }).click();
await expect(page.getByRole("heading", { name: "Фильтр таблицы" })).toBeVisible();
await page.locator("#filter-field").selectOption("client_name");
await page.locator("#filter-op").selectOption("~");
await page.locator("#filter-value").fill("Клиент");
await page.locator("#filter-overlay").getByRole("button", { name: "Добавить/Сохранить" }).click();
await expect(page.locator("#section-kanban .filter-chip")).toHaveCount(1);
const sortButton = page.locator("#section-kanban .section-head").getByRole("button", { name: "Сортировка" });
await sortButton.click();
await expect(page.getByRole("heading", { name: "Сортировка канбана" })).toBeVisible();
await page.locator("#kanban-sort-mode").selectOption("deadline");
await page.locator("#kanban-sort-overlay").getByRole("button", { name: "Ок" }).click();
await expect(sortButton).toHaveClass(/active-success/);
const card = page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first(); const card = page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first();
await expect(card).toBeVisible(); await expect(card).toBeVisible();
@ -47,15 +62,10 @@ test("kanban flow via UI: lawyer sees unassigned card, claims and opens request
} }
const pagesBeforeOpen = context.pages().length; const pagesBeforeOpen = context.pages().length;
await page await page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first().click();
.locator("#section-kanban .kanban-card")
.filter({ hasText: trackNumber })
.first()
.getByRole("button", { name: "Открыть заявку" })
.click();
await page.waitForTimeout(250); await page.waitForTimeout(250);
await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen); await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen);
await expect(page.locator("#section-request-workspace h2")).toHaveText("Карточка заявки"); await expect(page.locator("#section-request-workspace h2")).toContainText("Карточка заявки");
await page.getByRole("button", { name: "Назад к заявкам" }).click(); await page.getByRole("button", { name: "Назад" }).click();
await expect(page.locator("#section-requests h2")).toHaveText("Заявки"); await expect(page.locator("#section-requests h2")).toHaveText("Заявки");
}); });

View file

@ -49,9 +49,8 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
await page.waitForTimeout(250); await page.waitForTimeout(250);
await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen); await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen);
const requestPage = page; const requestPage = page;
await expect(requestPage.getByRole("heading", { name: "Карточка заявки" })).toBeVisible(); await expect(requestPage.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
await expect(requestPage.locator("#section-request-workspace .breadcrumbs")).toContainText("Заявки -> Заявка"); await expect(requestPage.getByRole("button", { name: "Назад" })).toBeVisible();
await expect(requestPage.getByRole("button", { name: "Назад к заявкам" })).toBeVisible();
await expect(requestPage.locator("#request-modal-messages")).toContainText("Сообщение юристу"); await expect(requestPage.locator("#request-modal-messages")).toContainText("Сообщение юристу");
await requestPage.getByRole("tab", { name: /Файлы/ }).click(); await requestPage.getByRole("tab", { name: /Файлы/ }).click();
await expect(requestPage.locator("#request-modal-files")).toContainText(clientFileName); await expect(requestPage.locator("#request-modal-files")).toContainText(clientFileName);

View file

@ -1,4 +1,5 @@
import os import os
import json
import re import re
import unittest import unittest
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -33,6 +34,7 @@ from app.models.table_availability import TableAvailability
from app.models.quote import Quote from app.models.quote import Quote
from app.models.request import Request from app.models.request import Request
from app.models.status import Status from app.models.status import Status
from app.models.status_group import StatusGroup
from app.models.status_history import StatusHistory from app.models.status_history import StatusHistory
from app.models.topic_data_template import TopicDataTemplate from app.models.topic_data_template import TopicDataTemplate
from app.models.topic import Topic from app.models.topic import Topic
@ -55,6 +57,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
Quote.__table__.create(bind=cls.engine) Quote.__table__.create(bind=cls.engine)
FormField.__table__.create(bind=cls.engine) FormField.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine) Request.__table__.create(bind=cls.engine)
StatusGroup.__table__.create(bind=cls.engine)
Status.__table__.create(bind=cls.engine) Status.__table__.create(bind=cls.engine)
Message.__table__.create(bind=cls.engine) Message.__table__.create(bind=cls.engine)
Attachment.__table__.create(bind=cls.engine) Attachment.__table__.create(bind=cls.engine)
@ -84,6 +87,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
Attachment.__table__.drop(bind=cls.engine) Attachment.__table__.drop(bind=cls.engine)
Message.__table__.drop(bind=cls.engine) Message.__table__.drop(bind=cls.engine)
Status.__table__.drop(bind=cls.engine) Status.__table__.drop(bind=cls.engine)
StatusGroup.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine) Request.__table__.drop(bind=cls.engine)
FormField.__table__.drop(bind=cls.engine) FormField.__table__.drop(bind=cls.engine)
Quote.__table__.drop(bind=cls.engine) Quote.__table__.drop(bind=cls.engine)
@ -98,6 +102,7 @@ class AdminUniversalCrudTests(unittest.TestCase):
db.execute(delete(Attachment)) db.execute(delete(Attachment))
db.execute(delete(Message)) db.execute(delete(Message))
db.execute(delete(Request)) db.execute(delete(Request))
db.execute(delete(StatusGroup))
db.execute(delete(Client)) db.execute(delete(Client))
db.execute(delete(Status)) db.execute(delete(Status))
db.execute(delete(FormField)) db.execute(delete(FormField))
@ -174,6 +179,54 @@ class AdminUniversalCrudTests(unittest.TestCase):
actions = [row.action for row in db.query(AuditLog).filter(AuditLog.entity == "quotes", AuditLog.entity_id == quote_id).all()] actions = [row.action for row in db.query(AuditLog).filter(AuditLog.entity == "quotes", AuditLog.entity_id == quote_id).all()]
self.assertEqual(set(actions), {"CREATE", "UPDATE", "DELETE"}) self.assertEqual(set(actions), {"CREATE", "UPDATE", "DELETE"})
def test_status_can_be_bound_to_status_group_via_crud(self):
headers = self._auth_headers("ADMIN")
created_group = self.client.post(
"/api/admin/crud/status_groups",
headers=headers,
json={"name": "Этапы рассмотрения", "sort_order": 15},
)
self.assertEqual(created_group.status_code, 201)
group_id = created_group.json()["id"]
UUID(group_id)
created_status = self.client.post(
"/api/admin/crud/statuses",
headers=headers,
json={
"code": "GROUPED_STATUS",
"name": "Статус с группой",
"status_group_id": group_id,
"kind": "DEFAULT",
"enabled": True,
"sort_order": 11,
"is_terminal": False,
},
)
self.assertEqual(created_status.status_code, 201)
status_id = created_status.json()["id"]
self.assertEqual(created_status.json()["status_group_id"], group_id)
got_status = self.client.get(f"/api/admin/crud/statuses/{status_id}", headers=headers)
self.assertEqual(got_status.status_code, 200)
self.assertEqual(got_status.json()["status_group_id"], group_id)
bad_status = self.client.post(
"/api/admin/crud/statuses",
headers=headers,
json={
"code": "GROUPED_STATUS_BAD",
"name": "Статус с невалидной группой",
"status_group_id": str(uuid4()),
"kind": "DEFAULT",
"enabled": True,
"sort_order": 12,
"is_terminal": False,
},
)
self.assertEqual(bad_status.status_code, 400)
def test_admin_table_catalog_lists_db_tables_for_dynamic_references(self): def test_admin_table_catalog_lists_db_tables_for_dynamic_references(self):
admin_headers = self._auth_headers("ADMIN") admin_headers = self._auth_headers("ADMIN")
response = self.client.get("/api/admin/crud/meta/tables", headers=admin_headers) response = self.client.get("/api/admin/crud/meta/tables", headers=admin_headers)
@ -188,17 +241,30 @@ class AdminUniversalCrudTests(unittest.TestCase):
self.assertIn("clients", by_table) self.assertIn("clients", by_table)
self.assertIn("quotes", by_table) self.assertIn("quotes", by_table)
self.assertIn("statuses", by_table) self.assertIn("statuses", by_table)
self.assertIn("status_groups", by_table)
self.assertEqual(by_table["requests"]["section"], "main") self.assertEqual(by_table["requests"]["section"], "main")
self.assertEqual(by_table["invoices"]["section"], "main") self.assertEqual(by_table["invoices"]["section"], "main")
self.assertEqual(by_table["quotes"]["section"], "dictionary") self.assertEqual(by_table["quotes"]["section"], "dictionary")
self.assertTrue(by_table["quotes"]["default_sort"]) self.assertTrue(by_table["quotes"]["default_sort"])
self.assertEqual(by_table["quotes"]["label"], "Цитаты") self.assertEqual(by_table["quotes"]["label"], "Цитаты")
self.assertEqual(by_table["status_groups"]["label"], "Группы статусов")
self.assertEqual(by_table["request_data_requirements"]["label"], "Требования данных заявки") self.assertEqual(by_table["request_data_requirements"]["label"], "Требования данных заявки")
quotes_columns = {col["name"]: col for col in (by_table["quotes"].get("columns") or [])} quotes_columns = {col["name"]: col for col in (by_table["quotes"].get("columns") or [])}
self.assertEqual(quotes_columns["author"]["label"], "Автор") self.assertEqual(quotes_columns["author"]["label"], "Автор")
self.assertEqual(quotes_columns["sort_order"]["label"], "Порядок") self.assertEqual(quotes_columns["sort_order"]["label"], "Порядок")
self.assertTrue(all(str(col.get("label") or "").strip() for col in (by_table["quotes"].get("columns") or []))) self.assertTrue(all(str(col.get("label") or "").strip() for col in (by_table["quotes"].get("columns") or [])))
statuses_columns = {col["name"]: col for col in (by_table["statuses"].get("columns") or [])}
self.assertEqual(statuses_columns["status_group_id"]["reference"]["table"], "status_groups")
self.assertEqual(statuses_columns["status_group_id"]["reference"]["label_field"], "name")
requests_columns = {col["name"]: col for col in (by_table["requests"].get("columns") or [])}
self.assertEqual(requests_columns["assigned_lawyer_id"]["reference"]["table"], "admin_users")
self.assertEqual(requests_columns["assigned_lawyer_id"]["reference"]["label_field"], "name")
invoices_columns = {col["name"]: col for col in (by_table["invoices"].get("columns") or [])}
self.assertEqual(invoices_columns["request_id"]["reference"]["table"], "requests")
self.assertEqual(invoices_columns["request_id"]["reference"]["label_field"], "track_number")
self.assertEqual(invoices_columns["client_id"]["reference"]["table"], "clients")
self.assertEqual(invoices_columns["client_id"]["reference"]["label_field"], "full_name")
for table_name, table_meta in by_table.items(): for table_name, table_meta in by_table.items():
if table_name in {"requests", "invoices"}: if table_name in {"requests", "invoices"}:
expected_section = "main" expected_section = "main"
@ -1202,12 +1268,50 @@ class AdminUniversalCrudTests(unittest.TestCase):
def test_requests_kanban_returns_grouped_cards_and_role_scope(self): def test_requests_kanban_returns_grouped_cards_and_role_scope(self):
with self.SessionLocal() as db: with self.SessionLocal() as db:
group_new = StatusGroup(name="Новые", sort_order=10)
group_progress = StatusGroup(name="В работе", sort_order=20)
group_waiting = StatusGroup(name="Ожидание", sort_order=30)
group_done = StatusGroup(name="Завершены", sort_order=40)
db.add_all([group_new, group_progress, group_waiting, group_done])
db.flush()
db.add_all( db.add_all(
[ [
Status(code="NEW", name="Новая", enabled=True, sort_order=1, is_terminal=False, kind="DEFAULT"), Status(
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=2, is_terminal=False, kind="DEFAULT"), code="NEW",
Status(code="WAITING_CLIENT", name="Ожидание клиента", enabled=True, sort_order=3, is_terminal=False, kind="DEFAULT"), name="Новая",
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=4, is_terminal=True, kind="DEFAULT"), enabled=True,
sort_order=1,
is_terminal=False,
kind="DEFAULT",
status_group_id=group_new.id,
),
Status(
code="IN_PROGRESS",
name="В работе",
enabled=True,
sort_order=2,
is_terminal=False,
kind="DEFAULT",
status_group_id=group_progress.id,
),
Status(
code="WAITING_CLIENT",
name="Ожидание клиента",
enabled=True,
sort_order=3,
is_terminal=False,
kind="DEFAULT",
status_group_id=group_waiting.id,
),
Status(
code="CLOSED",
name="Закрыта",
enabled=True,
sort_order=4,
is_terminal=True,
kind="DEFAULT",
status_group_id=group_done.id,
),
] ]
) )
db.add(Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1)) db.add(Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1))
@ -1287,10 +1391,21 @@ class AdminUniversalCrudTests(unittest.TestCase):
extra_fields={}, extra_fields={},
assigned_lawyer_id=str(lawyer_other.id), assigned_lawyer_id=str(lawyer_other.id),
) )
db.add_all([request_new, request_progress, request_waiting]) request_overdue = Request(
track_number="TRK-KANBAN-OVERDUE",
client_name="Клиент 4",
client_phone="+79990000004",
topic_code="civil-law",
status_code="IN_PROGRESS",
description="Просроченная заявка",
extra_fields={},
assigned_lawyer_id=str(lawyer_main.id),
)
db.add_all([request_new, request_progress, request_waiting, request_overdue])
db.flush() db.flush()
entered_progress_at = datetime.now(timezone.utc) - timedelta(hours=2) entered_progress_at = datetime.now(timezone.utc) - timedelta(hours=2)
entered_overdue_at = datetime.now(timezone.utc) - timedelta(hours=30)
db.add( db.add(
StatusHistory( StatusHistory(
request_id=request_progress.id, request_id=request_progress.id,
@ -1301,31 +1416,46 @@ class AdminUniversalCrudTests(unittest.TestCase):
created_at=entered_progress_at, created_at=entered_progress_at,
) )
) )
db.add(
StatusHistory(
request_id=request_overdue.id,
from_status="NEW",
to_status="IN_PROGRESS",
changed_by_admin_id=None,
comment="overdue",
created_at=entered_overdue_at,
)
)
db.commit() db.commit()
request_new_id = str(request_new.id) request_new_id = str(request_new.id)
request_progress_id = str(request_progress.id) request_progress_id = str(request_progress.id)
request_waiting_id = str(request_waiting.id) request_waiting_id = str(request_waiting.id)
request_overdue_id = str(request_overdue.id)
lawyer_main_id = str(lawyer_main.id) lawyer_main_id = str(lawyer_main.id)
group_new_id = str(group_new.id)
group_progress_id = str(group_progress.id)
admin_headers = self._auth_headers("ADMIN", email="root@example.com") admin_headers = self._auth_headers("ADMIN", email="root@example.com")
admin_response = self.client.get("/api/admin/requests/kanban?limit=100", headers=admin_headers) admin_response = self.client.get("/api/admin/requests/kanban?limit=100", headers=admin_headers)
self.assertEqual(admin_response.status_code, 200) self.assertEqual(admin_response.status_code, 200)
admin_payload = admin_response.json() admin_payload = admin_response.json()
self.assertEqual(admin_payload["scope"], "ADMIN") self.assertEqual(admin_payload["scope"], "ADMIN")
self.assertEqual(admin_payload["total"], 3) self.assertEqual(admin_payload["total"], 4)
rows = {item["id"]: item for item in (admin_payload.get("rows") or [])} rows = {item["id"]: item for item in (admin_payload.get("rows") or [])}
self.assertIn(request_new_id, rows) self.assertIn(request_new_id, rows)
self.assertIn(request_progress_id, rows) self.assertIn(request_progress_id, rows)
self.assertIn(request_waiting_id, rows) self.assertIn(request_waiting_id, rows)
self.assertEqual(rows[request_new_id]["status_group"], "NEW") self.assertIn(request_overdue_id, rows)
self.assertEqual(rows[request_progress_id]["status_group"], "IN_PROGRESS") self.assertEqual(rows[request_new_id]["status_group"], group_new_id)
self.assertEqual(rows[request_progress_id]["status_group"], group_progress_id)
self.assertEqual(rows[request_progress_id]["assigned_lawyer_id"], lawyer_main_id) self.assertEqual(rows[request_progress_id]["assigned_lawyer_id"], lawyer_main_id)
transitions = rows[request_progress_id].get("available_transitions") or [] transitions = rows[request_progress_id].get("available_transitions") or []
self.assertTrue(any(item.get("to_status") == "WAITING_CLIENT" for item in transitions)) self.assertTrue(any(item.get("to_status") == "WAITING_CLIENT" for item in transitions))
self.assertEqual(rows[request_progress_id]["case_deadline_at"], "2031-01-01T10:00:00+00:00") self.assertEqual(rows[request_progress_id]["case_deadline_at"], "2031-01-01T10:00:00+00:00")
self.assertIsNotNone(rows[request_progress_id]["sla_deadline_at"]) self.assertIsNotNone(rows[request_progress_id]["sla_deadline_at"])
self.assertFalse(bool(admin_payload.get("truncated"))) self.assertFalse(bool(admin_payload.get("truncated")))
self.assertEqual([item.get("label") for item in (admin_payload.get("columns") or [])][:4], ["Новые", "В работе", "Ожидание", "Завершены"])
lawyer_headers = self._auth_headers("LAWYER", email="lawyer.kanban@example.com", sub=lawyer_main_id) lawyer_headers = self._auth_headers("LAWYER", email="lawyer.kanban@example.com", sub=lawyer_main_id)
lawyer_response = self.client.get("/api/admin/requests/kanban?limit=100", headers=lawyer_headers) lawyer_response = self.client.get("/api/admin/requests/kanban?limit=100", headers=lawyer_headers)
@ -1335,8 +1465,43 @@ class AdminUniversalCrudTests(unittest.TestCase):
lawyer_rows = {item["id"]: item for item in (lawyer_payload.get("rows") or [])} lawyer_rows = {item["id"]: item for item in (lawyer_payload.get("rows") or [])}
self.assertIn(request_new_id, lawyer_rows) self.assertIn(request_new_id, lawyer_rows)
self.assertIn(request_progress_id, lawyer_rows) self.assertIn(request_progress_id, lawyer_rows)
self.assertIn(request_overdue_id, lawyer_rows)
self.assertNotIn(request_waiting_id, lawyer_rows) self.assertNotIn(request_waiting_id, lawyer_rows)
self.assertEqual(lawyer_payload["total"], 2) self.assertEqual(lawyer_payload["total"], 3)
filtered_by_lawyer = self.client.get(
"/api/admin/requests/kanban",
headers=admin_headers,
params={
"limit": 100,
"filters": json.dumps([{"field": "assigned_lawyer_id", "op": "=", "value": lawyer_main_id}]),
},
)
self.assertEqual(filtered_by_lawyer.status_code, 200)
filtered_rows = {item["id"] for item in (filtered_by_lawyer.json().get("rows") or [])}
self.assertEqual(filtered_rows, {request_progress_id, request_overdue_id})
filtered_overdue = self.client.get(
"/api/admin/requests/kanban",
headers=admin_headers,
params={
"limit": 100,
"filters": json.dumps([{"field": "overdue", "op": "=", "value": True}]),
},
)
self.assertEqual(filtered_overdue.status_code, 200)
overdue_rows = {item["id"] for item in (filtered_overdue.json().get("rows") or [])}
self.assertEqual(overdue_rows, {request_overdue_id})
sorted_by_deadline = self.client.get(
"/api/admin/requests/kanban",
headers=admin_headers,
params={"limit": 100, "sort_mode": "deadline"},
)
self.assertEqual(sorted_by_deadline.status_code, 200)
sorted_rows = sorted_by_deadline.json().get("rows") or []
self.assertTrue(sorted_rows)
self.assertEqual(sorted_rows[0]["id"], request_overdue_id)
def test_lawyer_can_claim_unassigned_request_and_takeover_is_forbidden(self): def test_lawyer_can_claim_unassigned_request_and_takeover_is_forbidden(self):
with self.SessionLocal() as db: with self.SessionLocal() as db:

View file

@ -84,6 +84,7 @@ class MigrationTests(unittest.TestCase):
"table_availability", "table_availability",
"topics", "topics",
"statuses", "statuses",
"status_groups",
"form_fields", "form_fields",
"topic_required_fields", "topic_required_fields",
"topic_data_templates", "topic_data_templates",
@ -108,7 +109,7 @@ class MigrationTests(unittest.TestCase):
def test_alembic_version_is_set(self): def test_alembic_version_is_set(self):
with self.engine.connect() as conn: with self.engine.connect() as conn:
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one() version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
self.assertEqual(version, "0017_transition_requirements") self.assertEqual(version, "0018_status_groups")
def test_responsible_column_exists_in_all_domain_tables(self): def test_responsible_column_exists_in_all_domain_tables(self):
tables = { tables = {
@ -117,6 +118,7 @@ class MigrationTests(unittest.TestCase):
"table_availability", "table_availability",
"topics", "topics",
"statuses", "statuses",
"status_groups",
"form_fields", "form_fields",
"topic_required_fields", "topic_required_fields",
"topic_data_templates", "topic_data_templates",
@ -202,6 +204,15 @@ class MigrationTests(unittest.TestCase):
columns = {column["name"] for column in self.inspector.get_columns("statuses")} columns = {column["name"] for column in self.inspector.get_columns("statuses")}
self.assertIn("kind", columns) self.assertIn("kind", columns)
self.assertIn("invoice_template", columns) self.assertIn("invoice_template", columns)
self.assertIn("status_group_id", columns)
def test_status_groups_contains_core_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("status_groups")}
self.assertIn("id", columns)
self.assertIn("name", columns)
self.assertIn("sort_order", columns)
self.assertIn("created_at", columns)
self.assertIn("responsible", columns)
def test_clients_contains_core_columns(self): def test_clients_contains_core_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("clients")} columns = {column["name"] for column in self.inspector.get_columns("clients")}