fix user UI 7

This commit is contained in:
TronoSfera 2026-03-03 14:13:59 +03:00
parent bc5db5ce35
commit d3244ff662
38 changed files with 13185 additions and 583 deletions

View file

@ -1,6 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y build-essential curl openssl ca-certificates && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y \
build-essential \
curl \
openssl \
ca-certificates \
fontconfig \
fonts-dejavu-core \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

View file

@ -56,7 +56,10 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
"audit_log": {"ADMIN": {"query", "read"}},
"security_audit_log": {"ADMIN": {"query", "read"}},
"otp_sessions": {"ADMIN": {"query", "read"}},
"admin_users": {"ADMIN": set(CRUD_ACTIONS)},
"admin_users": {
"ADMIN": set(CRUD_ACTIONS),
"LAWYER": {"read", "update"},
},
"admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)},
"landing_featured_staff": {"ADMIN": set(CRUD_ACTIONS)},
"topic_status_transitions": {"ADMIN": set(CRUD_ACTIONS)},

View file

@ -82,6 +82,15 @@ from .payloads import (
)
def _ensure_lawyer_owns_admin_user_row_or_403(admin: dict, row_id: str) -> None:
if not _is_lawyer(admin):
return
actor_id = _lawyer_actor_id_or_401(admin).strip().lower()
target_id = str(row_id or "").strip().lower()
if not actor_id or not target_id or actor_id != target_id:
raise HTTPException(status_code=403, detail="Недостаточно прав")
def _apply_create_side_effects(db: Session, *, table_name: str, row: Any, admin: dict) -> None:
if table_name == "messages" and isinstance(row, Message):
req = db.get(Request, row.request_id)
@ -254,6 +263,8 @@ def query_table_service(table_name: str, uq: UniversalQuery, db: Session, admin:
def get_row_service(table_name: str, row_id: str, db: Session, admin: dict) -> dict[str, Any]:
normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "read")
if normalized == "admin_users":
_ensure_lawyer_owns_admin_user_row_or_403(admin, row_id)
row = _load_row_or_404(db, model, row_id)
if normalized == "requests":
req = row if isinstance(row, Request) else None
@ -408,6 +419,12 @@ def update_row_service(table_name: str, row_id: str, payload: dict[str, Any], db
normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "update")
responsible = _resolve_responsible(admin)
if normalized == "admin_users" and _is_lawyer(admin):
_ensure_lawyer_owns_admin_user_row_or_403(admin, row_id)
allowed_fields = {"name", "email", "phone", "password", "avatar_url"}
forbidden_fields = sorted(set(payload.keys()) - allowed_fields)
if forbidden_fields:
raise HTTPException(status_code=403, detail="Недостаточно прав")
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
if "assigned_lawyer_id" in payload:
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import json
from datetime import datetime, timezone
from decimal import Decimal
from uuid import UUID, uuid4
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest
from fastapi.responses import StreamingResponse
@ -16,7 +16,9 @@ from app.models.admin_user import AdminUser
from app.models.invoice import Invoice
from app.models.request import Request
from app.schemas.universal import UniversalQuery
from app.services.invoice_chat import create_invoice_chat_message_with_attachment
from app.services.invoice_crypto import decrypt_requisites, encrypt_requisites
from app.services.invoice_numbering import generate_invoice_number
from app.services.invoice_pdf import build_invoice_pdf_bytes
from app.services.security_audit import extract_client_ip, record_pii_access_event
from app.services.universal_query import apply_universal_query
@ -90,15 +92,6 @@ def _now_utc() -> datetime:
return datetime.now(timezone.utc)
def _invoice_number(db: Session) -> str:
prefix = _now_utc().strftime("%Y%m%d")
candidate = f"INV-{prefix}-{uuid4().hex[:8].upper()}"
exists = db.query(Invoice.id).filter(Invoice.invoice_number == candidate).first()
if exists is None:
return candidate
return f"INV-{prefix}-{uuid4().hex[:12].upper()}"
def _parse_requisites(raw) -> dict:
if raw is None:
return {}
@ -290,6 +283,8 @@ def create_invoice(
role = str(admin.get("role") or "").upper()
actor_id = _actor_uuid_or_401(admin)
actor_email = str(admin.get("email") or "").strip() or "Администратор системы"
actor_user = db.get(AdminUser, actor_id)
actor_name = str(actor_user.name if actor_user else "").strip() or str(actor_user.email if actor_user else "").strip() or actor_email
req = _request_from_payload_or_404(db, payload)
_ensure_lawyer_owns_request_or_403(role, actor_id, req)
@ -302,10 +297,11 @@ def create_invoice(
if not payer_display_name:
raise HTTPException(status_code=400, detail='Поле "payer_display_name" обязательно')
issued_at = _now_utc()
invoice = Invoice(
request_id=req.id,
client_id=req.client_id,
invoice_number=str(payload.get("invoice_number") or "").strip() or _invoice_number(db),
invoice_number=str(payload.get("invoice_number") or "").strip() or generate_invoice_number(db, issued_at),
status=status,
amount=_amount_or_400(payload.get("amount")),
currency=_normalize_currency(payload.get("currency")),
@ -313,7 +309,7 @@ def create_invoice(
payer_details_encrypted=encrypt_requisites(_parse_requisites(payload.get("payer_details"))),
issued_by_admin_user_id=actor_id,
issued_by_role=role,
issued_at=_now_utc(),
issued_at=issued_at,
paid_at=None,
responsible=actor_email,
)
@ -327,6 +323,15 @@ def create_invoice(
db.add(invoice)
db.add(req)
create_invoice_chat_message_with_attachment(
db,
request=req,
invoice=invoice,
actor_role=role,
actor_name=actor_name,
actor_admin_user_id=str(actor_id),
responsible=actor_email,
)
_commit_or_400(db, "Счет с таким номером уже существует")
db.refresh(invoice)

View file

@ -10,6 +10,7 @@ from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.models.admin_user import AdminUser
from app.models.notification import Notification
from app.models.request import Request
from app.models.status import Status
from app.models.status_group import StatusGroup
@ -20,8 +21,18 @@ from app.services.universal_query import apply_universal_query
from .common import parse_datetime_safe
ALLOWED_KANBAN_FILTER_FIELDS = {"assigned_lawyer_id", "client_name", "status_code", "created_at", "topic_code", "overdue"}
ALLOWED_KANBAN_FILTER_FIELDS = {
"assigned_lawyer_id",
"client_name",
"status_code",
"created_at",
"topic_code",
"overdue",
"has_unread_updates",
"deadline_alert",
}
ALLOWED_KANBAN_SORT_MODES = {"created_newest", "lawyer", "deadline"}
BOOLEAN_KANBAN_FILTER_FIELDS = {"overdue", "has_unread_updates", "deadline_alert"}
FALLBACK_KANBAN_GROUPS = [
("fallback_new", "Новые", 10),
("fallback_in_progress", "В работе", 20),
@ -86,7 +97,7 @@ def extract_case_deadline(extra_fields: object) -> datetime | None:
return None
def coerce_kanban_bool(value: object) -> bool:
def coerce_kanban_bool(value: object, field_name: str) -> bool:
if isinstance(value, bool):
return value
text = str(value or "").strip().lower()
@ -94,10 +105,10 @@ def coerce_kanban_bool(value: object) -> bool:
return True
if text in {"0", "false", "no", "n", "off"}:
return False
raise HTTPException(status_code=400, detail='Поле "overdue" должно быть boolean')
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть boolean')
def parse_kanban_filters_or_400(raw_filters: str | None) -> tuple[list[FilterClause], list[tuple[str, bool]]]:
def parse_kanban_filters_or_400(raw_filters: str | None) -> tuple[list[FilterClause], list[tuple[str, str, bool]]]:
if not raw_filters:
return [], []
try:
@ -108,7 +119,7 @@ def parse_kanban_filters_or_400(raw_filters: str | None) -> tuple[list[FilterCla
raise HTTPException(status_code=400, detail="Фильтры канбана должны быть массивом")
universal_filters: list[FilterClause] = []
overdue_filters: list[tuple[str, bool]] = []
boolean_filters: list[tuple[str, str, bool]] = []
for index, item in enumerate(parsed):
if not isinstance(item, dict):
raise HTTPException(status_code=400, detail=f"Фильтр #{index + 1} должен быть объектом")
@ -119,30 +130,40 @@ def parse_kanban_filters_or_400(raw_filters: str | None) -> tuple[list[FilterCla
raise HTTPException(status_code=400, detail=f'Недоступное поле фильтра: "{field}"')
if op not in {"=", "!=", ">", "<", ">=", "<=", "~"}:
raise HTTPException(status_code=400, detail=f'Недопустимый оператор фильтра: "{op}"')
if field == "overdue":
if field in BOOLEAN_KANBAN_FILTER_FIELDS:
if op not in {"=", "!="}:
raise HTTPException(status_code=400, detail='Для поля "overdue" доступны только операторы "=" и "!="')
overdue_filters.append((op, coerce_kanban_bool(value)))
raise HTTPException(status_code=400, detail=f'Для поля "{field}" доступны только операторы "=" и "!="')
boolean_filters.append((field, op, coerce_kanban_bool(value, field)))
continue
universal_filters.append(FilterClause(field=field, op=op, value=value))
return universal_filters, overdue_filters
return universal_filters, boolean_filters
def apply_overdue_filters(items: list[dict[str, object]], overdue_filters: list[tuple[str, bool]]) -> list[dict[str, object]]:
if not overdue_filters:
def apply_boolean_kanban_filters(
items: list[dict[str, object]],
boolean_filters: list[tuple[str, str, bool]],
) -> list[dict[str, object]]:
if not boolean_filters:
return items
now = datetime.now(timezone.utc)
out: list[dict[str, object]] = []
for item in items:
ok = True
for field, op, expected in boolean_filters:
if field == "overdue":
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:
actual = bool(deadline_at and deadline_at <= now)
elif field == "has_unread_updates":
actual = bool(item.get("has_unread_updates"))
elif field == "deadline_alert":
actual = bool(item.get("deadline_alert"))
else:
actual = False
if op == "=":
ok = ok and (is_overdue == expected)
ok = ok and (actual == expected)
elif op == "!=":
ok = ok and (is_overdue != expected)
ok = ok and (actual != expected)
if not ok:
break
if ok:
@ -204,7 +225,7 @@ def get_requests_kanban_service(
)
normalized_sort_mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest"
query_filters, overdue_filters = parse_kanban_filters_or_400(filters)
query_filters, boolean_filters = parse_kanban_filters_or_400(filters)
if query_filters:
base_query = apply_universal_query(
base_query,
@ -220,7 +241,33 @@ def get_requests_kanban_service(
request_id_to_row = {str(row.id): row for row in request_rows}
request_ids = [row.id for row in request_rows]
unread_notification_request_ids: set[str] = set()
actor_uuid = None
if actor:
try:
actor_uuid = UUID(actor)
except ValueError:
actor_uuid = None
if actor_uuid is not None and request_ids:
unread_notification_rows = (
db.query(Notification.request_id)
.filter(
Notification.recipient_type == "ADMIN_USER",
Notification.recipient_admin_user_id == actor_uuid,
Notification.is_read.is_(False),
Notification.request_id.is_not(None),
Notification.request_id.in_(request_ids),
)
.all()
)
unread_notification_request_ids = {
str(notification_request_id)
for (notification_request_id,) in unread_notification_rows
if notification_request_id is not None
}
status_codes = {str(row.status_code or "").strip() for row in request_rows if str(row.status_code or "").strip()}
now_utc = datetime.now(timezone.utc)
next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1)
status_meta_map: dict[str, dict[str, object]] = {}
if status_codes:
@ -448,9 +495,21 @@ def get_requests_kanban_service(
sla_deadline = entered_at + timedelta(hours=int(transition_rule.sla_hours))
assigned_id = str(row.assigned_lawyer_id or "").strip() or None
request_id = str(row.id)
status_is_terminal = bool(status_meta.get("is_terminal"))
has_actor_unread_notification = request_id in unread_notification_request_ids
has_unread_updates = bool(row.lawyer_has_unread_updates)
if role != "LAWYER":
has_unread_updates = bool(row.lawyer_has_unread_updates or row.client_has_unread_updates)
if has_actor_unread_notification:
has_unread_updates = True
important_date_at = parse_datetime_safe(row.important_date_at)
deadline_alert = bool(important_date_at and important_date_at < next_day_start and not status_is_terminal)
if role == "LAWYER":
deadline_alert = deadline_alert and bool(assigned_id) and assigned_id == actor
items.append(
{
"id": str(row.id),
"id": request_id,
"track_number": row.track_number,
"client_name": row.client_name,
"client_phone": row.client_phone,
@ -470,13 +529,15 @@ def get_requests_kanban_service(
"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,
"has_unread_updates": has_unread_updates,
"deadline_alert": deadline_alert,
"case_deadline_at": case_deadline.isoformat() if case_deadline else None,
"sla_deadline_at": sla_deadline.isoformat() if sla_deadline is not None else None,
"available_transitions": available_transitions,
}
)
items = apply_overdue_filters(items, overdue_filters)
items = apply_boolean_kanban_filters(items, boolean_filters)
items = sort_kanban_items(items, normalized_sort_mode)
total = len(items)
if total > limit:

View file

@ -294,55 +294,92 @@ def get_request_status_route_service(
transition_sla_by_edge[(from_status, to_status)] = sla_hours
incoming_sla_by_status.setdefault(to_status, sla_hours)
sequence_from_history: list[str] = []
route_steps: list[dict[str, Any]] = []
if history_rows:
first_from = str(history_rows[0].from_status or "").strip()
if first_from:
sequence_from_history.append(first_from)
route_steps.append(
{
"code": first_from,
"edge_from": None,
"changed_at": None,
"source": "history",
}
)
for row in history_rows:
to_code = str(row.to_status or "").strip()
if to_code:
sequence_from_history.append(to_code)
if not to_code:
continue
from_code = str(row.from_status or "").strip() or None
route_steps.append(
{
"code": to_code,
"edge_from": from_code,
"changed_at": row.created_at.isoformat() if row.created_at else None,
"source": "history",
}
)
elif current_status:
sequence_from_history.append(current_status)
route_steps.append(
{
"code": current_status,
"edge_from": None,
"changed_at": (req.updated_at or req.created_at).isoformat() if (req.updated_at or req.created_at) else None,
"source": "current",
}
)
ordered_codes: list[str] = []
seen_codes: set[str] = set()
if current_status and not any(str(step.get("code") or "").strip() == current_status for step in route_steps):
route_steps.append(
{
"code": current_status,
"edge_from": None,
"changed_at": (req.updated_at or req.created_at).isoformat() if (req.updated_at or req.created_at) else None,
"source": "current",
}
)
def add_code(code: str) -> None:
normalized = str(code or "").strip()
if not normalized or normalized in seen_codes:
return
seen_codes.add(normalized)
ordered_codes.append(normalized)
for code in sequence_from_history:
add_code(code)
add_code(current_status)
for to_status in outgoing_by_status.get(current_status, []):
add_code(to_status)
normalized = str(to_status or "").strip()
if not normalized:
continue
route_steps.append(
{
"code": normalized,
"edge_from": current_status or None,
"changed_at": None,
"source": "outgoing",
}
)
changed_at_by_status: dict[str, str] = {}
for row in history_rows:
to_code = str(row.to_status or "").strip()
if to_code and row.created_at:
changed_at_by_status[to_code] = row.created_at.isoformat()
visited_codes = {code for code in sequence_from_history if code}
current_index = ordered_codes.index(current_status) if current_status in ordered_codes else -1
current_index = -1
if current_status:
for idx in range(len(route_steps) - 1, -1, -1):
code = str(route_steps[idx].get("code") or "").strip()
source = str(route_steps[idx].get("source") or "").strip()
if code != current_status:
continue
if source == "outgoing":
continue
current_index = idx
break
if current_index < 0 and route_steps:
current_index = len(route_steps) - 1
def status_name(code: str) -> str:
meta = statuses_map.get(code) or {}
return str(meta.get("name") or code)
nodes: list[dict[str, str | int | None]] = []
for index, code in enumerate(ordered_codes):
for index, step in enumerate(route_steps):
code = str(step.get("code") or "").strip()
if not code:
continue
meta = statuses_map.get(code) or {}
state = "pending"
if code == current_status:
if index == current_index:
state = "current"
elif code in visited_codes or (current_index >= 0 and index < current_index):
elif current_index >= 0 and index < current_index:
state = "completed"
note_parts: list[str] = []
@ -358,10 +395,10 @@ def get_request_status_route_service(
"name": status_name(code),
"kind": kind,
"state": state,
"changed_at": changed_at_by_status.get(code),
"changed_at": str(step.get("changed_at") or "").strip() or None,
"sla_hours": (
transition_sla_by_edge.get((ordered_codes[index - 1], code))
if index > 0
transition_sla_by_edge.get((str(step.get("edge_from") or "").strip(), code))
if str(step.get("edge_from") or "").strip()
else None
)
or incoming_sla_by_status.get(code),

View file

@ -28,7 +28,6 @@ from app.models.status_history import StatusHistory
from app.models.topic import Topic
from app.services.invoice_crypto import decrypt_requisites
from app.services.invoice_pdf import build_invoice_pdf_bytes
from app.services.chat_secure_service import create_client_message, list_messages_for_request
from app.services.origin_guard import enforce_public_origin_or_403
from app.services.notifications import (
get_client_notification,
@ -44,8 +43,6 @@ from app.services.security_audit import extract_client_ip, record_pii_access_eve
from app.api.admin.requests_modules.status_flow import get_request_status_route_service
from app.schemas.public import (
PublicAttachmentRead,
PublicMessageCreate,
PublicMessageRead,
PublicRequestCreate,
PublicRequestCreated,
PublicServiceRequestCreate,
@ -427,6 +424,14 @@ def get_request_by_track(
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, session, track_number)
status_name = str(req.status_code or "")
if str(req.status_code or "").strip():
try:
status_row = db.query(Status).filter(Status.code == req.status_code).first()
except SQLAlchemyError:
status_row = None
if status_row is not None:
status_name = str(status_row.name or req.status_code or "")
topic_name = None
if str(req.topic_code or "").strip():
try:
@ -478,15 +483,13 @@ def get_request_by_track(
"topic_code": req.topic_code,
"topic_name": topic_name,
"status_code": req.status_code,
"status_name": status_name,
"important_date_at": _to_iso(req.important_date_at),
"description": req.description,
"extra_fields": req.extra_fields,
"assigned_lawyer_id": req.assigned_lawyer_id,
"assigned_lawyer_name": lawyer_name or req.assigned_lawyer_id,
"assigned_lawyer_phone": lawyer_phone,
"request_cost": float(req.request_cost) if req.request_cost is not None else None,
"effective_rate": float(req.effective_rate) if req.effective_rate is not None else None,
"paid_at": _to_iso(req.paid_at),
"client_has_unread_updates": req.client_has_unread_updates,
"client_unread_event_type": req.client_unread_event_type,
"lawyer_has_unread_updates": req.lawyer_has_unread_updates,
@ -589,62 +592,6 @@ def get_status_route_by_track(
return payload
@router.get("/{track_number}/messages", response_model=list[PublicMessageRead])
def list_messages_by_track(
track_number: str,
request: FastapiRequest,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, session, track_number)
rows = list_messages_for_request(db, req.id)
payload = [
PublicMessageRead(
id=row.id,
request_id=row.request_id,
author_type=row.author_type,
author_name=row.author_name,
body=row.body,
created_at=_to_iso(row.created_at),
updated_at=_to_iso(row.updated_at),
)
for row in rows
]
_record_public_read_audit(
db,
session=session,
http_request=request,
action="READ_CHAT_MESSAGES",
scope="CHAT",
request_id=req.id,
details={"rows": len(rows)},
)
return payload
@router.post("/{track_number}/messages", response_model=PublicMessageRead, status_code=201)
def create_message_by_track(
track_number: str,
payload: PublicMessageCreate,
request: FastapiRequest,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
enforce_public_origin_or_403(request, endpoint="/api/public/requests/{track_number}/messages")
req = _request_for_track_or_404(db, session, track_number)
row = create_client_message(db, request=req, body=payload.body)
return PublicMessageRead(
id=row.id,
request_id=row.request_id,
author_type=row.author_type,
author_name=row.author_name,
body=row.body,
created_at=_to_iso(row.created_at),
updated_at=_to_iso(row.updated_at),
)
@router.get("/{track_number}/attachments", response_model=list[PublicAttachmentRead])
def list_attachments_by_track(
track_number: str,

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

View file

@ -4,7 +4,7 @@ from datetime import datetime, timezone
from decimal import Decimal
from string import Formatter
from typing import Any
from uuid import UUID, uuid4
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import inspect
@ -14,7 +14,9 @@ from sqlalchemy.orm import Session
from app.models.invoice import Invoice
from app.models.request import Request
from app.models.status import Status
from app.services.invoice_chat import create_invoice_chat_message_with_attachment
from app.services.invoice_crypto import encrypt_requisites
from app.services.invoice_numbering import generate_invoice_number
STATUS_KIND_DEFAULT = "DEFAULT"
STATUS_KIND_INVOICE = "INVOICE"
@ -109,15 +111,6 @@ def _status_template(db: Session, status_code: str) -> str | None:
return value or None
def _invoice_number(db: Session) -> str:
prefix = _now_utc().strftime("%Y%m%d")
candidate = f"INV-{prefix}-{uuid4().hex[:8].upper()}"
exists = db.query(Invoice.id).filter(Invoice.invoice_number == candidate).first()
if exists is None:
return candidate
return f"INV-{prefix}-{uuid4().hex[:12].upper()}"
def _safe_render_template(template: str, values: dict[str, Any]) -> str:
source = str(template or "").strip() or DEFAULT_INVOICE_TEMPLATE
allowed = {
@ -188,9 +181,10 @@ def _create_waiting_invoice(
actor = _actor_uuid_or_none(admin)
role = str((admin or {}).get("role") or "").strip().upper() or None
issued_at = _now_utc()
invoice = Invoice(
request_id=req.id,
invoice_number=_invoice_number(db),
invoice_number=generate_invoice_number(db, issued_at),
status=INVOICE_STATUS_WAITING,
amount=amount,
currency="RUB",
@ -204,7 +198,7 @@ def _create_waiting_invoice(
),
issued_by_admin_user_id=actor,
issued_by_role=role,
issued_at=_now_utc(),
issued_at=issued_at,
paid_at=None,
responsible=responsible,
)
@ -213,6 +207,15 @@ def _create_waiting_invoice(
req.invoice_amount = amount
req.responsible = responsible
db.add(req)
create_invoice_chat_message_with_attachment(
db,
request=req,
invoice=invoice,
actor_role=role or "ADMIN",
actor_name=str((admin or {}).get("name") or (admin or {}).get("email") or responsible),
actor_admin_user_id=(admin or {}).get("sub"),
responsible=responsible,
)
return invoice.invoice_number

View file

@ -43,6 +43,9 @@ def serialize_message(row: Message) -> dict[str, Any]:
"author_type": row.author_type,
"author_name": row.author_name,
"body": row.body,
"message_kind": "TEXT",
"request_data_items": [],
"request_data_all_filled": False,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}

View file

@ -0,0 +1,181 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.models.admin_user import AdminUser
from app.models.attachment import Attachment
from app.models.invoice import Invoice
from app.models.message import Message
from app.models.request import Request
from app.services.attachment_scan import SCAN_STATUS_CLEAN
from app.services.invoice_crypto import decrypt_requisites
from app.services.invoice_pdf import build_invoice_pdf_bytes
from app.services.notifications import EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, notify_request_event
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_client
from app.services.s3_storage import build_object_key, get_s3_storage
INVOICE_CHAT_MESSAGE_BODY = "Счет на оплату"
CHAT_PARTICIPANT_ADMIN_IDS_KEY = "chat_participant_admin_ids"
INVOICE_STATUS_LABELS = {
"WAITING_PAYMENT": "Ожидает оплату",
"PAID": "Оплачен",
"CANCELED": "Отменен",
}
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
def _normalize_admin_uuid(value: Any) -> str | None:
raw = str(value or "").strip()
if not raw:
return None
try:
return str(uuid.UUID(raw))
except (TypeError, ValueError):
return None
def _register_chat_participant(request: Request, admin_user_id: Any) -> None:
normalized = _normalize_admin_uuid(admin_user_id)
if not normalized:
return
current = request.extra_fields if isinstance(request.extra_fields, dict) else {}
extra = dict(current or {})
raw_ids = extra.get(CHAT_PARTICIPANT_ADMIN_IDS_KEY)
known_ids: set[str] = set()
if isinstance(raw_ids, list):
for value in raw_ids:
item = _normalize_admin_uuid(value)
if item:
known_ids.add(item)
elif isinstance(raw_ids, str):
item = _normalize_admin_uuid(raw_ids)
if item:
known_ids.add(item)
known_ids.add(normalized)
extra[CHAT_PARTICIPANT_ADMIN_IDS_KEY] = sorted(known_ids)
request.extra_fields = extra
def _write_invoice_pdf_to_storage_or_500(*, key: str, content: bytes) -> None:
storage = get_s3_storage()
if hasattr(storage, "client") and hasattr(storage.client, "put_object") and hasattr(storage, "bucket"):
storage.client.put_object(
Bucket=storage.bucket,
Key=key,
Body=content,
ContentType="application/pdf",
)
return
objects = getattr(storage, "objects", None)
if isinstance(objects, dict):
objects[key] = {
"size": int(len(content)),
"mime": "application/pdf",
"content": bytes(content),
}
return
raise HTTPException(status_code=500, detail="Хранилище не поддерживает запись PDF счета")
def _status_label(status: str | None) -> str:
normalized = str(status or "").strip().upper()
if not normalized:
return "-"
return INVOICE_STATUS_LABELS.get(normalized, normalized)
def _issuer_name(db: Session, *, actor_admin_user_id: Any, actor_name: str) -> str:
normalized = _normalize_admin_uuid(actor_admin_user_id)
if not normalized:
return str(actor_name or "").strip() or "Администратор системы"
row = db.get(AdminUser, uuid.UUID(normalized))
if row is None:
return str(actor_name or "").strip() or "Администратор системы"
return str(row.name or row.email or actor_name or "Администратор системы").strip() or "Администратор системы"
def create_invoice_chat_message_with_attachment(
db: Session,
*,
request: Request,
invoice: Invoice,
actor_role: str,
actor_name: str,
actor_admin_user_id: Any,
responsible: str,
) -> tuple[Message, Attachment]:
normalized_role = str(actor_role or "").strip().upper() or "ADMIN"
author_type = "LAWYER" if normalized_role in {"LAWYER", "CURATOR"} else "SYSTEM"
author_name = str(actor_name or "").strip() or ("Юрист" if author_type == "LAWYER" else "Администратор системы")
safe_responsible = str(responsible or "").strip() or "Администратор системы"
message = Message(
request_id=request.id,
author_type=author_type,
author_name=author_name,
body=INVOICE_CHAT_MESSAGE_BODY,
responsible=safe_responsible,
)
db.add(message)
db.flush()
requisites = decrypt_requisites(invoice.payer_details_encrypted)
pdf_bytes = build_invoice_pdf_bytes(
invoice_number=invoice.invoice_number,
amount=float(invoice.amount or 0),
currency=str(invoice.currency or "RUB"),
status=_status_label(invoice.status),
issued_at=invoice.issued_at,
paid_at=invoice.paid_at,
payer_display_name=str(invoice.payer_display_name or "").strip() or "Клиент",
request_track_number=str(request.track_number or "").strip() or str(request.id),
issued_by_name=_issuer_name(db, actor_admin_user_id=actor_admin_user_id, actor_name=author_name),
requisites=requisites,
)
if not pdf_bytes:
raise HTTPException(status_code=500, detail="Не удалось сформировать PDF счета")
file_name = f"Счет {invoice.invoice_number}.pdf"
object_key = build_object_key(f"requests/{request.id}", file_name)
_write_invoice_pdf_to_storage_or_500(key=object_key, content=pdf_bytes)
attachment = Attachment(
request_id=request.id,
message_id=message.id,
file_name=file_name,
mime_type="application/pdf",
size_bytes=int(len(pdf_bytes)),
s3_key=object_key,
immutable=False,
scan_status=SCAN_STATUS_CLEAN,
scan_signature=None,
scan_error=None,
scanned_at=_now_utc(),
detected_mime="application/pdf",
responsible=safe_responsible,
)
db.add(attachment)
_register_chat_participant(request, actor_admin_user_id)
mark_unread_for_client(request, EVENT_MESSAGE)
request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + int(len(pdf_bytes))
request.responsible = safe_responsible
db.add(request)
notify_request_event(
db,
request=request,
event_type=NOTIFICATION_EVENT_MESSAGE,
actor_role=normalized_role,
actor_admin_user_id=actor_admin_user_id,
body=None,
responsible=safe_responsible,
)
return message, attachment

View file

@ -0,0 +1,46 @@
from __future__ import annotations
import re
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.models.invoice import Invoice
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
def generate_invoice_number(db: Session, issued_at: datetime | None = None) -> str:
dt = issued_at or _now_utc()
prefix = dt.strftime("%Y%m%d")
pattern = re.compile(rf"^{re.escape(prefix)}(?:-(\d+))?$")
rows = db.query(Invoice.invoice_number).filter(Invoice.invoice_number.like(f"{prefix}%")).all()
max_order = 0
has_base = False
for (raw_number,) in rows:
number = str(raw_number or "").strip()
match = pattern.match(number)
if not match:
continue
suffix = match.group(1)
if not suffix:
has_base = True
max_order = max(max_order, 1)
continue
try:
order = int(suffix)
except ValueError:
continue
if order <= 1:
order = 1
max_order = max(max_order, order)
if not has_base and max_order == 0:
return prefix
next_order = max(max_order, 1) + 1
return f"{prefix}-{next_order}"

View file

@ -1,8 +1,87 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import io
import os
import unicodedata
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from typing import Any
REPORTLAB_AVAILABLE = True
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader, simpleSplit
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas
from reportlab.platypus import Table, TableStyle
except Exception:
REPORTLAB_AVAILABLE = False
_DEFAULT_ISSUER = 'ООО "Аудиторы корпоративной безопасности"'
_DEFAULT_ISSUER_ADDRESS = "г. Ярославль, ул. Богдановича, 6А"
_DEFAULT_ISSUER_PHONE = "+7 (977) 268-94-06"
_DEFAULT_ISSUER_INN = "7604226740"
_DEFAULT_ISSUER_KPP = "760401001"
_DEFAULT_ISSUER_OGRN = "1127604008806"
_DEFAULT_BANK_NAME = 'АО "АЛЬФА-БАНК"'
_DEFAULT_BANK_BIK = "044525593"
_DEFAULT_BANK_ACCOUNT = "40702810501860000582"
_DEFAULT_BANK_CORR_ACCOUNT = "30101810200000000593"
_DEFAULT_SIGNATURE_STAMP_IMAGE = "invoice_signature_stamp.png"
_DEFAULT_DIRECTOR_NAME = "Андрианова С.С."
_RU_MONTHS = [
"января",
"февраля",
"марта",
"апреля",
"мая",
"июня",
"июля",
"августа",
"сентября",
"октября",
"ноября",
"декабря",
]
_FONT_CANDIDATES: list[tuple[str, str | None]] = [
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf"),
("/usr/share/fonts/truetype/freefont/FreeSans.ttf", "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf"),
("/System/Library/Fonts/Supplemental/Arial.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf"),
("/System/Library/Fonts/Supplemental/Arial Unicode.ttf", None),
("/Library/Fonts/Arial.ttf", "/Library/Fonts/Arial Bold.ttf"),
("/Library/Fonts/Arial Unicode.ttf", None),
]
_FONT_CACHE: tuple[str, str] | None = None
_UNITS_MALE = ("", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь", "девять")
_UNITS_FEMALE = ("", "одна", "две", "три", "четыре", "пять", "шесть", "семь", "восемь", "девять")
_TEENS = (
"десять",
"одиннадцать",
"двенадцать",
"тринадцать",
"четырнадцать",
"пятнадцать",
"шестнадцать",
"семнадцать",
"восемнадцать",
"девятнадцать",
)
_TENS = ("", "", "двадцать", "тридцать", "сорок", "пятьдесят", "шестьдесят", "семьдесят", "восемьдесят", "девяносто")
_HUNDREDS = ("", "сто", "двести", "триста", "четыреста", "пятьсот", "шестьсот", "семьсот", "восемьсот", "девятьсот")
_SCALES = [
("", "", "", False),
("тысяча", "тысячи", "тысяч", True),
("миллион", "миллиона", "миллионов", False),
("миллиард", "миллиарда", "миллиардов", False),
]
def _ascii_text(value: Any) -> str:
@ -30,6 +109,413 @@ def _build_content_stream(lines: list[str]) -> bytes:
return "\n".join(parts).encode("latin-1", errors="ignore")
def _build_legacy_invoice_pdf_bytes(lines: list[str]) -> bytes:
stream = _build_content_stream(lines)
objects = [
b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n",
b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n",
b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj\n",
b"4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n",
f"5 0 obj << /Length {len(stream)} >> stream\n".encode("latin-1") + stream + b"\nendstream endobj\n",
]
body = b"%PDF-1.4\n"
offsets = [0]
for obj in objects:
offsets.append(len(body))
body += obj
xref_offset = len(body)
body += f"xref\n0 {len(objects)+1}\n".encode("latin-1")
body += b"0000000000 65535 f \n"
for offset in offsets[1:]:
body += f"{offset:010d} 00000 n \n".encode("latin-1")
body += f"trailer << /Size {len(objects)+1} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode("latin-1")
return body
def _first_non_empty(source: dict[str, Any], *keys: str, default: str = "") -> str:
for key in keys:
value = source.get(key)
if value is None:
continue
text = str(value).strip()
if text:
return text
return default
def _format_amount(value: float) -> str:
amount = Decimal(str(value or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return f"{amount:.2f}"
def _format_amount_ru(value: float) -> str:
amount = Decimal(str(value or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
integer_part = int(amount)
fraction = int((amount - Decimal(integer_part)) * 100)
grouped = f"{integer_part:,}".replace(",", " ")
if fraction == 0:
return grouped
return f"{grouped},{fraction:02d}"
def _plural_ru(value: int, forms: tuple[str, str, str]) -> str:
n = abs(int(value)) % 100
if 11 <= n <= 19:
return forms[2]
n = n % 10
if n == 1:
return forms[0]
if 2 <= n <= 4:
return forms[1]
return forms[2]
def _triplet_to_words(value: int, *, female: bool) -> list[str]:
n = int(value) % 1000
if n == 0:
return []
words: list[str] = []
words.append(_HUNDREDS[n // 100])
n = n % 100
if 10 <= n <= 19:
words.append(_TEENS[n - 10])
else:
words.append(_TENS[n // 10])
unit_map = _UNITS_FEMALE if female else _UNITS_MALE
words.append(unit_map[n % 10])
return [word for word in words if word]
def _integer_to_words_ru(value: int) -> str:
number = int(value)
if number == 0:
return "ноль"
parts: list[str] = []
scale_index = 0
while number > 0:
triplet = number % 1000
if triplet:
one, two, five, female = _SCALES[min(scale_index, len(_SCALES) - 1)]
segment = _triplet_to_words(triplet, female=female)
if scale_index > 0:
segment.append(_plural_ru(triplet, (one, two, five)))
parts.append(" ".join(segment))
number //= 1000
scale_index += 1
return " ".join(reversed(parts)).strip()
def _amount_words_ru(amount: float) -> str:
dec = Decimal(str(amount or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
rub = int(dec)
kop = int((dec - Decimal(rub)) * 100)
words = _integer_to_words_ru(rub)
rub_label = _plural_ru(rub, ("рубль", "рубля", "рублей"))
kop_label = _plural_ru(kop, ("копейка", "копейки", "копеек"))
return f"{words} {rub_label} {kop:02d} {kop_label}".strip()
def _capitalize_first(text: str) -> str:
value = str(text or "").strip()
if not value:
return ""
return value[0].upper() + value[1:]
def _format_invoice_date(value: datetime | None) -> str:
dt = value or datetime.now()
month = _RU_MONTHS[max(0, min(11, dt.month - 1))]
return f"{dt.day:02d} {month} {dt.year} г."
def _resolve_reportlab_fonts() -> tuple[str, str]:
global _FONT_CACHE
if _FONT_CACHE is not None:
return _FONT_CACHE
regular_name = "Helvetica"
bold_name = "Helvetica-Bold"
for regular_path, bold_path in _FONT_CANDIDATES:
if not os.path.exists(regular_path):
continue
try:
regular_name = "InvoiceSans"
pdfmetrics.registerFont(TTFont(regular_name, regular_path))
if bold_path and os.path.exists(bold_path):
bold_name = "InvoiceSansBold"
pdfmetrics.registerFont(TTFont(bold_name, bold_path))
else:
bold_name = regular_name
_FONT_CACHE = (regular_name, bold_name)
return _FONT_CACHE
except Exception:
regular_name = "Helvetica"
bold_name = "Helvetica-Bold"
_FONT_CACHE = (regular_name, bold_name)
return _FONT_CACHE
def _resolve_signature_stamp_image_path(req: dict[str, Any]) -> str:
provided = _first_non_empty(
req,
"signature_stamp_image_path",
"signature_stamp_path",
"signature_image_path",
default="",
)
local_default = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets", _DEFAULT_SIGNATURE_STAMP_IMAGE)
candidates = [provided, local_default, f"/app/app/assets/{_DEFAULT_SIGNATURE_STAMP_IMAGE}"]
for path in candidates:
candidate = str(path or "").strip()
if candidate and os.path.exists(candidate):
return candidate
return ""
def _display_invoice_number(raw_number: str, issued_at: datetime | None) -> str:
value = str(raw_number or "").strip()
if not value:
return (issued_at or datetime.now()).strftime("%Y%m%d")
upper = value.upper()
if upper.startswith("INV-"):
tail = value[4:]
if len(tail) >= 8 and tail[:8].isdigit():
date_part = tail[:8]
remainder = tail[8:]
if not remainder:
return date_part
if remainder.startswith("-"):
suffix = remainder[1:]
if suffix.isdigit():
return f"{date_part}-{suffix}"
return date_part
return date_part
return value
def _draw_wrapped_line(pdf: Any, *, text: str, x: float, y: float, width: float, font: str, size: int, leading: float) -> float:
lines = simpleSplit(str(text or ""), font, size, width) or [""]
pdf.setFont(font, size)
cursor = y
for line in lines:
pdf.drawString(x, cursor, line)
cursor -= leading
return cursor
def _build_reportlab_invoice_pdf_bytes(
*,
invoice_number: str,
amount: float,
currency: str,
status: str,
issued_at: datetime | None,
paid_at: datetime | None,
payer_display_name: str,
request_track_number: str,
issued_by_name: str | None,
requisites: dict[str, Any] | None,
) -> bytes:
regular_font, bold_font = _resolve_reportlab_fonts()
req = dict(requisites or {})
issuer_name = _first_non_empty(req, "issuer_name", "beneficiary_name", "recipient_name", default=_DEFAULT_ISSUER)
issuer_address = _first_non_empty(req, "issuer_address", "address", default=_DEFAULT_ISSUER_ADDRESS)
issuer_phone = _first_non_empty(req, "issuer_phone", "phone", default=_DEFAULT_ISSUER_PHONE)
issuer_inn = _first_non_empty(req, "issuer_inn", "inn", default=_DEFAULT_ISSUER_INN)
issuer_kpp = _first_non_empty(req, "issuer_kpp", "kpp", default=_DEFAULT_ISSUER_KPP)
issuer_ogrn = _first_non_empty(req, "issuer_ogrn", "ogrn", default=_DEFAULT_ISSUER_OGRN)
bank_name = _first_non_empty(req, "bank_name", "bank", default=_DEFAULT_BANK_NAME)
bank_bik = _first_non_empty(req, "bank_bik", "bik", default=_DEFAULT_BANK_BIK)
bank_account = _first_non_empty(req, "bank_account", "account", default=_DEFAULT_BANK_ACCOUNT)
bank_corr_account = _first_non_empty(req, "bank_corr_account", "corr_account", default=_DEFAULT_BANK_CORR_ACCOUNT)
service_description = _first_non_empty(req, "service_description", "service", "template_rendered", default="Юридические услуги")
vat_note = _first_non_empty(req, "vat_note", default="без НДС")
director_name = _DEFAULT_DIRECTOR_NAME
signature_stamp_image_path = _resolve_signature_stamp_image_path(req)
amount_text = _format_amount_ru(amount)
amount_words = _capitalize_first(_amount_words_ru(amount))
issue_date = issued_at or datetime.now()
invoice_number_display = _display_invoice_number(invoice_number, issue_date)
issue_date_compact = issue_date.strftime("%d.%m.%Y")
buffer = io.BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)
page_width, page_height = A4
left = 15 * mm
content_width = page_width - 30 * mm
cursor_y = page_height - 13 * mm
# Header block close to the supplied invoice sample.
pdf.setFillColorRGB(0.17, 0.35, 0.40)
pdf.setFont(bold_font, 18)
pdf.drawCentredString(page_width / 2, cursor_y, "АУДИТОРЫ КОРПОРАТИВНОЙ БЕЗОПАСНОСТИ")
cursor_y -= 6.5 * mm
pdf.setFillColorRGB(0, 0, 0)
pdf.setFont(bold_font, 7)
pdf.drawCentredString(page_width / 2, cursor_y, "О Б Щ Е С Т В О С О Г Р А Н И Ч Е Н Н О Й О Т В Е Т С Т В Е Н Н О С Т Ь Ю")
cursor_y -= 4.6 * mm
pdf.setFont(regular_font, 8)
pdf.drawCentredString(page_width / 2, cursor_y, "Россия, 150014, Ярославль, ул. Богдановича, 6А")
cursor_y -= 2.2 * mm
pdf.line(left, cursor_y, page_width - left, cursor_y)
cursor_y -= 6.2 * mm
pdf.setFont(bold_font, 10)
pdf.drawString(left + 1 * mm, cursor_y, "Образец заполнения платежного поручения")
cursor_y -= 2.2 * mm
bank_table = Table(
[
[f"ИНН {issuer_inn}", f"КПП {issuer_kpp}", "", "Сч. №", bank_account],
[f"Получатель\n{issuer_name}", "", "", "", ""],
[f"Банк получателя\n{bank_name}", "", "", "БИК", bank_bik],
["", "", "", "Сч. №", bank_corr_account],
],
colWidths=[37 * mm, 34 * mm, 39 * mm, 25 * mm, 50 * mm],
)
bank_table.setStyle(
TableStyle(
[
("FONT", (0, 0), (-1, -1), regular_font, 9),
("FONT", (0, 0), (2, 0), bold_font, 8),
("GRID", (0, 0), (-1, -1), 0.7, colors.black),
("SPAN", (1, 0), (2, 0)),
("SPAN", (0, 1), (2, 1)),
("SPAN", (0, 2), (2, 3)),
("SPAN", (3, 0), (3, 1)),
("SPAN", (4, 0), (4, 1)),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("ALIGN", (3, 0), (3, -1), "CENTER"),
("ALIGN", (4, 0), (4, -1), "LEFT"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
)
)
_, bank_table_height = bank_table.wrap(content_width, cursor_y)
bank_table.drawOn(pdf, left, cursor_y - bank_table_height)
cursor_y -= bank_table_height + 5.5 * mm
pdf.setFont(bold_font, 13)
pdf.drawCentredString(page_width / 2, cursor_y, f"СЧЕТ № {invoice_number_display} от {issue_date_compact} года")
cursor_y -= 6.2 * mm
details_table = Table(
[
["Исполнитель", issuer_name],
["Адрес", issuer_address],
["Телефон", issuer_phone],
["Расчетный счет", bank_account],
["Банк", bank_name],
["БИК", bank_bik],
["Корр. счет", bank_corr_account],
["ИНН", issuer_inn],
["КПП", issuer_kpp],
["ОГРН", issuer_ogrn],
],
colWidths=[30 * mm, content_width - 30 * mm],
)
details_table.setStyle(
TableStyle(
[
("FONT", (0, 0), (-1, -1), regular_font, 9),
("GRID", (0, 0), (-1, -1), 0.7, colors.black),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]
)
)
_, details_table_height = details_table.wrap(content_width, cursor_y)
details_table.drawOn(pdf, left, cursor_y - details_table_height)
cursor_y -= details_table_height + 5 * mm
pdf.line(left, cursor_y, page_width - left, cursor_y)
cursor_y -= 2.4 * mm
item_name_width = 95 * mm - 8
wrapped_service = "\n".join(simpleSplit(service_description, regular_font, 9, item_name_width) or [service_description])
item_table = Table(
[
["\nПП", "Наименование", "Кол-во", "Цена\n(за единицу)", "ВСЕГО"],
["1", wrapped_service, "1", amount_text, amount_text],
["ВСЕГО", "", "", "", amount_text],
],
colWidths=[13 * mm, 95 * mm, 18 * mm, 27 * mm, 28 * mm],
)
item_table.setStyle(
TableStyle(
[
("FONT", (0, 0), (-1, -1), regular_font, 9),
("FONT", (0, 0), (-1, 0), bold_font, 9),
("FONT", (0, 2), (4, 2), bold_font, 9),
("GRID", (0, 0), (-1, -1), 0.7, colors.black),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("ALIGN", (0, 0), (0, -1), "CENTER"),
("ALIGN", (2, 0), (4, -1), "CENTER"),
("ALIGN", (3, 1), (4, -1), "RIGHT"),
("SPAN", (0, 2), (3, 2)),
("ALIGN", (0, 2), (3, 2), "LEFT"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
)
)
_, item_table_height = item_table.wrap(content_width, cursor_y)
item_table.drawOn(pdf, left, cursor_y - item_table_height)
cursor_y -= item_table_height + 5.5 * mm
pdf.setFont(regular_font, 9)
prefix = "Сумма прописью: "
pdf.drawString(left, cursor_y, prefix)
prefix_width = pdfmetrics.stringWidth(prefix, regular_font, 9)
pdf.setFont(bold_font, 10)
pdf.drawString(left + prefix_width, cursor_y, f"{amount_words} ({vat_note}).")
cursor_y -= 10 * mm
block_width = min(155 * mm, content_width)
block_left = left + (content_width - block_width) / 2
block_center_x = block_left + block_width / 2
block_top = cursor_y
signature_name = director_name or _DEFAULT_DIRECTOR_NAME
pdf.setFont(regular_font, 11)
pdf.drawString(block_left + 2 * mm, block_top, "С уважением,")
pdf.drawString(block_left + 2 * mm, block_top - 13 * mm, "Генеральный директор")
pdf.drawString(block_left + 2 * mm, block_top - 19 * mm, "ООО «АКБ»")
pdf.drawString(block_left + block_width - 35 * mm, block_top - 19 * mm, signature_name)
if signature_stamp_image_path:
try:
stamp_image = ImageReader(signature_stamp_image_path)
img_w, img_h = stamp_image.getSize()
target_h = 40 * mm
target_w = target_h * (float(img_w) / max(float(img_h), 1.0))
x = block_center_x - target_w / 2
y = max(12 * mm, block_top - 43 * mm)
pdf.drawImage(stamp_image, x, y, width=target_w, height=target_h, mask="auto")
pdf.setFont(regular_font, 11)
pdf.drawString(x + target_w + 3 * mm, y + 6 * mm, "МП")
except Exception:
pdf.drawString(block_center_x + 28 * mm, block_top - 19 * mm, "МП")
else:
pdf.drawString(block_center_x + 28 * mm, block_top - 19 * mm, "МП")
pdf.showPage()
pdf.save()
return buffer.getvalue()
def build_invoice_pdf_bytes(
*,
invoice_number: str,
@ -43,6 +529,24 @@ def build_invoice_pdf_bytes(
issued_by_name: str | None,
requisites: dict[str, Any] | None,
) -> bytes:
if REPORTLAB_AVAILABLE:
try:
return _build_reportlab_invoice_pdf_bytes(
invoice_number=invoice_number,
amount=amount,
currency=currency,
status=status,
issued_at=issued_at,
paid_at=paid_at,
payer_display_name=payer_display_name,
request_track_number=request_track_number,
issued_by_name=issued_by_name,
requisites=requisites,
)
except Exception:
# Safety fallback for environments without fonts/reportlab internals.
pass
lines = [
f"Invoice: {invoice_number}",
f"Request: {request_track_number}",
@ -60,25 +564,4 @@ def build_invoice_pdf_bytes(
lines.append(f"{key}: {req.get(key)}")
else:
lines.append("-")
stream = _build_content_stream(lines)
objects = [
b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n",
b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n",
b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj\n",
b"4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n",
f"5 0 obj << /Length {len(stream)} >> stream\n".encode("latin-1") + stream + b"\nendstream endobj\n",
]
body = b"%PDF-1.4\n"
offsets = [0]
for obj in objects:
offsets.append(len(body))
body += obj
xref_offset = len(body)
body += f"xref\n0 {len(objects)+1}\n".encode("latin-1")
body += b"0000000000 65535 f \n"
for offset in offsets[1:]:
body += f"{offset:010d} 00000 n \n".encode("latin-1")
body += f"trailer << /Size {len(objects)+1} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode("latin-1")
return body
return _build_legacy_invoice_pdf_bytes(lines)

View file

@ -355,6 +355,7 @@
margin-top: 0.3rem;
font-size: 1.2rem;
color: #f6dab0;
text-align: right;
}
.lawyer-dashboard-grid {
@ -1694,6 +1695,110 @@
margin-top: 0.2rem;
}
.request-finance-actions {
margin-top: 0.65rem;
padding-top: 0.2rem;
}
.request-finance-actions-inline {
margin-top: 0.1rem;
padding-top: 0;
}
.request-finance-issue-form {
margin-top: 0.2rem;
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.55rem;
background: rgba(255, 255, 255, 0.02);
gap: 0.55rem;
}
.request-finance-issue-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.request-finance-invoices {
margin-top: 0.75rem;
border-top: 1px solid var(--line);
padding-top: 0.6rem;
max-height: min(42vh, 340px);
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 0.45rem;
min-height: 0;
}
.request-finance-invoices-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.45rem;
margin-bottom: 0.4rem;
}
.request-finance-invoices-head h4 {
margin: 0;
font-size: 0.92rem;
}
.request-finance-invoice-list {
display: grid;
gap: 0.45rem;
max-height: none;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding-right: 0.1rem;
}
.request-finance-invoice-row {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.5rem 0.55rem;
background: rgba(255, 255, 255, 0.02);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.request-finance-invoice-meta {
display: grid;
gap: 0.3rem;
min-width: 0;
flex: 1 1 auto;
}
.request-finance-invoice-number {
color: #d8e5f7;
font-size: 0.83rem;
}
.request-finance-invoice-details {
display: flex;
flex-wrap: wrap;
gap: 0.45rem 0.65rem;
color: #b8c8db;
font-size: 0.79rem;
}
.request-finance-empty {
margin: 0.1rem 0 0;
}
.request-finance-invoice-download-btn {
width: 32px;
height: 32px;
border-radius: 8px;
font-size: 0.95rem;
line-height: 1;
padding: 0;
flex: 0 0 auto;
}
.request-data-modal {
width: min(860px, 100%);
}
@ -2558,6 +2663,14 @@
margin-bottom: 0.2rem;
}
.chat-service-head {
font-size: 0.84rem;
font-weight: 800;
color: #ffe0a6;
margin-bottom: 0.2rem;
line-height: 1.3;
}
.chat-request-data-bubble.all-filled .chat-request-data-head {
color: #d3f4dc;
margin-bottom: 0.08rem;
@ -2688,6 +2801,22 @@
line-height: 1.2;
}
.request-chat-list li.chat-empty-state {
align-self: center;
width: fit-content;
max-width: min(70%, 360px);
margin: 0.1rem 0 0;
padding: 0.36rem 0.72rem;
border-radius: 999px;
border: 1px solid rgba(131, 151, 178, 0.36);
background: rgba(46, 61, 84, 0.44);
color: #bfd0e6;
font-size: 0.78rem;
line-height: 1.2;
text-align: center;
white-space: nowrap;
}
.request-chat-composer-actions {
display: flex;
align-items: center;
@ -3026,6 +3155,10 @@
.request-main-column {
order: 2;
}
.request-finance-invoice-row {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 620px) {
@ -3082,4 +3215,7 @@
flex-direction: column;
align-items: flex-start;
}
.request-finance-issue-grid {
grid-template-columns: 1fr;
}
}

View file

@ -5,12 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Административная панель • Правовой трекер</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
<link rel="stylesheet" href="/admin.css?v=20260225-12">
<link rel="stylesheet" href="/admin.css?v=20260303-05">
</head>
<body>
<div id="admin-root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/admin.js?v=20260225-12"></script>
<script src="/admin.js?v=20260303-05"></script>
</body>
</html>

10015
app/web/admin.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -953,9 +953,13 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
}
if (field.type === "reference" || field.type === "enum") {
const extraOptions = Array.isArray(field.extraOptions) ? field.extraOptions : [];
const hasCurrentValue =
String(value || "").trim() !== "" &&
[...extraOptions, ...options].some((option) => String(option?.value || "") === String(value));
return (
<select id={id} value={value} onChange={(event) => onChange(field.key, event.target.value)} disabled={disabled}>
{field.optional ? <option value="">-</option> : null}
{!hasCurrentValue && String(value || "").trim() !== "" ? <option value={String(value)}>{String(value)}</option> : null}
{extraOptions.map((option) => (
<option value={String(option.value)} key={String(option.value)}>
{option.label}
@ -1275,6 +1279,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
loadRequestDataTemplateDetails,
saveRequestDataTemplate,
saveRequestDataBatch,
issueRequestInvoice,
} = useRequestWorkspace({
api,
setStatus,
@ -1288,21 +1293,21 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
const getStatusOptions = useCallback(() => {
return (dictionaries.statuses || [])
.filter((item) => item && item.code)
.map((item) => ({ value: item.code, label: (item.name || statusLabel(item.code)) + " (" + item.code + ")" }));
.map((item) => ({ value: item.code, label: String(item.name || "").trim() || humanizeKey(item.code) }));
}, [dictionaries.statuses]);
const getInvoiceStatusOptions = useCallback(() => {
return Object.entries(INVOICE_STATUS_LABELS).map(([code, name]) => ({ value: code, label: name + " (" + code + ")" }));
return Object.entries(INVOICE_STATUS_LABELS).map(([code, name]) => ({ value: code, label: name }));
}, []);
const getStatusKindOptions = useCallback(() => {
return Object.entries(STATUS_KIND_LABELS).map(([code, name]) => ({ value: code, label: name + " (" + code + ")" }));
return Object.entries(STATUS_KIND_LABELS).map(([code, name]) => ({ value: code, label: name }));
}, []);
const getTopicOptions = useCallback(() => {
return (dictionaries.topics || [])
.filter((item) => item && item.code)
.map((item) => ({ value: item.code, label: (item.name || item.code) + " (" + item.code + ")" }));
.map((item) => ({ value: item.code, label: String(item.name || "").trim() || humanizeKey(item.code) }));
}, [dictionaries.topics]);
const getLawyerOptions = useCallback(() => {
@ -1320,22 +1325,22 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
const getRequestDataValueTypeOptions = useCallback(() => {
return [
{ value: "string", label: "Строка (string)" },
{ value: "date", label: "Дата (date)" },
{ value: "number", label: "Число (number)" },
{ value: "file", label: "Файл (file)" },
{ value: "text", label: "Текст (text)" },
{ value: "string", label: "Строка" },
{ value: "date", label: "Дата" },
{ value: "number", label: "Число" },
{ value: "file", label: "Файл" },
{ value: "text", label: "Текст" },
];
}, []);
const getFormFieldKeyOptions = useCallback(() => {
return (dictionaries.formFieldKeys || [])
.filter((item) => item && item.key)
.map((item) => ({ value: item.key, label: (item.label || item.key) + " (" + item.key + ")" }));
.map((item) => ({ value: item.key, label: String(item.label || "").trim() || humanizeKey(item.key) }));
}, [dictionaries.formFieldKeys]);
const getRoleOptions = useCallback(() => {
return Object.entries(ROLE_LABELS).map(([code, label]) => ({ value: code, label: label + " (" + code + ")" }));
return Object.entries(ROLE_LABELS).map(([code, label]) => ({ value: code, label }));
}, []);
const tableCatalogMap = useMemo(() => {
@ -1388,6 +1393,54 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
return getReferenceOptions({ table: "clients", value_field: "id", label_field: "full_name" });
}, [getReferenceOptions]);
const getInvoiceRequestRows = useCallback(() => {
const fromReferences = Array.isArray(referenceRowsMap.requests) ? referenceRowsMap.requests : [];
const fromTable = Array.isArray(tables.requests?.rows) ? tables.requests.rows : [];
const byTrack = new Map();
[...fromReferences, ...fromTable].forEach((row) => {
const track = String(row?.track_number || "").trim().toUpperCase();
if (!track) return;
if (!byTrack.has(track)) byTrack.set(track, row);
});
return Array.from(byTrack.values());
}, [referenceRowsMap.requests, tables.requests?.rows]);
const getInvoiceRequestTrackOptions = useCallback(() => {
const rows = getInvoiceRequestRows();
return rows
.map((row) => {
const track = String(row?.track_number || "").trim().toUpperCase();
if (!track) return null;
const clientName = String(row?.client_name || "").trim();
const clientPhone = String(row?.client_phone || "").trim();
const parts = [track];
if (clientName) parts.push(clientName);
if (clientPhone) parts.push(clientPhone);
return { value: track, label: parts.join(" • ") };
})
.filter(Boolean)
.sort((a, b) => String(a.label).localeCompare(String(b.label), "ru"));
}, [getInvoiceRequestRows]);
const getInvoicePayerOptions = useCallback(() => {
const map = new Map();
const addPayer = (nameRaw, phoneRaw) => {
const name = String(nameRaw || "").trim();
if (!name) return;
const phone = String(phoneRaw || "").trim();
if (map.has(name)) return;
map.set(name, phone ? `${name} (${phone})` : name);
};
const clientRows = Array.isArray(referenceRowsMap.clients) ? referenceRowsMap.clients : [];
clientRows.forEach((row) => addPayer(row?.full_name || row?.client_name, row?.phone || row?.client_phone));
getInvoiceRequestRows().forEach((row) => addPayer(row?.client_name, row?.client_phone));
return Array.from(map.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => String(a.label).localeCompare(String(b.label), "ru"));
}, [getInvoiceRequestRows, referenceRowsMap.clients]);
const dictionaryTableItems = useMemo(() => {
return (tableCatalog || [])
.filter(
@ -1440,6 +1493,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
{ field: "status_code", label: "Статус", type: "reference", options: getStatusOptions },
{ field: "created_at", label: "Дата", type: "date" },
{ field: "topic_code", label: "Тема", type: "reference", options: getTopicOptions },
{ field: "has_unread_updates", label: "Есть оповещения", type: "boolean" },
{ field: "deadline_alert", label: "Горящие дедлайны", type: "boolean" },
{ field: "overdue", label: "Просрочен", type: "boolean" },
];
}
@ -1761,12 +1816,12 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
}
if (tableKey === "invoices") {
return [
{ key: "request_track_number", label: "Номер заявки", type: "text", required: true, createOnly: true },
{ key: "request_track_number", label: "Номер заявки", type: "reference", required: true, createOnly: true, options: getInvoiceRequestTrackOptions },
{ key: "invoice_number", label: "Номер счета", type: "text", optional: true, placeholder: "Оставьте пустым для автогенерации" },
{ key: "status", label: "Статус", type: "enum", required: true, options: getInvoiceStatusOptions, defaultValue: "WAITING_PAYMENT" },
{ key: "amount", label: "Сумма", type: "number", required: true },
{ key: "currency", label: "Валюта", type: "text", optional: true, defaultValue: "RUB" },
{ key: "payer_display_name", label: "Плательщик (ФИО / компания)", type: "text", required: true },
{ key: "payer_display_name", label: "Плательщик (ФИО / компания)", type: "reference", required: true, options: getInvoicePayerOptions },
{ key: "payer_details", label: "Реквизиты (JSON, шифруется)", type: "json", optional: true, omitIfEmpty: true, placeholder: "{\"inn\":\"...\"}" },
];
}
@ -1910,6 +1965,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
getFormFieldKeyOptions,
getFormFieldTypeOptions,
getInvoiceStatusOptions,
getInvoicePayerOptions,
getInvoiceRequestTrackOptions,
getClientOptions,
getLawyerOptions,
getRoleOptions,
@ -2147,9 +2204,9 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
if (!(tokenOverride !== undefined ? tokenOverride : token)) return;
if (section === "dashboard") return loadDashboard(tokenOverride);
if (section === "kanban") return loadKanban(tokenOverride);
if (section === "requests") return loadTable("requests", {}, tokenOverride);
if (section === "serviceRequests") return loadTable("serviceRequests", {}, tokenOverride);
if (section === "invoices") return loadTable("invoices", {}, tokenOverride);
if (section === "requests" && canAccessSection(role, "requests")) return loadTable("requests", {}, tokenOverride);
if (section === "serviceRequests" && canAccessSection(role, "serviceRequests")) return loadTable("serviceRequests", {}, tokenOverride);
if (section === "invoices" && canAccessSection(role, "invoices")) return loadTable("invoices", {}, tokenOverride);
if (section === "quotes" && canAccessSection(role, "quotes")) return loadTable("quotes", {}, tokenOverride);
if (section === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride);
if (section === "availableTables" && canAccessSection(role, "availableTables")) return loadAvailableTables(tokenOverride);
@ -2352,10 +2409,22 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
}
}
}
if (prev.tableKey === "invoices" && field === "request_track_number") {
const selectedTrack = String(value || "").trim().toUpperCase();
if (selectedTrack) {
const rows = getInvoiceRequestRows();
const found = rows.find((row) => String(row?.track_number || "").trim().toUpperCase() === selectedTrack);
if (found) {
nextForm.request_track_number = String(found.track_number || selectedTrack).trim().toUpperCase();
const autoPayer = String(found.client_name || "").trim();
if (autoPayer) nextForm.payer_display_name = autoPayer;
}
}
}
return { ...prev, form: nextForm };
});
},
[referenceRowsMap.clients]
[getInvoiceRequestRows, referenceRowsMap.clients]
);
const uploadRecordFieldFile = useCallback(
@ -2521,13 +2590,16 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
await api("/api/admin/requests/" + requestId + "/claim", { method: "POST" });
setStatus("requests", "Заявка взята в работу", "ok");
setStatus("kanban", "Заявка взята в работу", "ok");
await Promise.all([loadTable("requests", { resetOffset: true }), loadKanban()]);
const refreshRequests = canAccessSection(role, "requests")
? loadTable("requests", { resetOffset: true })
: Promise.resolve();
await Promise.all([refreshRequests, loadKanban()]);
} catch (error) {
setStatus("requests", "Ошибка назначения: " + error.message, "error");
setStatus("kanban", "Ошибка назначения: " + error.message, "error");
}
},
[api, loadKanban, loadTable, setStatus]
[api, loadKanban, loadTable, role, setStatus]
);
const openInvoiceRequest = useCallback(
@ -2588,7 +2660,10 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
setStatus("kanban", "Переводим заявку...", "");
await submitRequestStatusChange({ requestId, statusCode: targetStatus });
setStatus("kanban", "Статус заявки обновлен", "ok");
await Promise.all([loadKanban(), loadTable("requests", { resetOffset: true })]);
const refreshRequests = canAccessSection(role, "requests")
? loadTable("requests", { resetOffset: true })
: Promise.resolve();
await Promise.all([loadKanban(), refreshRequests]);
} catch (error) {
setStatus("kanban", "Ошибка перехода: " + error.message, "error");
}
@ -2597,10 +2672,10 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
);
const downloadInvoicePdf = useCallback(
async (row) => {
async (row, statusKey = "invoices") => {
if (!row || !row.id || !token) return;
try {
setStatus("invoices", "Формируем PDF...", "");
setStatus(statusKey, "Формируем PDF...", "");
const response = await fetch("/api/admin/invoices/" + row.id + "/pdf", {
headers: { Authorization: "Bearer " + token },
});
@ -2625,14 +2700,21 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
link.click();
link.remove();
URL.revokeObjectURL(url);
setStatus("invoices", "PDF скачан", "ok");
setStatus(statusKey, "PDF скачан", "ok");
} catch (error) {
setStatus("invoices", "Ошибка скачивания: " + error.message, "error");
setStatus(statusKey, "Ошибка скачивания: " + error.message, "error");
}
},
[setStatus, token]
);
const downloadRequestInvoicePdf = useCallback(
async (row) => {
await downloadInvoicePdf(row, "requestModal");
},
[downloadInvoicePdf]
);
const resetAdminRoute = useCallback(() => {
const nextUrl = "/admin.html";
if (window.location.pathname !== nextUrl || window.location.search) {
@ -2641,10 +2723,11 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
}, []);
const goBackFromRequestWorkspace = useCallback(() => {
const targetSection = canAccessSection(role, "requests") ? "requests" : "kanban";
resetAdminRoute();
setActiveSection("requests");
refreshSection("requests");
}, [refreshSection, resetAdminRoute]);
setActiveSection(targetSection);
refreshSection(targetSection);
}, [refreshSection, resetAdminRoute, role]);
const openReassignModal = useCallback(
(row) => {
@ -2827,6 +2910,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
const applyRequestsQuickFilterPreset = useCallback(
async (filters, statusMessage) => {
if (!canAccessSection(role, "requests")) return;
const nextFilters = Array.isArray(filters) ? filters.filter((item) => item && item.field) : [];
resetAdminRoute();
setActiveSection("requests");
@ -2840,7 +2924,25 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
if (statusMessage) setStatus("requests", statusMessage, "");
await loadTable("requests", { resetOffset: true, filtersOverride: nextFilters });
},
[loadTable, resetAdminRoute, setStatus, setTableState, tablesRef]
[loadTable, resetAdminRoute, role, setStatus, setTableState, tablesRef]
);
const applyKanbanQuickFilterPreset = useCallback(
async (filters, statusMessage) => {
const nextFilters = Array.isArray(filters) ? filters.filter((item) => item && item.field) : [];
resetAdminRoute();
setActiveSection("kanban");
const currentState = tablesRef.current.kanban || createTableState();
setTableState("kanban", {
...currentState,
filters: nextFilters,
offset: 0,
showAll: false,
});
if (statusMessage) setStatus("kanban", statusMessage, "");
await loadKanban(undefined, { filtersOverride: nextFilters });
},
[loadKanban, resetAdminRoute, setStatus, setTableState, tablesRef]
);
const openRequestsWithUnreadAlerts = useCallback(async () => {
@ -2851,6 +2953,14 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
await applyRequestsQuickFilterPreset([{ field: "deadline_alert", op: "=", value: true }], "Показаны заявки с горящими дедлайнами");
}, [applyRequestsQuickFilterPreset]);
const openKanbanWithUnreadAlerts = useCallback(async () => {
await applyKanbanQuickFilterPreset([{ field: "has_unread_updates", op: "=", value: true }], "Показаны заявки с новыми оповещениями");
}, [applyKanbanQuickFilterPreset]);
const openKanbanWithDeadlineAlerts = useCallback(async () => {
await applyKanbanQuickFilterPreset([{ field: "deadline_alert", op: "=", value: true }], "Показаны заявки с горящими дедлайнами");
}, [applyKanbanQuickFilterPreset]);
const applyServiceRequestsQuickFilterPreset = useCallback(
async (filters, statusMessage) => {
const nextFilters = Array.isArray(filters) ? filters.filter((item) => item && item.field) : [];
@ -2891,13 +3001,13 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
setStatus("serviceRequests", "Отмечаем как прочитанный...", "");
await api("/api/admin/requests/service-requests/" + encodeURIComponent(rowId) + "/read", { method: "POST" });
await Promise.all([loadTable("serviceRequests", { resetOffset: true }), loadDashboard()]);
await loadTable("requests", { resetOffset: true });
if (canAccessSection(role, "requests")) await loadTable("requests", { resetOffset: true });
setStatus("serviceRequests", "Запрос отмечен как прочитанный", "ok");
} catch (error) {
setStatus("serviceRequests", "Ошибка: " + error.message, "error");
}
},
[api, loadDashboard, loadTable, setStatus]
[api, loadDashboard, loadTable, role, setStatus]
);
const loadTotpStatus = useCallback(
@ -3399,14 +3509,15 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
}, [closeAccountModal, closeKanbanSortModal, closeTotpSetupModal]);
const menuItems = useMemo(() => {
return [
const baseItems = [
{ key: "dashboard", label: "Обзор" },
{ key: "kanban", label: "Канбан" },
{ key: "requests", label: "Заявки" },
{ key: "serviceRequests", label: "Запросы" },
{ key: "invoices", label: "Счета" },
];
}, []);
return baseItems.filter((item) => canAccessSection(role, item.key));
}, [role]);
const topbarUnreadCount = useMemo(() => {
const roleCode = String(role || "").toUpperCase();
@ -3421,6 +3532,11 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
() => Number(dashboardData.serviceRequestUnreadTotal || 0),
[dashboardData.serviceRequestUnreadTotal]
);
const topbarRoleCode = String(role || "").toUpperCase();
const canUseRequestsAlerts = topbarRoleCode === "ADMIN";
const canUseKanbanAlerts = topbarRoleCode === "LAWYER";
const showRequestAlertIcons = canUseRequestsAlerts || canUseKanbanAlerts;
const showServiceRequestIcon = canAccessSection(role, "serviceRequests");
const activeFilterFields = useMemo(() => {
if (!filterModal.tableKey) return [];
@ -3515,11 +3631,13 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
</>
) : null}
</nav>
{role !== "LAWYER" ? (
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<button className="btn secondary" type="button" onClick={refreshAll}>
Обновить
</button>
</div>
) : null}
</aside>
<main className="main">
@ -3529,6 +3647,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
<p className="muted">UniversalQuery, RBAC и аудит действий по ключевым сущностям системы.</p>
</div>
<div className="topbar-actions" aria-label="Быстрые уведомления и профиль">
{showServiceRequestIcon ? (
<button
type="button"
className={
@ -3550,6 +3669,9 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
</svg>
<span className="topbar-alert-dot" aria-hidden="true" />
</button>
) : null}
{showRequestAlertIcons ? (
<>
<button
type="button"
className={
@ -3561,7 +3683,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
: "Горящих дедлайнов нет"
}
aria-label="Показать заявки с горящими дедлайнами"
onClick={openRequestsWithDeadlineAlerts}
onClick={canUseRequestsAlerts ? openRequestsWithDeadlineAlerts : openKanbanWithDeadlineAlerts}
>
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false">
<path
@ -3582,7 +3704,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
: "Новых оповещений нет"
}
aria-label="Показать заявки с новыми оповещениями"
onClick={openRequestsWithUnreadAlerts}
onClick={canUseRequestsAlerts ? openRequestsWithUnreadAlerts : openKanbanWithUnreadAlerts}
>
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false">
<path
@ -3592,6 +3714,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
</svg>
<span className="topbar-alert-dot" aria-hidden="true" />
</button>
</>
) : null}
<button
type="button"
className="icon-btn topbar-alert-btn"
@ -3649,6 +3773,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
/>
</Section>
{canAccessSection(role, "requests") ? (
<Section active={activeSection === "requests"} id="section-requests">
<RequestsSection
role={role}
@ -3678,6 +3803,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
IconButtonComponent={IconButton}
/>
</Section>
) : null}
<Section active={activeSection === "serviceRequests"} id="section-service-requests">
<ServiceRequestsSection
@ -3734,6 +3860,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
trackNumber={requestModal.trackNumber}
requestData={requestModal.requestData}
financeSummary={requestModal.financeSummary}
invoices={requestModal.invoices || []}
statusRouteNodes={requestModal.statusRouteNodes}
statusHistory={requestModal.statusHistory || []}
availableStatuses={requestModal.availableStatuses || []}
@ -3755,6 +3882,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
onLoadRequestDataTemplateDetails={loadRequestDataTemplateDetails}
onSaveRequestDataTemplate={saveRequestDataTemplate}
onSaveRequestDataBatch={saveRequestDataBatch}
onIssueInvoice={issueRequestInvoice}
onDownloadInvoicePdf={downloadRequestInvoicePdf}
onChangeStatus={submitRequestStatusChange}
onConsumePendingStatusChangePreset={clearPendingStatusChangePreset}
onLiveProbe={probeRequestLive}

View file

@ -70,14 +70,26 @@ export function DashboardSection({
setLawyerModal({ open: false, loading: false, error: "", lawyer: null, rows: [], totals: { amount: 0, salary: 0 } });
};
const isLawyerScope = dashboardData?.scope === "LAWYER";
const lawyerCards = Array.isArray(dashboardData?.lawyerLoads) ? dashboardData.lawyerLoads : [];
const currentLawyer = lawyerCards[0] || null;
const lawyerMetrics = currentLawyer
? [
{ label: "В работе", value: String(currentLawyer.active_load ?? 0) },
{ label: "Новые", value: String(currentLawyer.monthly_assigned_count ?? 0) },
{ label: "Закрыто", value: String(currentLawyer.monthly_completed_count ?? 0) },
{ label: "ЗП, тыс.", value: fmtThousandsCompact(currentLawyer.monthly_salary) },
]
: [];
return (
<>
<div className="section-head">
<div>
<h2>Обзор метрик</h2>
<p className="muted">Состояние заявок, финансы месяца и загрузка юристов.</p>
<p className="muted">
{isLawyerScope ? "Состояние заявок и персональная загрузка." : "Состояние заявок, финансы месяца и загрузка юристов."}
</p>
</div>
</div>
@ -109,12 +121,26 @@ export function DashboardSection({
</div>
) : null}
{dashboardData?.scope === "LAWYER" ? (
<div className="json" style={{ marginTop: "0.5rem" }}>
{JSON.stringify(dashboardData?.myUnreadByEvent || {}, null, 2)}
{isLawyerScope ? (
<div style={{ marginTop: "0.9rem" }}>
<h3 style={{ margin: "0 0 0.55rem" }}>Моя загрузка</h3>
<div className="cards">
{lawyerMetrics.length ? (
lawyerMetrics.map((metric) => (
<div className="card" key={"lawyer-metric-" + metric.label}>
<p>{metric.label}</p>
<b>{metric.value}</b>
</div>
) : null}
))
) : (
<div className="card">
<p>Моя загрузка</p>
<b>Нет данных</b>
</div>
)}
</div>
</div>
) : (
<div style={{ marginTop: "0.9rem" }}>
<h3 style={{ margin: "0 0 0.55rem" }}>Загрузка юристов</h3>
<div className="lawyer-dashboard-grid">
@ -151,6 +177,7 @@ export function DashboardSection({
)}
</div>
</div>
)}
<StatusLine status={status} />

View file

@ -7,6 +7,7 @@ import {
fmtShortDateTime,
fmtTimeOnly,
humanizeKey,
invoiceStatusLabel,
statusLabel,
} from "../../shared/utils.js";
@ -17,6 +18,7 @@ export function RequestWorkspace({
trackNumber,
requestData,
financeSummary,
invoices,
statusRouteNodes,
statusHistory,
availableStatuses,
@ -38,6 +40,8 @@ export function RequestWorkspace({
onLoadRequestDataTemplateDetails,
onSaveRequestDataTemplate,
onSaveRequestDataBatch,
onIssueInvoice,
onDownloadInvoicePdf,
onSaveRequestDataValues,
onUploadRequestAttachment,
onChangeStatus,
@ -53,6 +57,14 @@ export function RequestWorkspace({
const [chatTab, setChatTab] = useState("chat");
const [dropActive, setDropActive] = useState(false);
const [financeOpen, setFinanceOpen] = useState(false);
const [financeIssueForm, setFinanceIssueForm] = useState({
open: false,
saving: false,
amount: "",
serviceDescription: "",
payerDisplayName: "",
error: "",
});
const [requestDataListOpen, setRequestDataListOpen] = useState(false);
const [descriptionOpen, setDescriptionOpen] = useState(false);
const [requestTemplateSuggestOpen, setRequestTemplateSuggestOpen] = useState(false);
@ -178,6 +190,7 @@ export function RequestWorkspace({
const showContactsInCard = viewerRoleCode !== "CLIENT";
const safeMessages = Array.isArray(messages) ? messages : [];
const safeAttachments = Array.isArray(attachments) ? attachments : [];
const safeInvoices = Array.isArray(invoices) ? invoices : [];
const safeStatusHistory = Array.isArray(statusHistory) ? statusHistory : [];
const safeAvailableStatuses = Array.isArray(availableStatuses) ? availableStatuses : [];
const totalFilesBytes = safeAttachments.reduce((acc, item) => acc + Number(item?.size_bytes || 0), 0);
@ -265,7 +278,7 @@ export function RequestWorkspace({
.filter((item) => item && item.code)
.map((item) => ({
code: String(item.code),
name: String(item.name || item.code),
name: String(item.name || "").trim() || humanizeKey(item.code),
groupName: item.status_group_name ? String(item.status_group_name) : "",
isTerminal: Boolean(item.is_terminal),
})),
@ -312,6 +325,61 @@ export function RequestWorkspace({
return Math.max(0, minutes) + " мин";
};
const formatMoneyInput = (value) => {
const amount = Number(value);
if (!Number.isFinite(amount) || amount <= 0) return "";
return String(Math.round((amount + Number.EPSILON) * 100) / 100);
};
const openFinanceIssueForm = () => {
const defaultAmount =
finance?.request_cost ??
row?.request_cost ??
row?.invoice_amount ??
finance?.effective_rate ??
row?.effective_rate ??
"";
setFinanceIssueForm({
open: true,
saving: false,
amount: formatMoneyInput(defaultAmount),
serviceDescription: String(row?.topic_name || row?.topic_code || "Юридические услуги"),
payerDisplayName: String(row?.client_name || "").trim() || "Клиент",
error: "",
});
};
const closeFinanceIssueForm = () => {
setFinanceIssueForm((prev) => ({ ...prev, open: false, saving: false, error: "" }));
};
const closeFinanceModal = () => {
setFinanceOpen(false);
closeFinanceIssueForm();
};
const submitFinanceIssueForm = async (event) => {
if (event && typeof event.preventDefault === "function") event.preventDefault();
if (!row?.id || typeof onIssueInvoice !== "function") return;
const normalizedAmount = Number(String(financeIssueForm.amount || "").replace(",", "."));
if (!Number.isFinite(normalizedAmount) || normalizedAmount <= 0) {
setFinanceIssueForm((prev) => ({ ...prev, error: "Укажите корректную сумму счета" }));
return;
}
setFinanceIssueForm((prev) => ({ ...prev, saving: true, error: "" }));
try {
await onIssueInvoice({
requestId: String(row.id),
amount: normalizedAmount,
serviceDescription: String(financeIssueForm.serviceDescription || ""),
payerDisplayName: String(financeIssueForm.payerDisplayName || ""),
});
setFinanceIssueForm((prev) => ({ ...prev, open: false, saving: false, error: "" }));
} catch (error) {
setFinanceIssueForm((prev) => ({ ...prev, saving: false, error: error?.message || "Не удалось выставить счет" }));
}
};
const openStatusChangeModal = (preset) => {
const suggested = Array.isArray(preset?.suggestedStatuses) ? preset.suggestedStatuses.filter(Boolean) : [];
const currentCode = String(row?.status_code || "").trim();
@ -1146,7 +1214,7 @@ export function RequestWorkspace({
Array.isArray(statusRouteNodes) && statusRouteNodes.length
? statusRouteNodes
: row?.status_code
? [{ code: row.status_code, name: statusLabel(row.status_code), state: "current", note: "Текущий этап обработки заявки" }]
? [{ code: row.status_code, name: String(row?.status_name || statusLabel(row.status_code) || row.status_code), state: "current", note: "Текущий этап обработки заявки" }]
: [];
const upcomingImportantDate = useMemo(() => {
const source = String(currentImportantDateAt || row?.important_date_at || "").trim();
@ -1156,7 +1224,7 @@ export function RequestWorkspace({
return new Date(timestamp).toISOString();
}, [currentImportantDateAt, row?.important_date_at]);
const routeNodes = useMemo(() => {
if (viewerRoleCode !== "CLIENT" || !upcomingImportantDate) return baseRouteNodes;
if ((viewerRoleCode !== "CLIENT" && viewerRoleCode !== "LAWYER") || !upcomingImportantDate) return baseRouteNodes;
if (!Array.isArray(baseRouteNodes) || !baseRouteNodes.length) {
return [
{
@ -1183,6 +1251,31 @@ export function RequestWorkspace({
next.splice(currentIndex + 1, 0, virtualNode);
return next;
}, [baseRouteNodes, upcomingImportantDate, viewerRoleCode]);
const routeNodesForDisplay = useMemo(() => {
if (!Array.isArray(routeNodes) || !routeNodes.length) return [];
const important = [];
const current = [];
const completed = [];
const pending = [];
routeNodes.forEach((node) => {
const code = String(node?.code || "").trim();
const state = String(node?.state || "pending").trim().toLowerCase();
if (code === "__IMPORTANT_DATE__") {
important.push(node);
return;
}
if (state === "current") {
current.push(node);
return;
}
if (state === "completed") {
completed.push(node);
return;
}
pending.push(node);
});
return [...important, ...current, ...completed.reverse(), ...pending];
}, [routeNodes]);
const AttachmentPreviewModal = AttachmentPreviewModalComponent;
const StatusLine = StatusLineComponent;
@ -1213,6 +1306,56 @@ export function RequestWorkspace({
);
};
const resolveServiceMessageContent = (payload) => {
const messageKind = String(payload?.message_kind || "");
if (messageKind === "REQUEST_DATA") return null;
const bodyRaw = String(payload?.body || "").replace(/\r/g, "").trim();
if (!bodyRaw) return null;
const lines = bodyRaw.split("\n");
const firstLine = String(lines[0] || "").trim();
const restLines = lines.slice(1);
const normalizeDetail = (value) => String(value || "").trim();
const withTail = (firstDetail) =>
[normalizeDetail(firstDetail), ...restLines.map((line) => normalizeDetail(line)).filter(Boolean)].filter(Boolean).join("\n");
if (firstLine === "Счет на оплату" || firstLine.startsWith("Счет на оплату:")) {
return {
title: "Счет на оплату",
text: withTail(firstLine.startsWith("Счет на оплату:") ? firstLine.slice("Счет на оплату:".length) : ""),
};
}
if (firstLine.startsWith("Изменился статус:") || firstLine.startsWith("Смена статуса:")) {
const source = firstLine.startsWith("Изменился статус:") ? firstLine : firstLine.slice("Смена статуса:".length);
const detail = firstLine.startsWith("Изменился статус:") ? source.slice("Изменился статус:".length) : source;
return {
title: "Изменился статус",
text: withTail(detail),
};
}
if (firstLine.startsWith("Назначен юрист:") || firstLine.startsWith("Переназначено:")) {
const detail = firstLine.startsWith("Назначен юрист:")
? firstLine.slice("Назначен юрист:".length)
: firstLine.slice("Переназначено:".length);
return {
title: "Назначен юрист",
text: withTail(detail),
};
}
return null;
};
const resolveStatusDisplayName = (code, explicitName) => {
const explicit = String(explicitName || "").trim();
if (explicit) return explicit;
const normalizedCode = String(code || "").trim();
if (!normalizedCode) return "-";
const optionName = String(statusByCode.get(normalizedCode)?.name || "").trim();
if (optionName) return optionName;
const legacyName = String(statusLabel(normalizedCode) || "").trim();
if (legacyName && legacyName !== normalizedCode) return legacyName;
return humanizeKey(normalizedCode);
};
const formatRequestDataValue = (item) => {
const type = String(item?.field_type || "string").toLowerCase();
if (type === "date") {
@ -1235,6 +1378,8 @@ export function RequestWorkspace({
return text || "Не заполнено";
};
const currentStatusName = resolveStatusDisplayName(row?.status_code, row?.status_name || "");
const dataRequestProgress = useMemo(() => {
const rows = Array.isArray(dataRequestModal.rows) ? dataRequestModal.rows : [];
const total = rows.length;
@ -1300,7 +1445,7 @@ export function RequestWorkspace({
</div>
<div className="request-field">
<span className="request-field-label">Статус</span>
<span className="request-field-value">{statusLabel(row.status_code)}</span>
<span className="request-field-value">{currentStatusName}</span>
</div>
</>
) : null}
@ -1363,17 +1508,19 @@ export function RequestWorkspace({
</div>
<div className="request-status-route">
<h4>Маршрут статусов</h4>
{routeNodes.length ? (
{routeNodesForDisplay.length ? (
<ol className="request-route-list" id="request-status-route">
{routeNodes.map((node, index) => {
{routeNodesForDisplay.map((node, index) => {
const state = String(node?.state || "pending");
const code = String(node?.code || "").trim();
const rawName = String(node?.name || "").trim();
const name = rawName && rawName !== code ? rawName : statusLabel(code || rawName);
const name = resolveStatusDisplayName(code, rawName && rawName !== code ? rawName : "");
const note = String(node?.note || "").trim();
const changedAtSource = String(node?.changed_at || "").trim() || (index === 0 ? String(row?.created_at || "").trim() : "");
const changedAt = changedAtSource ? fmtDate(changedAtSource) : "";
const isImportantDateNode = code === "__IMPORTANT_DATE__";
const changedAtSource =
String(node?.changed_at || "").trim() ||
(isImportantDateNode ? String(currentImportantDateAt || row?.important_date_at || "").trim() : "");
const changedAt = changedAtSource ? fmtDate(changedAtSource) : "";
const className =
"route-item " +
(state === "current" ? "current" : state === "completed" ? "completed" : "pending") +
@ -1383,8 +1530,14 @@ export function RequestWorkspace({
<span className="route-dot" />
<div className="route-body">
<b>{name}</b>
{isImportantDateNode ? (
<p>{"Контрольный срок: " + (changedAt || "-")}</p>
) : (
<>
{note ? <p>{note}</p> : null}
<div className="muted route-time">Дата статуса: {changedAt || "-"}</div>
<div className="muted route-time">Дата изменения: {changedAt || "-"}</div>
</>
)}
</div>
</li>
);
@ -1479,6 +1632,7 @@ export function RequestWorkspace({
(() => {
const messageKind = String(entry.payload?.message_kind || "");
const isRequestDataMessage = messageKind === "REQUEST_DATA";
const serviceMessageContent = resolveServiceMessageContent(entry.payload);
const requestDataInteractive = isRequestDataMessage && (canRequestData || canFillRequestData);
const bubbleClass =
"chat-message-bubble" +
@ -1521,9 +1675,16 @@ export function RequestWorkspace({
<div className="chat-request-data-head">Запрос</div>
{renderRequestDataMessageItems(entry.payload)}
</>
) : (
<>
{serviceMessageContent?.title ? <div className="chat-service-head">{serviceMessageContent.title}</div> : null}
{serviceMessageContent ? (
serviceMessageContent.text ? <p className="chat-message-text">{serviceMessageContent.text}</p> : null
) : (
<p className="chat-message-text">{String(entry.payload?.body || "")}</p>
)}
</>
)}
{(() => {
if (String(entry.payload?.message_kind || "") === "REQUEST_DATA") return null;
const messageId = String(entry.payload?.id || "").trim();
@ -1557,7 +1718,7 @@ export function RequestWorkspace({
)
)
) : (
<li className="muted">Сообщений нет</li>
<li className="muted chat-empty-state">Сообщений нет</li>
)}
</ul>
<form className="stack" onSubmit={onSendMessage}>
@ -1883,7 +2044,7 @@ export function RequestWorkspace({
)
.map((item) => (
<option key={item.code} value={item.code}>
{item.name + " (" + item.code + ")" + (item.groupName ? " • " + item.groupName : "")}
{item.name + (item.groupName ? " • " + item.groupName : "")}
</option>
))}
</select>
@ -1965,7 +2126,7 @@ export function RequestWorkspace({
<span className="route-dot" />
<div className="route-body">
<div className="request-status-history-row">
<b>{String(item?.to_status_name || statusMeta?.name || statusLabel(statusCode) || statusCode || "-")}</b>
<b>{resolveStatusDisplayName(statusCode, item?.to_status_name || statusMeta?.name || "")}</b>
{statusMeta?.isTerminal ? <span className="request-status-history-chip">Терминальный</span> : null}
</div>
<div className="muted route-time">{fmtShortDateTime(item?.changed_at)}</div>
@ -1975,7 +2136,7 @@ export function RequestWorkspace({
</div>
{item?.from_status ? (
<div className="request-status-history-meta">
<span>{"Из: " + statusLabel(item.from_status)}</span>
<span>{"Из: " + resolveStatusDisplayName(item.from_status, "")}</span>
</div>
) : null}
{String(item?.comment || "").trim() ? (
@ -2005,7 +2166,7 @@ export function RequestWorkspace({
</div>
<div
className={"overlay" + (financeOpen ? " open" : "")}
onClick={() => setFinanceOpen(false)}
onClick={closeFinanceModal}
aria-hidden={financeOpen ? "false" : "true"}
>
<div className="modal request-finance-modal" onClick={(event) => event.stopPropagation()}>
@ -2016,7 +2177,7 @@ export function RequestWorkspace({
{row?.track_number ? "Заявка " + String(row.track_number) : "Данные по заявке"}
</p>
</div>
<button className="close" type="button" onClick={() => setFinanceOpen(false)} aria-label="Закрыть">
<button className="close" type="button" onClick={closeFinanceModal} aria-label="Закрыть">
×
</button>
</div>
@ -2040,6 +2201,112 @@ export function RequestWorkspace({
</div>
) : null}
</div>
{typeof onIssueInvoice === "function" ? (
<div className="request-finance-actions">
{!financeIssueForm.open ? (
<button
type="button"
className="btn btn-sm"
onClick={openFinanceIssueForm}
disabled={loading || !row}
>
Выставить счет
</button>
) : (
<form className="stack request-finance-issue-form" onSubmit={submitFinanceIssueForm}>
<div className="request-finance-issue-grid">
<div className="field">
<label htmlFor="request-finance-invoice-amount">Сумма</label>
<input
id="request-finance-invoice-amount"
type="number"
min="0.01"
step="0.01"
value={financeIssueForm.amount}
onChange={(event) => setFinanceIssueForm((prev) => ({ ...prev, amount: event.target.value, error: "" }))}
disabled={financeIssueForm.saving || loading}
placeholder="0.00"
/>
</div>
<div className="field">
<label htmlFor="request-finance-invoice-payer">Плательщик</label>
<input
id="request-finance-invoice-payer"
type="text"
value={financeIssueForm.payerDisplayName}
onChange={(event) =>
setFinanceIssueForm((prev) => ({ ...prev, payerDisplayName: event.target.value, error: "" }))
}
disabled={financeIssueForm.saving || loading}
placeholder="ФИО / компания"
/>
</div>
</div>
<div className="field">
<label htmlFor="request-finance-invoice-service">Услуга</label>
<input
id="request-finance-invoice-service"
type="text"
value={financeIssueForm.serviceDescription}
onChange={(event) =>
setFinanceIssueForm((prev) => ({ ...prev, serviceDescription: event.target.value, error: "" }))
}
disabled={financeIssueForm.saving || loading}
placeholder="Юридические услуги"
/>
</div>
{financeIssueForm.error ? <div className="status error">{financeIssueForm.error}</div> : null}
<div className="modal-actions modal-actions-right request-finance-actions-inline">
<button type="button" className="btn secondary btn-sm" onClick={closeFinanceIssueForm} disabled={financeIssueForm.saving}>
Отмена
</button>
<button type="submit" className="btn btn-sm" disabled={financeIssueForm.saving || loading}>
{financeIssueForm.saving ? "Выставление..." : "Выставить"}
</button>
</div>
</form>
)}
</div>
) : null}
<div className="request-finance-invoices">
<div className="request-finance-invoices-head">
<h4>Счета</h4>
<span className="muted">{safeInvoices.length ? String(safeInvoices.length) + " шт." : "Нет выставленных счетов"}</span>
</div>
{safeInvoices.length ? (
<div className="request-finance-invoice-list">
{safeInvoices.map((item) => (
<div className="request-finance-invoice-row" key={String(item?.id || item?.invoice_number || item?.issued_at || "-")}>
<div className="request-finance-invoice-meta">
<div className="request-finance-invoice-number">
<code>{String(item?.invoice_number || "-")}</code>
</div>
<div className="request-finance-invoice-details">
<span>{invoiceStatusLabel(item?.status)}</span>
<span>{fmtAmount(item?.amount) + " " + String(item?.currency || "RUB")}</span>
<span>{"Создан: " + fmtDate(item?.issued_at)}</span>
<span>{"Оплачен: " + fmtDate(item?.paid_at)}</span>
</div>
</div>
{typeof onDownloadInvoicePdf === "function" ? (
<button
type="button"
className="icon-btn request-finance-invoice-download-btn"
onClick={() => onDownloadInvoicePdf(item)}
disabled={loading}
aria-label="Скачать счет PDF"
data-tooltip="Скачать PDF"
>
</button>
) : null}
</div>
))}
</div>
) : (
<p className="muted request-finance-empty">Счета по заявке пока не выставлялись</p>
)}
</div>
</div>
</div>
<div
@ -2055,7 +2322,7 @@ export function RequestWorkspace({
<p className="muted request-finance-subtitle">
{String(row?.topic_name || row?.topic_code || "Тема не указана")}
</p>
<span className="request-description-status-chip">{statusLabel(row?.status_code)}</span>
<span className="request-description-status-chip">{currentStatusName}</span>
</div>
</div>
<button className="close" type="button" onClick={() => setDescriptionOpen(false)} aria-label="Закрыть">

View file

@ -1,4 +1,16 @@
import { createRequestModalState } from "../shared/state.js";
import { fmtShortDateTime } from "../shared/utils.js";
const DEFAULT_INVOICE_REQUISITES = Object.freeze({
issuer_name: 'ООО "Аудиторы корпоративной безопасности"',
issuer_inn: "7604226740",
issuer_kpp: "760401001",
issuer_address: "г. Ярославль, ул. Богдановича, 6А",
bank_name: 'АО "АЛЬФА-БАНК"',
bank_bik: "044525593",
bank_account: "40702810501860000582",
bank_corr_account: "30101810200000000593",
});
async function buildStorageUploadError(response, fallbackMessage) {
const base = String(fallbackMessage || "Не удалось загрузить файл в хранилище");
@ -86,6 +98,7 @@ export function useRequestWorkspace(options) {
requestId,
requestData: null,
financeSummary: null,
invoices: [],
statusRouteNodes: [],
}));
}
@ -102,7 +115,7 @@ export function useRequestWorkspace(options) {
api("/api/admin/requests/" + requestId + "/status-route").catch(() => ({ nodes: [] })),
api("/api/admin/invoices/query", {
method: "POST",
body: buildUniversalQuery(requestFilter, [{ field: "paid_at", dir: "desc" }], 500, 0),
body: buildUniversalQuery(requestFilter, [{ field: "issued_at", dir: "desc" }], 500, 0),
}).catch(() => ({ rows: [] })),
]);
const usersById = new Map(users.filter((user) => user && user.id).map((user) => [String(user.id), user]));
@ -134,7 +147,8 @@ export function useRequestWorkspace(options) {
}
return item;
});
const paidInvoices = (invoicesData?.rows || []).filter(
const invoices = Array.isArray(invoicesData?.rows) ? invoicesData.rows : [];
const paidInvoices = invoices.filter(
(item) => String(item?.status || "").toUpperCase() === "PAID"
);
const paidTotal = paidInvoices.reduce((acc, item) => {
@ -161,6 +175,7 @@ export function useRequestWorkspace(options) {
paid_total: Math.round((paidTotal + Number.EPSILON) * 100) / 100,
last_paid_at: latestPaidAt || rowData?.paid_at || null,
},
invoices,
statusRouteNodes: Array.isArray(statusRouteData?.nodes) ? statusRouteData.nodes : [],
statusHistory: Array.isArray(statusRouteData?.history) ? statusRouteData.history : [],
availableStatuses: Array.isArray(statusRouteData?.available_statuses) ? statusRouteData.available_statuses : [],
@ -178,6 +193,7 @@ export function useRequestWorkspace(options) {
requestId,
requestData: null,
financeSummary: null,
invoices: [],
statusRouteNodes: [],
statusHistory: [],
availableStatuses: [],
@ -402,14 +418,23 @@ export function useRequestWorkspace(options) {
const attachedFiles = Array.isArray(files) ? files.filter(Boolean) : [];
const commentText = String(comment || "").trim();
if (commentText || attachedFiles.length) {
const availableStatuses = Array.isArray(requestModal.availableStatuses) ? requestModal.availableStatuses : [];
const statusName = availableStatuses.find((item) => String(item?.code || "").trim() === String(result?.to_status || nextStatus).trim())?.name;
const nextStatusLabel = String(statusName || result?.to_status || nextStatus).trim() || nextStatus;
const importantDateRaw = String(result?.important_date_at || importantDateAt || "").trim();
const importantDateLabel = importantDateRaw ? fmtShortDateTime(importantDateRaw) : "";
const serviceLines = [`Изменился статус: "${nextStatusLabel}"`];
if (importantDateRaw) {
serviceLines.push("Важная дата: " + (importantDateLabel && importantDateLabel !== "-" ? importantDateLabel : importantDateRaw));
}
if (commentText) serviceLines.push(commentText);
let messageId = null;
const statusLine = "Смена статуса: " + String(result?.from_status || "—") + " -> " + String(result?.to_status || nextStatus);
const messageBody = [statusLine, commentText].filter(Boolean).join("\n");
if (messageBody) {
const serviceMessageBody = serviceLines.filter(Boolean).join("\n").trim();
if (serviceMessageBody) {
const message = await api("/api/admin/chat/requests/" + targetRequestId + "/messages", {
method: "POST",
body: { body: messageBody },
body: { body: serviceMessageBody },
});
messageId = String(message?.id || "").trim() || null;
}
@ -444,13 +469,57 @@ export function useRequestWorkspace(options) {
},
});
}
}
if (typeof setStatus === "function") setStatus("requestModal", "Статус заявки обновлен", "ok");
await loadRequestModalData(targetRequestId, { showLoading: false });
return result;
},
[api, loadRequestModalData, requestModal.requestId, setStatus]
[api, loadRequestModalData, requestModal.availableStatuses, requestModal.requestId, setStatus]
);
const issueRequestInvoice = useCallback(
async ({ requestId, amount, serviceDescription, payerDisplayName } = {}) => {
if (!api) throw new Error("API недоступен");
const targetRequestId = String(requestId || requestModal.requestId || "").trim();
if (!targetRequestId) throw new Error("Не выбрана заявка");
const parsedAmount = Number(amount);
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
throw new Error("Сумма счета должна быть больше нуля");
}
const roundedAmount = Math.round((parsedAmount + Number.EPSILON) * 100) / 100;
const rowData = requestModal.requestData && typeof requestModal.requestData === "object" ? requestModal.requestData : null;
const payerName = String(payerDisplayName || rowData?.client_name || "").trim() || "Клиент";
const serviceLabel = String(serviceDescription || "").trim() || "Юридические услуги";
const trackNumber = String(rowData?.track_number || requestModal.trackNumber || "").trim();
const topicLabel = String(rowData?.topic_name || rowData?.topic_code || "").trim();
if (typeof setStatus === "function") setStatus("requestModal", "Выставляем счет...", "");
const created = await api("/api/admin/invoices", {
method: "POST",
body: {
request_id: targetRequestId,
status: "WAITING_PAYMENT",
amount: roundedAmount,
currency: "RUB",
payer_display_name: payerName,
payer_details: {
...DEFAULT_INVOICE_REQUISITES,
request_track_number: trackNumber,
service_description: serviceLabel,
topic_name: topicLabel,
},
},
});
await loadRequestModalData(targetRequestId, { showLoading: false });
if (typeof setStatus === "function") {
const invoiceNumber = String(created?.invoice_number || "").trim();
setStatus("requestModal", invoiceNumber ? "Счет выставлен: " + invoiceNumber : "Счет выставлен", "ok");
}
return created;
},
[api, loadRequestModalData, requestModal.requestData, requestModal.requestId, requestModal.trackNumber, setStatus]
);
return {
@ -475,6 +544,7 @@ export function useRequestWorkspace(options) {
loadRequestDataTemplateDetails,
saveRequestDataTemplate,
saveRequestDataBatch,
issueRequestInvoice,
};
}

View file

@ -16,6 +16,7 @@ export function createRequestModalState() {
trackNumber: "",
requestData: null,
financeSummary: null,
invoices: [],
statusRouteNodes: [],
statusHistory: [],
availableStatuses: [],

View file

@ -269,6 +269,7 @@ export function buildUniversalQuery(filters, sort, limit, offset) {
}
export function canAccessSection(role, section) {
const roleCode = String(role || "").toUpperCase();
const allowed = new Set([
"dashboard",
"kanban",
@ -282,7 +283,9 @@ export function canAccessSection(role, section) {
"availableTables",
]);
if (!allowed.has(section)) return false;
if (section === "quotes" || section === "config" || section === "availableTables") return role === "ADMIN";
if (section === "requests") return roleCode === "ADMIN";
if (section === "serviceRequests") return roleCode === "ADMIN" || roleCode === "CURATOR";
if (section === "quotes" || section === "config" || section === "availableTables") return roleCode === "ADMIN";
return true;
}

View file

@ -5,13 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Страница клиента • Правовой трекер</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
<link rel="stylesheet" href="/admin.css?v=20260227-01">
<link rel="stylesheet" href="/admin.css?v=20260303-05">
<link rel="stylesheet" href="/client.css">
</head>
<body>
<div id="client-root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/client.js?v=20260227-02"></script>
<script src="/client.js?v=20260303-05"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -566,6 +566,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
statusHistory: Array.isArray(statusRouteData?.history) ? statusRouteData.history : [],
availableStatuses: [],
currentImportantDateAt: String(statusRouteData?.current_important_date_at || requestData?.important_date_at || ""),
invoices,
messages: Array.isArray(messagesData) ? messagesData : [],
attachments: Array.isArray(attachmentsData) ? attachmentsData : [],
fileUploading: false,
@ -592,6 +593,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
requestData: null,
trackNumber: "",
financeSummary: null,
invoices: [],
statusRouteNodes: [],
statusHistory: [],
messages: [],
@ -689,6 +691,45 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
setRequestModal((prev) => ({ ...prev, selectedFiles: [] }));
}, []);
const downloadPublicInvoicePdf = useCallback(
async (row) => {
const url = String(row?.download_url || "").trim();
if (!url) {
setPageStatus("Ссылка на PDF счета недоступна.", "error");
return;
}
try {
setPageStatus("Скачиваем PDF...", "");
const response = await fetch(url, { credentials: "same-origin" });
if (!response.ok) {
const text = await response.text();
let payload = {};
try {
payload = text ? JSON.parse(text) : {};
} catch (_) {
payload = { raw: text };
}
const message = payload.detail || payload.error || payload.raw || ("HTTP " + response.status);
throw new Error(String(message));
}
const blob = await response.blob();
const fileName = String(row?.invoice_number || "invoice") + ".pdf";
const fileUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = fileUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(fileUrl);
setPageStatus("PDF скачан", "ok");
} catch (error) {
setPageStatus("Ошибка скачивания: " + (error?.message || "неизвестная ошибка"), "error");
}
},
[setPageStatus]
);
const submitMessage = useCallback(
async (event) => {
if (event && typeof event.preventDefault === "function") event.preventDefault();
@ -909,6 +950,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
}, [loadMyRequests, setPageStatus]);
const summary = requestModal.requestData || null;
const summaryStatusName = String(summary?.status_name || statusLabel(summary?.status_code) || "-");
const viewerFullName = useMemo(() => {
const fullName = String(requestModal.requestData?.client_name || "").trim();
return fullName || "Клиент";
@ -974,7 +1016,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
<div className="client-summary-row">
<div className="client-summary-chips">
<span className="client-summary-chip client-summary-chip-status">
Статус: <span id="cabinet-request-status">{summary ? statusLabel(summary.status_code) : "-"}</span>
Статус: <span id="cabinet-request-status">{summary ? summaryStatusName : "-"}</span>
</span>
<span className="client-summary-chip client-summary-chip-topic">
Тема: <span id="cabinet-request-topic">{summary ? String(summary.topic_name || summary.topic_code || "-") : "-"}</span>
@ -998,6 +1040,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
trackNumber={requestModal.trackNumber}
requestData={requestModal.requestData}
financeSummary={requestModal.financeSummary}
invoices={requestModal.invoices || []}
statusRouteNodes={requestModal.statusRouteNodes || []}
statusHistory={requestModal.statusHistory || []}
availableStatuses={[]}
@ -1018,6 +1061,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
onSaveRequestDataValues={saveRequestDataValues}
onUploadRequestAttachment={uploadPublicRequestAttachment}
onChangeStatus={() => Promise.resolve(null)}
onDownloadInvoicePdf={downloadPublicInvoicePdf}
onLiveProbe={probeLiveState}
onTypingSignal={setTypingSignal}
AttachmentPreviewModalComponent={AttachmentPreviewModal}

View file

@ -285,6 +285,6 @@
</div>
</div>
<script src="/landing.js?v=20260227-03"></script>
<script src="/landing.js?v=20260302-02"></script>
</body>
</html>

View file

@ -11,6 +11,7 @@
const requestForm = document.getElementById("request-form");
const requestStatus = document.getElementById("form-status");
const topicSelect = document.getElementById("topic");
const requestPhoneInput = document.getElementById("phone");
const accessForm = document.getElementById("access-form");
const accessPhoneInput = document.getElementById("access-phone");
@ -65,6 +66,62 @@
return String(value || "").trim().toLowerCase();
}
function extractRuPhoneLocalDigits(raw) {
const text = String(raw || "");
const trimmed = text.trim();
const startsWithPlus7 = trimmed.startsWith("+7");
const startsWith8 = trimmed.startsWith("8");
let digits = text.replace(/\D+/g, "");
if (startsWithPlus7 && digits.startsWith("7")) {
digits = digits.slice(1);
} else if (startsWith8 && digits.startsWith("8")) {
digits = digits.slice(1);
} else if (digits.length === 11 && (digits.startsWith("7") || digits.startsWith("8"))) {
digits = digits.slice(1);
}
return digits.slice(0, 10);
}
function formatRuPhone(raw) {
const digits = extractRuPhoneLocalDigits(raw);
if (!digits) return "+7";
const part1 = digits.slice(0, 3);
const part2 = digits.slice(3, 6);
const part3 = digits.slice(6, 8);
const part4 = digits.slice(8, 10);
let out = "+7";
if (part1) out += " (" + part1;
if (part1.length === 3) out += ")";
if (part2) out += " " + part2;
if (part3) out += "-" + part3;
if (part4) out += "-" + part4;
return out;
}
function normalizeRuPhone(raw) {
const digits = extractRuPhoneLocalDigits(raw);
if (digits.length !== 10) return "";
return "+7" + digits;
}
function isValidRuPhone(phone) {
return /^\+7\d{10}$/.test(String(phone || ""));
}
function bindRuPhoneMask(input) {
if (!input) return;
input.addEventListener("focus", () => {
if (!String(input.value || "").trim()) input.value = "+7";
});
input.addEventListener("input", () => {
input.value = formatRuPhone(input.value);
});
input.addEventListener("blur", () => {
const digits = extractRuPhoneLocalDigits(input.value);
if (!digits.length) input.value = "";
});
}
function currentAuthMode() {
return String(authConfig?.public_auth_mode || "sms").trim().toLowerCase();
}
@ -408,7 +465,8 @@
}
accessSendOtpButton.addEventListener("click", async () => {
const phone = String(accessPhoneInput.value || "").trim();
const phone = normalizeRuPhone(accessPhoneInput?.value);
if (accessPhoneInput && phone) accessPhoneInput.value = formatRuPhone(phone);
const email = normalizeEmail(accessEmailInput?.value);
const hpField = String(accessHpInput?.value || "").trim();
const channel = preferredChannel({ phone, email });
@ -424,6 +482,10 @@
setStatus(accessStatus, "Введите номер телефона.", "error");
return;
}
if (channel === "SMS" && !isValidRuPhone(phone)) {
setStatus(accessStatus, "Введите корректный номер телефона РФ в формате +7XXXXXXXXXX.", "error");
return;
}
try {
setStatus(accessStatus, "Отправляем OTP-код...", null);
@ -454,7 +516,8 @@
accessForm.addEventListener("submit", async (event) => {
event.preventDefault();
const phone = String(accessPhoneInput.value || "").trim();
const phone = normalizeRuPhone(accessPhoneInput?.value);
if (accessPhoneInput && phone) accessPhoneInput.value = formatRuPhone(phone);
const email = normalizeEmail(accessEmailInput?.value);
const code = String(accessCodeInput.value || "").trim();
const channel = preferredChannel({ phone, email });
@ -466,6 +529,10 @@
setStatus(accessStatus, "Введите телефон и OTP-код.", "error");
return;
}
if (channel === "SMS" && !isValidRuPhone(phone)) {
setStatus(accessStatus, "Введите корректный номер телефона РФ в формате +7XXXXXXXXXX.", "error");
return;
}
try {
setStatus(accessStatus, "Проверяем OTP...", null);
@ -495,7 +562,7 @@
const payload = {
client_name: String(document.getElementById("name").value || "").trim(),
client_phone: String(document.getElementById("phone").value || "").trim(),
client_phone: normalizeRuPhone(requestPhoneInput?.value),
client_email: normalizeEmail(requestEmailInput?.value),
pdn_consent: Boolean(document.getElementById("pdn-consent")?.checked),
hp_field: String(requestHpInput?.value || "").trim(),
@ -513,6 +580,11 @@
setStatus(requestStatus, "Введите телефон для получения OTP.", "error");
return;
}
if (!isValidRuPhone(payload.client_phone)) {
setStatus(requestStatus, "Введите корректный номер телефона РФ в формате +7XXXXXXXXXX.", "error");
return;
}
if (requestPhoneInput && payload.client_phone) requestPhoneInput.value = formatRuPhone(payload.client_phone);
if (!payload.client_name || !payload.topic_code) {
setStatus(requestStatus, "Заполните имя и тему обращения.", "error");
return;
@ -587,6 +659,8 @@
});
loadAuthConfig();
bindRuPhoneMask(requestPhoneInput);
bindRuPhoneMask(accessPhoneInput);
loadTopics();
loadQuotes();
loadFeaturedStaff();

View file

@ -15,3 +15,4 @@ httpx==0.27.2
python-multipart==0.0.22
smsaero-api-async
Pillow==11.2.1
reportlab==4.2.2

View file

@ -484,3 +484,63 @@ class AdminAssignmentAndUsersTests(AdminUniversalCrudBase):
deleted = self.client.delete(f"/api/admin/crud/admin_users/{user_id}", headers=headers)
self.assertEqual(deleted.status_code, 200)
def test_lawyer_can_manage_only_own_profile(self):
with self.SessionLocal() as db:
self_lawyer = AdminUser(
role="LAWYER",
name="Свои Данные",
email="self-lawyer@example.com",
password_hash="hash",
is_active=True,
phone="+79990001122",
)
other_lawyer = AdminUser(
role="LAWYER",
name="Чужие Данные",
email="other-lawyer@example.com",
password_hash="hash",
is_active=True,
phone="+79990001123",
)
db.add_all([self_lawyer, other_lawyer])
db.commit()
self_id = str(self_lawyer.id)
other_id = str(other_lawyer.id)
headers = self._auth_headers("LAWYER", email="self-lawyer@example.com", sub=self_id)
own_get = self.client.get(f"/api/admin/crud/admin_users/{self_id}", headers=headers)
self.assertEqual(own_get.status_code, 200)
self.assertEqual(own_get.json().get("email"), "self-lawyer@example.com")
own_update = self.client.patch(
f"/api/admin/crud/admin_users/{self_id}",
headers=headers,
json={"name": "Обновленное имя", "phone": "+79991234567", "password": "LawyerPass-123"},
)
self.assertEqual(own_update.status_code, 200)
self.assertEqual(own_update.json().get("name"), "Обновленное имя")
self.assertEqual(own_update.json().get("phone"), "+79991234567")
with self.SessionLocal() as db:
row = db.get(AdminUser, UUID(self_id))
self.assertIsNotNone(row)
self.assertTrue(verify_password("LawyerPass-123", row.password_hash))
foreign_get = self.client.get(f"/api/admin/crud/admin_users/{other_id}", headers=headers)
self.assertEqual(foreign_get.status_code, 403)
foreign_update = self.client.patch(
f"/api/admin/crud/admin_users/{other_id}",
headers=headers,
json={"name": "Попытка изменить чужой профиль"},
)
self.assertEqual(foreign_update.status_code, 403)
forbidden_field_update = self.client.patch(
f"/api/admin/crud/admin_users/{self_id}",
headers=headers,
json={"role": "ADMIN"},
)
self.assertEqual(forbidden_field_update.status_code, 403)

View file

@ -523,6 +523,105 @@ class AdminStatusFlowKanbanTests(AdminUniversalCrudBase):
outsider_forbidden = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=outsider_headers)
self.assertEqual(outsider_forbidden.status_code, 403)
def test_request_status_route_preserves_repeated_statuses_in_path(self):
with self.SessionLocal() as db:
db.add_all(
[
Status(code="NEW", name="Новая", enabled=True, sort_order=1, kind="DEFAULT"),
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=2, kind="DEFAULT"),
Status(code="WAITING_CLIENT", name="Ожидание клиента", enabled=True, sort_order=3, kind="DEFAULT"),
]
)
db.add_all(
[
TopicStatusTransition(
topic_code="civil-loop",
from_status="NEW",
to_status="IN_PROGRESS",
enabled=True,
sla_hours=24,
sort_order=1,
),
TopicStatusTransition(
topic_code="civil-loop",
from_status="IN_PROGRESS",
to_status="WAITING_CLIENT",
enabled=True,
sla_hours=72,
sort_order=2,
),
TopicStatusTransition(
topic_code="civil-loop",
from_status="WAITING_CLIENT",
to_status="IN_PROGRESS",
enabled=True,
sla_hours=12,
sort_order=3,
),
]
)
req = Request(
track_number="TRK-ROUTE-LOOP",
client_name="Клиент",
client_phone="+79990002233",
topic_code="civil-loop",
status_code="IN_PROGRESS",
description="route loop check",
extra_fields={},
)
db.add(req)
db.flush()
started_at = datetime.now(timezone.utc) - timedelta(hours=9)
db.add_all(
[
StatusHistory(
request_id=req.id,
from_status="NEW",
to_status="IN_PROGRESS",
comment="started",
changed_by_admin_id=None,
created_at=started_at,
),
StatusHistory(
request_id=req.id,
from_status="IN_PROGRESS",
to_status="WAITING_CLIENT",
comment="waiting docs",
changed_by_admin_id=None,
created_at=started_at + timedelta(hours=2),
),
StatusHistory(
request_id=req.id,
from_status="WAITING_CLIENT",
to_status="IN_PROGRESS",
comment="docs received",
changed_by_admin_id=None,
created_at=started_at + timedelta(hours=5),
),
]
)
db.commit()
request_id = str(req.id)
headers = self._auth_headers("ADMIN", email="root@example.com")
response = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=headers)
self.assertEqual(response.status_code, 200)
payload = response.json()
nodes = payload.get("nodes") or []
self.assertEqual(
[item["code"] for item in nodes],
["NEW", "IN_PROGRESS", "WAITING_CLIENT", "IN_PROGRESS", "WAITING_CLIENT"],
)
self.assertEqual([item["state"] for item in nodes], ["completed", "completed", "completed", "current", "pending"])
self.assertEqual(nodes[1]["sla_hours"], 24)
self.assertEqual(nodes[2]["sla_hours"], 72)
self.assertEqual(nodes[3]["sla_hours"], 12)
self.assertEqual(nodes[4]["sla_hours"], 72)
history = payload.get("history") or []
self.assertEqual([item.get("to_status") for item in history], ["IN_PROGRESS", "WAITING_CLIENT", "IN_PROGRESS"])
def test_requests_kanban_returns_grouped_cards_and_role_scope(self):
with self.SessionLocal() as db:
group_new = StatusGroup(name="Новые", sort_order=10)
@ -637,6 +736,8 @@ class AdminStatusFlowKanbanTests(AdminUniversalCrudBase):
description="Заявка в работе",
extra_fields={"deadline_at": "2031-01-01T10:00:00+00:00"},
assigned_lawyer_id=str(lawyer_main.id),
lawyer_has_unread_updates=True,
lawyer_unread_event_type="MESSAGE",
)
request_waiting = Request(
track_number="TRK-KANBAN-WAITING",
@ -657,6 +758,7 @@ class AdminStatusFlowKanbanTests(AdminUniversalCrudBase):
description="Просроченная заявка",
extra_fields={},
assigned_lawyer_id=str(lawyer_main.id),
important_date_at=datetime.now(timezone.utc) - timedelta(hours=1),
)
db.add_all([request_new, request_progress, request_waiting, request_overdue])
db.flush()
@ -683,6 +785,17 @@ class AdminStatusFlowKanbanTests(AdminUniversalCrudBase):
created_at=entered_overdue_at,
)
)
db.add(
Notification(
request_id=request_new.id,
recipient_type="ADMIN_USER",
recipient_admin_user_id=lawyer_main.id,
event_type="MESSAGE",
title="Новое сообщение",
payload={},
is_read=False,
)
)
db.commit()
request_new_id = str(request_new.id)
@ -750,6 +863,30 @@ class AdminStatusFlowKanbanTests(AdminUniversalCrudBase):
overdue_rows = {item["id"] for item in (filtered_overdue.json().get("rows") or [])}
self.assertEqual(overdue_rows, {request_overdue_id})
filtered_unread_lawyer = self.client.get(
"/api/admin/requests/kanban",
headers=lawyer_headers,
params={
"limit": 100,
"filters": json.dumps([{"field": "has_unread_updates", "op": "=", "value": True}]),
},
)
self.assertEqual(filtered_unread_lawyer.status_code, 200)
unread_rows = {item["id"] for item in (filtered_unread_lawyer.json().get("rows") or [])}
self.assertEqual(unread_rows, {request_new_id, request_progress_id})
filtered_deadline_alert_lawyer = self.client.get(
"/api/admin/requests/kanban",
headers=lawyer_headers,
params={
"limit": 100,
"filters": json.dumps([{"field": "deadline_alert", "op": "=", "value": True}]),
},
)
self.assertEqual(filtered_deadline_alert_lawyer.status_code, 200)
deadline_rows = {item["id"] for item in (filtered_deadline_alert_lawyer.json().get("rows") or [])}
self.assertEqual(deadline_rows, {request_overdue_id})
sorted_by_deadline = self.client.get(
"/api/admin/requests/kanban",
headers=admin_headers,
@ -759,4 +896,3 @@ class AdminStatusFlowKanbanTests(AdminUniversalCrudBase):
sorted_rows = sorted_by_deadline.json().get("rows") or []
self.assertTrue(sorted_rows)
self.assertEqual(sorted_rows[0]["id"], request_overdue_id)

View file

@ -2,6 +2,7 @@ import os
import unittest
from datetime import timedelta
from uuid import UUID, uuid4
from unittest.mock import patch
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, delete
@ -27,9 +28,15 @@ from app.models.notification import Notification
from app.models.request import Request
from app.models.status import Status
from app.models.status_history import StatusHistory
from app.models.topic_status_transition import TopicStatusTransition
from app.services.invoice_crypto import decrypt_requisites
class _FakeS3Storage:
def __init__(self):
self.objects = {}
class BillingFlowTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
@ -48,10 +55,12 @@ class BillingFlowTests(unittest.TestCase):
StatusHistory.__table__.create(bind=cls.engine)
Notification.__table__.create(bind=cls.engine)
Invoice.__table__.create(bind=cls.engine)
TopicStatusTransition.__table__.create(bind=cls.engine)
@classmethod
def tearDownClass(cls):
Invoice.__table__.drop(bind=cls.engine)
TopicStatusTransition.__table__.drop(bind=cls.engine)
Notification.__table__.drop(bind=cls.engine)
StatusHistory.__table__.drop(bind=cls.engine)
Attachment.__table__.drop(bind=cls.engine)
@ -66,6 +75,7 @@ class BillingFlowTests(unittest.TestCase):
db.execute(delete(Invoice))
db.execute(delete(Notification))
db.execute(delete(StatusHistory))
db.execute(delete(TopicStatusTransition))
db.execute(delete(Attachment))
db.execute(delete(Message))
db.execute(delete(Request))
@ -82,9 +92,13 @@ class BillingFlowTests(unittest.TestCase):
app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(app)
self.fake_s3 = _FakeS3Storage()
self.s3_patch = patch("app.services.invoice_chat.get_s3_storage", return_value=self.fake_s3)
self.s3_patch.start()
def tearDown(self):
self.client.close()
self.s3_patch.stop()
app.dependency_overrides.clear()
@staticmethod
@ -158,6 +172,76 @@ class BillingFlowTests(unittest.TestCase):
self.assertIn("TRK-BILL-1", rendered)
self.assertIn("ООО Клиент", rendered)
def test_workflow_billing_invoice_contains_autofilled_requisites(self):
self._seed_statuses()
with self.SessionLocal() as db:
req = Request(
track_number="TRK-BILL-AUTO",
client_name="ООО Авто",
client_phone="+79990000111",
status_code="NEW",
topic_code="consulting",
description="auto requisites",
extra_fields={},
invoice_amount=12500.5,
)
db.add(req)
db.commit()
request_id = str(req.id)
admin_headers = self._auth_headers("ADMIN", "root@example.com")
changed = self.client.patch(
f"/api/admin/requests/{request_id}",
headers=admin_headers,
json={"status_code": "BILLING"},
)
self.assertEqual(changed.status_code, 200)
with self.SessionLocal() as db:
req = db.get(Request, UUID(request_id))
self.assertIsNotNone(req)
invoice = (
db.query(Invoice)
.filter(Invoice.request_id == req.id)
.order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc())
.first()
)
self.assertIsNotNone(invoice)
details = decrypt_requisites(invoice.payer_details_encrypted)
self.assertEqual(details.get("request_track_number"), "TRK-BILL-AUTO")
self.assertEqual(details.get("topic_code"), "consulting")
rendered = str(details.get("template_rendered") or "")
self.assertTrue(rendered)
self.assertIn("TRK-BILL-AUTO", rendered)
self.assertIn("ООО Авто", rendered)
message = None
message_rows = (
db.query(Message)
.filter(Message.request_id == req.id)
.order_by(Message.created_at.desc(), Message.id.desc())
.all()
)
for item in message_rows:
if str(item.body or "").strip() == "Счет на оплату":
message = item
break
self.assertIsNotNone(message)
attachment = (
db.query(Attachment)
.filter(Attachment.request_id == req.id, Attachment.message_id == message.id)
.order_by(Attachment.created_at.desc(), Attachment.id.desc())
.first()
)
self.assertIsNotNone(attachment)
self.assertEqual(attachment.mime_type, "application/pdf")
self.assertIn(str(invoice.invoice_number), str(attachment.file_name))
self.assertGreater(int(req.total_attachments_bytes or 0), 0)
stored = self.fake_s3.objects.get(str(attachment.s3_key))
self.assertIsNotNone(stored)
self.assertEqual(stored.get("mime"), "application/pdf")
self.assertTrue(bytes(stored.get("content") or b"").startswith(b"%PDF"))
def test_paid_status_requires_admin_and_marks_waiting_invoice_paid(self):
self._seed_statuses()
with self.SessionLocal() as db:

View file

@ -96,7 +96,9 @@ class DashboardFinanceTests(unittest.TestCase):
def test_admin_dashboard_contains_lawyer_financial_metrics(self):
now = datetime.now(timezone.utc)
current_month_event = now - timedelta(days=2)
month_start = datetime(now.year, now.month, 1, tzinfo=timezone.utc)
current_month_event = month_start + timedelta(hours=12)
previous_month_event = month_start - timedelta(hours=12)
with self.SessionLocal() as db:
db.add_all(
[
@ -190,8 +192,8 @@ class DashboardFinanceTests(unittest.TestCase):
from_status="INVOICE",
to_status="PAID",
changed_by_admin_id=None,
created_at=now - timedelta(days=40),
updated_at=now - timedelta(days=40),
created_at=previous_month_event,
updated_at=previous_month_event,
),
StatusHistory(
request_id=req_a_closed.id,
@ -264,6 +266,7 @@ class DashboardFinanceTests(unittest.TestCase):
def test_admin_can_get_lawyer_active_requests_dashboard_detail(self):
now = datetime.now(timezone.utc)
month_start = datetime(now.year, now.month, 1, tzinfo=timezone.utc)
with self.SessionLocal() as db:
db.add_all(
[
@ -312,8 +315,8 @@ class DashboardFinanceTests(unittest.TestCase):
from_status="INVOICE",
to_status="PAID",
changed_by_admin_id=None,
created_at=now - timedelta(days=1),
updated_at=now - timedelta(days=1),
created_at=month_start + timedelta(hours=6),
updated_at=month_start + timedelta(hours=6),
)
)
db.commit()

View file

@ -1,8 +1,9 @@
import os
import unittest
from datetime import timedelta
from datetime import timedelta, datetime, timezone
from uuid import UUID
from uuid import uuid4
from unittest.mock import patch
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, delete
@ -21,11 +22,19 @@ from app.core.security import create_jwt
from app.db.session import get_db
from app.main import app
from app.models.admin_user import AdminUser
from app.models.attachment import Attachment
from app.models.invoice import Invoice
from app.models.message import Message
from app.models.notification import Notification
from app.models.request import Request
from app.services.invoice_crypto import decrypt_requisites
class _FakeS3Storage:
def __init__(self):
self.objects = {}
class InvoiceApiTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
@ -37,11 +46,17 @@ class InvoiceApiTests(unittest.TestCase):
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
AdminUser.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine)
Notification.__table__.create(bind=cls.engine)
Message.__table__.create(bind=cls.engine)
Attachment.__table__.create(bind=cls.engine)
Invoice.__table__.create(bind=cls.engine)
@classmethod
def tearDownClass(cls):
Invoice.__table__.drop(bind=cls.engine)
Attachment.__table__.drop(bind=cls.engine)
Message.__table__.drop(bind=cls.engine)
Notification.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
AdminUser.__table__.drop(bind=cls.engine)
cls.engine.dispose()
@ -49,6 +64,9 @@ class InvoiceApiTests(unittest.TestCase):
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(Invoice))
db.execute(delete(Attachment))
db.execute(delete(Message))
db.execute(delete(Notification))
db.execute(delete(Request))
db.execute(delete(AdminUser))
db.commit()
@ -115,9 +133,13 @@ class InvoiceApiTests(unittest.TestCase):
app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(app)
self.fake_s3 = _FakeS3Storage()
self.s3_patch = patch("app.services.invoice_chat.get_s3_storage", return_value=self.fake_s3)
self.s3_patch.start()
def tearDown(self):
self.client.close()
self.s3_patch.stop()
app.dependency_overrides.clear()
@staticmethod
@ -154,7 +176,8 @@ class InvoiceApiTests(unittest.TestCase):
self.assertEqual(body["request_track_number"], "TRK-INV-A")
self.assertEqual(body["status"], "WAITING_PAYMENT")
self.assertEqual(body["amount"], 12345.67)
self.assertTrue(str(body["invoice_number"]).startswith("INV-"))
date_prefix = datetime.now(timezone.utc).strftime("%Y%m%d")
self.assertRegex(str(body["invoice_number"]), rf"^{date_prefix}(?:-\d+)?$")
invoice_id = body["id"]
with self.SessionLocal() as db:
@ -165,6 +188,23 @@ class InvoiceApiTests(unittest.TestCase):
decrypted = decrypt_requisites(row.payer_details_encrypted)
self.assertEqual(decrypted["inn"], "7700000000")
self.assertEqual(decrypted["kpp"], "770001001")
message = db.query(Message).filter(Message.request_id == UUID(self.request_a_id)).order_by(Message.created_at.desc()).first()
self.assertIsNotNone(message)
self.assertEqual(message.body, "Счет на оплату")
attachment = (
db.query(Attachment)
.filter(Attachment.request_id == UUID(self.request_a_id), Attachment.message_id == message.id)
.order_by(Attachment.created_at.desc())
.first()
)
self.assertIsNotNone(attachment)
self.assertEqual(attachment.mime_type, "application/pdf")
self.assertTrue(str(attachment.file_name).endswith(".pdf"))
stored = self.fake_s3.objects.get(str(attachment.s3_key))
self.assertIsNotNone(stored)
self.assertEqual(stored.get("mime"), "application/pdf")
self.assertTrue(bytes(stored.get("content") or b"").startswith(b"%PDF"))
def test_lawyer_scope_and_paid_restriction(self):
admin_headers = self._admin_headers(self.admin_id, "ADMIN", "admin@example.com")
@ -292,4 +332,25 @@ class InvoiceApiTests(unittest.TestCase):
f"/api/public/requests/TRK-INV-A/invoices/{invoice_id}/pdf",
cookies=self._public_cookie("TRK-INV-B"),
)
self.assertEqual(denied.status_code, 403)
self.assertEqual(denied.status_code, 404)
def test_invoice_number_autonumber_uses_date_and_sequence_suffix(self):
headers = self._admin_headers(self.admin_id, "ADMIN", "admin@example.com")
first = self.client.post(
"/api/admin/invoices",
headers=headers,
json={"request_id": self.request_a_id, "amount": 1500, "payer_display_name": "ООО Первый"},
)
self.assertEqual(first.status_code, 201)
second = self.client.post(
"/api/admin/invoices",
headers=headers,
json={"request_id": self.request_b_id, "amount": 2300, "payer_display_name": "ООО Второй"},
)
self.assertEqual(second.status_code, 201)
date_prefix = datetime.now(timezone.utc).strftime("%Y%m%d")
first_number = str(first.json().get("invoice_number") or "")
second_number = str(second.json().get("invoice_number") or "")
self.assertEqual(first_number, date_prefix)
self.assertEqual(second_number, f"{date_prefix}-2")

View file

@ -114,7 +114,7 @@ class MigrationTests(unittest.TestCase):
def test_alembic_version_is_set(self):
with self.engine.connect() as conn:
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
self.assertEqual(version, "0031_pii_retention_and_consent")
self.assertEqual(version, "0032_email_cols_fix")
def test_responsible_column_exists_in_all_domain_tables(self):
tables = {

View file

@ -20,9 +20,11 @@ os.environ.setdefault("S3_BUCKET", "test")
from app.core.config import settings
from app.core.security import create_jwt
from app.db.session import get_db
from app.main import app
from app.chat_main import app as chat_app
from app.main import app as main_app
from app.models.admin_user import AdminUser
from app.models.attachment import Attachment
from app.models.audit_log import AuditLog
from app.models.message import Message
from app.models.notification import Notification
from app.models.request import Request
@ -58,6 +60,7 @@ class NotificationFlowTests(unittest.TestCase):
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
AdminUser.__table__.create(bind=cls.engine)
AuditLog.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine)
Message.__table__.create(bind=cls.engine)
Attachment.__table__.create(bind=cls.engine)
@ -73,6 +76,7 @@ class NotificationFlowTests(unittest.TestCase):
Attachment.__table__.drop(bind=cls.engine)
Message.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
AuditLog.__table__.drop(bind=cls.engine)
AdminUser.__table__.drop(bind=cls.engine)
cls.engine.dispose()
@ -84,6 +88,7 @@ class NotificationFlowTests(unittest.TestCase):
db.execute(delete(Attachment))
db.execute(delete(Message))
db.execute(delete(Request))
db.execute(delete(AuditLog))
db.execute(delete(AdminUser))
db.commit()
@ -94,12 +99,16 @@ class NotificationFlowTests(unittest.TestCase):
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(app)
main_app.dependency_overrides[get_db] = override_get_db
chat_app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(main_app)
self.chat_client = TestClient(chat_app)
def tearDown(self):
self.chat_client.close()
self.client.close()
app.dependency_overrides.clear()
chat_app.dependency_overrides.clear()
main_app.dependency_overrides.clear()
@staticmethod
def _admin_headers(sub: str, role: str, email: str) -> dict[str, str]:
@ -140,8 +149,8 @@ class NotificationFlowTests(unittest.TestCase):
db.commit()
lawyer_id = str(lawyer.id)
created = self.client.post(
"/api/public/requests/TRK-NOTIF-MSG/messages",
created = self.chat_client.post(
"/api/public/chat/requests/TRK-NOTIF-MSG/messages",
cookies=self._public_cookies("TRK-NOTIF-MSG"),
json={"body": "Есть новое сообщение"},
)

View file

@ -164,7 +164,7 @@ class PublicCabinetTests(unittest.TestCase):
db.commit()
cookies = self._public_cookies("TRK-CAB-001")
messages = self.client.get("/api/public/requests/TRK-CAB-001/messages", cookies=cookies)
messages = self.chat_client.get("/api/public/chat/requests/TRK-CAB-001/messages", cookies=cookies)
self.assertEqual(messages.status_code, 200)
self.assertEqual(len(messages.json()), 1)
self.assertEqual(messages.json()[0]["author_type"], "LAWYER")
@ -201,12 +201,15 @@ class PublicCabinetTests(unittest.TestCase):
request_id = req.id
cookies = self._public_cookies("TRK-CAB-MSG")
created = self.client.post(
"/api/public/requests/TRK-CAB-MSG/messages",
created = self.chat_client.post(
"/api/public/chat/requests/TRK-CAB-MSG/messages",
cookies=cookies,
json={"body": "Добрый день, есть вопрос по документам."},
)
self.assertEqual(created.status_code, 201)
self.assertEqual(created.json().get("message_kind"), "TEXT")
self.assertEqual(created.json().get("request_data_items"), [])
self.assertFalse(bool(created.json().get("request_data_all_filled")))
message_id = UUID(created.json()["id"])
with self.SessionLocal() as db:
@ -221,6 +224,31 @@ class PublicCabinetTests(unittest.TestCase):
self.assertTrue(req.lawyer_has_unread_updates)
self.assertEqual(req.lawyer_unread_event_type, "MESSAGE")
def test_legacy_public_request_messages_routes_are_not_exposed(self):
with self.SessionLocal() as db:
req = Request(
track_number="TRK-CAB-LEGACY",
client_name="Клиент Legacy Route",
client_phone="+79992220001",
topic_code="consulting",
status_code="NEW",
description="Проверка отсутствия legacy chat route",
extra_fields={},
)
db.add(req)
db.commit()
cookies = self._public_cookies("TRK-CAB-LEGACY")
listed = self.client.get("/api/public/requests/TRK-CAB-LEGACY/messages", cookies=cookies)
self.assertEqual(listed.status_code, 404)
created = self.client.post(
"/api/public/requests/TRK-CAB-LEGACY/messages",
cookies=cookies,
json={"body": "Legacy endpoint"},
)
self.assertEqual(created.status_code, 404)
def test_public_chat_service_endpoints_work_for_authorized_client(self):
with self.SessionLocal() as db:
req = Request(
@ -250,7 +278,7 @@ class PublicCabinetTests(unittest.TestCase):
self.assertIn("выделенный сервис", listed.json()[0]["body"])
denied = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=self._public_cookies("TRK-OTHER"))
self.assertEqual(denied.status_code, 403)
self.assertEqual(denied.status_code, 404)
def test_chat_message_is_encrypted_at_rest(self):
with self.SessionLocal() as db:
@ -460,8 +488,8 @@ class PublicCabinetTests(unittest.TestCase):
db.commit()
cookies = self._public_cookies("TRK-OTHER")
denied = self.client.get("/api/public/requests/TRK-REAL/messages", cookies=cookies)
self.assertEqual(denied.status_code, 403)
denied = self.chat_client.get("/api/public/chat/requests/TRK-REAL/messages", cookies=cookies)
self.assertEqual(denied.status_code, 404)
def test_public_attachment_download_requires_access(self):
fake_s3 = _FakeS3Storage()
@ -508,7 +536,7 @@ class PublicCabinetTests(unittest.TestCase):
f"/api/public/uploads/object/{attachment_id}",
cookies=self._public_cookies("TRK-OTHER"),
)
self.assertEqual(denied.status_code, 403)
self.assertEqual(denied.status_code, 404)
def test_public_upload_complete_links_attachment_to_message_when_message_id_provided(self):
fake_s3 = _FakeS3Storage()

View file

@ -213,7 +213,7 @@ class PublicRequestCreateTests(unittest.TestCase):
self.assertEqual(ok.json()["track_number"], track_number)
denied_other_track = self.client.get("/api/public/requests/TRK-OTHER")
self.assertEqual(denied_other_track.status_code, 403)
self.assertEqual(denied_other_track.status_code, 404)
def test_otp_send_rejects_honeypot_field(self):
response = self.client.post(
@ -321,7 +321,7 @@ class PublicRequestCreateTests(unittest.TestCase):
self.assertEqual(opened.json()["track_number"], "TRK-MULTI-2")
denied = self.client.get("/api/public/requests/TRK-FOREIGN-1")
self.assertEqual(denied.status_code, 403)
self.assertEqual(denied.status_code, 404)
def test_email_auth_mode_allows_create_flow_via_email_otp(self):
phone = self._unique_phone()

View file

@ -27,6 +27,7 @@ class SmsProviderHealthTests(unittest.TestCase):
"SMSAERO_API_KEY": settings.SMSAERO_API_KEY,
"OTP_DEV_MODE": settings.OTP_DEV_MODE,
}
settings.OTP_DEV_MODE = False
def tearDown(self):
self.client.close()