mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
fix user UI 7
This commit is contained in:
parent
bc5db5ce35
commit
d3244ff662
38 changed files with 13185 additions and 583 deletions
10
Dockerfile
10
Dockerfile
|
|
@ -1,6 +1,14 @@
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
WORKDIR /app
|
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 .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,10 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
|
||||||
"audit_log": {"ADMIN": {"query", "read"}},
|
"audit_log": {"ADMIN": {"query", "read"}},
|
||||||
"security_audit_log": {"ADMIN": {"query", "read"}},
|
"security_audit_log": {"ADMIN": {"query", "read"}},
|
||||||
"otp_sessions": {"ADMIN": {"query", "read"}},
|
"otp_sessions": {"ADMIN": {"query", "read"}},
|
||||||
"admin_users": {"ADMIN": set(CRUD_ACTIONS)},
|
"admin_users": {
|
||||||
|
"ADMIN": set(CRUD_ACTIONS),
|
||||||
|
"LAWYER": {"read", "update"},
|
||||||
|
},
|
||||||
"admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)},
|
"admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"landing_featured_staff": {"ADMIN": set(CRUD_ACTIONS)},
|
"landing_featured_staff": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"topic_status_transitions": {"ADMIN": set(CRUD_ACTIONS)},
|
"topic_status_transitions": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
|
|
||||||
|
|
@ -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:
|
def _apply_create_side_effects(db: Session, *, table_name: str, row: Any, admin: dict) -> None:
|
||||||
if table_name == "messages" and isinstance(row, Message):
|
if table_name == "messages" and isinstance(row, Message):
|
||||||
req = db.get(Request, row.request_id)
|
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]:
|
def get_row_service(table_name: str, row_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||||||
normalized, model = _resolve_table_model(table_name)
|
normalized, model = _resolve_table_model(table_name)
|
||||||
_require_table_action(admin, normalized, "read")
|
_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)
|
row = _load_row_or_404(db, model, row_id)
|
||||||
if normalized == "requests":
|
if normalized == "requests":
|
||||||
req = row if isinstance(row, Request) else None
|
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)
|
normalized, model = _resolve_table_model(table_name)
|
||||||
_require_table_action(admin, normalized, "update")
|
_require_table_action(admin, normalized, "update")
|
||||||
responsible = _resolve_responsible(admin)
|
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 normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
|
||||||
if "assigned_lawyer_id" in payload:
|
if "assigned_lawyer_id" in payload:
|
||||||
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
|
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest
|
from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest
|
||||||
from fastapi.responses import StreamingResponse
|
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.invoice import Invoice
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.schemas.universal import UniversalQuery
|
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_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.invoice_pdf import build_invoice_pdf_bytes
|
||||||
from app.services.security_audit import extract_client_ip, record_pii_access_event
|
from app.services.security_audit import extract_client_ip, record_pii_access_event
|
||||||
from app.services.universal_query import apply_universal_query
|
from app.services.universal_query import apply_universal_query
|
||||||
|
|
@ -90,15 +92,6 @@ def _now_utc() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
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:
|
def _parse_requisites(raw) -> dict:
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -290,6 +283,8 @@ def create_invoice(
|
||||||
role = str(admin.get("role") or "").upper()
|
role = str(admin.get("role") or "").upper()
|
||||||
actor_id = _actor_uuid_or_401(admin)
|
actor_id = _actor_uuid_or_401(admin)
|
||||||
actor_email = str(admin.get("email") or "").strip() or "Администратор системы"
|
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)
|
req = _request_from_payload_or_404(db, payload)
|
||||||
_ensure_lawyer_owns_request_or_403(role, actor_id, req)
|
_ensure_lawyer_owns_request_or_403(role, actor_id, req)
|
||||||
|
|
@ -302,10 +297,11 @@ def create_invoice(
|
||||||
if not payer_display_name:
|
if not payer_display_name:
|
||||||
raise HTTPException(status_code=400, detail='Поле "payer_display_name" обязательно')
|
raise HTTPException(status_code=400, detail='Поле "payer_display_name" обязательно')
|
||||||
|
|
||||||
|
issued_at = _now_utc()
|
||||||
invoice = Invoice(
|
invoice = Invoice(
|
||||||
request_id=req.id,
|
request_id=req.id,
|
||||||
client_id=req.client_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,
|
status=status,
|
||||||
amount=_amount_or_400(payload.get("amount")),
|
amount=_amount_or_400(payload.get("amount")),
|
||||||
currency=_normalize_currency(payload.get("currency")),
|
currency=_normalize_currency(payload.get("currency")),
|
||||||
|
|
@ -313,7 +309,7 @@ def create_invoice(
|
||||||
payer_details_encrypted=encrypt_requisites(_parse_requisites(payload.get("payer_details"))),
|
payer_details_encrypted=encrypt_requisites(_parse_requisites(payload.get("payer_details"))),
|
||||||
issued_by_admin_user_id=actor_id,
|
issued_by_admin_user_id=actor_id,
|
||||||
issued_by_role=role,
|
issued_by_role=role,
|
||||||
issued_at=_now_utc(),
|
issued_at=issued_at,
|
||||||
paid_at=None,
|
paid_at=None,
|
||||||
responsible=actor_email,
|
responsible=actor_email,
|
||||||
)
|
)
|
||||||
|
|
@ -327,6 +323,15 @@ def create_invoice(
|
||||||
|
|
||||||
db.add(invoice)
|
db.add(invoice)
|
||||||
db.add(req)
|
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, "Счет с таким номером уже существует")
|
_commit_or_400(db, "Счет с таким номером уже существует")
|
||||||
db.refresh(invoice)
|
db.refresh(invoice)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.admin_user import AdminUser
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.notification import Notification
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.status import Status
|
from app.models.status import Status
|
||||||
from app.models.status_group import StatusGroup
|
from app.models.status_group import StatusGroup
|
||||||
|
|
@ -20,8 +21,18 @@ from app.services.universal_query import apply_universal_query
|
||||||
|
|
||||||
from .common import parse_datetime_safe
|
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"}
|
ALLOWED_KANBAN_SORT_MODES = {"created_newest", "lawyer", "deadline"}
|
||||||
|
BOOLEAN_KANBAN_FILTER_FIELDS = {"overdue", "has_unread_updates", "deadline_alert"}
|
||||||
FALLBACK_KANBAN_GROUPS = [
|
FALLBACK_KANBAN_GROUPS = [
|
||||||
("fallback_new", "Новые", 10),
|
("fallback_new", "Новые", 10),
|
||||||
("fallback_in_progress", "В работе", 20),
|
("fallback_in_progress", "В работе", 20),
|
||||||
|
|
@ -86,7 +97,7 @@ def extract_case_deadline(extra_fields: object) -> datetime | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def coerce_kanban_bool(value: object) -> bool:
|
def coerce_kanban_bool(value: object, field_name: str) -> bool:
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return value
|
return value
|
||||||
text = str(value or "").strip().lower()
|
text = str(value or "").strip().lower()
|
||||||
|
|
@ -94,10 +105,10 @@ def coerce_kanban_bool(value: object) -> bool:
|
||||||
return True
|
return True
|
||||||
if text in {"0", "false", "no", "n", "off"}:
|
if text in {"0", "false", "no", "n", "off"}:
|
||||||
return False
|
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:
|
if not raw_filters:
|
||||||
return [], []
|
return [], []
|
||||||
try:
|
try:
|
||||||
|
|
@ -108,7 +119,7 @@ def parse_kanban_filters_or_400(raw_filters: str | None) -> tuple[list[FilterCla
|
||||||
raise HTTPException(status_code=400, detail="Фильтры канбана должны быть массивом")
|
raise HTTPException(status_code=400, detail="Фильтры канбана должны быть массивом")
|
||||||
|
|
||||||
universal_filters: list[FilterClause] = []
|
universal_filters: list[FilterClause] = []
|
||||||
overdue_filters: list[tuple[str, bool]] = []
|
boolean_filters: list[tuple[str, str, bool]] = []
|
||||||
for index, item in enumerate(parsed):
|
for index, item in enumerate(parsed):
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
raise HTTPException(status_code=400, detail=f"Фильтр #{index + 1} должен быть объектом")
|
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}"')
|
raise HTTPException(status_code=400, detail=f'Недоступное поле фильтра: "{field}"')
|
||||||
if op not in {"=", "!=", ">", "<", ">=", "<=", "~"}:
|
if op not in {"=", "!=", ">", "<", ">=", "<=", "~"}:
|
||||||
raise HTTPException(status_code=400, detail=f'Недопустимый оператор фильтра: "{op}"')
|
raise HTTPException(status_code=400, detail=f'Недопустимый оператор фильтра: "{op}"')
|
||||||
if field == "overdue":
|
if field in BOOLEAN_KANBAN_FILTER_FIELDS:
|
||||||
if op not in {"=", "!="}:
|
if op not in {"=", "!="}:
|
||||||
raise HTTPException(status_code=400, detail='Для поля "overdue" доступны только операторы "=" и "!="')
|
raise HTTPException(status_code=400, detail=f'Для поля "{field}" доступны только операторы "=" и "!="')
|
||||||
overdue_filters.append((op, coerce_kanban_bool(value)))
|
boolean_filters.append((field, op, coerce_kanban_bool(value, field)))
|
||||||
continue
|
continue
|
||||||
universal_filters.append(FilterClause(field=field, op=op, value=value))
|
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]]:
|
def apply_boolean_kanban_filters(
|
||||||
if not overdue_filters:
|
items: list[dict[str, object]],
|
||||||
|
boolean_filters: list[tuple[str, str, bool]],
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
if not boolean_filters:
|
||||||
return items
|
return items
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
out: list[dict[str, object]] = []
|
out: list[dict[str, object]] = []
|
||||||
for item in items:
|
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")
|
raw_deadline = item.get("sla_deadline_at") or item.get("case_deadline_at")
|
||||||
deadline_at = parse_datetime_safe(raw_deadline)
|
deadline_at = parse_datetime_safe(raw_deadline)
|
||||||
is_overdue = bool(deadline_at and deadline_at <= now)
|
actual = bool(deadline_at and deadline_at <= now)
|
||||||
ok = True
|
elif field == "has_unread_updates":
|
||||||
for op, expected in overdue_filters:
|
actual = bool(item.get("has_unread_updates"))
|
||||||
|
elif field == "deadline_alert":
|
||||||
|
actual = bool(item.get("deadline_alert"))
|
||||||
|
else:
|
||||||
|
actual = False
|
||||||
if op == "=":
|
if op == "=":
|
||||||
ok = ok and (is_overdue == expected)
|
ok = ok and (actual == expected)
|
||||||
elif op == "!=":
|
elif op == "!=":
|
||||||
ok = ok and (is_overdue != expected)
|
ok = ok and (actual != expected)
|
||||||
if not ok:
|
if not ok:
|
||||||
break
|
break
|
||||||
if ok:
|
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"
|
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:
|
if query_filters:
|
||||||
base_query = apply_universal_query(
|
base_query = apply_universal_query(
|
||||||
base_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_id_to_row = {str(row.id): row for row in request_rows}
|
||||||
request_ids = [row.id for row in request_rows]
|
request_ids = [row.id for row in request_rows]
|
||||||
|
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()}
|
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]] = {}
|
status_meta_map: dict[str, dict[str, object]] = {}
|
||||||
if status_codes:
|
if status_codes:
|
||||||
|
|
@ -448,9 +495,21 @@ def get_requests_kanban_service(
|
||||||
sla_deadline = entered_at + timedelta(hours=int(transition_rule.sla_hours))
|
sla_deadline = entered_at + timedelta(hours=int(transition_rule.sla_hours))
|
||||||
|
|
||||||
assigned_id = str(row.assigned_lawyer_id or "").strip() or None
|
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(
|
items.append(
|
||||||
{
|
{
|
||||||
"id": str(row.id),
|
"id": request_id,
|
||||||
"track_number": row.track_number,
|
"track_number": row.track_number,
|
||||||
"client_name": row.client_name,
|
"client_name": row.client_name,
|
||||||
"client_phone": row.client_phone,
|
"client_phone": row.client_phone,
|
||||||
|
|
@ -470,13 +529,15 @@ def get_requests_kanban_service(
|
||||||
"lawyer_unread_event_type": row.lawyer_unread_event_type,
|
"lawyer_unread_event_type": row.lawyer_unread_event_type,
|
||||||
"client_has_unread_updates": bool(row.client_has_unread_updates),
|
"client_has_unread_updates": bool(row.client_has_unread_updates),
|
||||||
"client_unread_event_type": row.client_unread_event_type,
|
"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,
|
"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,
|
"sla_deadline_at": sla_deadline.isoformat() if sla_deadline is not None else None,
|
||||||
"available_transitions": available_transitions,
|
"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)
|
items = sort_kanban_items(items, normalized_sort_mode)
|
||||||
total = len(items)
|
total = len(items)
|
||||||
if total > limit:
|
if total > limit:
|
||||||
|
|
|
||||||
|
|
@ -294,55 +294,92 @@ def get_request_status_route_service(
|
||||||
transition_sla_by_edge[(from_status, to_status)] = sla_hours
|
transition_sla_by_edge[(from_status, to_status)] = sla_hours
|
||||||
incoming_sla_by_status.setdefault(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:
|
if history_rows:
|
||||||
first_from = str(history_rows[0].from_status or "").strip()
|
first_from = str(history_rows[0].from_status or "").strip()
|
||||||
if first_from:
|
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:
|
for row in history_rows:
|
||||||
to_code = str(row.to_status or "").strip()
|
to_code = str(row.to_status or "").strip()
|
||||||
if to_code:
|
if not to_code:
|
||||||
sequence_from_history.append(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:
|
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] = []
|
if current_status and not any(str(step.get("code") or "").strip() == current_status for step in route_steps):
|
||||||
seen_codes: set[str] = set()
|
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, []):
|
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] = {}
|
current_index = -1
|
||||||
for row in history_rows:
|
if current_status:
|
||||||
to_code = str(row.to_status or "").strip()
|
for idx in range(len(route_steps) - 1, -1, -1):
|
||||||
if to_code and row.created_at:
|
code = str(route_steps[idx].get("code") or "").strip()
|
||||||
changed_at_by_status[to_code] = row.created_at.isoformat()
|
source = str(route_steps[idx].get("source") or "").strip()
|
||||||
|
if code != current_status:
|
||||||
visited_codes = {code for code in sequence_from_history if code}
|
continue
|
||||||
current_index = ordered_codes.index(current_status) if current_status in ordered_codes else -1
|
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:
|
def status_name(code: str) -> str:
|
||||||
meta = statuses_map.get(code) or {}
|
meta = statuses_map.get(code) or {}
|
||||||
return str(meta.get("name") or code)
|
return str(meta.get("name") or code)
|
||||||
|
|
||||||
nodes: list[dict[str, str | int | None]] = []
|
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 {}
|
meta = statuses_map.get(code) or {}
|
||||||
state = "pending"
|
state = "pending"
|
||||||
if code == current_status:
|
if index == current_index:
|
||||||
state = "current"
|
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"
|
state = "completed"
|
||||||
|
|
||||||
note_parts: list[str] = []
|
note_parts: list[str] = []
|
||||||
|
|
@ -358,10 +395,10 @@ def get_request_status_route_service(
|
||||||
"name": status_name(code),
|
"name": status_name(code),
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
"state": state,
|
"state": state,
|
||||||
"changed_at": changed_at_by_status.get(code),
|
"changed_at": str(step.get("changed_at") or "").strip() or None,
|
||||||
"sla_hours": (
|
"sla_hours": (
|
||||||
transition_sla_by_edge.get((ordered_codes[index - 1], code))
|
transition_sla_by_edge.get((str(step.get("edge_from") or "").strip(), code))
|
||||||
if index > 0
|
if str(step.get("edge_from") or "").strip()
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
or incoming_sla_by_status.get(code),
|
or incoming_sla_by_status.get(code),
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ from app.models.status_history import StatusHistory
|
||||||
from app.models.topic import Topic
|
from app.models.topic import Topic
|
||||||
from app.services.invoice_crypto import decrypt_requisites
|
from app.services.invoice_crypto import decrypt_requisites
|
||||||
from app.services.invoice_pdf import build_invoice_pdf_bytes
|
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.origin_guard import enforce_public_origin_or_403
|
||||||
from app.services.notifications import (
|
from app.services.notifications import (
|
||||||
get_client_notification,
|
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.api.admin.requests_modules.status_flow import get_request_status_route_service
|
||||||
from app.schemas.public import (
|
from app.schemas.public import (
|
||||||
PublicAttachmentRead,
|
PublicAttachmentRead,
|
||||||
PublicMessageCreate,
|
|
||||||
PublicMessageRead,
|
|
||||||
PublicRequestCreate,
|
PublicRequestCreate,
|
||||||
PublicRequestCreated,
|
PublicRequestCreated,
|
||||||
PublicServiceRequestCreate,
|
PublicServiceRequestCreate,
|
||||||
|
|
@ -427,6 +424,14 @@ def get_request_by_track(
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
req = _request_for_track_or_404(db, session, track_number)
|
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
|
topic_name = None
|
||||||
if str(req.topic_code or "").strip():
|
if str(req.topic_code or "").strip():
|
||||||
try:
|
try:
|
||||||
|
|
@ -478,15 +483,13 @@ def get_request_by_track(
|
||||||
"topic_code": req.topic_code,
|
"topic_code": req.topic_code,
|
||||||
"topic_name": topic_name,
|
"topic_name": topic_name,
|
||||||
"status_code": req.status_code,
|
"status_code": req.status_code,
|
||||||
|
"status_name": status_name,
|
||||||
"important_date_at": _to_iso(req.important_date_at),
|
"important_date_at": _to_iso(req.important_date_at),
|
||||||
"description": req.description,
|
"description": req.description,
|
||||||
"extra_fields": req.extra_fields,
|
"extra_fields": req.extra_fields,
|
||||||
"assigned_lawyer_id": req.assigned_lawyer_id,
|
"assigned_lawyer_id": req.assigned_lawyer_id,
|
||||||
"assigned_lawyer_name": lawyer_name or req.assigned_lawyer_id,
|
"assigned_lawyer_name": lawyer_name or req.assigned_lawyer_id,
|
||||||
"assigned_lawyer_phone": lawyer_phone,
|
"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_has_unread_updates": req.client_has_unread_updates,
|
||||||
"client_unread_event_type": req.client_unread_event_type,
|
"client_unread_event_type": req.client_unread_event_type,
|
||||||
"lawyer_has_unread_updates": req.lawyer_has_unread_updates,
|
"lawyer_has_unread_updates": req.lawyer_has_unread_updates,
|
||||||
|
|
@ -589,62 +592,6 @@ def get_status_route_by_track(
|
||||||
return payload
|
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])
|
@router.get("/{track_number}/attachments", response_model=list[PublicAttachmentRead])
|
||||||
def list_attachments_by_track(
|
def list_attachments_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
|
|
||||||
BIN
app/assets/invoice_signature_stamp.png
Normal file
BIN
app/assets/invoice_signature_stamp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
|
|
@ -4,7 +4,7 @@ from datetime import datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from string import Formatter
|
from string import Formatter
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
|
|
@ -14,7 +14,9 @@ from sqlalchemy.orm import Session
|
||||||
from app.models.invoice import Invoice
|
from app.models.invoice import Invoice
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.status import Status
|
from app.models.status import Status
|
||||||
|
from app.services.invoice_chat import create_invoice_chat_message_with_attachment
|
||||||
from app.services.invoice_crypto import encrypt_requisites
|
from app.services.invoice_crypto import encrypt_requisites
|
||||||
|
from app.services.invoice_numbering import generate_invoice_number
|
||||||
|
|
||||||
STATUS_KIND_DEFAULT = "DEFAULT"
|
STATUS_KIND_DEFAULT = "DEFAULT"
|
||||||
STATUS_KIND_INVOICE = "INVOICE"
|
STATUS_KIND_INVOICE = "INVOICE"
|
||||||
|
|
@ -109,15 +111,6 @@ def _status_template(db: Session, status_code: str) -> str | None:
|
||||||
return value or 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:
|
def _safe_render_template(template: str, values: dict[str, Any]) -> str:
|
||||||
source = str(template or "").strip() or DEFAULT_INVOICE_TEMPLATE
|
source = str(template or "").strip() or DEFAULT_INVOICE_TEMPLATE
|
||||||
allowed = {
|
allowed = {
|
||||||
|
|
@ -188,9 +181,10 @@ def _create_waiting_invoice(
|
||||||
|
|
||||||
actor = _actor_uuid_or_none(admin)
|
actor = _actor_uuid_or_none(admin)
|
||||||
role = str((admin or {}).get("role") or "").strip().upper() or None
|
role = str((admin or {}).get("role") or "").strip().upper() or None
|
||||||
|
issued_at = _now_utc()
|
||||||
invoice = Invoice(
|
invoice = Invoice(
|
||||||
request_id=req.id,
|
request_id=req.id,
|
||||||
invoice_number=_invoice_number(db),
|
invoice_number=generate_invoice_number(db, issued_at),
|
||||||
status=INVOICE_STATUS_WAITING,
|
status=INVOICE_STATUS_WAITING,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
currency="RUB",
|
currency="RUB",
|
||||||
|
|
@ -204,7 +198,7 @@ def _create_waiting_invoice(
|
||||||
),
|
),
|
||||||
issued_by_admin_user_id=actor,
|
issued_by_admin_user_id=actor,
|
||||||
issued_by_role=role,
|
issued_by_role=role,
|
||||||
issued_at=_now_utc(),
|
issued_at=issued_at,
|
||||||
paid_at=None,
|
paid_at=None,
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
|
|
@ -213,6 +207,15 @@ def _create_waiting_invoice(
|
||||||
req.invoice_amount = amount
|
req.invoice_amount = amount
|
||||||
req.responsible = responsible
|
req.responsible = responsible
|
||||||
db.add(req)
|
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
|
return invoice.invoice_number
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ def serialize_message(row: Message) -> dict[str, Any]:
|
||||||
"author_type": row.author_type,
|
"author_type": row.author_type,
|
||||||
"author_name": row.author_name,
|
"author_name": row.author_name,
|
||||||
"body": row.body,
|
"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,
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
181
app/services/invoice_chat.py
Normal file
181
app/services/invoice_chat.py
Normal 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
|
||||||
46
app/services/invoice_numbering.py
Normal file
46
app/services/invoice_numbering.py
Normal 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}"
|
||||||
|
|
||||||
|
|
@ -1,8 +1,87 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
import io
|
||||||
from typing import Any
|
import os
|
||||||
import unicodedata
|
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:
|
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")
|
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(
|
def build_invoice_pdf_bytes(
|
||||||
*,
|
*,
|
||||||
invoice_number: str,
|
invoice_number: str,
|
||||||
|
|
@ -43,6 +529,24 @@ def build_invoice_pdf_bytes(
|
||||||
issued_by_name: str | None,
|
issued_by_name: str | None,
|
||||||
requisites: dict[str, Any] | None,
|
requisites: dict[str, Any] | None,
|
||||||
) -> bytes:
|
) -> 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 = [
|
lines = [
|
||||||
f"Invoice: {invoice_number}",
|
f"Invoice: {invoice_number}",
|
||||||
f"Request: {request_track_number}",
|
f"Request: {request_track_number}",
|
||||||
|
|
@ -60,25 +564,4 @@ def build_invoice_pdf_bytes(
|
||||||
lines.append(f"{key}: {req.get(key)}")
|
lines.append(f"{key}: {req.get(key)}")
|
||||||
else:
|
else:
|
||||||
lines.append("-")
|
lines.append("-")
|
||||||
|
return _build_legacy_invoice_pdf_bytes(lines)
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -355,6 +355,7 @@
|
||||||
margin-top: 0.3rem;
|
margin-top: 0.3rem;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
color: #f6dab0;
|
color: #f6dab0;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lawyer-dashboard-grid {
|
.lawyer-dashboard-grid {
|
||||||
|
|
@ -1694,6 +1695,110 @@
|
||||||
margin-top: 0.2rem;
|
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 {
|
.request-data-modal {
|
||||||
width: min(860px, 100%);
|
width: min(860px, 100%);
|
||||||
}
|
}
|
||||||
|
|
@ -2558,6 +2663,14 @@
|
||||||
margin-bottom: 0.2rem;
|
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 {
|
.chat-request-data-bubble.all-filled .chat-request-data-head {
|
||||||
color: #d3f4dc;
|
color: #d3f4dc;
|
||||||
margin-bottom: 0.08rem;
|
margin-bottom: 0.08rem;
|
||||||
|
|
@ -2688,6 +2801,22 @@
|
||||||
line-height: 1.2;
|
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 {
|
.request-chat-composer-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -3026,6 +3155,10 @@
|
||||||
.request-main-column {
|
.request-main-column {
|
||||||
order: 2;
|
order: 2;
|
||||||
}
|
}
|
||||||
|
.request-finance-invoice-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
|
|
@ -3082,4 +3215,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
.request-finance-issue-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Административная панель • Правовой трекер</title>
|
<title>Административная панель • Правовой трекер</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="admin-root"></div>
|
<div id="admin-root"></div>
|
||||||
<script src="/vendor/react.production.min.js"></script>
|
<script src="/vendor/react.production.min.js"></script>
|
||||||
<script src="/vendor/react-dom.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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
10015
app/web/admin.js
Normal file
10015
app/web/admin.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -953,9 +953,13 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
}
|
}
|
||||||
if (field.type === "reference" || field.type === "enum") {
|
if (field.type === "reference" || field.type === "enum") {
|
||||||
const extraOptions = Array.isArray(field.extraOptions) ? field.extraOptions : [];
|
const extraOptions = Array.isArray(field.extraOptions) ? field.extraOptions : [];
|
||||||
|
const hasCurrentValue =
|
||||||
|
String(value || "").trim() !== "" &&
|
||||||
|
[...extraOptions, ...options].some((option) => String(option?.value || "") === String(value));
|
||||||
return (
|
return (
|
||||||
<select id={id} value={value} onChange={(event) => onChange(field.key, event.target.value)} disabled={disabled}>
|
<select id={id} value={value} onChange={(event) => onChange(field.key, event.target.value)} disabled={disabled}>
|
||||||
{field.optional ? <option value="">-</option> : null}
|
{field.optional ? <option value="">-</option> : null}
|
||||||
|
{!hasCurrentValue && String(value || "").trim() !== "" ? <option value={String(value)}>{String(value)}</option> : null}
|
||||||
{extraOptions.map((option) => (
|
{extraOptions.map((option) => (
|
||||||
<option value={String(option.value)} key={String(option.value)}>
|
<option value={String(option.value)} key={String(option.value)}>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|
@ -1275,6 +1279,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
loadRequestDataTemplateDetails,
|
loadRequestDataTemplateDetails,
|
||||||
saveRequestDataTemplate,
|
saveRequestDataTemplate,
|
||||||
saveRequestDataBatch,
|
saveRequestDataBatch,
|
||||||
|
issueRequestInvoice,
|
||||||
} = useRequestWorkspace({
|
} = useRequestWorkspace({
|
||||||
api,
|
api,
|
||||||
setStatus,
|
setStatus,
|
||||||
|
|
@ -1288,21 +1293,21 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
const getStatusOptions = useCallback(() => {
|
const getStatusOptions = useCallback(() => {
|
||||||
return (dictionaries.statuses || [])
|
return (dictionaries.statuses || [])
|
||||||
.filter((item) => item && item.code)
|
.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]);
|
}, [dictionaries.statuses]);
|
||||||
|
|
||||||
const getInvoiceStatusOptions = useCallback(() => {
|
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(() => {
|
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(() => {
|
const getTopicOptions = useCallback(() => {
|
||||||
return (dictionaries.topics || [])
|
return (dictionaries.topics || [])
|
||||||
.filter((item) => item && item.code)
|
.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]);
|
}, [dictionaries.topics]);
|
||||||
|
|
||||||
const getLawyerOptions = useCallback(() => {
|
const getLawyerOptions = useCallback(() => {
|
||||||
|
|
@ -1320,22 +1325,22 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
|
|
||||||
const getRequestDataValueTypeOptions = useCallback(() => {
|
const getRequestDataValueTypeOptions = useCallback(() => {
|
||||||
return [
|
return [
|
||||||
{ value: "string", label: "Строка (string)" },
|
{ value: "string", label: "Строка" },
|
||||||
{ value: "date", label: "Дата (date)" },
|
{ value: "date", label: "Дата" },
|
||||||
{ value: "number", label: "Число (number)" },
|
{ value: "number", label: "Число" },
|
||||||
{ value: "file", label: "Файл (file)" },
|
{ value: "file", label: "Файл" },
|
||||||
{ value: "text", label: "Текст (text)" },
|
{ value: "text", label: "Текст" },
|
||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getFormFieldKeyOptions = useCallback(() => {
|
const getFormFieldKeyOptions = useCallback(() => {
|
||||||
return (dictionaries.formFieldKeys || [])
|
return (dictionaries.formFieldKeys || [])
|
||||||
.filter((item) => item && item.key)
|
.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]);
|
}, [dictionaries.formFieldKeys]);
|
||||||
|
|
||||||
const getRoleOptions = useCallback(() => {
|
const getRoleOptions = useCallback(() => {
|
||||||
return Object.entries(ROLE_LABELS).map(([code, label]) => ({ value: code, label: label + " (" + code + ")" }));
|
return Object.entries(ROLE_LABELS).map(([code, label]) => ({ value: code, label }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const tableCatalogMap = useMemo(() => {
|
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" });
|
return getReferenceOptions({ table: "clients", value_field: "id", label_field: "full_name" });
|
||||||
}, [getReferenceOptions]);
|
}, [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(() => {
|
const dictionaryTableItems = useMemo(() => {
|
||||||
return (tableCatalog || [])
|
return (tableCatalog || [])
|
||||||
.filter(
|
.filter(
|
||||||
|
|
@ -1440,6 +1493,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
{ field: "status_code", label: "Статус", type: "reference", options: getStatusOptions },
|
{ field: "status_code", label: "Статус", type: "reference", options: getStatusOptions },
|
||||||
{ field: "created_at", label: "Дата", type: "date" },
|
{ field: "created_at", label: "Дата", type: "date" },
|
||||||
{ field: "topic_code", label: "Тема", type: "reference", options: getTopicOptions },
|
{ 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" },
|
{ field: "overdue", label: "Просрочен", type: "boolean" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -1761,12 +1816,12 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
}
|
}
|
||||||
if (tableKey === "invoices") {
|
if (tableKey === "invoices") {
|
||||||
return [
|
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: "invoice_number", label: "Номер счета", type: "text", optional: true, placeholder: "Оставьте пустым для автогенерации" },
|
||||||
{ key: "status", label: "Статус", type: "enum", required: true, options: getInvoiceStatusOptions, defaultValue: "WAITING_PAYMENT" },
|
{ key: "status", label: "Статус", type: "enum", required: true, options: getInvoiceStatusOptions, defaultValue: "WAITING_PAYMENT" },
|
||||||
{ key: "amount", label: "Сумма", type: "number", required: true },
|
{ key: "amount", label: "Сумма", type: "number", required: true },
|
||||||
{ key: "currency", label: "Валюта", type: "text", optional: true, defaultValue: "RUB" },
|
{ 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\":\"...\"}" },
|
{ 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,
|
getFormFieldKeyOptions,
|
||||||
getFormFieldTypeOptions,
|
getFormFieldTypeOptions,
|
||||||
getInvoiceStatusOptions,
|
getInvoiceStatusOptions,
|
||||||
|
getInvoicePayerOptions,
|
||||||
|
getInvoiceRequestTrackOptions,
|
||||||
getClientOptions,
|
getClientOptions,
|
||||||
getLawyerOptions,
|
getLawyerOptions,
|
||||||
getRoleOptions,
|
getRoleOptions,
|
||||||
|
|
@ -2147,9 +2204,9 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
if (!(tokenOverride !== undefined ? tokenOverride : token)) return;
|
if (!(tokenOverride !== undefined ? tokenOverride : token)) return;
|
||||||
if (section === "dashboard") return loadDashboard(tokenOverride);
|
if (section === "dashboard") return loadDashboard(tokenOverride);
|
||||||
if (section === "kanban") return loadKanban(tokenOverride);
|
if (section === "kanban") return loadKanban(tokenOverride);
|
||||||
if (section === "requests") return loadTable("requests", {}, tokenOverride);
|
if (section === "requests" && canAccessSection(role, "requests")) return loadTable("requests", {}, tokenOverride);
|
||||||
if (section === "serviceRequests") return loadTable("serviceRequests", {}, tokenOverride);
|
if (section === "serviceRequests" && canAccessSection(role, "serviceRequests")) return loadTable("serviceRequests", {}, tokenOverride);
|
||||||
if (section === "invoices") return loadTable("invoices", {}, tokenOverride);
|
if (section === "invoices" && canAccessSection(role, "invoices")) return loadTable("invoices", {}, tokenOverride);
|
||||||
if (section === "quotes" && canAccessSection(role, "quotes")) return loadTable("quotes", {}, tokenOverride);
|
if (section === "quotes" && canAccessSection(role, "quotes")) return loadTable("quotes", {}, tokenOverride);
|
||||||
if (section === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride);
|
if (section === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride);
|
||||||
if (section === "availableTables" && canAccessSection(role, "availableTables")) return loadAvailableTables(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 };
|
return { ...prev, form: nextForm };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[referenceRowsMap.clients]
|
[getInvoiceRequestRows, referenceRowsMap.clients]
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadRecordFieldFile = useCallback(
|
const uploadRecordFieldFile = useCallback(
|
||||||
|
|
@ -2521,13 +2590,16 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
await api("/api/admin/requests/" + requestId + "/claim", { method: "POST" });
|
await api("/api/admin/requests/" + requestId + "/claim", { method: "POST" });
|
||||||
setStatus("requests", "Заявка взята в работу", "ok");
|
setStatus("requests", "Заявка взята в работу", "ok");
|
||||||
setStatus("kanban", "Заявка взята в работу", "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) {
|
} catch (error) {
|
||||||
setStatus("requests", "Ошибка назначения: " + error.message, "error");
|
setStatus("requests", "Ошибка назначения: " + error.message, "error");
|
||||||
setStatus("kanban", "Ошибка назначения: " + error.message, "error");
|
setStatus("kanban", "Ошибка назначения: " + error.message, "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, loadKanban, loadTable, setStatus]
|
[api, loadKanban, loadTable, role, setStatus]
|
||||||
);
|
);
|
||||||
|
|
||||||
const openInvoiceRequest = useCallback(
|
const openInvoiceRequest = useCallback(
|
||||||
|
|
@ -2588,7 +2660,10 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
setStatus("kanban", "Переводим заявку...", "");
|
setStatus("kanban", "Переводим заявку...", "");
|
||||||
await submitRequestStatusChange({ requestId, statusCode: targetStatus });
|
await submitRequestStatusChange({ requestId, statusCode: targetStatus });
|
||||||
setStatus("kanban", "Статус заявки обновлен", "ok");
|
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) {
|
} catch (error) {
|
||||||
setStatus("kanban", "Ошибка перехода: " + error.message, "error");
|
setStatus("kanban", "Ошибка перехода: " + error.message, "error");
|
||||||
}
|
}
|
||||||
|
|
@ -2597,10 +2672,10 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloadInvoicePdf = useCallback(
|
const downloadInvoicePdf = useCallback(
|
||||||
async (row) => {
|
async (row, statusKey = "invoices") => {
|
||||||
if (!row || !row.id || !token) return;
|
if (!row || !row.id || !token) return;
|
||||||
try {
|
try {
|
||||||
setStatus("invoices", "Формируем PDF...", "");
|
setStatus(statusKey, "Формируем PDF...", "");
|
||||||
const response = await fetch("/api/admin/invoices/" + row.id + "/pdf", {
|
const response = await fetch("/api/admin/invoices/" + row.id + "/pdf", {
|
||||||
headers: { Authorization: "Bearer " + token },
|
headers: { Authorization: "Bearer " + token },
|
||||||
});
|
});
|
||||||
|
|
@ -2625,14 +2700,21 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
setStatus("invoices", "PDF скачан", "ok");
|
setStatus(statusKey, "PDF скачан", "ok");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("invoices", "Ошибка скачивания: " + error.message, "error");
|
setStatus(statusKey, "Ошибка скачивания: " + error.message, "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setStatus, token]
|
[setStatus, token]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const downloadRequestInvoicePdf = useCallback(
|
||||||
|
async (row) => {
|
||||||
|
await downloadInvoicePdf(row, "requestModal");
|
||||||
|
},
|
||||||
|
[downloadInvoicePdf]
|
||||||
|
);
|
||||||
|
|
||||||
const resetAdminRoute = useCallback(() => {
|
const resetAdminRoute = useCallback(() => {
|
||||||
const nextUrl = "/admin.html";
|
const nextUrl = "/admin.html";
|
||||||
if (window.location.pathname !== nextUrl || window.location.search) {
|
if (window.location.pathname !== nextUrl || window.location.search) {
|
||||||
|
|
@ -2641,10 +2723,11 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const goBackFromRequestWorkspace = useCallback(() => {
|
const goBackFromRequestWorkspace = useCallback(() => {
|
||||||
|
const targetSection = canAccessSection(role, "requests") ? "requests" : "kanban";
|
||||||
resetAdminRoute();
|
resetAdminRoute();
|
||||||
setActiveSection("requests");
|
setActiveSection(targetSection);
|
||||||
refreshSection("requests");
|
refreshSection(targetSection);
|
||||||
}, [refreshSection, resetAdminRoute]);
|
}, [refreshSection, resetAdminRoute, role]);
|
||||||
|
|
||||||
const openReassignModal = useCallback(
|
const openReassignModal = useCallback(
|
||||||
(row) => {
|
(row) => {
|
||||||
|
|
@ -2827,6 +2910,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
|
|
||||||
const applyRequestsQuickFilterPreset = useCallback(
|
const applyRequestsQuickFilterPreset = useCallback(
|
||||||
async (filters, statusMessage) => {
|
async (filters, statusMessage) => {
|
||||||
|
if (!canAccessSection(role, "requests")) return;
|
||||||
const nextFilters = Array.isArray(filters) ? filters.filter((item) => item && item.field) : [];
|
const nextFilters = Array.isArray(filters) ? filters.filter((item) => item && item.field) : [];
|
||||||
resetAdminRoute();
|
resetAdminRoute();
|
||||||
setActiveSection("requests");
|
setActiveSection("requests");
|
||||||
|
|
@ -2840,7 +2924,25 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
if (statusMessage) setStatus("requests", statusMessage, "");
|
if (statusMessage) setStatus("requests", statusMessage, "");
|
||||||
await loadTable("requests", { resetOffset: true, filtersOverride: nextFilters });
|
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 () => {
|
const openRequestsWithUnreadAlerts = useCallback(async () => {
|
||||||
|
|
@ -2851,6 +2953,14 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
await applyRequestsQuickFilterPreset([{ field: "deadline_alert", op: "=", value: true }], "Показаны заявки с горящими дедлайнами");
|
await applyRequestsQuickFilterPreset([{ field: "deadline_alert", op: "=", value: true }], "Показаны заявки с горящими дедлайнами");
|
||||||
}, [applyRequestsQuickFilterPreset]);
|
}, [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(
|
const applyServiceRequestsQuickFilterPreset = useCallback(
|
||||||
async (filters, statusMessage) => {
|
async (filters, statusMessage) => {
|
||||||
const nextFilters = Array.isArray(filters) ? filters.filter((item) => item && item.field) : [];
|
const nextFilters = Array.isArray(filters) ? filters.filter((item) => item && item.field) : [];
|
||||||
|
|
@ -2891,13 +3001,13 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
setStatus("serviceRequests", "Отмечаем как прочитанный...", "");
|
setStatus("serviceRequests", "Отмечаем как прочитанный...", "");
|
||||||
await api("/api/admin/requests/service-requests/" + encodeURIComponent(rowId) + "/read", { method: "POST" });
|
await api("/api/admin/requests/service-requests/" + encodeURIComponent(rowId) + "/read", { method: "POST" });
|
||||||
await Promise.all([loadTable("serviceRequests", { resetOffset: true }), loadDashboard()]);
|
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");
|
setStatus("serviceRequests", "Запрос отмечен как прочитанный", "ok");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("serviceRequests", "Ошибка: " + error.message, "error");
|
setStatus("serviceRequests", "Ошибка: " + error.message, "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, loadDashboard, loadTable, setStatus]
|
[api, loadDashboard, loadTable, role, setStatus]
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadTotpStatus = useCallback(
|
const loadTotpStatus = useCallback(
|
||||||
|
|
@ -3399,14 +3509,15 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
}, [closeAccountModal, closeKanbanSortModal, closeTotpSetupModal]);
|
}, [closeAccountModal, closeKanbanSortModal, closeTotpSetupModal]);
|
||||||
|
|
||||||
const menuItems = useMemo(() => {
|
const menuItems = useMemo(() => {
|
||||||
return [
|
const baseItems = [
|
||||||
{ key: "dashboard", label: "Обзор" },
|
{ key: "dashboard", label: "Обзор" },
|
||||||
{ key: "kanban", label: "Канбан" },
|
{ key: "kanban", label: "Канбан" },
|
||||||
{ key: "requests", label: "Заявки" },
|
{ key: "requests", label: "Заявки" },
|
||||||
{ key: "serviceRequests", label: "Запросы" },
|
{ key: "serviceRequests", label: "Запросы" },
|
||||||
{ key: "invoices", label: "Счета" },
|
{ key: "invoices", label: "Счета" },
|
||||||
];
|
];
|
||||||
}, []);
|
return baseItems.filter((item) => canAccessSection(role, item.key));
|
||||||
|
}, [role]);
|
||||||
|
|
||||||
const topbarUnreadCount = useMemo(() => {
|
const topbarUnreadCount = useMemo(() => {
|
||||||
const roleCode = String(role || "").toUpperCase();
|
const roleCode = String(role || "").toUpperCase();
|
||||||
|
|
@ -3421,6 +3532,11 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
() => Number(dashboardData.serviceRequestUnreadTotal || 0),
|
() => Number(dashboardData.serviceRequestUnreadTotal || 0),
|
||||||
[dashboardData.serviceRequestUnreadTotal]
|
[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(() => {
|
const activeFilterFields = useMemo(() => {
|
||||||
if (!filterModal.tableKey) return [];
|
if (!filterModal.tableKey) return [];
|
||||||
|
|
@ -3515,11 +3631,13 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</nav>
|
</nav>
|
||||||
|
{role !== "LAWYER" ? (
|
||||||
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
<button className="btn secondary" type="button" onClick={refreshAll}>
|
<button className="btn secondary" type="button" onClick={refreshAll}>
|
||||||
Обновить
|
Обновить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="main">
|
<main className="main">
|
||||||
|
|
@ -3529,6 +3647,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
<p className="muted">UniversalQuery, RBAC и аудит действий по ключевым сущностям системы.</p>
|
<p className="muted">UniversalQuery, RBAC и аудит действий по ключевым сущностям системы.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-actions" aria-label="Быстрые уведомления и профиль">
|
<div className="topbar-actions" aria-label="Быстрые уведомления и профиль">
|
||||||
|
{showServiceRequestIcon ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={
|
className={
|
||||||
|
|
@ -3550,6 +3669,9 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
</svg>
|
</svg>
|
||||||
<span className="topbar-alert-dot" aria-hidden="true" />
|
<span className="topbar-alert-dot" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
|
{showRequestAlertIcons ? (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={
|
className={
|
||||||
|
|
@ -3561,7 +3683,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
: "Горящих дедлайнов нет"
|
: "Горящих дедлайнов нет"
|
||||||
}
|
}
|
||||||
aria-label="Показать заявки с горящими дедлайнами"
|
aria-label="Показать заявки с горящими дедлайнами"
|
||||||
onClick={openRequestsWithDeadlineAlerts}
|
onClick={canUseRequestsAlerts ? openRequestsWithDeadlineAlerts : openKanbanWithDeadlineAlerts}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false">
|
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false">
|
||||||
<path
|
<path
|
||||||
|
|
@ -3582,7 +3704,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
: "Новых оповещений нет"
|
: "Новых оповещений нет"
|
||||||
}
|
}
|
||||||
aria-label="Показать заявки с новыми оповещениями"
|
aria-label="Показать заявки с новыми оповещениями"
|
||||||
onClick={openRequestsWithUnreadAlerts}
|
onClick={canUseRequestsAlerts ? openRequestsWithUnreadAlerts : openKanbanWithUnreadAlerts}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false">
|
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false">
|
||||||
<path
|
<path
|
||||||
|
|
@ -3592,6 +3714,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
</svg>
|
</svg>
|
||||||
<span className="topbar-alert-dot" aria-hidden="true" />
|
<span className="topbar-alert-dot" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-btn topbar-alert-btn"
|
className="icon-btn topbar-alert-btn"
|
||||||
|
|
@ -3649,6 +3773,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{canAccessSection(role, "requests") ? (
|
||||||
<Section active={activeSection === "requests"} id="section-requests">
|
<Section active={activeSection === "requests"} id="section-requests">
|
||||||
<RequestsSection
|
<RequestsSection
|
||||||
role={role}
|
role={role}
|
||||||
|
|
@ -3678,6 +3803,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
IconButtonComponent={IconButton}
|
IconButtonComponent={IconButton}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Section active={activeSection === "serviceRequests"} id="section-service-requests">
|
<Section active={activeSection === "serviceRequests"} id="section-service-requests">
|
||||||
<ServiceRequestsSection
|
<ServiceRequestsSection
|
||||||
|
|
@ -3734,6 +3860,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
trackNumber={requestModal.trackNumber}
|
trackNumber={requestModal.trackNumber}
|
||||||
requestData={requestModal.requestData}
|
requestData={requestModal.requestData}
|
||||||
financeSummary={requestModal.financeSummary}
|
financeSummary={requestModal.financeSummary}
|
||||||
|
invoices={requestModal.invoices || []}
|
||||||
statusRouteNodes={requestModal.statusRouteNodes}
|
statusRouteNodes={requestModal.statusRouteNodes}
|
||||||
statusHistory={requestModal.statusHistory || []}
|
statusHistory={requestModal.statusHistory || []}
|
||||||
availableStatuses={requestModal.availableStatuses || []}
|
availableStatuses={requestModal.availableStatuses || []}
|
||||||
|
|
@ -3755,6 +3882,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
onLoadRequestDataTemplateDetails={loadRequestDataTemplateDetails}
|
onLoadRequestDataTemplateDetails={loadRequestDataTemplateDetails}
|
||||||
onSaveRequestDataTemplate={saveRequestDataTemplate}
|
onSaveRequestDataTemplate={saveRequestDataTemplate}
|
||||||
onSaveRequestDataBatch={saveRequestDataBatch}
|
onSaveRequestDataBatch={saveRequestDataBatch}
|
||||||
|
onIssueInvoice={issueRequestInvoice}
|
||||||
|
onDownloadInvoicePdf={downloadRequestInvoicePdf}
|
||||||
onChangeStatus={submitRequestStatusChange}
|
onChangeStatus={submitRequestStatusChange}
|
||||||
onConsumePendingStatusChangePreset={clearPendingStatusChangePreset}
|
onConsumePendingStatusChangePreset={clearPendingStatusChangePreset}
|
||||||
onLiveProbe={probeRequestLive}
|
onLiveProbe={probeRequestLive}
|
||||||
|
|
|
||||||
|
|
@ -70,14 +70,26 @@ export function DashboardSection({
|
||||||
setLawyerModal({ open: false, loading: false, error: "", lawyer: null, rows: [], totals: { amount: 0, salary: 0 } });
|
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 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Обзор метрик</h2>
|
<h2>Обзор метрик</h2>
|
||||||
<p className="muted">Состояние заявок, финансы месяца и загрузка юристов.</p>
|
<p className="muted">
|
||||||
|
{isLawyerScope ? "Состояние заявок и персональная загрузка." : "Состояние заявок, финансы месяца и загрузка юристов."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -109,12 +121,26 @@ export function DashboardSection({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{dashboardData?.scope === "LAWYER" ? (
|
{isLawyerScope ? (
|
||||||
<div className="json" style={{ marginTop: "0.5rem" }}>
|
<div style={{ marginTop: "0.9rem" }}>
|
||||||
{JSON.stringify(dashboardData?.myUnreadByEvent || {}, null, 2)}
|
<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>
|
</div>
|
||||||
) : null}
|
))
|
||||||
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
<p>Моя загрузка</p>
|
||||||
|
<b>Нет данных</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div style={{ marginTop: "0.9rem" }}>
|
<div style={{ marginTop: "0.9rem" }}>
|
||||||
<h3 style={{ margin: "0 0 0.55rem" }}>Загрузка юристов</h3>
|
<h3 style={{ margin: "0 0 0.55rem" }}>Загрузка юристов</h3>
|
||||||
<div className="lawyer-dashboard-grid">
|
<div className="lawyer-dashboard-grid">
|
||||||
|
|
@ -151,6 +177,7 @@ export function DashboardSection({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<StatusLine status={status} />
|
<StatusLine status={status} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
fmtShortDateTime,
|
fmtShortDateTime,
|
||||||
fmtTimeOnly,
|
fmtTimeOnly,
|
||||||
humanizeKey,
|
humanizeKey,
|
||||||
|
invoiceStatusLabel,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
} from "../../shared/utils.js";
|
} from "../../shared/utils.js";
|
||||||
|
|
||||||
|
|
@ -17,6 +18,7 @@ export function RequestWorkspace({
|
||||||
trackNumber,
|
trackNumber,
|
||||||
requestData,
|
requestData,
|
||||||
financeSummary,
|
financeSummary,
|
||||||
|
invoices,
|
||||||
statusRouteNodes,
|
statusRouteNodes,
|
||||||
statusHistory,
|
statusHistory,
|
||||||
availableStatuses,
|
availableStatuses,
|
||||||
|
|
@ -38,6 +40,8 @@ export function RequestWorkspace({
|
||||||
onLoadRequestDataTemplateDetails,
|
onLoadRequestDataTemplateDetails,
|
||||||
onSaveRequestDataTemplate,
|
onSaveRequestDataTemplate,
|
||||||
onSaveRequestDataBatch,
|
onSaveRequestDataBatch,
|
||||||
|
onIssueInvoice,
|
||||||
|
onDownloadInvoicePdf,
|
||||||
onSaveRequestDataValues,
|
onSaveRequestDataValues,
|
||||||
onUploadRequestAttachment,
|
onUploadRequestAttachment,
|
||||||
onChangeStatus,
|
onChangeStatus,
|
||||||
|
|
@ -53,6 +57,14 @@ export function RequestWorkspace({
|
||||||
const [chatTab, setChatTab] = useState("chat");
|
const [chatTab, setChatTab] = useState("chat");
|
||||||
const [dropActive, setDropActive] = useState(false);
|
const [dropActive, setDropActive] = useState(false);
|
||||||
const [financeOpen, setFinanceOpen] = 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 [requestDataListOpen, setRequestDataListOpen] = useState(false);
|
||||||
const [descriptionOpen, setDescriptionOpen] = useState(false);
|
const [descriptionOpen, setDescriptionOpen] = useState(false);
|
||||||
const [requestTemplateSuggestOpen, setRequestTemplateSuggestOpen] = useState(false);
|
const [requestTemplateSuggestOpen, setRequestTemplateSuggestOpen] = useState(false);
|
||||||
|
|
@ -178,6 +190,7 @@ export function RequestWorkspace({
|
||||||
const showContactsInCard = viewerRoleCode !== "CLIENT";
|
const showContactsInCard = viewerRoleCode !== "CLIENT";
|
||||||
const safeMessages = Array.isArray(messages) ? messages : [];
|
const safeMessages = Array.isArray(messages) ? messages : [];
|
||||||
const safeAttachments = Array.isArray(attachments) ? attachments : [];
|
const safeAttachments = Array.isArray(attachments) ? attachments : [];
|
||||||
|
const safeInvoices = Array.isArray(invoices) ? invoices : [];
|
||||||
const safeStatusHistory = Array.isArray(statusHistory) ? statusHistory : [];
|
const safeStatusHistory = Array.isArray(statusHistory) ? statusHistory : [];
|
||||||
const safeAvailableStatuses = Array.isArray(availableStatuses) ? availableStatuses : [];
|
const safeAvailableStatuses = Array.isArray(availableStatuses) ? availableStatuses : [];
|
||||||
const totalFilesBytes = safeAttachments.reduce((acc, item) => acc + Number(item?.size_bytes || 0), 0);
|
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)
|
.filter((item) => item && item.code)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
code: String(item.code),
|
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) : "",
|
groupName: item.status_group_name ? String(item.status_group_name) : "",
|
||||||
isTerminal: Boolean(item.is_terminal),
|
isTerminal: Boolean(item.is_terminal),
|
||||||
})),
|
})),
|
||||||
|
|
@ -312,6 +325,61 @@ export function RequestWorkspace({
|
||||||
return Math.max(0, minutes) + " мин";
|
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 openStatusChangeModal = (preset) => {
|
||||||
const suggested = Array.isArray(preset?.suggestedStatuses) ? preset.suggestedStatuses.filter(Boolean) : [];
|
const suggested = Array.isArray(preset?.suggestedStatuses) ? preset.suggestedStatuses.filter(Boolean) : [];
|
||||||
const currentCode = String(row?.status_code || "").trim();
|
const currentCode = String(row?.status_code || "").trim();
|
||||||
|
|
@ -1146,7 +1214,7 @@ export function RequestWorkspace({
|
||||||
Array.isArray(statusRouteNodes) && statusRouteNodes.length
|
Array.isArray(statusRouteNodes) && statusRouteNodes.length
|
||||||
? statusRouteNodes
|
? statusRouteNodes
|
||||||
: row?.status_code
|
: 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 upcomingImportantDate = useMemo(() => {
|
||||||
const source = String(currentImportantDateAt || row?.important_date_at || "").trim();
|
const source = String(currentImportantDateAt || row?.important_date_at || "").trim();
|
||||||
|
|
@ -1156,7 +1224,7 @@ export function RequestWorkspace({
|
||||||
return new Date(timestamp).toISOString();
|
return new Date(timestamp).toISOString();
|
||||||
}, [currentImportantDateAt, row?.important_date_at]);
|
}, [currentImportantDateAt, row?.important_date_at]);
|
||||||
const routeNodes = useMemo(() => {
|
const routeNodes = useMemo(() => {
|
||||||
if (viewerRoleCode !== "CLIENT" || !upcomingImportantDate) return baseRouteNodes;
|
if ((viewerRoleCode !== "CLIENT" && viewerRoleCode !== "LAWYER") || !upcomingImportantDate) return baseRouteNodes;
|
||||||
if (!Array.isArray(baseRouteNodes) || !baseRouteNodes.length) {
|
if (!Array.isArray(baseRouteNodes) || !baseRouteNodes.length) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
@ -1183,6 +1251,31 @@ export function RequestWorkspace({
|
||||||
next.splice(currentIndex + 1, 0, virtualNode);
|
next.splice(currentIndex + 1, 0, virtualNode);
|
||||||
return next;
|
return next;
|
||||||
}, [baseRouteNodes, upcomingImportantDate, viewerRoleCode]);
|
}, [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 AttachmentPreviewModal = AttachmentPreviewModalComponent;
|
||||||
const StatusLine = StatusLineComponent;
|
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 formatRequestDataValue = (item) => {
|
||||||
const type = String(item?.field_type || "string").toLowerCase();
|
const type = String(item?.field_type || "string").toLowerCase();
|
||||||
if (type === "date") {
|
if (type === "date") {
|
||||||
|
|
@ -1235,6 +1378,8 @@ export function RequestWorkspace({
|
||||||
return text || "Не заполнено";
|
return text || "Не заполнено";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const currentStatusName = resolveStatusDisplayName(row?.status_code, row?.status_name || "");
|
||||||
|
|
||||||
const dataRequestProgress = useMemo(() => {
|
const dataRequestProgress = useMemo(() => {
|
||||||
const rows = Array.isArray(dataRequestModal.rows) ? dataRequestModal.rows : [];
|
const rows = Array.isArray(dataRequestModal.rows) ? dataRequestModal.rows : [];
|
||||||
const total = rows.length;
|
const total = rows.length;
|
||||||
|
|
@ -1300,7 +1445,7 @@ export function RequestWorkspace({
|
||||||
</div>
|
</div>
|
||||||
<div className="request-field">
|
<div className="request-field">
|
||||||
<span className="request-field-label">Статус</span>
|
<span className="request-field-label">Статус</span>
|
||||||
<span className="request-field-value">{statusLabel(row.status_code)}</span>
|
<span className="request-field-value">{currentStatusName}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -1363,17 +1508,19 @@ export function RequestWorkspace({
|
||||||
</div>
|
</div>
|
||||||
<div className="request-status-route">
|
<div className="request-status-route">
|
||||||
<h4>Маршрут статусов</h4>
|
<h4>Маршрут статусов</h4>
|
||||||
{routeNodes.length ? (
|
{routeNodesForDisplay.length ? (
|
||||||
<ol className="request-route-list" id="request-status-route">
|
<ol className="request-route-list" id="request-status-route">
|
||||||
{routeNodes.map((node, index) => {
|
{routeNodesForDisplay.map((node, index) => {
|
||||||
const state = String(node?.state || "pending");
|
const state = String(node?.state || "pending");
|
||||||
const code = String(node?.code || "").trim();
|
const code = String(node?.code || "").trim();
|
||||||
const rawName = String(node?.name || "").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 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 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 =
|
const className =
|
||||||
"route-item " +
|
"route-item " +
|
||||||
(state === "current" ? "current" : state === "completed" ? "completed" : "pending") +
|
(state === "current" ? "current" : state === "completed" ? "completed" : "pending") +
|
||||||
|
|
@ -1383,8 +1530,14 @@ export function RequestWorkspace({
|
||||||
<span className="route-dot" />
|
<span className="route-dot" />
|
||||||
<div className="route-body">
|
<div className="route-body">
|
||||||
<b>{name}</b>
|
<b>{name}</b>
|
||||||
|
{isImportantDateNode ? (
|
||||||
|
<p>{"Контрольный срок: " + (changedAt || "-")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{note ? <p>{note}</p> : null}
|
{note ? <p>{note}</p> : null}
|
||||||
<div className="muted route-time">Дата статуса: {changedAt || "-"}</div>
|
<div className="muted route-time">Дата изменения: {changedAt || "-"}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
@ -1479,6 +1632,7 @@ export function RequestWorkspace({
|
||||||
(() => {
|
(() => {
|
||||||
const messageKind = String(entry.payload?.message_kind || "");
|
const messageKind = String(entry.payload?.message_kind || "");
|
||||||
const isRequestDataMessage = messageKind === "REQUEST_DATA";
|
const isRequestDataMessage = messageKind === "REQUEST_DATA";
|
||||||
|
const serviceMessageContent = resolveServiceMessageContent(entry.payload);
|
||||||
const requestDataInteractive = isRequestDataMessage && (canRequestData || canFillRequestData);
|
const requestDataInteractive = isRequestDataMessage && (canRequestData || canFillRequestData);
|
||||||
const bubbleClass =
|
const bubbleClass =
|
||||||
"chat-message-bubble" +
|
"chat-message-bubble" +
|
||||||
|
|
@ -1521,9 +1675,16 @@ export function RequestWorkspace({
|
||||||
<div className="chat-request-data-head">Запрос</div>
|
<div className="chat-request-data-head">Запрос</div>
|
||||||
{renderRequestDataMessageItems(entry.payload)}
|
{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>
|
<p className="chat-message-text">{String(entry.payload?.body || "")}</p>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
if (String(entry.payload?.message_kind || "") === "REQUEST_DATA") return null;
|
if (String(entry.payload?.message_kind || "") === "REQUEST_DATA") return null;
|
||||||
const messageId = String(entry.payload?.id || "").trim();
|
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>
|
</ul>
|
||||||
<form className="stack" onSubmit={onSendMessage}>
|
<form className="stack" onSubmit={onSendMessage}>
|
||||||
|
|
@ -1883,7 +2044,7 @@ export function RequestWorkspace({
|
||||||
)
|
)
|
||||||
.map((item) => (
|
.map((item) => (
|
||||||
<option key={item.code} value={item.code}>
|
<option key={item.code} value={item.code}>
|
||||||
{item.name + " (" + item.code + ")" + (item.groupName ? " • " + item.groupName : "")}
|
{item.name + (item.groupName ? " • " + item.groupName : "")}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -1965,7 +2126,7 @@ export function RequestWorkspace({
|
||||||
<span className="route-dot" />
|
<span className="route-dot" />
|
||||||
<div className="route-body">
|
<div className="route-body">
|
||||||
<div className="request-status-history-row">
|
<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}
|
{statusMeta?.isTerminal ? <span className="request-status-history-chip">Терминальный</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="muted route-time">{fmtShortDateTime(item?.changed_at)}</div>
|
<div className="muted route-time">{fmtShortDateTime(item?.changed_at)}</div>
|
||||||
|
|
@ -1975,7 +2136,7 @@ export function RequestWorkspace({
|
||||||
</div>
|
</div>
|
||||||
{item?.from_status ? (
|
{item?.from_status ? (
|
||||||
<div className="request-status-history-meta">
|
<div className="request-status-history-meta">
|
||||||
<span>{"Из: " + statusLabel(item.from_status)}</span>
|
<span>{"Из: " + resolveStatusDisplayName(item.from_status, "")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{String(item?.comment || "").trim() ? (
|
{String(item?.comment || "").trim() ? (
|
||||||
|
|
@ -2005,7 +2166,7 @@ export function RequestWorkspace({
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={"overlay" + (financeOpen ? " open" : "")}
|
className={"overlay" + (financeOpen ? " open" : "")}
|
||||||
onClick={() => setFinanceOpen(false)}
|
onClick={closeFinanceModal}
|
||||||
aria-hidden={financeOpen ? "false" : "true"}
|
aria-hidden={financeOpen ? "false" : "true"}
|
||||||
>
|
>
|
||||||
<div className="modal request-finance-modal" onClick={(event) => event.stopPropagation()}>
|
<div className="modal request-finance-modal" onClick={(event) => event.stopPropagation()}>
|
||||||
|
|
@ -2016,7 +2177,7 @@ export function RequestWorkspace({
|
||||||
{row?.track_number ? "Заявка " + String(row.track_number) : "Данные по заявке"}
|
{row?.track_number ? "Заявка " + String(row.track_number) : "Данные по заявке"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="close" type="button" onClick={() => setFinanceOpen(false)} aria-label="Закрыть">
|
<button className="close" type="button" onClick={closeFinanceModal} aria-label="Закрыть">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2040,6 +2201,112 @@ export function RequestWorkspace({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -2055,7 +2322,7 @@ export function RequestWorkspace({
|
||||||
<p className="muted request-finance-subtitle">
|
<p className="muted request-finance-subtitle">
|
||||||
{String(row?.topic_name || row?.topic_code || "Тема не указана")}
|
{String(row?.topic_name || row?.topic_code || "Тема не указана")}
|
||||||
</p>
|
</p>
|
||||||
<span className="request-description-status-chip">{statusLabel(row?.status_code)}</span>
|
<span className="request-description-status-chip">{currentStatusName}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="close" type="button" onClick={() => setDescriptionOpen(false)} aria-label="Закрыть">
|
<button className="close" type="button" onClick={() => setDescriptionOpen(false)} aria-label="Закрыть">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,16 @@
|
||||||
import { createRequestModalState } from "../shared/state.js";
|
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) {
|
async function buildStorageUploadError(response, fallbackMessage) {
|
||||||
const base = String(fallbackMessage || "Не удалось загрузить файл в хранилище");
|
const base = String(fallbackMessage || "Не удалось загрузить файл в хранилище");
|
||||||
|
|
@ -86,6 +98,7 @@ export function useRequestWorkspace(options) {
|
||||||
requestId,
|
requestId,
|
||||||
requestData: null,
|
requestData: null,
|
||||||
financeSummary: null,
|
financeSummary: null,
|
||||||
|
invoices: [],
|
||||||
statusRouteNodes: [],
|
statusRouteNodes: [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -102,7 +115,7 @@ export function useRequestWorkspace(options) {
|
||||||
api("/api/admin/requests/" + requestId + "/status-route").catch(() => ({ nodes: [] })),
|
api("/api/admin/requests/" + requestId + "/status-route").catch(() => ({ nodes: [] })),
|
||||||
api("/api/admin/invoices/query", {
|
api("/api/admin/invoices/query", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: buildUniversalQuery(requestFilter, [{ field: "paid_at", dir: "desc" }], 500, 0),
|
body: buildUniversalQuery(requestFilter, [{ field: "issued_at", dir: "desc" }], 500, 0),
|
||||||
}).catch(() => ({ rows: [] })),
|
}).catch(() => ({ rows: [] })),
|
||||||
]);
|
]);
|
||||||
const usersById = new Map(users.filter((user) => user && user.id).map((user) => [String(user.id), user]));
|
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;
|
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"
|
(item) => String(item?.status || "").toUpperCase() === "PAID"
|
||||||
);
|
);
|
||||||
const paidTotal = paidInvoices.reduce((acc, item) => {
|
const paidTotal = paidInvoices.reduce((acc, item) => {
|
||||||
|
|
@ -161,6 +175,7 @@ export function useRequestWorkspace(options) {
|
||||||
paid_total: Math.round((paidTotal + Number.EPSILON) * 100) / 100,
|
paid_total: Math.round((paidTotal + Number.EPSILON) * 100) / 100,
|
||||||
last_paid_at: latestPaidAt || rowData?.paid_at || null,
|
last_paid_at: latestPaidAt || rowData?.paid_at || null,
|
||||||
},
|
},
|
||||||
|
invoices,
|
||||||
statusRouteNodes: Array.isArray(statusRouteData?.nodes) ? statusRouteData.nodes : [],
|
statusRouteNodes: Array.isArray(statusRouteData?.nodes) ? statusRouteData.nodes : [],
|
||||||
statusHistory: Array.isArray(statusRouteData?.history) ? statusRouteData.history : [],
|
statusHistory: Array.isArray(statusRouteData?.history) ? statusRouteData.history : [],
|
||||||
availableStatuses: Array.isArray(statusRouteData?.available_statuses) ? statusRouteData.available_statuses : [],
|
availableStatuses: Array.isArray(statusRouteData?.available_statuses) ? statusRouteData.available_statuses : [],
|
||||||
|
|
@ -178,6 +193,7 @@ export function useRequestWorkspace(options) {
|
||||||
requestId,
|
requestId,
|
||||||
requestData: null,
|
requestData: null,
|
||||||
financeSummary: null,
|
financeSummary: null,
|
||||||
|
invoices: [],
|
||||||
statusRouteNodes: [],
|
statusRouteNodes: [],
|
||||||
statusHistory: [],
|
statusHistory: [],
|
||||||
availableStatuses: [],
|
availableStatuses: [],
|
||||||
|
|
@ -402,14 +418,23 @@ export function useRequestWorkspace(options) {
|
||||||
|
|
||||||
const attachedFiles = Array.isArray(files) ? files.filter(Boolean) : [];
|
const attachedFiles = Array.isArray(files) ? files.filter(Boolean) : [];
|
||||||
const commentText = String(comment || "").trim();
|
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;
|
let messageId = null;
|
||||||
const statusLine = "Смена статуса: " + String(result?.from_status || "—") + " -> " + String(result?.to_status || nextStatus);
|
const serviceMessageBody = serviceLines.filter(Boolean).join("\n").trim();
|
||||||
const messageBody = [statusLine, commentText].filter(Boolean).join("\n");
|
if (serviceMessageBody) {
|
||||||
if (messageBody) {
|
|
||||||
const message = await api("/api/admin/chat/requests/" + targetRequestId + "/messages", {
|
const message = await api("/api/admin/chat/requests/" + targetRequestId + "/messages", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { body: messageBody },
|
body: { body: serviceMessageBody },
|
||||||
});
|
});
|
||||||
messageId = String(message?.id || "").trim() || null;
|
messageId = String(message?.id || "").trim() || null;
|
||||||
}
|
}
|
||||||
|
|
@ -444,13 +469,57 @@ export function useRequestWorkspace(options) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof setStatus === "function") setStatus("requestModal", "Статус заявки обновлен", "ok");
|
if (typeof setStatus === "function") setStatus("requestModal", "Статус заявки обновлен", "ok");
|
||||||
await loadRequestModalData(targetRequestId, { showLoading: false });
|
await loadRequestModalData(targetRequestId, { showLoading: false });
|
||||||
return result;
|
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 {
|
return {
|
||||||
|
|
@ -475,6 +544,7 @@ export function useRequestWorkspace(options) {
|
||||||
loadRequestDataTemplateDetails,
|
loadRequestDataTemplateDetails,
|
||||||
saveRequestDataTemplate,
|
saveRequestDataTemplate,
|
||||||
saveRequestDataBatch,
|
saveRequestDataBatch,
|
||||||
|
issueRequestInvoice,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export function createRequestModalState() {
|
||||||
trackNumber: "",
|
trackNumber: "",
|
||||||
requestData: null,
|
requestData: null,
|
||||||
financeSummary: null,
|
financeSummary: null,
|
||||||
|
invoices: [],
|
||||||
statusRouteNodes: [],
|
statusRouteNodes: [],
|
||||||
statusHistory: [],
|
statusHistory: [],
|
||||||
availableStatuses: [],
|
availableStatuses: [],
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,7 @@ export function buildUniversalQuery(filters, sort, limit, offset) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canAccessSection(role, section) {
|
export function canAccessSection(role, section) {
|
||||||
|
const roleCode = String(role || "").toUpperCase();
|
||||||
const allowed = new Set([
|
const allowed = new Set([
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"kanban",
|
"kanban",
|
||||||
|
|
@ -282,7 +283,9 @@ export function canAccessSection(role, section) {
|
||||||
"availableTables",
|
"availableTables",
|
||||||
]);
|
]);
|
||||||
if (!allowed.has(section)) return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Страница клиента • Правовой трекер</title>
|
<title>Страница клиента • Правовой трекер</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
|
<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">
|
<link rel="stylesheet" href="/client.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="client-root"></div>
|
<div id="client-root"></div>
|
||||||
<script src="/vendor/react.production.min.js"></script>
|
<script src="/vendor/react.production.min.js"></script>
|
||||||
<script src="/vendor/react-dom.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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -566,6 +566,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
statusHistory: Array.isArray(statusRouteData?.history) ? statusRouteData.history : [],
|
statusHistory: Array.isArray(statusRouteData?.history) ? statusRouteData.history : [],
|
||||||
availableStatuses: [],
|
availableStatuses: [],
|
||||||
currentImportantDateAt: String(statusRouteData?.current_important_date_at || requestData?.important_date_at || ""),
|
currentImportantDateAt: String(statusRouteData?.current_important_date_at || requestData?.important_date_at || ""),
|
||||||
|
invoices,
|
||||||
messages: Array.isArray(messagesData) ? messagesData : [],
|
messages: Array.isArray(messagesData) ? messagesData : [],
|
||||||
attachments: Array.isArray(attachmentsData) ? attachmentsData : [],
|
attachments: Array.isArray(attachmentsData) ? attachmentsData : [],
|
||||||
fileUploading: false,
|
fileUploading: false,
|
||||||
|
|
@ -592,6 +593,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
requestData: null,
|
requestData: null,
|
||||||
trackNumber: "",
|
trackNumber: "",
|
||||||
financeSummary: null,
|
financeSummary: null,
|
||||||
|
invoices: [],
|
||||||
statusRouteNodes: [],
|
statusRouteNodes: [],
|
||||||
statusHistory: [],
|
statusHistory: [],
|
||||||
messages: [],
|
messages: [],
|
||||||
|
|
@ -689,6 +691,45 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
setRequestModal((prev) => ({ ...prev, selectedFiles: [] }));
|
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(
|
const submitMessage = useCallback(
|
||||||
async (event) => {
|
async (event) => {
|
||||||
if (event && typeof event.preventDefault === "function") event.preventDefault();
|
if (event && typeof event.preventDefault === "function") event.preventDefault();
|
||||||
|
|
@ -909,6 +950,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
}, [loadMyRequests, setPageStatus]);
|
}, [loadMyRequests, setPageStatus]);
|
||||||
|
|
||||||
const summary = requestModal.requestData || null;
|
const summary = requestModal.requestData || null;
|
||||||
|
const summaryStatusName = String(summary?.status_name || statusLabel(summary?.status_code) || "-");
|
||||||
const viewerFullName = useMemo(() => {
|
const viewerFullName = useMemo(() => {
|
||||||
const fullName = String(requestModal.requestData?.client_name || "").trim();
|
const fullName = String(requestModal.requestData?.client_name || "").trim();
|
||||||
return fullName || "Клиент";
|
return fullName || "Клиент";
|
||||||
|
|
@ -974,7 +1016,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
<div className="client-summary-row">
|
<div className="client-summary-row">
|
||||||
<div className="client-summary-chips">
|
<div className="client-summary-chips">
|
||||||
<span className="client-summary-chip client-summary-chip-status">
|
<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>
|
||||||
<span className="client-summary-chip client-summary-chip-topic">
|
<span className="client-summary-chip client-summary-chip-topic">
|
||||||
Тема: <span id="cabinet-request-topic">{summary ? String(summary.topic_name || summary.topic_code || "-") : "-"}</span>
|
Тема: <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}
|
trackNumber={requestModal.trackNumber}
|
||||||
requestData={requestModal.requestData}
|
requestData={requestModal.requestData}
|
||||||
financeSummary={requestModal.financeSummary}
|
financeSummary={requestModal.financeSummary}
|
||||||
|
invoices={requestModal.invoices || []}
|
||||||
statusRouteNodes={requestModal.statusRouteNodes || []}
|
statusRouteNodes={requestModal.statusRouteNodes || []}
|
||||||
statusHistory={requestModal.statusHistory || []}
|
statusHistory={requestModal.statusHistory || []}
|
||||||
availableStatuses={[]}
|
availableStatuses={[]}
|
||||||
|
|
@ -1018,6 +1061,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
onSaveRequestDataValues={saveRequestDataValues}
|
onSaveRequestDataValues={saveRequestDataValues}
|
||||||
onUploadRequestAttachment={uploadPublicRequestAttachment}
|
onUploadRequestAttachment={uploadPublicRequestAttachment}
|
||||||
onChangeStatus={() => Promise.resolve(null)}
|
onChangeStatus={() => Promise.resolve(null)}
|
||||||
|
onDownloadInvoicePdf={downloadPublicInvoicePdf}
|
||||||
onLiveProbe={probeLiveState}
|
onLiveProbe={probeLiveState}
|
||||||
onTypingSignal={setTypingSignal}
|
onTypingSignal={setTypingSignal}
|
||||||
AttachmentPreviewModalComponent={AttachmentPreviewModal}
|
AttachmentPreviewModalComponent={AttachmentPreviewModal}
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/landing.js?v=20260227-03"></script>
|
<script src="/landing.js?v=20260302-02"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
const requestForm = document.getElementById("request-form");
|
const requestForm = document.getElementById("request-form");
|
||||||
const requestStatus = document.getElementById("form-status");
|
const requestStatus = document.getElementById("form-status");
|
||||||
const topicSelect = document.getElementById("topic");
|
const topicSelect = document.getElementById("topic");
|
||||||
|
const requestPhoneInput = document.getElementById("phone");
|
||||||
|
|
||||||
const accessForm = document.getElementById("access-form");
|
const accessForm = document.getElementById("access-form");
|
||||||
const accessPhoneInput = document.getElementById("access-phone");
|
const accessPhoneInput = document.getElementById("access-phone");
|
||||||
|
|
@ -65,6 +66,62 @@
|
||||||
return String(value || "").trim().toLowerCase();
|
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() {
|
function currentAuthMode() {
|
||||||
return String(authConfig?.public_auth_mode || "sms").trim().toLowerCase();
|
return String(authConfig?.public_auth_mode || "sms").trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
@ -408,7 +465,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
accessSendOtpButton.addEventListener("click", async () => {
|
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 email = normalizeEmail(accessEmailInput?.value);
|
||||||
const hpField = String(accessHpInput?.value || "").trim();
|
const hpField = String(accessHpInput?.value || "").trim();
|
||||||
const channel = preferredChannel({ phone, email });
|
const channel = preferredChannel({ phone, email });
|
||||||
|
|
@ -424,6 +482,10 @@
|
||||||
setStatus(accessStatus, "Введите номер телефона.", "error");
|
setStatus(accessStatus, "Введите номер телефона.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (channel === "SMS" && !isValidRuPhone(phone)) {
|
||||||
|
setStatus(accessStatus, "Введите корректный номер телефона РФ в формате +7XXXXXXXXXX.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStatus(accessStatus, "Отправляем OTP-код...", null);
|
setStatus(accessStatus, "Отправляем OTP-код...", null);
|
||||||
|
|
@ -454,7 +516,8 @@
|
||||||
|
|
||||||
accessForm.addEventListener("submit", async (event) => {
|
accessForm.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
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 email = normalizeEmail(accessEmailInput?.value);
|
||||||
const code = String(accessCodeInput.value || "").trim();
|
const code = String(accessCodeInput.value || "").trim();
|
||||||
const channel = preferredChannel({ phone, email });
|
const channel = preferredChannel({ phone, email });
|
||||||
|
|
@ -466,6 +529,10 @@
|
||||||
setStatus(accessStatus, "Введите телефон и OTP-код.", "error");
|
setStatus(accessStatus, "Введите телефон и OTP-код.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (channel === "SMS" && !isValidRuPhone(phone)) {
|
||||||
|
setStatus(accessStatus, "Введите корректный номер телефона РФ в формате +7XXXXXXXXXX.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStatus(accessStatus, "Проверяем OTP...", null);
|
setStatus(accessStatus, "Проверяем OTP...", null);
|
||||||
|
|
@ -495,7 +562,7 @@
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
client_name: String(document.getElementById("name").value || "").trim(),
|
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),
|
client_email: normalizeEmail(requestEmailInput?.value),
|
||||||
pdn_consent: Boolean(document.getElementById("pdn-consent")?.checked),
|
pdn_consent: Boolean(document.getElementById("pdn-consent")?.checked),
|
||||||
hp_field: String(requestHpInput?.value || "").trim(),
|
hp_field: String(requestHpInput?.value || "").trim(),
|
||||||
|
|
@ -513,6 +580,11 @@
|
||||||
setStatus(requestStatus, "Введите телефон для получения OTP.", "error");
|
setStatus(requestStatus, "Введите телефон для получения OTP.", "error");
|
||||||
return;
|
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) {
|
if (!payload.client_name || !payload.topic_code) {
|
||||||
setStatus(requestStatus, "Заполните имя и тему обращения.", "error");
|
setStatus(requestStatus, "Заполните имя и тему обращения.", "error");
|
||||||
return;
|
return;
|
||||||
|
|
@ -587,6 +659,8 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
loadAuthConfig();
|
loadAuthConfig();
|
||||||
|
bindRuPhoneMask(requestPhoneInput);
|
||||||
|
bindRuPhoneMask(accessPhoneInput);
|
||||||
loadTopics();
|
loadTopics();
|
||||||
loadQuotes();
|
loadQuotes();
|
||||||
loadFeaturedStaff();
|
loadFeaturedStaff();
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,4 @@ httpx==0.27.2
|
||||||
python-multipart==0.0.22
|
python-multipart==0.0.22
|
||||||
smsaero-api-async
|
smsaero-api-async
|
||||||
Pillow==11.2.1
|
Pillow==11.2.1
|
||||||
|
reportlab==4.2.2
|
||||||
|
|
|
||||||
|
|
@ -484,3 +484,63 @@ class AdminAssignmentAndUsersTests(AdminUniversalCrudBase):
|
||||||
|
|
||||||
deleted = self.client.delete(f"/api/admin/crud/admin_users/{user_id}", headers=headers)
|
deleted = self.client.delete(f"/api/admin/crud/admin_users/{user_id}", headers=headers)
|
||||||
self.assertEqual(deleted.status_code, 200)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -523,6 +523,105 @@ class AdminStatusFlowKanbanTests(AdminUniversalCrudBase):
|
||||||
outsider_forbidden = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=outsider_headers)
|
outsider_forbidden = self.client.get(f"/api/admin/requests/{request_id}/status-route", headers=outsider_headers)
|
||||||
self.assertEqual(outsider_forbidden.status_code, 403)
|
self.assertEqual(outsider_forbidden.status_code, 403)
|
||||||
|
|
||||||
|
def test_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):
|
def test_requests_kanban_returns_grouped_cards_and_role_scope(self):
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
group_new = StatusGroup(name="Новые", sort_order=10)
|
group_new = StatusGroup(name="Новые", sort_order=10)
|
||||||
|
|
@ -637,6 +736,8 @@ class AdminStatusFlowKanbanTests(AdminUniversalCrudBase):
|
||||||
description="Заявка в работе",
|
description="Заявка в работе",
|
||||||
extra_fields={"deadline_at": "2031-01-01T10:00:00+00:00"},
|
extra_fields={"deadline_at": "2031-01-01T10:00:00+00:00"},
|
||||||
assigned_lawyer_id=str(lawyer_main.id),
|
assigned_lawyer_id=str(lawyer_main.id),
|
||||||
|
lawyer_has_unread_updates=True,
|
||||||
|
lawyer_unread_event_type="MESSAGE",
|
||||||
)
|
)
|
||||||
request_waiting = Request(
|
request_waiting = Request(
|
||||||
track_number="TRK-KANBAN-WAITING",
|
track_number="TRK-KANBAN-WAITING",
|
||||||
|
|
@ -657,6 +758,7 @@ class AdminStatusFlowKanbanTests(AdminUniversalCrudBase):
|
||||||
description="Просроченная заявка",
|
description="Просроченная заявка",
|
||||||
extra_fields={},
|
extra_fields={},
|
||||||
assigned_lawyer_id=str(lawyer_main.id),
|
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.add_all([request_new, request_progress, request_waiting, request_overdue])
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
@ -683,6 +785,17 @@ class AdminStatusFlowKanbanTests(AdminUniversalCrudBase):
|
||||||
created_at=entered_overdue_at,
|
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()
|
db.commit()
|
||||||
|
|
||||||
request_new_id = str(request_new.id)
|
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 [])}
|
overdue_rows = {item["id"] for item in (filtered_overdue.json().get("rows") or [])}
|
||||||
self.assertEqual(overdue_rows, {request_overdue_id})
|
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(
|
sorted_by_deadline = self.client.get(
|
||||||
"/api/admin/requests/kanban",
|
"/api/admin/requests/kanban",
|
||||||
headers=admin_headers,
|
headers=admin_headers,
|
||||||
|
|
@ -759,4 +896,3 @@ class AdminStatusFlowKanbanTests(AdminUniversalCrudBase):
|
||||||
sorted_rows = sorted_by_deadline.json().get("rows") or []
|
sorted_rows = sorted_by_deadline.json().get("rows") or []
|
||||||
self.assertTrue(sorted_rows)
|
self.assertTrue(sorted_rows)
|
||||||
self.assertEqual(sorted_rows[0]["id"], request_overdue_id)
|
self.assertEqual(sorted_rows[0]["id"], request_overdue_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import os
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine, delete
|
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.request import Request
|
||||||
from app.models.status import Status
|
from app.models.status import Status
|
||||||
from app.models.status_history import StatusHistory
|
from app.models.status_history import StatusHistory
|
||||||
|
from app.models.topic_status_transition import TopicStatusTransition
|
||||||
from app.services.invoice_crypto import decrypt_requisites
|
from app.services.invoice_crypto import decrypt_requisites
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeS3Storage:
|
||||||
|
def __init__(self):
|
||||||
|
self.objects = {}
|
||||||
|
|
||||||
|
|
||||||
class BillingFlowTests(unittest.TestCase):
|
class BillingFlowTests(unittest.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
|
|
@ -48,10 +55,12 @@ class BillingFlowTests(unittest.TestCase):
|
||||||
StatusHistory.__table__.create(bind=cls.engine)
|
StatusHistory.__table__.create(bind=cls.engine)
|
||||||
Notification.__table__.create(bind=cls.engine)
|
Notification.__table__.create(bind=cls.engine)
|
||||||
Invoice.__table__.create(bind=cls.engine)
|
Invoice.__table__.create(bind=cls.engine)
|
||||||
|
TopicStatusTransition.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def tearDownClass(cls):
|
def tearDownClass(cls):
|
||||||
Invoice.__table__.drop(bind=cls.engine)
|
Invoice.__table__.drop(bind=cls.engine)
|
||||||
|
TopicStatusTransition.__table__.drop(bind=cls.engine)
|
||||||
Notification.__table__.drop(bind=cls.engine)
|
Notification.__table__.drop(bind=cls.engine)
|
||||||
StatusHistory.__table__.drop(bind=cls.engine)
|
StatusHistory.__table__.drop(bind=cls.engine)
|
||||||
Attachment.__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(Invoice))
|
||||||
db.execute(delete(Notification))
|
db.execute(delete(Notification))
|
||||||
db.execute(delete(StatusHistory))
|
db.execute(delete(StatusHistory))
|
||||||
|
db.execute(delete(TopicStatusTransition))
|
||||||
db.execute(delete(Attachment))
|
db.execute(delete(Attachment))
|
||||||
db.execute(delete(Message))
|
db.execute(delete(Message))
|
||||||
db.execute(delete(Request))
|
db.execute(delete(Request))
|
||||||
|
|
@ -82,9 +92,13 @@ class BillingFlowTests(unittest.TestCase):
|
||||||
|
|
||||||
app.dependency_overrides[get_db] = override_get_db
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
self.client = TestClient(app)
|
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):
|
def tearDown(self):
|
||||||
self.client.close()
|
self.client.close()
|
||||||
|
self.s3_patch.stop()
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -158,6 +172,76 @@ class BillingFlowTests(unittest.TestCase):
|
||||||
self.assertIn("TRK-BILL-1", rendered)
|
self.assertIn("TRK-BILL-1", rendered)
|
||||||
self.assertIn("ООО Клиент", 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):
|
def test_paid_status_requires_admin_and_marks_waiting_invoice_paid(self):
|
||||||
self._seed_statuses()
|
self._seed_statuses()
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,9 @@ class DashboardFinanceTests(unittest.TestCase):
|
||||||
|
|
||||||
def test_admin_dashboard_contains_lawyer_financial_metrics(self):
|
def test_admin_dashboard_contains_lawyer_financial_metrics(self):
|
||||||
now = datetime.now(timezone.utc)
|
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:
|
with self.SessionLocal() as db:
|
||||||
db.add_all(
|
db.add_all(
|
||||||
[
|
[
|
||||||
|
|
@ -190,8 +192,8 @@ class DashboardFinanceTests(unittest.TestCase):
|
||||||
from_status="INVOICE",
|
from_status="INVOICE",
|
||||||
to_status="PAID",
|
to_status="PAID",
|
||||||
changed_by_admin_id=None,
|
changed_by_admin_id=None,
|
||||||
created_at=now - timedelta(days=40),
|
created_at=previous_month_event,
|
||||||
updated_at=now - timedelta(days=40),
|
updated_at=previous_month_event,
|
||||||
),
|
),
|
||||||
StatusHistory(
|
StatusHistory(
|
||||||
request_id=req_a_closed.id,
|
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):
|
def test_admin_can_get_lawyer_active_requests_dashboard_detail(self):
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
month_start = datetime(now.year, now.month, 1, tzinfo=timezone.utc)
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
db.add_all(
|
db.add_all(
|
||||||
[
|
[
|
||||||
|
|
@ -312,8 +315,8 @@ class DashboardFinanceTests(unittest.TestCase):
|
||||||
from_status="INVOICE",
|
from_status="INVOICE",
|
||||||
to_status="PAID",
|
to_status="PAID",
|
||||||
changed_by_admin_id=None,
|
changed_by_admin_id=None,
|
||||||
created_at=now - timedelta(days=1),
|
created_at=month_start + timedelta(hours=6),
|
||||||
updated_at=now - timedelta(days=1),
|
updated_at=month_start + timedelta(hours=6),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import timedelta
|
from datetime import timedelta, datetime, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine, delete
|
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.db.session import get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models.admin_user import AdminUser
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.attachment import Attachment
|
||||||
from app.models.invoice import Invoice
|
from app.models.invoice import Invoice
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.notification import Notification
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.services.invoice_crypto import decrypt_requisites
|
from app.services.invoice_crypto import decrypt_requisites
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeS3Storage:
|
||||||
|
def __init__(self):
|
||||||
|
self.objects = {}
|
||||||
|
|
||||||
|
|
||||||
class InvoiceApiTests(unittest.TestCase):
|
class InvoiceApiTests(unittest.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
|
|
@ -37,11 +46,17 @@ class InvoiceApiTests(unittest.TestCase):
|
||||||
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
AdminUser.__table__.create(bind=cls.engine)
|
AdminUser.__table__.create(bind=cls.engine)
|
||||||
Request.__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)
|
Invoice.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def tearDownClass(cls):
|
def tearDownClass(cls):
|
||||||
Invoice.__table__.drop(bind=cls.engine)
|
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)
|
Request.__table__.drop(bind=cls.engine)
|
||||||
AdminUser.__table__.drop(bind=cls.engine)
|
AdminUser.__table__.drop(bind=cls.engine)
|
||||||
cls.engine.dispose()
|
cls.engine.dispose()
|
||||||
|
|
@ -49,6 +64,9 @@ class InvoiceApiTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
db.execute(delete(Invoice))
|
db.execute(delete(Invoice))
|
||||||
|
db.execute(delete(Attachment))
|
||||||
|
db.execute(delete(Message))
|
||||||
|
db.execute(delete(Notification))
|
||||||
db.execute(delete(Request))
|
db.execute(delete(Request))
|
||||||
db.execute(delete(AdminUser))
|
db.execute(delete(AdminUser))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -115,9 +133,13 @@ class InvoiceApiTests(unittest.TestCase):
|
||||||
|
|
||||||
app.dependency_overrides[get_db] = override_get_db
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
self.client = TestClient(app)
|
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):
|
def tearDown(self):
|
||||||
self.client.close()
|
self.client.close()
|
||||||
|
self.s3_patch.stop()
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -154,7 +176,8 @@ class InvoiceApiTests(unittest.TestCase):
|
||||||
self.assertEqual(body["request_track_number"], "TRK-INV-A")
|
self.assertEqual(body["request_track_number"], "TRK-INV-A")
|
||||||
self.assertEqual(body["status"], "WAITING_PAYMENT")
|
self.assertEqual(body["status"], "WAITING_PAYMENT")
|
||||||
self.assertEqual(body["amount"], 12345.67)
|
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"]
|
invoice_id = body["id"]
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
|
|
@ -165,6 +188,23 @@ class InvoiceApiTests(unittest.TestCase):
|
||||||
decrypted = decrypt_requisites(row.payer_details_encrypted)
|
decrypted = decrypt_requisites(row.payer_details_encrypted)
|
||||||
self.assertEqual(decrypted["inn"], "7700000000")
|
self.assertEqual(decrypted["inn"], "7700000000")
|
||||||
self.assertEqual(decrypted["kpp"], "770001001")
|
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):
|
def test_lawyer_scope_and_paid_restriction(self):
|
||||||
admin_headers = self._admin_headers(self.admin_id, "ADMIN", "admin@example.com")
|
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",
|
f"/api/public/requests/TRK-INV-A/invoices/{invoice_id}/pdf",
|
||||||
cookies=self._public_cookie("TRK-INV-B"),
|
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")
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ class MigrationTests(unittest.TestCase):
|
||||||
def test_alembic_version_is_set(self):
|
def test_alembic_version_is_set(self):
|
||||||
with self.engine.connect() as conn:
|
with self.engine.connect() as conn:
|
||||||
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
||||||
self.assertEqual(version, "0031_pii_retention_and_consent")
|
self.assertEqual(version, "0032_email_cols_fix")
|
||||||
|
|
||||||
def test_responsible_column_exists_in_all_domain_tables(self):
|
def test_responsible_column_exists_in_all_domain_tables(self):
|
||||||
tables = {
|
tables = {
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,11 @@ os.environ.setdefault("S3_BUCKET", "test")
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import create_jwt
|
from app.core.security import create_jwt
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.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.admin_user import AdminUser
|
||||||
from app.models.attachment import Attachment
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.notification import Notification
|
from app.models.notification import Notification
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
|
|
@ -58,6 +60,7 @@ class NotificationFlowTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
AdminUser.__table__.create(bind=cls.engine)
|
AdminUser.__table__.create(bind=cls.engine)
|
||||||
|
AuditLog.__table__.create(bind=cls.engine)
|
||||||
Request.__table__.create(bind=cls.engine)
|
Request.__table__.create(bind=cls.engine)
|
||||||
Message.__table__.create(bind=cls.engine)
|
Message.__table__.create(bind=cls.engine)
|
||||||
Attachment.__table__.create(bind=cls.engine)
|
Attachment.__table__.create(bind=cls.engine)
|
||||||
|
|
@ -73,6 +76,7 @@ class NotificationFlowTests(unittest.TestCase):
|
||||||
Attachment.__table__.drop(bind=cls.engine)
|
Attachment.__table__.drop(bind=cls.engine)
|
||||||
Message.__table__.drop(bind=cls.engine)
|
Message.__table__.drop(bind=cls.engine)
|
||||||
Request.__table__.drop(bind=cls.engine)
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
AuditLog.__table__.drop(bind=cls.engine)
|
||||||
AdminUser.__table__.drop(bind=cls.engine)
|
AdminUser.__table__.drop(bind=cls.engine)
|
||||||
cls.engine.dispose()
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
|
@ -84,6 +88,7 @@ class NotificationFlowTests(unittest.TestCase):
|
||||||
db.execute(delete(Attachment))
|
db.execute(delete(Attachment))
|
||||||
db.execute(delete(Message))
|
db.execute(delete(Message))
|
||||||
db.execute(delete(Request))
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(AuditLog))
|
||||||
db.execute(delete(AdminUser))
|
db.execute(delete(AdminUser))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
@ -94,12 +99,16 @@ class NotificationFlowTests(unittest.TestCase):
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
app.dependency_overrides[get_db] = override_get_db
|
main_app.dependency_overrides[get_db] = override_get_db
|
||||||
self.client = TestClient(app)
|
chat_app.dependency_overrides[get_db] = override_get_db
|
||||||
|
self.client = TestClient(main_app)
|
||||||
|
self.chat_client = TestClient(chat_app)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
self.chat_client.close()
|
||||||
self.client.close()
|
self.client.close()
|
||||||
app.dependency_overrides.clear()
|
chat_app.dependency_overrides.clear()
|
||||||
|
main_app.dependency_overrides.clear()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _admin_headers(sub: str, role: str, email: str) -> dict[str, str]:
|
def _admin_headers(sub: str, role: str, email: str) -> dict[str, str]:
|
||||||
|
|
@ -140,8 +149,8 @@ class NotificationFlowTests(unittest.TestCase):
|
||||||
db.commit()
|
db.commit()
|
||||||
lawyer_id = str(lawyer.id)
|
lawyer_id = str(lawyer.id)
|
||||||
|
|
||||||
created = self.client.post(
|
created = self.chat_client.post(
|
||||||
"/api/public/requests/TRK-NOTIF-MSG/messages",
|
"/api/public/chat/requests/TRK-NOTIF-MSG/messages",
|
||||||
cookies=self._public_cookies("TRK-NOTIF-MSG"),
|
cookies=self._public_cookies("TRK-NOTIF-MSG"),
|
||||||
json={"body": "Есть новое сообщение"},
|
json={"body": "Есть новое сообщение"},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,7 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
cookies = self._public_cookies("TRK-CAB-001")
|
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(messages.status_code, 200)
|
||||||
self.assertEqual(len(messages.json()), 1)
|
self.assertEqual(len(messages.json()), 1)
|
||||||
self.assertEqual(messages.json()[0]["author_type"], "LAWYER")
|
self.assertEqual(messages.json()[0]["author_type"], "LAWYER")
|
||||||
|
|
@ -201,12 +201,15 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
request_id = req.id
|
request_id = req.id
|
||||||
|
|
||||||
cookies = self._public_cookies("TRK-CAB-MSG")
|
cookies = self._public_cookies("TRK-CAB-MSG")
|
||||||
created = self.client.post(
|
created = self.chat_client.post(
|
||||||
"/api/public/requests/TRK-CAB-MSG/messages",
|
"/api/public/chat/requests/TRK-CAB-MSG/messages",
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
json={"body": "Добрый день, есть вопрос по документам."},
|
json={"body": "Добрый день, есть вопрос по документам."},
|
||||||
)
|
)
|
||||||
self.assertEqual(created.status_code, 201)
|
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"])
|
message_id = UUID(created.json()["id"])
|
||||||
|
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
|
|
@ -221,6 +224,31 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
self.assertTrue(req.lawyer_has_unread_updates)
|
self.assertTrue(req.lawyer_has_unread_updates)
|
||||||
self.assertEqual(req.lawyer_unread_event_type, "MESSAGE")
|
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):
|
def test_public_chat_service_endpoints_work_for_authorized_client(self):
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
req = Request(
|
req = Request(
|
||||||
|
|
@ -250,7 +278,7 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
self.assertIn("выделенный сервис", listed.json()[0]["body"])
|
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"))
|
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):
|
def test_chat_message_is_encrypted_at_rest(self):
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
|
|
@ -460,8 +488,8 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
cookies = self._public_cookies("TRK-OTHER")
|
cookies = self._public_cookies("TRK-OTHER")
|
||||||
denied = self.client.get("/api/public/requests/TRK-REAL/messages", cookies=cookies)
|
denied = self.chat_client.get("/api/public/chat/requests/TRK-REAL/messages", cookies=cookies)
|
||||||
self.assertEqual(denied.status_code, 403)
|
self.assertEqual(denied.status_code, 404)
|
||||||
|
|
||||||
def test_public_attachment_download_requires_access(self):
|
def test_public_attachment_download_requires_access(self):
|
||||||
fake_s3 = _FakeS3Storage()
|
fake_s3 = _FakeS3Storage()
|
||||||
|
|
@ -508,7 +536,7 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
f"/api/public/uploads/object/{attachment_id}",
|
f"/api/public/uploads/object/{attachment_id}",
|
||||||
cookies=self._public_cookies("TRK-OTHER"),
|
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):
|
def test_public_upload_complete_links_attachment_to_message_when_message_id_provided(self):
|
||||||
fake_s3 = _FakeS3Storage()
|
fake_s3 = _FakeS3Storage()
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
self.assertEqual(ok.json()["track_number"], track_number)
|
self.assertEqual(ok.json()["track_number"], track_number)
|
||||||
|
|
||||||
denied_other_track = self.client.get("/api/public/requests/TRK-OTHER")
|
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):
|
def test_otp_send_rejects_honeypot_field(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
|
|
@ -321,7 +321,7 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
self.assertEqual(opened.json()["track_number"], "TRK-MULTI-2")
|
self.assertEqual(opened.json()["track_number"], "TRK-MULTI-2")
|
||||||
|
|
||||||
denied = self.client.get("/api/public/requests/TRK-FOREIGN-1")
|
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):
|
def test_email_auth_mode_allows_create_flow_via_email_otp(self):
|
||||||
phone = self._unique_phone()
|
phone = self._unique_phone()
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ class SmsProviderHealthTests(unittest.TestCase):
|
||||||
"SMSAERO_API_KEY": settings.SMSAERO_API_KEY,
|
"SMSAERO_API_KEY": settings.SMSAERO_API_KEY,
|
||||||
"OTP_DEV_MODE": settings.OTP_DEV_MODE,
|
"OTP_DEV_MODE": settings.OTP_DEV_MODE,
|
||||||
}
|
}
|
||||||
|
settings.OTP_DEV_MODE = False
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.client.close()
|
self.client.close()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue