Test-4 commit

This commit is contained in:
TronoSfera 2026-02-25 21:10:13 +03:00
parent 90450b8918
commit 7754a6fedf
16 changed files with 2286 additions and 258 deletions

View file

@ -0,0 +1,27 @@
"""add transition requirements fields for status designer
Revision ID: 0017_transition_requirements
Revises: 0016_table_availability
Create Date: 2026-02-25
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0017_transition_requirements"
down_revision = "0016_table_availability"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("topic_status_transitions", sa.Column("required_data_keys", sa.JSON(), nullable=True))
op.add_column("topic_status_transitions", sa.Column("required_mime_types", sa.JSON(), nullable=True))
def downgrade():
op.drop_column("topic_status_transitions", "required_mime_types")
op.drop_column("topic_status_transitions", "required_data_keys")

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import importlib import importlib
import json
import pkgutil import pkgutil
import uuid import uuid
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
@ -52,6 +53,7 @@ from app.services.request_read_markers import (
from app.services.request_status import apply_status_change_effects from app.services.request_status import apply_status_change_effects
from app.services.status_flow import transition_allowed_for_topic from app.services.status_flow import transition_allowed_for_topic
from app.services.request_templates import validate_required_topic_fields_or_400 from app.services.request_templates import validate_required_topic_fields_or_400
from app.services.status_transition_requirements import validate_transition_requirements_or_400
from app.services.billing_flow import apply_billing_transition_effects, normalize_status_kind_or_400 from app.services.billing_flow import apply_billing_transition_effects, normalize_status_kind_or_400
from app.services.universal_query import apply_universal_query from app.services.universal_query import apply_universal_query
@ -399,6 +401,8 @@ def _column_label(table_name: str, column_name: str) -> str:
"options": "Опции", "options": "Опции",
"field_key": "Поле формы", "field_key": "Поле формы",
"sla_hours": "SLA (часы)", "sla_hours": "SLA (часы)",
"required_data_keys": "Обязательные данные шага",
"required_mime_types": "Обязательные файлы шага",
"avatar_url": "Аватар", "avatar_url": "Аватар",
"file_name": "Имя файла", "file_name": "Имя файла",
"mime_type": "MIME-тип", "mime_type": "MIME-тип",
@ -742,6 +746,40 @@ def _as_positive_int_or_400(value: Any, field_name: str) -> int:
return number return number
def _normalize_string_list_or_400(value: Any, field_name: str) -> list[str] | None:
if value is None:
return None
source = value
if isinstance(source, str):
text = source.strip()
if not text:
return None
if text.startswith("["):
try:
source = json.loads(text)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть JSON-массивом строк')
else:
source = [chunk.strip() for chunk in text.replace("\n", ",").split(",")]
if not isinstance(source, (list, tuple, set)):
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть массивом строк')
out: list[str] = []
seen: set[str] = set()
for item in source:
text = str(item or "").strip()
if not text:
continue
lowered = text.lower()
if lowered in seen:
continue
seen.add(lowered)
out.append(text)
return out
def _apply_topic_required_fields_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]: def _apply_topic_required_fields_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload) data = dict(payload)
if "topic_code" in data: if "topic_code" in data:
@ -831,6 +869,10 @@ def _apply_topic_status_transitions_fields(db: Session, payload: dict[str, Any])
data["sla_hours"] = None data["sla_hours"] = None
else: else:
data["sla_hours"] = _as_positive_int_or_400(raw, "sla_hours") data["sla_hours"] = _as_positive_int_or_400(raw, "sla_hours")
if "required_data_keys" in data:
data["required_data_keys"] = _normalize_string_list_or_400(data.get("required_data_keys"), "required_data_keys")
if "required_mime_types" in data:
data["required_mime_types"] = _normalize_string_list_or_400(data.get("required_mime_types"), "required_mime_types")
return data return data
@ -1432,6 +1474,16 @@ def update_row(
detail="Переход статуса не разрешен для выбранной темы", detail="Переход статуса не разрешен для выбранной темы",
) )
if before_status != after_status and isinstance(row, Request): 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,
)
billing_note = apply_billing_transition_effects( billing_note = apply_billing_transition_effects(
db, db,
req=row, req=row,

View file

@ -1,7 +1,7 @@
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy import case, or_, update from sqlalchemy import case, or_, update
@ -33,11 +33,86 @@ from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_law
from app.services.request_status import actor_admin_uuid, apply_status_change_effects from app.services.request_status import actor_admin_uuid, apply_status_change_effects
from app.services.status_flow import transition_allowed_for_topic from app.services.status_flow import transition_allowed_for_topic
from app.services.request_templates import validate_required_topic_fields_or_400 from app.services.request_templates import validate_required_topic_fields_or_400
from app.services.status_transition_requirements import normalize_string_list, validate_transition_requirements_or_400
from app.services.billing_flow import apply_billing_transition_effects from app.services.billing_flow import apply_billing_transition_effects
from app.services.universal_query import apply_universal_query from app.services.universal_query import apply_universal_query
router = APIRouter() router = APIRouter()
REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"} REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"}
KANBAN_GROUP_LABELS = {
"NEW": "Новые",
"IN_PROGRESS": "В работе",
"WAITING": "Ожидание",
"DONE": "Завершены",
}
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}
def _kanban_group_for_status(status_code: str, status_meta: dict[str, object]) -> str:
code = str(status_code or "").strip().upper()
kind = str(status_meta.get("kind") or "DEFAULT").upper()
name = str(status_meta.get("name") or "").upper()
is_terminal = bool(status_meta.get("is_terminal"))
if is_terminal:
return "DONE"
if kind == "PAID":
return "DONE"
if code.startswith("NEW") or "НОВ" in name:
return "NEW"
waiting_tokens = ("WAIT", "PEND", "HOLD", "SUSPEND", "BLOCK")
waiting_ru_tokens = ("ОЖИД", "ПАУЗ", "СОГЛАС", "ОПЛАТ", "СУД")
if kind == "INVOICE":
return "WAITING"
if any(token in code for token in waiting_tokens) or any(token in name for token in waiting_ru_tokens):
return "WAITING"
done_tokens = ("CLOSE", "RESOLV", "REJECT", "DONE", "PAID")
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 "IN_PROGRESS"
def _parse_datetime_safe(value: object) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
text = str(value).strip()
if not text:
return None
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _extract_case_deadline(extra_fields: object) -> datetime | None:
if not isinstance(extra_fields, dict):
return None
deadline_keys = (
"deadline_at",
"deadline",
"due_date",
"due_at",
"case_deadline",
"court_date",
"hearing_date",
"next_action_deadline",
)
for key in deadline_keys:
parsed = _parse_datetime_safe(extra_fields.get(key))
if parsed:
return parsed
return None
def _request_uuid_or_400(request_id: str) -> UUID: def _request_uuid_or_400(request_id: str) -> UUID:
@ -140,6 +215,216 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
} }
@router.get("/kanban")
def get_requests_kanban(
db: Session = Depends(get_db),
admin=Depends(require_role("ADMIN", "LAWYER")),
limit: int = Query(default=400, ge=1, le=1000),
):
role = str(admin.get("role") or "").upper()
actor = str(admin.get("sub") or "").strip()
base_query = db.query(Request)
if role == "LAWYER":
if not actor:
raise HTTPException(status_code=401, detail="Некорректный токен")
base_query = base_query.filter(
or_(
Request.assigned_lawyer_id == actor,
Request.assigned_lawyer_id.is_(None),
)
)
request_rows: list[Request] = (
base_query
.order_by(Request.created_at.desc())
.limit(limit)
.all()
)
total = int(base_query.count() or 0)
request_id_to_row = {str(row.id): row 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()}
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 = db.query(Status).filter(Status.code.in_(list(status_codes))).all()
status_meta_map = {
str(row.code): {
"name": str(row.name or row.code),
"kind": str(row.kind or "DEFAULT"),
"is_terminal": bool(row.is_terminal),
"sort_order": int(row.sort_order or 0),
}
for 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()}
lawyer_name_map: dict[str, str] = {}
if assigned_ids:
valid_lawyer_ids: list[UUID] = []
for raw in assigned_ids:
try:
valid_lawyer_ids.append(UUID(raw))
except ValueError:
continue
if valid_lawyer_ids:
lawyer_rows = db.query(AdminUser).filter(AdminUser.id.in_(valid_lawyer_ids)).all()
lawyer_name_map = {
str(row.id): str(row.name or row.email or row.id)
for row in lawyer_rows
}
history_rows: list[StatusHistory] = []
if request_ids:
history_rows = (
db.query(StatusHistory)
.filter(StatusHistory.request_id.in_(request_ids))
.order_by(StatusHistory.request_id.asc(), StatusHistory.created_at.desc())
.all()
)
current_status_changed_at: dict[str, datetime] = {}
previous_status_by_request: dict[str, str] = {}
for row in history_rows:
request_id = str(row.request_id)
request_row = request_id_to_row.get(request_id)
if request_row is None:
continue
current_status = str(request_row.status_code or "").strip()
to_status = str(row.to_status or "").strip()
if not current_status or to_status != current_status:
continue
if request_id not in current_status_changed_at and row.created_at:
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:
continue
transitions_by_key.setdefault((topic_code, from_status), []).append(row)
transitions_to_key.setdefault((topic_code, to_status), []).append(row)
items: list[dict[str, object]] = []
group_totals = {key: 0 for key in KANBAN_GROUP_LABELS.keys()}
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 = _kanban_group_for_status(status_code, status_meta)
group_totals[status_group] = int(group_totals.get(status_group, 0)) + 1
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:
continue
to_meta = _status_meta_or_default(status_meta_map, to_status)
available_transitions.append(
{
"to_status": to_status,
"to_status_name": str(to_meta.get("name") or to_status),
"target_group": _kanban_group_for_status(to_status, to_meta),
"sla_hours": transition.sla_hours,
}
)
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]
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(
{
"id": request_id,
"track_number": row.track_number,
"client_name": row.client_name,
"client_phone": row.client_phone,
"topic_code": row.topic_code,
"status_code": status_code,
"status_name": str(status_meta.get("name") or status_code),
"status_group": status_group,
"assigned_lawyer_id": assigned_id,
"assigned_lawyer_name": lawyer_name_map.get(assigned_id or "", assigned_id),
"description": row.description,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"lawyer_has_unread_updates": bool(row.lawyer_has_unread_updates),
"lawyer_unread_event_type": row.lawyer_unread_event_type,
"client_has_unread_updates": bool(row.client_has_unread_updates),
"client_unread_event_type": row.client_unread_event_type,
"case_deadline_at": case_deadline.isoformat() if case_deadline else None,
"sla_deadline_at": sla_deadline.isoformat() if sla_deadline else None,
"available_transitions": available_transitions,
}
)
columns = [
{
"key": key,
"label": label,
"total": int(group_totals.get(key, 0)),
}
for key, label in KANBAN_GROUP_LABELS.items()
]
return {
"scope": role,
"rows": items,
"columns": columns,
"total": total,
"limit": int(limit),
"truncated": bool(total > len(items)),
}
@router.post("", status_code=201) @router.post("", status_code=201)
def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))): def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
actor_role = str(admin.get("role") or "").upper() actor_role = str(admin.get("role") or "").upper()
@ -227,6 +512,13 @@ def update_request(
next_status, next_status,
): ):
raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы") 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,
)
billing_note = apply_billing_transition_effects( billing_note = apply_billing_transition_effects(
db, db,
req=row, req=row,
@ -418,7 +710,7 @@ def get_request_status_route(
add_code(current_status) add_code(current_status)
transition_by_to_status: dict[str, dict[str, str | int | None]] = {} transition_by_to_status: dict[str, dict[str, object]] = {}
for row in transitions: for row in transitions:
to_code = str(row.to_status or "").strip() to_code = str(row.to_status or "").strip()
if not to_code: if not to_code:
@ -428,6 +720,8 @@ def get_request_status_route(
transition_by_to_status[to_code] = { transition_by_to_status[to_code] = {
"from_status": str(row.from_status or "").strip() or None, "from_status": str(row.from_status or "").strip() or None,
"sla_hours": row.sla_hours, "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), "sort_order": int(row.sort_order or 0),
} }
@ -461,6 +755,12 @@ def get_request_status_route(
sla_hours = transition_meta.get("sla_hours") sla_hours = transition_meta.get("sla_hours")
if sla_hours is not None: if sla_hours is not None:
note_parts.append(f"SLA: {sla_hours} ч") 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") kind = str(meta.get("kind") or "DEFAULT")
if kind == "INVOICE": if kind == "INVOICE":
note_parts.append("Этап выставления счета") note_parts.append("Этап выставления счета")
@ -474,6 +774,8 @@ def get_request_status_route(
"kind": kind, "kind": kind,
"state": state, "state": state,
"sla_hours": sla_hours, "sla_hours": sla_hours,
"required_data_keys": required_data_keys,
"required_mime_types": required_mime_types,
"changed_at": changed_at_by_status.get(code), "changed_at": changed_at_by_status.get(code),
"note": "".join(note_parts), "note": "".join(note_parts),
} }

View file

@ -1,4 +1,4 @@
from sqlalchemy import String, Integer, Boolean, UniqueConstraint from sqlalchemy import String, Integer, Boolean, UniqueConstraint, JSON
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
@ -21,4 +21,6 @@ class TopicStatusTransition(Base, UUIDMixin, TimestampMixin):
to_status: Mapped[str] = mapped_column(String(50), nullable=False, index=True) to_status: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
sla_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) sla_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
required_data_keys: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
required_mime_types: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)

View file

@ -0,0 +1,120 @@
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.models.attachment import Attachment
from app.models.request import Request
from app.models.topic_status_transition import TopicStatusTransition
def normalize_string_list(value: Any) -> list[str]:
if not isinstance(value, (list, tuple, set)):
return []
out: list[str] = []
seen: set[str] = set()
for item in value:
text = str(item or "").strip()
if not text:
continue
lowered = text.lower()
if lowered in seen:
continue
seen.add(lowered)
out.append(text)
return out
def _is_missing_value(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str):
return not value.strip()
if isinstance(value, (list, tuple, dict, set)):
return len(value) == 0
return False
def _find_transition_rule(
db: Session,
topic_code: str | None,
from_status: str,
to_status: str,
) -> TopicStatusTransition | None:
topic = str(topic_code or "").strip()
from_code = str(from_status or "").strip()
to_code = str(to_status or "").strip()
if not topic or not from_code or not to_code or from_code == to_code:
return None
return (
db.query(TopicStatusTransition)
.filter(
TopicStatusTransition.topic_code == topic,
TopicStatusTransition.from_status == from_code,
TopicStatusTransition.to_status == to_code,
TopicStatusTransition.enabled.is_(True),
)
.order_by(TopicStatusTransition.sort_order.asc(), TopicStatusTransition.created_at.asc())
.first()
)
def _mime_matches(requirement: str, value: str) -> bool:
required = str(requirement or "").strip().lower()
actual = str(value or "").strip().lower()
if not required or not actual:
return False
if required.endswith("/*"):
return actual.startswith(required[:-1])
return actual == required
def validate_transition_requirements_or_400(
db: Session,
req: Request,
from_status: str,
to_status: str,
*,
extra_fields_override: dict[str, Any] | None = None,
) -> None:
transition = _find_transition_rule(
db,
topic_code=str(req.topic_code or "").strip() or None,
from_status=from_status,
to_status=to_status,
)
if transition is None:
return
required_data_keys = normalize_string_list(transition.required_data_keys)
required_mime_types = normalize_string_list(transition.required_mime_types)
if not required_data_keys and not required_mime_types:
return
payload = extra_fields_override if isinstance(extra_fields_override, dict) else req.extra_fields
if not isinstance(payload, dict):
payload = {}
missing_data_keys = [key for key in required_data_keys if _is_missing_value(payload.get(key))]
available_mime_types = [
str(mime_type or "").strip().lower()
for (mime_type,) in db.query(Attachment.mime_type).filter(Attachment.request_id == req.id).all()
if str(mime_type or "").strip()
]
missing_mime_types: list[str] = []
for required in required_mime_types:
if any(_mime_matches(required, mime) for mime in available_mime_types):
continue
missing_mime_types.append(required)
if not missing_data_keys and not missing_mime_types:
return
parts: list[str] = []
if missing_data_keys:
parts.append("обязательные данные: " + ", ".join(missing_data_keys))
if missing_mime_types:
parts.append("обязательные файлы: " + ", ".join(missing_mime_types))
raise HTTPException(status_code=400, detail="Переход требует заполнения шага: " + "; ".join(parts))

View file

@ -252,6 +252,178 @@
color: #f6dab0; color: #f6dab0;
} }
.kanban-wrap {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.kanban-board {
display: grid;
grid-template-columns: repeat(4, minmax(260px, 1fr));
gap: 0.75rem;
overflow-x: auto;
padding-bottom: 0.25rem;
}
.kanban-column {
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.02);
min-height: 460px;
display: flex;
flex-direction: column;
transition: border-color 0.18s ease, background 0.18s ease;
}
.kanban-column.drag-over {
border-color: rgba(212, 168, 106, 0.55);
background: rgba(212, 168, 106, 0.08);
}
.kanban-column-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 0.75rem;
border-bottom: 1px solid var(--line);
}
.kanban-column-head b {
font-size: 0.95rem;
color: #e5eefb;
}
.kanban-column-head span {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.12rem 0.45rem;
font-size: 0.76rem;
color: var(--muted);
background: rgba(255, 255, 255, 0.04);
}
.kanban-column-body {
padding: 0.65rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
overflow-y: auto;
max-height: 68vh;
}
.kanban-card {
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
padding: 0.6rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
cursor: grab;
}
.kanban-card:active {
cursor: grabbing;
}
.kanban-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
}
.kanban-card-head code {
color: #f5dbb5;
font-size: 0.8rem;
font-weight: 700;
}
.kanban-status-badge {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.15rem 0.5rem;
font-size: 0.72rem;
line-height: 1.2;
color: #dce8f9;
background: rgba(255, 255, 255, 0.06);
max-width: 68%;
text-align: right;
word-break: break-word;
}
.kanban-status-badge.group-new {
border-color: rgba(76, 160, 255, 0.5);
color: #b8d8ff;
}
.kanban-status-badge.group-in_progress {
border-color: rgba(212, 168, 106, 0.5);
color: #f8d8aa;
}
.kanban-status-badge.group-waiting {
border-color: rgba(137, 165, 218, 0.5);
color: #c9dbff;
}
.kanban-status-badge.group-done {
border-color: rgba(77, 190, 147, 0.55);
color: #b7efda;
}
.kanban-card-desc {
margin: 0;
color: #e8f0fb;
font-size: 0.86rem;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 2.8rem;
}
.kanban-card-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.4rem;
color: var(--muted);
font-size: 0.76rem;
}
.danger-text {
color: #ffb7b7;
}
.kanban-card-actions {
margin-top: 0.1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.45rem;
flex-wrap: wrap;
}
.kanban-transition-select {
flex: 1;
min-width: 140px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
color: #dbe7f8;
padding: 0.34rem 0.55rem;
font-size: 0.78rem;
}
.kanban-empty {
margin: 0.3rem 0 0;
text-align: center;
}
.filters { .filters {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@ -609,6 +781,116 @@
min-width: 640px; min-width: 640px;
} }
.status-designer {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.65rem;
background: rgba(255, 255, 255, 0.015);
margin-bottom: 0.72rem;
}
.status-designer-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.7rem;
flex-wrap: wrap;
margin-bottom: 0.6rem;
}
.status-designer-head h4 {
margin: 0;
font-size: 0.95rem;
}
.status-designer-head p {
margin: 0.3rem 0 0;
font-size: 0.82rem;
}
.status-designer-controls {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.status-designer-controls select {
min-width: 220px;
max-width: 340px;
}
.status-designer-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.55rem;
}
.status-node-card {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.52rem;
background: rgba(255, 255, 255, 0.018);
display: grid;
gap: 0.46rem;
}
.status-node-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.45rem;
}
.status-node-head b {
display: block;
color: #e6effd;
margin-bottom: 0.1rem;
font-size: 0.9rem;
}
.status-node-head code {
font-size: 0.74rem;
color: #aac0dd;
}
.status-node-terminal {
border: 1px solid rgba(214, 144, 95, 0.45);
border-radius: 999px;
padding: 0.14rem 0.48rem;
font-size: 0.68rem;
color: #f3d2a8;
white-space: nowrap;
}
.status-node-links li {
margin-bottom: 0.34rem;
}
.status-link-chip {
width: 100%;
text-align: left;
border: 1px solid rgba(118, 145, 184, 0.45);
border-radius: 10px;
background: rgba(45, 67, 98, 0.28);
color: #dce8f8;
padding: 0.34rem 0.46rem;
cursor: pointer;
display: grid;
gap: 0.2rem;
}
.status-link-chip small {
color: #9eb1ca;
font-size: 0.72rem;
line-height: 1.35;
}
.status-link-chip:hover {
border-color: rgba(138, 168, 210, 0.7);
background: rgba(71, 102, 148, 0.3);
}
.json { .json {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: 12px;
@ -836,6 +1118,17 @@
justify-content: center; justify-content: center;
} }
.file-action-btn {
width: 34px;
height: 34px;
border-radius: 10px;
color: #d8e4f5;
}
.request-file-link-icon {
text-decoration: none;
}
.request-attachments-head { .request-attachments-head {
display: flex; display: flex;
align-items: center; align-items: center;
@ -855,8 +1148,44 @@
max-height: 480px; max-height: 480px;
} }
.request-chat-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
margin-bottom: 0.58rem;
flex-wrap: wrap;
}
.request-chat-tabs {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 255, 255, 0.02);
}
.tab-btn {
border: none;
background: transparent;
color: #9fb3cf;
border-radius: 999px;
padding: 0.26rem 0.64rem;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
}
.tab-btn.active {
background: rgba(90, 126, 194, 0.38);
color: #e8f1ff;
box-shadow: inset 0 0 0 1px rgba(104, 145, 223, 0.45);
}
.request-chat-list { .request-chat-list {
max-height: 520px; max-height: 470px;
overflow: auto; overflow: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -929,16 +1258,18 @@
text-align: right; text-align: right;
} }
.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;
border: none; border: none;
background: transparent; background: transparent;
display: flex; display: flex;
justify-content: center; justify-content: center;
max-width: none;
width: 100%;
} }
.chat-date-divider span { .request-chat-list li.chat-date-divider span {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -952,6 +1283,100 @@
line-height: 1.2; line-height: 1.2;
} }
.request-chat-composer-actions {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.composer-attach-btn {
width: 36px;
height: 36px;
border-radius: 50%;
}
.request-chat-composer-dropzone {
border: 1px dashed rgba(111, 140, 186, 0.42);
border-radius: 12px;
padding: 0.55rem;
background: rgba(255, 255, 255, 0.015);
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.request-chat-composer-dropzone.drag-active {
border-color: rgba(119, 165, 241, 0.88);
background: rgba(87, 128, 206, 0.12);
box-shadow: inset 0 0 0 1px rgba(119, 165, 241, 0.32);
}
.request-drop-hint {
margin-top: 0.38rem;
font-size: 0.77rem;
}
.request-pending-files {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin-top: -0.18rem;
}
.pending-file-chip {
display: inline-flex;
align-items: center;
gap: 0.34rem;
border: 1px solid rgba(112, 142, 191, 0.4);
border-radius: 999px;
background: rgba(49, 73, 109, 0.32);
padding: 0.2rem 0.34rem 0.2rem 0.42rem;
max-width: min(100%, 360px);
min-height: 30px;
}
.pending-file-icon {
color: #a4bde0;
font-size: 0.8rem;
line-height: 1;
flex-shrink: 0;
}
.pending-file-name {
font-size: 0.79rem;
color: #dce7f8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pending-file-remove {
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid rgba(156, 184, 224, 0.4);
background: rgba(17, 29, 44, 0.72);
color: #d7e4f8;
cursor: pointer;
line-height: 1;
padding: 0;
font-size: 0.88rem;
flex-shrink: 0;
}
.request-files-tab .request-modal-list {
max-height: 520px;
overflow: auto;
}
.request-files-tab-actions {
margin-top: 0.55rem;
display: flex;
justify-content: flex-start;
gap: 0.45rem;
flex-wrap: wrap;
}
.request-preview-modal { .request-preview-modal {
width: min(980px, 100%); width: min(980px, 100%);
} }
@ -1075,6 +1500,7 @@
@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)); }
.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; }
@ -1096,6 +1522,9 @@
.filters { .filters {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.kanban-board {
grid-template-columns: 1fr;
}
.filter-toolbar { .filter-toolbar {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;

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-1"> <link rel="stylesheet" href="/admin.css?v=20260225-7">
</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-1"></script> <script src="/admin.js?v=20260225-7"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -56,8 +56,8 @@
| P35 | сделано | Предпросмотр документов | Добавить предпросмотр загруженных документов (pdf/jpg/mp4) в модальном окне или выделенной зоне страницы заявки, не ломая текущую загрузку/скачивание | Предпросмотр реализован в `client.html` и в рабочей вкладке заявки `admin.jsx` (`/admin.html?view=request&requestId=...`); сохранено действие «Открыть / скачать», добавлен backend тест inline-preview | | P35 | сделано | Предпросмотр документов | Добавить предпросмотр загруженных документов (pdf/jpg/mp4) в модальном окне или выделенной зоне страницы заявки, не ломая текущую загрузку/скачивание | Предпросмотр реализован в `client.html` и в рабочей вкладке заявки `admin.jsx` (`/admin.html?view=request&requestId=...`); сохранено действие «Открыть / скачать», добавлен backend тест inline-preview |
| P36 | сделано | Навигация в админ-панель | Убрать кнопку «Админ-панель» с лендинга, исправить редиректы/роутинг (`/admin`, `/admin.html`) чтобы не было перехода на неверный host/port | Кнопка админки удалена с лендинга; `/admin` корректно переводит на `/admin.html`; добавлен e2e smoke `admin_entry_flow` | | P36 | сделано | Навигация в админ-панель | Убрать кнопку «Админ-панель» с лендинга, исправить редиректы/роутинг (`/admin`, `/admin.html`) чтобы не было перехода на неверный host/port | Кнопка админки удалена с лендинга; `/admin` корректно переводит на `/admin.html`; добавлен e2e smoke `admin_entry_flow` |
| P37 | сделано | Админ-авторизация и креды | Привести к единому правилу bootstrap-креды администратора (`admin@example.com` + согласованный пароль), обновить документацию/контекст и smoke-проверки логина | Реализован bootstrap-login с автосозданием администратора `admin@example.com` / `admin123`; добавлены автотесты `tests/test_admin_auth.py` | | P37 | сделано | Админ-авторизация и креды | Привести к единому правилу bootstrap-креды администратора (`admin@example.com` + согласованный пароль), обновить документацию/контекст и smoke-проверки логина | Реализован bootstrap-login с автосозданием администратора `admin@example.com` / `admin123`; добавлены автотесты `tests/test_admin_auth.py` |
| P38 | к разработке | Конструктор маршрутов статусов | Реализовать для администратора визуальный конструктор маршрутов статусов по каждой теме: вариативные переходы (в т.ч. возврат на предыдущий статус, переход в завершение и альтернативные ветки), SLA на переход, список обязательных документов/данных для закрытия шага | Админ может собрать/изменить граф переходов для темы, задать SLA и требования на каждом шаге; API валидирует переходы и требования, UI отображает и редактирует граф без ручного JSON | | P38 | сделано | Конструктор маршрутов статусов | Реализовать для администратора визуальный конструктор маршрутов статусов по каждой теме: вариативные переходы (в т.ч. возврат на предыдущий статус, переход в завершение и альтернативные ветки), SLA на переход, список обязательных документов/данных для закрытия шага | Добавлен UI-конструктор в справочнике переходов статусов (выбор темы, визуальные карточки статусов и исходящих переходов), расширены поля перехода (`required_data_keys`, `required_mime_types`), переходы валидируются API по требованиям шага (данные заявки + MIME вложений), добавлены backend/e2e тесты |
| P39 | к разработке | Канбан по заявкам (LAWYER/ADMIN) | Реализовать канбан-доску заявок с унификацией разных статусных флоу через группы колонок (например: `Новые`, `В работе`, `Ожидание`, `Завершены`) + карточки заявок с ключевыми данными (дата создания, клиент, описание, новые сообщения/файлы, SLA deadline/дедлайн дела) | Для `LAWYER` видны свои + неназначенные заявки, для `ADMIN` — все юристы и заявки; карточки перетаскиваются/переводятся между допустимыми этапами с серверной валидацией | | P39 | сделано | Канбан по заявкам (LAWYER/ADMIN) | Реализовать канбан-доску заявок с унификацией разных статусных флоу через группы колонок (например: `Новые`, `В работе`, `Ожидание`, `Завершены`) + карточки заявок с ключевыми данными (дата создания, клиент, описание, новые сообщения/файлы, SLA deadline/дедлайн дела) | Добавлен backend endpoint `/api/admin/requests/kanban` (role-scope, группы статусов, SLA/case deadline, допустимые переходы); в `admin.jsx` добавлена секция `Канбан` с карточками, claim для юриста, drag&drop/быстрый перевод по допустимым переходам, open-in-place; покрыто unittest и e2e (`kanban_role_flow`) |
## Критический маршрут (обязательный порядок) ## Критический маршрут (обязательный порядок)
1. `P07 -> P08 -> P09 -> P10` (полный контур назначения). 1. `P07 -> P08 -> P09 -> P10` (полный контур назначения).

View file

@ -76,8 +76,8 @@ docker compose run --rm --no-deps \
| P35 | Предпросмотр документов | `tests/test_uploads_s3.py` (`test_public_attachment_object_preview_returns_inline_response`) + Playwright (`e2e/tests/public_client_flow.spec.js`, `e2e/tests/lawyer_role_flow.spec.js`) | `docker compose run --rm backend python -m unittest tests.test_uploads_s3 -v` + Playwright UI-прогон preview в клиенте и во вкладке работы с заявкой юриста/админа через сервис `e2e` | | P35 | Предпросмотр документов | `tests/test_uploads_s3.py` (`test_public_attachment_object_preview_returns_inline_response`) + Playwright (`e2e/tests/public_client_flow.spec.js`, `e2e/tests/lawyer_role_flow.spec.js`) | `docker compose run --rm backend python -m unittest tests.test_uploads_s3 -v` + Playwright UI-прогон preview в клиенте и во вкладке работы с заявкой юриста/админа через сервис `e2e` |
| P36 | Навигация в админку и редиректы | `e2e/tests/admin_entry_flow.spec.js` + redirect checks | Playwright `admin_entry_flow` + `curl -I -H 'Host: localhost:8081' http://localhost:8081/admin` (ожидается `302` и `Location: /admin.html`) + `curl -I http://localhost:8081/admin.html` | | P36 | Навигация в админку и редиректы | `e2e/tests/admin_entry_flow.spec.js` + redirect checks | Playwright `admin_entry_flow` + `curl -I -H 'Host: localhost:8081' http://localhost:8081/admin` (ожидается `302` и `Location: /admin.html`) + `curl -I http://localhost:8081/admin.html` |
| P37 | Единые bootstrap-креды админа | `tests/test_admin_auth.py` + auth smoke (`/api/admin/auth/login`) + docs consistency check | `docker compose run --rm backend python -m unittest tests.test_admin_auth -v` + UI/API login smoke с `admin@example.com` / `admin123` | | P37 | Единые bootstrap-креды админа | `tests/test_admin_auth.py` + auth smoke (`/api/admin/auth/login`) + docs consistency check | `docker compose run --rm backend python -m unittest tests.test_admin_auth -v` + UI/API login smoke с `admin@example.com` / `admin123` |
| P38 | Конструктор маршрутов статусов (темы) | `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py` + новый e2e `e2e/tests/admin_status_designer_flow.spec.js` | backend: валидация графа переходов/SLA/требуемых документов; UI: создание/редактирование ветвлений, возвратов, терминальных переходов | | P38 | Конструктор маршрутов статусов (темы) | `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py` + e2e `e2e/tests/admin_status_designer_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud tests.test_worker_maintenance -v` + `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/admin_status_designer_flow.spec.js` |
| P39 | Канбан заявок для LAWYER/ADMIN | `tests/test_admin_universal_crud.py`, `tests/test_dashboard_finance.py` + новые e2e `e2e/tests/lawyer_kanban_flow.spec.js`, `e2e/tests/admin_kanban_flow.spec.js` | Проверить группировку статусов, ролевой scope карточек, перемещение по допустимым переходам, отображение дедлайнов SLA и индикаторов новых сообщений/файлов | | P39 | Канбан заявок для LAWYER/ADMIN | `tests/test_admin_universal_crud.py` (`test_requests_kanban_returns_grouped_cards_and_role_scope`) + e2e `e2e/tests/kanban_role_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud.AdminUniversalCrudTests.test_requests_kanban_returns_grouped_cards_and_role_scope -v` и `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/kanban_role_flow.spec.js`; дополнительно регресс `admin_role_flow`, `lawyer_role_flow` |
## Ролевое покрытие (PUBLIC / LAWYER / ADMIN) ## Ролевое покрытие (PUBLIC / LAWYER / ADMIN)
### PUBLIC (клиент) ### PUBLIC (клиент)
@ -122,4 +122,4 @@ docker compose run --rm --no-deps \
- `docker compose run --rm backend python -m unittest discover -s tests -p 'test_*.py' -v``105 passed`. - `docker compose run --rm backend python -m unittest discover -s tests -p 'test_*.py' -v``105 passed`.
- `docker compose run --rm backend python -m compileall app tests alembic` — успешно. - `docker compose run --rm backend python -m compileall app tests alembic` — успешно.
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test tests/admin_entry_flow.spec.js --config=playwright.config.js``1 passed`. - `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test tests/admin_entry_flow.spec.js --config=playwright.config.js``1 passed`.
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend -e E2E_ADMIN_EMAIL=admin@example.com -e E2E_ADMIN_PASSWORD=admin123 -e E2E_LAWYER_EMAIL=ivan@mail.ru -e E2E_LAWYER_PASSWORD='LawyerPass-123!' e2e playwright test --config=playwright.config.js` — `4 passed` (рольовые e2e: `admin_entry_flow`, `admin_role_flow`, `lawyer_role_flow`, `public_client_flow`). - `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js` — `6 passed` (рольовые e2e + конструктор статусов + канбан: `admin_entry_flow`, `admin_role_flow`, `admin_status_designer_flow`, `kanban_role_flow`, `lawyer_role_flow`, `public_client_flow`).

View file

@ -0,0 +1,29 @@
const { test, expect } = require("@playwright/test");
const { loginAdminPanel, openDictionaryTree } = require("./helpers");
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
test("admin status designer: open transitions dictionary and prefill topic in create modal", async ({ page }) => {
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
await openDictionaryTree(page);
await page.locator("aside .menu .menu-tree button").filter({ hasText: /Переходы статусов/ }).first().click();
await expect(page.locator("#section-config .config-panel h3")).toContainText("Переходы статусов");
await expect(page.getByRole("heading", { name: "Конструктор маршрута статусов" })).toBeVisible();
const topicSelect = page.locator("#status-designer-topic");
await expect(topicSelect).toBeVisible();
const optionCount = await topicSelect.locator("option").count();
expect(optionCount).toBeGreaterThan(1);
await topicSelect.selectOption({ index: 1 });
const selectedTopic = await topicSelect.inputValue();
expect(selectedTopic).not.toBe("");
await page.getByRole("button", { name: "Добавить переход" }).click();
await expect(page.getByRole("heading", { name: /Создание • Переходы статусов/ })).toBeVisible();
await expect(page.locator("#record-field-topic_code")).toHaveValue(selectedTopic);
await page.locator("#record-overlay .close").click();
});

View file

@ -0,0 +1,61 @@
const { test, expect } = require("@playwright/test");
const {
preparePublicSession,
createRequestViaLanding,
randomPhone,
loginAdminPanel,
} = require("./helpers");
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!";
test("kanban flow via UI: lawyer sees unassigned card, claims and opens request in same tab", async ({ context, page }) => {
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
const phone = randomPhone();
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Заявка для проверки канбана юриста",
});
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
await page.locator("aside .menu button[data-section='kanban']").click();
await expect(page.locator("#section-kanban h2")).toHaveText("Канбан заявок");
const card = page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first();
await expect(card).toBeVisible();
const claimBtn = card.getByRole("button", { name: "Взять в работу" });
if (await claimBtn.count()) {
await claimBtn.click();
await expect(page.locator("#section-kanban .status")).toContainText(/Заявка взята в работу|Канбан обновлен/);
}
const transitionSelect = page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first().locator(".kanban-transition-select");
if (await transitionSelect.count()) {
const targetValue = await transitionSelect
.first()
.locator("option:not([value=''])")
.first()
.getAttribute("value")
.catch(() => "");
if (targetValue) {
await transitionSelect.first().selectOption(targetValue);
await expect(page.locator("#section-kanban .status")).toContainText(/Статус заявки обновлен|Ошибка перехода/);
}
}
const pagesBeforeOpen = context.pages().length;
await page
.locator("#section-kanban .kanban-card")
.filter({ hasText: trackNumber })
.first()
.getByRole("button", { name: "Открыть заявку" })
.click();
await page.waitForTimeout(250);
await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen);
await expect(page.locator("#section-request-workspace h2")).toHaveText("Карточка заявки");
await page.getByRole("button", { name: "Назад к заявкам" }).click();
await expect(page.locator("#section-requests h2")).toHaveText("Заявки");
});

View file

@ -44,20 +44,23 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
await claimBtn.click(); await claimBtn.click();
await expect(page.locator("#section-requests .status")).toContainText(/Заявка взята в работу|Список обновлен/); await expect(page.locator("#section-requests .status")).toContainText(/Заявка взята в работу|Список обновлен/);
const requestPagePromise = context.waitForEvent("page"); const pagesBeforeOpen = context.pages().length;
await row.first().getByRole("button", { name: "Открыть заявку" }).click(); await row.first().getByRole("button", { name: "Открыть заявку" }).click();
const requestPage = await requestPagePromise; await page.waitForTimeout(250);
await requestPage.waitForLoadState("domcontentloaded"); await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen);
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.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 expect(requestPage.locator("#request-modal-files")).toContainText(clientFileName); await expect(requestPage.locator("#request-modal-files")).toContainText(clientFileName);
const clientFileRow = requestPage.locator("#request-modal-files li").filter({ hasText: clientFileName }).first(); const clientFileRow = requestPage.locator("#request-modal-files li").filter({ hasText: clientFileName }).first();
await clientFileRow.getByRole("button", { name: /Предпросмотр/ }).click(); await clientFileRow.getByRole("button", { name: /Предпросмотр/ }).click();
await expect(requestPage.locator("#request-file-preview-overlay")).toBeVisible(); await expect(requestPage.locator("#request-file-preview-overlay")).toBeVisible();
await expect(requestPage.locator("#request-file-preview-overlay .request-preview-frame")).toBeVisible(); await expect(requestPage.locator("#request-file-preview-overlay .request-preview-frame")).toBeVisible();
await requestPage.locator("#request-file-preview-overlay .close").click(); await requestPage.locator("#request-file-preview-overlay .close").click();
await requestPage.getByRole("tab", { name: "Чат" }).click();
const lawyerMessage = `Ответ юриста ${Date.now()}`; const lawyerMessage = `Ответ юриста ${Date.now()}`;
await requestPage.locator("#request-modal-message-body").fill(lawyerMessage); await requestPage.locator("#request-modal-message-body").fill(lawyerMessage);
@ -66,16 +69,28 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
await expect(requestPage.locator("#request-modal-messages")).toContainText(lawyerMessage); await expect(requestPage.locator("#request-modal-messages")).toContainText(lawyerMessage);
const lawyerFileName = `lawyer-admin-${Date.now()}.pdf`; const lawyerFileName = `lawyer-admin-${Date.now()}.pdf`;
await requestPage.locator("#request-modal-file-input").setInputFiles({ const droppedFileName = `lawyer-drop-${Date.now()}.txt`;
await requestPage.locator("#request-modal-file-input").setInputFiles([
{
name: lawyerFileName, name: lawyerFileName,
mimeType: "application/pdf", mimeType: "application/pdf",
buffer: Buffer.from("lawyer file from admin modal", "utf-8"), buffer: Buffer.from("lawyer file from admin modal", "utf-8"),
}); },
await requestPage.locator("#request-modal-file-upload").click(); {
await expect(requestPage.locator("#section-request-workspace .status")).toContainText("Файл загружен"); name: droppedFileName,
mimeType: "text/plain",
buffer: Buffer.from("temporary upload file", "utf-8"),
},
]);
await expect(requestPage.locator(".pending-file-chip").filter({ hasText: lawyerFileName })).toHaveCount(1);
await expect(requestPage.locator(".pending-file-chip").filter({ hasText: droppedFileName })).toHaveCount(1);
await requestPage.getByRole("button", { name: new RegExp("Удалить файл " + droppedFileName) }).click();
await expect(requestPage.locator(".pending-file-chip").filter({ hasText: droppedFileName })).toHaveCount(0);
await requestPage.locator("#request-modal-message-send").click();
await expect(requestPage.locator("#section-request-workspace .status")).toContainText(/Файлы отправлены|Сообщение и файлы отправлены/);
await requestPage.getByRole("tab", { name: /Файлы/ }).click();
await expect(requestPage.locator("#request-modal-files")).toContainText(lawyerFileName); await expect(requestPage.locator("#request-modal-files")).toContainText(lawyerFileName);
await requestPage.close(); await expect(requestPage.locator("#request-modal-files")).not.toContainText(droppedFileName);
await page.locator("aside .menu button[data-section='requests']").click(); await page.locator("aside .menu button[data-section='requests']").click();
await expect(page.locator("#section-requests h2")).toHaveText("Заявки"); await expect(page.locator("#section-requests h2")).toHaveText("Заявки");
await page.locator("#section-requests").getByRole("button", { name: "Обновить" }).click(); await page.locator("#section-requests").getByRole("button", { name: "Обновить" }).click();

View file

@ -865,6 +865,139 @@ class AdminUniversalCrudTests(unittest.TestCase):
) )
self.assertEqual(invalid_same_status.status_code, 400) self.assertEqual(invalid_same_status.status_code, 400)
def test_admin_can_configure_transition_step_requirements(self):
headers = self._auth_headers("ADMIN", email="root@example.com")
with self.SessionLocal() as db:
db.add(Topic(code="civil-designer", name="Гражданское (конструктор)", enabled=True, sort_order=1))
db.add_all(
[
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=1, is_terminal=False),
]
)
db.commit()
created = self.client.post(
"/api/admin/crud/topic_status_transitions",
headers=headers,
json={
"topic_code": "civil-designer",
"from_status": "NEW",
"to_status": "IN_PROGRESS",
"enabled": True,
"sort_order": 1,
"sla_hours": 24,
"required_data_keys": ["passport_scan", "client_address"],
"required_mime_types": ["application/pdf", "image/*"],
},
)
self.assertEqual(created.status_code, 201)
body = created.json()
self.assertEqual(body["required_data_keys"], ["passport_scan", "client_address"])
self.assertEqual(body["required_mime_types"], ["application/pdf", "image/*"])
row_id = body["id"]
updated = self.client.patch(
f"/api/admin/crud/topic_status_transitions/{row_id}",
headers=headers,
json={
"required_data_keys": ["passport_scan"],
"required_mime_types": [],
},
)
self.assertEqual(updated.status_code, 200)
self.assertEqual(updated.json()["required_data_keys"], ["passport_scan"])
self.assertEqual(updated.json()["required_mime_types"], [])
def test_request_status_transition_requires_step_data_and_files(self):
headers = self._auth_headers("ADMIN", email="root@example.com")
with self.SessionLocal() as db:
db.add(Topic(code="civil-step-check", name="Проверка шага", enabled=True, sort_order=1))
db.add_all(
[
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=1, is_terminal=False),
]
)
db.add(
TopicStatusTransition(
topic_code="civil-step-check",
from_status="NEW",
to_status="IN_PROGRESS",
enabled=True,
sort_order=1,
sla_hours=48,
required_data_keys=["passport_scan"],
required_mime_types=["application/pdf"],
)
)
req = Request(
track_number="TRK-STEP-REQ-1",
client_name="Клиент шага",
client_phone="+79990042211",
topic_code="civil-step-check",
status_code="NEW",
description="step requirements",
extra_fields={},
)
db.add(req)
db.commit()
request_id = str(req.id)
request_uuid = UUID(request_id)
blocked_without_all = self.client.patch(
f"/api/admin/crud/requests/{request_id}",
headers=headers,
json={"status_code": "IN_PROGRESS"},
)
self.assertEqual(blocked_without_all.status_code, 400)
self.assertIn("обязательные данные", blocked_without_all.json().get("detail", ""))
self.assertIn("обязательные файлы", blocked_without_all.json().get("detail", ""))
blocked_without_all_legacy = self.client.patch(
f"/api/admin/requests/{request_id}",
headers=headers,
json={"status_code": "IN_PROGRESS"},
)
self.assertEqual(blocked_without_all_legacy.status_code, 400)
self.assertIn("обязательные данные", blocked_without_all_legacy.json().get("detail", ""))
with_data_only = self.client.patch(
f"/api/admin/crud/requests/{request_id}",
headers=headers,
json={"extra_fields": {"passport_scan": "добавлено"}},
)
self.assertEqual(with_data_only.status_code, 200)
blocked_without_file = self.client.patch(
f"/api/admin/crud/requests/{request_id}",
headers=headers,
json={"status_code": "IN_PROGRESS"},
)
self.assertEqual(blocked_without_file.status_code, 400)
self.assertIn("обязательные файлы", blocked_without_file.json().get("detail", ""))
with self.SessionLocal() as db:
db.add(
Attachment(
request_id=request_uuid,
file_name="passport.pdf",
mime_type="application/pdf",
size_bytes=1024,
s3_key="requests/passport.pdf",
immutable=False,
)
)
db.commit()
moved = self.client.patch(
f"/api/admin/crud/requests/{request_id}",
headers=headers,
json={"status_code": "IN_PROGRESS"},
)
self.assertEqual(moved.status_code, 200)
self.assertEqual(moved.json().get("status_code"), "IN_PROGRESS")
def test_status_change_freezes_previous_messages_and_attachments_and_writes_history(self): def test_status_change_freezes_previous_messages_and_attachments_and_writes_history(self):
headers = self._auth_headers("ADMIN", email="root@example.com") headers = self._auth_headers("ADMIN", email="root@example.com")
with self.SessionLocal() as db: with self.SessionLocal() as db:
@ -1067,6 +1200,144 @@ class AdminUniversalCrudTests(unittest.TestCase):
outsider_forbidden = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=outsider_headers) outsider_forbidden = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=outsider_headers)
self.assertEqual(outsider_forbidden.status_code, 403) self.assertEqual(outsider_forbidden.status_code, 403)
def test_requests_kanban_returns_grouped_cards_and_role_scope(self):
with self.SessionLocal() as db:
db.add_all(
[
Status(code="NEW", name="Новая", enabled=True, sort_order=1, is_terminal=False, kind="DEFAULT"),
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=2, is_terminal=False, kind="DEFAULT"),
Status(code="WAITING_CLIENT", name="Ожидание клиента", enabled=True, sort_order=3, is_terminal=False, kind="DEFAULT"),
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=4, is_terminal=True, kind="DEFAULT"),
]
)
db.add(Topic(code="civil-law", name="Гражданское право", enabled=True, sort_order=1))
db.add_all(
[
TopicStatusTransition(
topic_code="civil-law",
from_status="NEW",
to_status="IN_PROGRESS",
enabled=True,
sla_hours=24,
sort_order=1,
),
TopicStatusTransition(
topic_code="civil-law",
from_status="IN_PROGRESS",
to_status="WAITING_CLIENT",
enabled=True,
sla_hours=12,
sort_order=2,
),
TopicStatusTransition(
topic_code="civil-law",
from_status="WAITING_CLIENT",
to_status="CLOSED",
enabled=True,
sla_hours=8,
sort_order=3,
),
]
)
lawyer_main = AdminUser(
role="LAWYER",
name="Юрист канбана",
email="lawyer.kanban@example.com",
password_hash="hash",
is_active=True,
)
lawyer_other = AdminUser(
role="LAWYER",
name="Другой юрист",
email="lawyer.kanban.other@example.com",
password_hash="hash",
is_active=True,
)
db.add_all([lawyer_main, lawyer_other])
db.flush()
request_new = Request(
track_number="TRK-KANBAN-NEW",
client_name="Клиент 1",
client_phone="+79990000001",
topic_code="civil-law",
status_code="NEW",
description="Новая неназначенная",
extra_fields={},
assigned_lawyer_id=None,
)
request_progress = Request(
track_number="TRK-KANBAN-PROGRESS",
client_name="Клиент 2",
client_phone="+79990000002",
topic_code="civil-law",
status_code="IN_PROGRESS",
description="Заявка в работе",
extra_fields={"deadline_at": "2031-01-01T10:00:00+00:00"},
assigned_lawyer_id=str(lawyer_main.id),
)
request_waiting = Request(
track_number="TRK-KANBAN-WAITING",
client_name="Клиент 3",
client_phone="+79990000003",
topic_code="civil-law",
status_code="WAITING_CLIENT",
description="Чужая заявка",
extra_fields={},
assigned_lawyer_id=str(lawyer_other.id),
)
db.add_all([request_new, request_progress, request_waiting])
db.flush()
entered_progress_at = datetime.now(timezone.utc) - timedelta(hours=2)
db.add(
StatusHistory(
request_id=request_progress.id,
from_status="NEW",
to_status="IN_PROGRESS",
changed_by_admin_id=None,
comment="started",
created_at=entered_progress_at,
)
)
db.commit()
request_new_id = str(request_new.id)
request_progress_id = str(request_progress.id)
request_waiting_id = str(request_waiting.id)
lawyer_main_id = str(lawyer_main.id)
admin_headers = self._auth_headers("ADMIN", email="root@example.com")
admin_response = self.client.get("/api/admin/requests/kanban?limit=100", headers=admin_headers)
self.assertEqual(admin_response.status_code, 200)
admin_payload = admin_response.json()
self.assertEqual(admin_payload["scope"], "ADMIN")
self.assertEqual(admin_payload["total"], 3)
rows = {item["id"]: item for item in (admin_payload.get("rows") or [])}
self.assertIn(request_new_id, rows)
self.assertIn(request_progress_id, rows)
self.assertIn(request_waiting_id, rows)
self.assertEqual(rows[request_new_id]["status_group"], "NEW")
self.assertEqual(rows[request_progress_id]["status_group"], "IN_PROGRESS")
self.assertEqual(rows[request_progress_id]["assigned_lawyer_id"], lawyer_main_id)
transitions = rows[request_progress_id].get("available_transitions") or []
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.assertIsNotNone(rows[request_progress_id]["sla_deadline_at"])
self.assertFalse(bool(admin_payload.get("truncated")))
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)
self.assertEqual(lawyer_response.status_code, 200)
lawyer_payload = lawyer_response.json()
self.assertEqual(lawyer_payload["scope"], "LAWYER")
lawyer_rows = {item["id"]: item for item in (lawyer_payload.get("rows") or [])}
self.assertIn(request_new_id, lawyer_rows)
self.assertIn(request_progress_id, lawyer_rows)
self.assertNotIn(request_waiting_id, lawyer_rows)
self.assertEqual(lawyer_payload["total"], 2)
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:
lawyer1 = AdminUser( lawyer1 = AdminUser(

View file

@ -108,7 +108,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, "0016_table_availability") self.assertEqual(version, "0017_transition_requirements")
def test_responsible_column_exists_in_all_domain_tables(self): def test_responsible_column_exists_in_all_domain_tables(self):
tables = { tables = {
@ -158,6 +158,8 @@ class MigrationTests(unittest.TestCase):
def test_status_transitions_contains_sla_hours_column(self): def test_status_transitions_contains_sla_hours_column(self):
columns = {column["name"] for column in self.inspector.get_columns("topic_status_transitions")} columns = {column["name"] for column in self.inspector.get_columns("topic_status_transitions")}
self.assertIn("sla_hours", columns) self.assertIn("sla_hours", columns)
self.assertIn("required_data_keys", columns)
self.assertIn("required_mime_types", columns)
def test_notifications_has_recipient_and_read_columns(self): def test_notifications_has_recipient_and_read_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("notifications")} columns = {column["name"] for column in self.inspector.get_columns("notifications")}