diff --git a/Dockerfile b/Dockerfile index 5842a81..5d92ed3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,14 @@ FROM python:3.12-slim WORKDIR /app -RUN apt-get update && apt-get install -y build-essential curl openssl ca-certificates && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y \ + build-essential \ + curl \ + openssl \ + ca-certificates \ + fontconfig \ + fonts-dejavu-core \ + fonts-liberation \ + && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . diff --git a/app/api/admin/crud_modules/access.py b/app/api/admin/crud_modules/access.py index 1003d25..a6d6683 100644 --- a/app/api/admin/crud_modules/access.py +++ b/app/api/admin/crud_modules/access.py @@ -56,7 +56,10 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = { "audit_log": {"ADMIN": {"query", "read"}}, "security_audit_log": {"ADMIN": {"query", "read"}}, "otp_sessions": {"ADMIN": {"query", "read"}}, - "admin_users": {"ADMIN": set(CRUD_ACTIONS)}, + "admin_users": { + "ADMIN": set(CRUD_ACTIONS), + "LAWYER": {"read", "update"}, + }, "admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)}, "landing_featured_staff": {"ADMIN": set(CRUD_ACTIONS)}, "topic_status_transitions": {"ADMIN": set(CRUD_ACTIONS)}, diff --git a/app/api/admin/crud_modules/service.py b/app/api/admin/crud_modules/service.py index a7b73da..09e4f26 100644 --- a/app/api/admin/crud_modules/service.py +++ b/app/api/admin/crud_modules/service.py @@ -82,6 +82,15 @@ from .payloads import ( ) +def _ensure_lawyer_owns_admin_user_row_or_403(admin: dict, row_id: str) -> None: + if not _is_lawyer(admin): + return + actor_id = _lawyer_actor_id_or_401(admin).strip().lower() + target_id = str(row_id or "").strip().lower() + if not actor_id or not target_id or actor_id != target_id: + raise HTTPException(status_code=403, detail="Недостаточно прав") + + def _apply_create_side_effects(db: Session, *, table_name: str, row: Any, admin: dict) -> None: if table_name == "messages" and isinstance(row, Message): req = db.get(Request, row.request_id) @@ -254,6 +263,8 @@ def query_table_service(table_name: str, uq: UniversalQuery, db: Session, admin: def get_row_service(table_name: str, row_id: str, db: Session, admin: dict) -> dict[str, Any]: normalized, model = _resolve_table_model(table_name) _require_table_action(admin, normalized, "read") + if normalized == "admin_users": + _ensure_lawyer_owns_admin_user_row_or_403(admin, row_id) row = _load_row_or_404(db, model, row_id) if normalized == "requests": req = row if isinstance(row, Request) else None @@ -408,6 +419,12 @@ def update_row_service(table_name: str, row_id: str, payload: dict[str, Any], db normalized, model = _resolve_table_model(table_name) _require_table_action(admin, normalized, "update") responsible = _resolve_responsible(admin) + if normalized == "admin_users" and _is_lawyer(admin): + _ensure_lawyer_owns_admin_user_row_or_403(admin, row_id) + allowed_fields = {"name", "email", "phone", "password", "avatar_url"} + forbidden_fields = sorted(set(payload.keys()) - allowed_fields) + if forbidden_fields: + raise HTTPException(status_code=403, detail="Недостаточно прав") if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict): if "assigned_lawyer_id" in payload: raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"') diff --git a/app/api/admin/invoices.py b/app/api/admin/invoices.py index 3344124..3a36e51 100644 --- a/app/api/admin/invoices.py +++ b/app/api/admin/invoices.py @@ -3,7 +3,7 @@ from __future__ import annotations import json from datetime import datetime, timezone from decimal import Decimal -from uuid import UUID, uuid4 +from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest from fastapi.responses import StreamingResponse @@ -16,7 +16,9 @@ from app.models.admin_user import AdminUser from app.models.invoice import Invoice from app.models.request import Request from app.schemas.universal import UniversalQuery +from app.services.invoice_chat import create_invoice_chat_message_with_attachment from app.services.invoice_crypto import decrypt_requisites, encrypt_requisites +from app.services.invoice_numbering import generate_invoice_number from app.services.invoice_pdf import build_invoice_pdf_bytes from app.services.security_audit import extract_client_ip, record_pii_access_event from app.services.universal_query import apply_universal_query @@ -90,15 +92,6 @@ def _now_utc() -> datetime: return datetime.now(timezone.utc) -def _invoice_number(db: Session) -> str: - prefix = _now_utc().strftime("%Y%m%d") - candidate = f"INV-{prefix}-{uuid4().hex[:8].upper()}" - exists = db.query(Invoice.id).filter(Invoice.invoice_number == candidate).first() - if exists is None: - return candidate - return f"INV-{prefix}-{uuid4().hex[:12].upper()}" - - def _parse_requisites(raw) -> dict: if raw is None: return {} @@ -290,6 +283,8 @@ def create_invoice( role = str(admin.get("role") or "").upper() actor_id = _actor_uuid_or_401(admin) actor_email = str(admin.get("email") or "").strip() or "Администратор системы" + actor_user = db.get(AdminUser, actor_id) + actor_name = str(actor_user.name if actor_user else "").strip() or str(actor_user.email if actor_user else "").strip() or actor_email req = _request_from_payload_or_404(db, payload) _ensure_lawyer_owns_request_or_403(role, actor_id, req) @@ -302,10 +297,11 @@ def create_invoice( if not payer_display_name: raise HTTPException(status_code=400, detail='Поле "payer_display_name" обязательно') + issued_at = _now_utc() invoice = Invoice( request_id=req.id, client_id=req.client_id, - invoice_number=str(payload.get("invoice_number") or "").strip() or _invoice_number(db), + invoice_number=str(payload.get("invoice_number") or "").strip() or generate_invoice_number(db, issued_at), status=status, amount=_amount_or_400(payload.get("amount")), currency=_normalize_currency(payload.get("currency")), @@ -313,7 +309,7 @@ def create_invoice( payer_details_encrypted=encrypt_requisites(_parse_requisites(payload.get("payer_details"))), issued_by_admin_user_id=actor_id, issued_by_role=role, - issued_at=_now_utc(), + issued_at=issued_at, paid_at=None, responsible=actor_email, ) @@ -327,6 +323,15 @@ def create_invoice( db.add(invoice) db.add(req) + create_invoice_chat_message_with_attachment( + db, + request=req, + invoice=invoice, + actor_role=role, + actor_name=actor_name, + actor_admin_user_id=str(actor_id), + responsible=actor_email, + ) _commit_or_400(db, "Счет с таким номером уже существует") db.refresh(invoice) diff --git a/app/api/admin/requests_modules/kanban.py b/app/api/admin/requests_modules/kanban.py index 5e5a31c..e767708 100644 --- a/app/api/admin/requests_modules/kanban.py +++ b/app/api/admin/requests_modules/kanban.py @@ -10,6 +10,7 @@ from sqlalchemy import or_ from sqlalchemy.orm import Session from app.models.admin_user import AdminUser +from app.models.notification import Notification from app.models.request import Request from app.models.status import Status from app.models.status_group import StatusGroup @@ -20,8 +21,18 @@ from app.services.universal_query import apply_universal_query from .common import parse_datetime_safe -ALLOWED_KANBAN_FILTER_FIELDS = {"assigned_lawyer_id", "client_name", "status_code", "created_at", "topic_code", "overdue"} +ALLOWED_KANBAN_FILTER_FIELDS = { + "assigned_lawyer_id", + "client_name", + "status_code", + "created_at", + "topic_code", + "overdue", + "has_unread_updates", + "deadline_alert", +} ALLOWED_KANBAN_SORT_MODES = {"created_newest", "lawyer", "deadline"} +BOOLEAN_KANBAN_FILTER_FIELDS = {"overdue", "has_unread_updates", "deadline_alert"} FALLBACK_KANBAN_GROUPS = [ ("fallback_new", "Новые", 10), ("fallback_in_progress", "В работе", 20), @@ -86,7 +97,7 @@ def extract_case_deadline(extra_fields: object) -> datetime | None: return None -def coerce_kanban_bool(value: object) -> bool: +def coerce_kanban_bool(value: object, field_name: str) -> bool: if isinstance(value, bool): return value text = str(value or "").strip().lower() @@ -94,10 +105,10 @@ def coerce_kanban_bool(value: object) -> bool: return True if text in {"0", "false", "no", "n", "off"}: return False - raise HTTPException(status_code=400, detail='Поле "overdue" должно быть boolean') + raise HTTPException(status_code=400, detail=f'Поле "{field_name}" должно быть boolean') -def parse_kanban_filters_or_400(raw_filters: str | None) -> tuple[list[FilterClause], list[tuple[str, bool]]]: +def parse_kanban_filters_or_400(raw_filters: str | None) -> tuple[list[FilterClause], list[tuple[str, str, bool]]]: if not raw_filters: return [], [] try: @@ -108,7 +119,7 @@ def parse_kanban_filters_or_400(raw_filters: str | None) -> tuple[list[FilterCla raise HTTPException(status_code=400, detail="Фильтры канбана должны быть массивом") universal_filters: list[FilterClause] = [] - overdue_filters: list[tuple[str, bool]] = [] + boolean_filters: list[tuple[str, str, bool]] = [] for index, item in enumerate(parsed): if not isinstance(item, dict): raise HTTPException(status_code=400, detail=f"Фильтр #{index + 1} должен быть объектом") @@ -119,30 +130,40 @@ def parse_kanban_filters_or_400(raw_filters: str | None) -> tuple[list[FilterCla raise HTTPException(status_code=400, detail=f'Недоступное поле фильтра: "{field}"') if op not in {"=", "!=", ">", "<", ">=", "<=", "~"}: raise HTTPException(status_code=400, detail=f'Недопустимый оператор фильтра: "{op}"') - if field == "overdue": + if field in BOOLEAN_KANBAN_FILTER_FIELDS: if op not in {"=", "!="}: - raise HTTPException(status_code=400, detail='Для поля "overdue" доступны только операторы "=" и "!="') - overdue_filters.append((op, coerce_kanban_bool(value))) + raise HTTPException(status_code=400, detail=f'Для поля "{field}" доступны только операторы "=" и "!="') + boolean_filters.append((field, op, coerce_kanban_bool(value, field))) continue universal_filters.append(FilterClause(field=field, op=op, value=value)) - return universal_filters, overdue_filters + return universal_filters, boolean_filters -def apply_overdue_filters(items: list[dict[str, object]], overdue_filters: list[tuple[str, bool]]) -> list[dict[str, object]]: - if not overdue_filters: +def apply_boolean_kanban_filters( + items: list[dict[str, object]], + boolean_filters: list[tuple[str, str, bool]], +) -> list[dict[str, object]]: + if not boolean_filters: return items now = datetime.now(timezone.utc) out: list[dict[str, object]] = [] for item in items: - raw_deadline = item.get("sla_deadline_at") or item.get("case_deadline_at") - deadline_at = parse_datetime_safe(raw_deadline) - is_overdue = bool(deadline_at and deadline_at <= now) ok = True - for op, expected in overdue_filters: + for field, op, expected in boolean_filters: + if field == "overdue": + raw_deadline = item.get("sla_deadline_at") or item.get("case_deadline_at") + deadline_at = parse_datetime_safe(raw_deadline) + actual = bool(deadline_at and deadline_at <= now) + elif field == "has_unread_updates": + actual = bool(item.get("has_unread_updates")) + elif field == "deadline_alert": + actual = bool(item.get("deadline_alert")) + else: + actual = False if op == "=": - ok = ok and (is_overdue == expected) + ok = ok and (actual == expected) elif op == "!=": - ok = ok and (is_overdue != expected) + ok = ok and (actual != expected) if not ok: break if ok: @@ -204,7 +225,7 @@ def get_requests_kanban_service( ) normalized_sort_mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest" - query_filters, overdue_filters = parse_kanban_filters_or_400(filters) + query_filters, boolean_filters = parse_kanban_filters_or_400(filters) if query_filters: base_query = apply_universal_query( base_query, @@ -220,7 +241,33 @@ def get_requests_kanban_service( request_id_to_row = {str(row.id): row for row in request_rows} request_ids = [row.id for row in request_rows] + unread_notification_request_ids: set[str] = set() + actor_uuid = None + if actor: + try: + actor_uuid = UUID(actor) + except ValueError: + actor_uuid = None + if actor_uuid is not None and request_ids: + unread_notification_rows = ( + db.query(Notification.request_id) + .filter( + Notification.recipient_type == "ADMIN_USER", + Notification.recipient_admin_user_id == actor_uuid, + Notification.is_read.is_(False), + Notification.request_id.is_not(None), + Notification.request_id.in_(request_ids), + ) + .all() + ) + unread_notification_request_ids = { + str(notification_request_id) + for (notification_request_id,) in unread_notification_rows + if notification_request_id is not None + } status_codes = {str(row.status_code or "").strip() for row in request_rows if str(row.status_code or "").strip()} + now_utc = datetime.now(timezone.utc) + next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1) status_meta_map: dict[str, dict[str, object]] = {} if status_codes: @@ -448,9 +495,21 @@ def get_requests_kanban_service( sla_deadline = entered_at + timedelta(hours=int(transition_rule.sla_hours)) assigned_id = str(row.assigned_lawyer_id or "").strip() or None + request_id = str(row.id) + status_is_terminal = bool(status_meta.get("is_terminal")) + has_actor_unread_notification = request_id in unread_notification_request_ids + has_unread_updates = bool(row.lawyer_has_unread_updates) + if role != "LAWYER": + has_unread_updates = bool(row.lawyer_has_unread_updates or row.client_has_unread_updates) + if has_actor_unread_notification: + has_unread_updates = True + important_date_at = parse_datetime_safe(row.important_date_at) + deadline_alert = bool(important_date_at and important_date_at < next_day_start and not status_is_terminal) + if role == "LAWYER": + deadline_alert = deadline_alert and bool(assigned_id) and assigned_id == actor items.append( { - "id": str(row.id), + "id": request_id, "track_number": row.track_number, "client_name": row.client_name, "client_phone": row.client_phone, @@ -470,13 +529,15 @@ def get_requests_kanban_service( "lawyer_unread_event_type": row.lawyer_unread_event_type, "client_has_unread_updates": bool(row.client_has_unread_updates), "client_unread_event_type": row.client_unread_event_type, + "has_unread_updates": has_unread_updates, + "deadline_alert": deadline_alert, "case_deadline_at": case_deadline.isoformat() if case_deadline else None, "sla_deadline_at": sla_deadline.isoformat() if sla_deadline is not None else None, "available_transitions": available_transitions, } ) - items = apply_overdue_filters(items, overdue_filters) + items = apply_boolean_kanban_filters(items, boolean_filters) items = sort_kanban_items(items, normalized_sort_mode) total = len(items) if total > limit: diff --git a/app/api/admin/requests_modules/status_flow.py b/app/api/admin/requests_modules/status_flow.py index 6184a43..fe6c456 100644 --- a/app/api/admin/requests_modules/status_flow.py +++ b/app/api/admin/requests_modules/status_flow.py @@ -294,55 +294,92 @@ def get_request_status_route_service( transition_sla_by_edge[(from_status, to_status)] = sla_hours incoming_sla_by_status.setdefault(to_status, sla_hours) - sequence_from_history: list[str] = [] + route_steps: list[dict[str, Any]] = [] if history_rows: first_from = str(history_rows[0].from_status or "").strip() if first_from: - sequence_from_history.append(first_from) + route_steps.append( + { + "code": first_from, + "edge_from": None, + "changed_at": None, + "source": "history", + } + ) for row in history_rows: to_code = str(row.to_status or "").strip() - if to_code: - sequence_from_history.append(to_code) + if not to_code: + continue + from_code = str(row.from_status or "").strip() or None + route_steps.append( + { + "code": to_code, + "edge_from": from_code, + "changed_at": row.created_at.isoformat() if row.created_at else None, + "source": "history", + } + ) elif current_status: - sequence_from_history.append(current_status) + route_steps.append( + { + "code": current_status, + "edge_from": None, + "changed_at": (req.updated_at or req.created_at).isoformat() if (req.updated_at or req.created_at) else None, + "source": "current", + } + ) - ordered_codes: list[str] = [] - seen_codes: set[str] = set() + if current_status and not any(str(step.get("code") or "").strip() == current_status for step in route_steps): + route_steps.append( + { + "code": current_status, + "edge_from": None, + "changed_at": (req.updated_at or req.created_at).isoformat() if (req.updated_at or req.created_at) else None, + "source": "current", + } + ) - def add_code(code: str) -> None: - normalized = str(code or "").strip() - if not normalized or normalized in seen_codes: - return - seen_codes.add(normalized) - ordered_codes.append(normalized) - - for code in sequence_from_history: - add_code(code) - - add_code(current_status) for to_status in outgoing_by_status.get(current_status, []): - add_code(to_status) + normalized = str(to_status or "").strip() + if not normalized: + continue + route_steps.append( + { + "code": normalized, + "edge_from": current_status or None, + "changed_at": None, + "source": "outgoing", + } + ) - changed_at_by_status: dict[str, str] = {} - for row in history_rows: - to_code = str(row.to_status or "").strip() - if to_code and row.created_at: - changed_at_by_status[to_code] = row.created_at.isoformat() - - visited_codes = {code for code in sequence_from_history if code} - current_index = ordered_codes.index(current_status) if current_status in ordered_codes else -1 + current_index = -1 + if current_status: + for idx in range(len(route_steps) - 1, -1, -1): + code = str(route_steps[idx].get("code") or "").strip() + source = str(route_steps[idx].get("source") or "").strip() + if code != current_status: + continue + if source == "outgoing": + continue + current_index = idx + break + if current_index < 0 and route_steps: + current_index = len(route_steps) - 1 def status_name(code: str) -> str: meta = statuses_map.get(code) or {} return str(meta.get("name") or code) nodes: list[dict[str, str | int | None]] = [] - for index, code in enumerate(ordered_codes): + for index, step in enumerate(route_steps): + code = str(step.get("code") or "").strip() + if not code: + continue meta = statuses_map.get(code) or {} state = "pending" - if code == current_status: + if index == current_index: state = "current" - elif code in visited_codes or (current_index >= 0 and index < current_index): + elif current_index >= 0 and index < current_index: state = "completed" note_parts: list[str] = [] @@ -358,10 +395,10 @@ def get_request_status_route_service( "name": status_name(code), "kind": kind, "state": state, - "changed_at": changed_at_by_status.get(code), + "changed_at": str(step.get("changed_at") or "").strip() or None, "sla_hours": ( - transition_sla_by_edge.get((ordered_codes[index - 1], code)) - if index > 0 + transition_sla_by_edge.get((str(step.get("edge_from") or "").strip(), code)) + if str(step.get("edge_from") or "").strip() else None ) or incoming_sla_by_status.get(code), diff --git a/app/api/public/requests.py b/app/api/public/requests.py index b3cb0eb..4cb0db7 100644 --- a/app/api/public/requests.py +++ b/app/api/public/requests.py @@ -28,7 +28,6 @@ from app.models.status_history import StatusHistory from app.models.topic import Topic from app.services.invoice_crypto import decrypt_requisites from app.services.invoice_pdf import build_invoice_pdf_bytes -from app.services.chat_secure_service import create_client_message, list_messages_for_request from app.services.origin_guard import enforce_public_origin_or_403 from app.services.notifications import ( get_client_notification, @@ -44,8 +43,6 @@ from app.services.security_audit import extract_client_ip, record_pii_access_eve from app.api.admin.requests_modules.status_flow import get_request_status_route_service from app.schemas.public import ( PublicAttachmentRead, - PublicMessageCreate, - PublicMessageRead, PublicRequestCreate, PublicRequestCreated, PublicServiceRequestCreate, @@ -427,6 +424,14 @@ def get_request_by_track( session: dict = Depends(get_public_session), ): req = _request_for_track_or_404(db, session, track_number) + status_name = str(req.status_code or "") + if str(req.status_code or "").strip(): + try: + status_row = db.query(Status).filter(Status.code == req.status_code).first() + except SQLAlchemyError: + status_row = None + if status_row is not None: + status_name = str(status_row.name or req.status_code or "") topic_name = None if str(req.topic_code or "").strip(): try: @@ -478,15 +483,13 @@ def get_request_by_track( "topic_code": req.topic_code, "topic_name": topic_name, "status_code": req.status_code, + "status_name": status_name, "important_date_at": _to_iso(req.important_date_at), "description": req.description, "extra_fields": req.extra_fields, "assigned_lawyer_id": req.assigned_lawyer_id, "assigned_lawyer_name": lawyer_name or req.assigned_lawyer_id, "assigned_lawyer_phone": lawyer_phone, - "request_cost": float(req.request_cost) if req.request_cost is not None else None, - "effective_rate": float(req.effective_rate) if req.effective_rate is not None else None, - "paid_at": _to_iso(req.paid_at), "client_has_unread_updates": req.client_has_unread_updates, "client_unread_event_type": req.client_unread_event_type, "lawyer_has_unread_updates": req.lawyer_has_unread_updates, @@ -589,62 +592,6 @@ def get_status_route_by_track( return payload -@router.get("/{track_number}/messages", response_model=list[PublicMessageRead]) -def list_messages_by_track( - track_number: str, - request: FastapiRequest, - db: Session = Depends(get_db), - session: dict = Depends(get_public_session), -): - req = _request_for_track_or_404(db, session, track_number) - rows = list_messages_for_request(db, req.id) - payload = [ - PublicMessageRead( - id=row.id, - request_id=row.request_id, - author_type=row.author_type, - author_name=row.author_name, - body=row.body, - created_at=_to_iso(row.created_at), - updated_at=_to_iso(row.updated_at), - ) - for row in rows - ] - _record_public_read_audit( - db, - session=session, - http_request=request, - action="READ_CHAT_MESSAGES", - scope="CHAT", - request_id=req.id, - details={"rows": len(rows)}, - ) - return payload - - -@router.post("/{track_number}/messages", response_model=PublicMessageRead, status_code=201) -def create_message_by_track( - track_number: str, - payload: PublicMessageCreate, - request: FastapiRequest, - db: Session = Depends(get_db), - session: dict = Depends(get_public_session), -): - enforce_public_origin_or_403(request, endpoint="/api/public/requests/{track_number}/messages") - req = _request_for_track_or_404(db, session, track_number) - row = create_client_message(db, request=req, body=payload.body) - - return PublicMessageRead( - id=row.id, - request_id=row.request_id, - author_type=row.author_type, - author_name=row.author_name, - body=row.body, - created_at=_to_iso(row.created_at), - updated_at=_to_iso(row.updated_at), - ) - - @router.get("/{track_number}/attachments", response_model=list[PublicAttachmentRead]) def list_attachments_by_track( track_number: str, diff --git a/app/assets/invoice_signature_stamp.png b/app/assets/invoice_signature_stamp.png new file mode 100644 index 0000000..b76dbd3 Binary files /dev/null and b/app/assets/invoice_signature_stamp.png differ diff --git a/app/services/billing_flow.py b/app/services/billing_flow.py index 7bfeaf1..5285b4c 100644 --- a/app/services/billing_flow.py +++ b/app/services/billing_flow.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from decimal import Decimal from string import Formatter from typing import Any -from uuid import UUID, uuid4 +from uuid import UUID from fastapi import HTTPException from sqlalchemy import inspect @@ -14,7 +14,9 @@ from sqlalchemy.orm import Session from app.models.invoice import Invoice from app.models.request import Request from app.models.status import Status +from app.services.invoice_chat import create_invoice_chat_message_with_attachment from app.services.invoice_crypto import encrypt_requisites +from app.services.invoice_numbering import generate_invoice_number STATUS_KIND_DEFAULT = "DEFAULT" STATUS_KIND_INVOICE = "INVOICE" @@ -109,15 +111,6 @@ def _status_template(db: Session, status_code: str) -> str | None: return value or None -def _invoice_number(db: Session) -> str: - prefix = _now_utc().strftime("%Y%m%d") - candidate = f"INV-{prefix}-{uuid4().hex[:8].upper()}" - exists = db.query(Invoice.id).filter(Invoice.invoice_number == candidate).first() - if exists is None: - return candidate - return f"INV-{prefix}-{uuid4().hex[:12].upper()}" - - def _safe_render_template(template: str, values: dict[str, Any]) -> str: source = str(template or "").strip() or DEFAULT_INVOICE_TEMPLATE allowed = { @@ -188,9 +181,10 @@ def _create_waiting_invoice( actor = _actor_uuid_or_none(admin) role = str((admin or {}).get("role") or "").strip().upper() or None + issued_at = _now_utc() invoice = Invoice( request_id=req.id, - invoice_number=_invoice_number(db), + invoice_number=generate_invoice_number(db, issued_at), status=INVOICE_STATUS_WAITING, amount=amount, currency="RUB", @@ -204,7 +198,7 @@ def _create_waiting_invoice( ), issued_by_admin_user_id=actor, issued_by_role=role, - issued_at=_now_utc(), + issued_at=issued_at, paid_at=None, responsible=responsible, ) @@ -213,6 +207,15 @@ def _create_waiting_invoice( req.invoice_amount = amount req.responsible = responsible db.add(req) + create_invoice_chat_message_with_attachment( + db, + request=req, + invoice=invoice, + actor_role=role or "ADMIN", + actor_name=str((admin or {}).get("name") or (admin or {}).get("email") or responsible), + actor_admin_user_id=(admin or {}).get("sub"), + responsible=responsible, + ) return invoice.invoice_number diff --git a/app/services/chat_secure_service.py b/app/services/chat_secure_service.py index cf4728b..7b01254 100644 --- a/app/services/chat_secure_service.py +++ b/app/services/chat_secure_service.py @@ -43,6 +43,9 @@ def serialize_message(row: Message) -> dict[str, Any]: "author_type": row.author_type, "author_name": row.author_name, "body": row.body, + "message_kind": "TEXT", + "request_data_items": [], + "request_data_all_filled": False, "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, } diff --git a/app/services/invoice_chat.py b/app/services/invoice_chat.py new file mode 100644 index 0000000..535f05a --- /dev/null +++ b/app/services/invoice_chat.py @@ -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 diff --git a/app/services/invoice_numbering.py b/app/services/invoice_numbering.py new file mode 100644 index 0000000..75bcca4 --- /dev/null +++ b/app/services/invoice_numbering.py @@ -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}" + diff --git a/app/services/invoice_pdf.py b/app/services/invoice_pdf.py index f01fe03..f4c885d 100644 --- a/app/services/invoice_pdf.py +++ b/app/services/invoice_pdf.py @@ -1,8 +1,87 @@ from __future__ import annotations -from datetime import datetime -from typing import Any +import io +import os import unicodedata +from datetime import datetime +from decimal import Decimal, ROUND_HALF_UP +from typing import Any + +REPORTLAB_AVAILABLE = True +try: + from reportlab.lib import colors + from reportlab.lib.pagesizes import A4 + from reportlab.lib.units import mm + from reportlab.lib.utils import ImageReader, simpleSplit + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + from reportlab.pdfgen import canvas + from reportlab.platypus import Table, TableStyle +except Exception: + REPORTLAB_AVAILABLE = False + + +_DEFAULT_ISSUER = 'ООО "Аудиторы корпоративной безопасности"' +_DEFAULT_ISSUER_ADDRESS = "г. Ярославль, ул. Богдановича, 6А" +_DEFAULT_ISSUER_PHONE = "+7 (977) 268-94-06" +_DEFAULT_ISSUER_INN = "7604226740" +_DEFAULT_ISSUER_KPP = "760401001" +_DEFAULT_ISSUER_OGRN = "1127604008806" +_DEFAULT_BANK_NAME = 'АО "АЛЬФА-БАНК"' +_DEFAULT_BANK_BIK = "044525593" +_DEFAULT_BANK_ACCOUNT = "40702810501860000582" +_DEFAULT_BANK_CORR_ACCOUNT = "30101810200000000593" +_DEFAULT_SIGNATURE_STAMP_IMAGE = "invoice_signature_stamp.png" +_DEFAULT_DIRECTOR_NAME = "Андрианова С.С." + +_RU_MONTHS = [ + "января", + "февраля", + "марта", + "апреля", + "мая", + "июня", + "июля", + "августа", + "сентября", + "октября", + "ноября", + "декабря", +] + +_FONT_CANDIDATES: list[tuple[str, str | None]] = [ + ("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"), + ("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf"), + ("/usr/share/fonts/truetype/freefont/FreeSans.ttf", "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf"), + ("/System/Library/Fonts/Supplemental/Arial.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf"), + ("/System/Library/Fonts/Supplemental/Arial Unicode.ttf", None), + ("/Library/Fonts/Arial.ttf", "/Library/Fonts/Arial Bold.ttf"), + ("/Library/Fonts/Arial Unicode.ttf", None), +] +_FONT_CACHE: tuple[str, str] | None = None + +_UNITS_MALE = ("", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь", "девять") +_UNITS_FEMALE = ("", "одна", "две", "три", "четыре", "пять", "шесть", "семь", "восемь", "девять") +_TEENS = ( + "десять", + "одиннадцать", + "двенадцать", + "тринадцать", + "четырнадцать", + "пятнадцать", + "шестнадцать", + "семнадцать", + "восемнадцать", + "девятнадцать", +) +_TENS = ("", "", "двадцать", "тридцать", "сорок", "пятьдесят", "шестьдесят", "семьдесят", "восемьдесят", "девяносто") +_HUNDREDS = ("", "сто", "двести", "триста", "четыреста", "пятьсот", "шестьсот", "семьсот", "восемьсот", "девятьсот") +_SCALES = [ + ("", "", "", False), + ("тысяча", "тысячи", "тысяч", True), + ("миллион", "миллиона", "миллионов", False), + ("миллиард", "миллиарда", "миллиардов", False), +] def _ascii_text(value: Any) -> str: @@ -30,6 +109,413 @@ def _build_content_stream(lines: list[str]) -> bytes: return "\n".join(parts).encode("latin-1", errors="ignore") +def _build_legacy_invoice_pdf_bytes(lines: list[str]) -> bytes: + stream = _build_content_stream(lines) + objects = [ + b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n", + b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n", + b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj\n", + b"4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n", + f"5 0 obj << /Length {len(stream)} >> stream\n".encode("latin-1") + stream + b"\nendstream endobj\n", + ] + body = b"%PDF-1.4\n" + offsets = [0] + for obj in objects: + offsets.append(len(body)) + body += obj + xref_offset = len(body) + body += f"xref\n0 {len(objects)+1}\n".encode("latin-1") + body += b"0000000000 65535 f \n" + for offset in offsets[1:]: + body += f"{offset:010d} 00000 n \n".encode("latin-1") + body += f"trailer << /Size {len(objects)+1} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode("latin-1") + return body + + +def _first_non_empty(source: dict[str, Any], *keys: str, default: str = "") -> str: + for key in keys: + value = source.get(key) + if value is None: + continue + text = str(value).strip() + if text: + return text + return default + + +def _format_amount(value: float) -> str: + amount = Decimal(str(value or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return f"{amount:.2f}" + + +def _format_amount_ru(value: float) -> str: + amount = Decimal(str(value or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + integer_part = int(amount) + fraction = int((amount - Decimal(integer_part)) * 100) + grouped = f"{integer_part:,}".replace(",", " ") + if fraction == 0: + return grouped + return f"{grouped},{fraction:02d}" + + +def _plural_ru(value: int, forms: tuple[str, str, str]) -> str: + n = abs(int(value)) % 100 + if 11 <= n <= 19: + return forms[2] + n = n % 10 + if n == 1: + return forms[0] + if 2 <= n <= 4: + return forms[1] + return forms[2] + + +def _triplet_to_words(value: int, *, female: bool) -> list[str]: + n = int(value) % 1000 + if n == 0: + return [] + words: list[str] = [] + words.append(_HUNDREDS[n // 100]) + n = n % 100 + if 10 <= n <= 19: + words.append(_TEENS[n - 10]) + else: + words.append(_TENS[n // 10]) + unit_map = _UNITS_FEMALE if female else _UNITS_MALE + words.append(unit_map[n % 10]) + return [word for word in words if word] + + +def _integer_to_words_ru(value: int) -> str: + number = int(value) + if number == 0: + return "ноль" + parts: list[str] = [] + scale_index = 0 + while number > 0: + triplet = number % 1000 + if triplet: + one, two, five, female = _SCALES[min(scale_index, len(_SCALES) - 1)] + segment = _triplet_to_words(triplet, female=female) + if scale_index > 0: + segment.append(_plural_ru(triplet, (one, two, five))) + parts.append(" ".join(segment)) + number //= 1000 + scale_index += 1 + return " ".join(reversed(parts)).strip() + + +def _amount_words_ru(amount: float) -> str: + dec = Decimal(str(amount or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + rub = int(dec) + kop = int((dec - Decimal(rub)) * 100) + words = _integer_to_words_ru(rub) + rub_label = _plural_ru(rub, ("рубль", "рубля", "рублей")) + kop_label = _plural_ru(kop, ("копейка", "копейки", "копеек")) + return f"{words} {rub_label} {kop:02d} {kop_label}".strip() + + +def _capitalize_first(text: str) -> str: + value = str(text or "").strip() + if not value: + return "" + return value[0].upper() + value[1:] + + +def _format_invoice_date(value: datetime | None) -> str: + dt = value or datetime.now() + month = _RU_MONTHS[max(0, min(11, dt.month - 1))] + return f"{dt.day:02d} {month} {dt.year} г." + + +def _resolve_reportlab_fonts() -> tuple[str, str]: + global _FONT_CACHE + if _FONT_CACHE is not None: + return _FONT_CACHE + + regular_name = "Helvetica" + bold_name = "Helvetica-Bold" + for regular_path, bold_path in _FONT_CANDIDATES: + if not os.path.exists(regular_path): + continue + try: + regular_name = "InvoiceSans" + pdfmetrics.registerFont(TTFont(regular_name, regular_path)) + if bold_path and os.path.exists(bold_path): + bold_name = "InvoiceSansBold" + pdfmetrics.registerFont(TTFont(bold_name, bold_path)) + else: + bold_name = regular_name + _FONT_CACHE = (regular_name, bold_name) + return _FONT_CACHE + except Exception: + regular_name = "Helvetica" + bold_name = "Helvetica-Bold" + + _FONT_CACHE = (regular_name, bold_name) + return _FONT_CACHE + + +def _resolve_signature_stamp_image_path(req: dict[str, Any]) -> str: + provided = _first_non_empty( + req, + "signature_stamp_image_path", + "signature_stamp_path", + "signature_image_path", + default="", + ) + local_default = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets", _DEFAULT_SIGNATURE_STAMP_IMAGE) + candidates = [provided, local_default, f"/app/app/assets/{_DEFAULT_SIGNATURE_STAMP_IMAGE}"] + for path in candidates: + candidate = str(path or "").strip() + if candidate and os.path.exists(candidate): + return candidate + return "" + + +def _display_invoice_number(raw_number: str, issued_at: datetime | None) -> str: + value = str(raw_number or "").strip() + if not value: + return (issued_at or datetime.now()).strftime("%Y%m%d") + upper = value.upper() + if upper.startswith("INV-"): + tail = value[4:] + if len(tail) >= 8 and tail[:8].isdigit(): + date_part = tail[:8] + remainder = tail[8:] + if not remainder: + return date_part + if remainder.startswith("-"): + suffix = remainder[1:] + if suffix.isdigit(): + return f"{date_part}-{suffix}" + return date_part + return date_part + return value + + +def _draw_wrapped_line(pdf: Any, *, text: str, x: float, y: float, width: float, font: str, size: int, leading: float) -> float: + lines = simpleSplit(str(text or ""), font, size, width) or [""] + pdf.setFont(font, size) + cursor = y + for line in lines: + pdf.drawString(x, cursor, line) + cursor -= leading + return cursor + + +def _build_reportlab_invoice_pdf_bytes( + *, + invoice_number: str, + amount: float, + currency: str, + status: str, + issued_at: datetime | None, + paid_at: datetime | None, + payer_display_name: str, + request_track_number: str, + issued_by_name: str | None, + requisites: dict[str, Any] | None, +) -> bytes: + regular_font, bold_font = _resolve_reportlab_fonts() + req = dict(requisites or {}) + + issuer_name = _first_non_empty(req, "issuer_name", "beneficiary_name", "recipient_name", default=_DEFAULT_ISSUER) + issuer_address = _first_non_empty(req, "issuer_address", "address", default=_DEFAULT_ISSUER_ADDRESS) + issuer_phone = _first_non_empty(req, "issuer_phone", "phone", default=_DEFAULT_ISSUER_PHONE) + issuer_inn = _first_non_empty(req, "issuer_inn", "inn", default=_DEFAULT_ISSUER_INN) + issuer_kpp = _first_non_empty(req, "issuer_kpp", "kpp", default=_DEFAULT_ISSUER_KPP) + issuer_ogrn = _first_non_empty(req, "issuer_ogrn", "ogrn", default=_DEFAULT_ISSUER_OGRN) + bank_name = _first_non_empty(req, "bank_name", "bank", default=_DEFAULT_BANK_NAME) + bank_bik = _first_non_empty(req, "bank_bik", "bik", default=_DEFAULT_BANK_BIK) + bank_account = _first_non_empty(req, "bank_account", "account", default=_DEFAULT_BANK_ACCOUNT) + bank_corr_account = _first_non_empty(req, "bank_corr_account", "corr_account", default=_DEFAULT_BANK_CORR_ACCOUNT) + service_description = _first_non_empty(req, "service_description", "service", "template_rendered", default="Юридические услуги") + vat_note = _first_non_empty(req, "vat_note", default="без НДС") + director_name = _DEFAULT_DIRECTOR_NAME + signature_stamp_image_path = _resolve_signature_stamp_image_path(req) + + amount_text = _format_amount_ru(amount) + amount_words = _capitalize_first(_amount_words_ru(amount)) + issue_date = issued_at or datetime.now() + invoice_number_display = _display_invoice_number(invoice_number, issue_date) + issue_date_compact = issue_date.strftime("%d.%m.%Y") + + buffer = io.BytesIO() + pdf = canvas.Canvas(buffer, pagesize=A4) + page_width, page_height = A4 + left = 15 * mm + content_width = page_width - 30 * mm + cursor_y = page_height - 13 * mm + + # Header block close to the supplied invoice sample. + pdf.setFillColorRGB(0.17, 0.35, 0.40) + pdf.setFont(bold_font, 18) + pdf.drawCentredString(page_width / 2, cursor_y, "АУДИТОРЫ КОРПОРАТИВНОЙ БЕЗОПАСНОСТИ") + cursor_y -= 6.5 * mm + pdf.setFillColorRGB(0, 0, 0) + pdf.setFont(bold_font, 7) + pdf.drawCentredString(page_width / 2, cursor_y, "О Б Щ Е С Т В О С О Г Р А Н И Ч Е Н Н О Й О Т В Е Т С Т В Е Н Н О С Т Ь Ю") + cursor_y -= 4.6 * mm + pdf.setFont(regular_font, 8) + pdf.drawCentredString(page_width / 2, cursor_y, "Россия, 150014, Ярославль, ул. Богдановича, 6А") + cursor_y -= 2.2 * mm + pdf.line(left, cursor_y, page_width - left, cursor_y) + cursor_y -= 6.2 * mm + + pdf.setFont(bold_font, 10) + pdf.drawString(left + 1 * mm, cursor_y, "Образец заполнения платежного поручения") + cursor_y -= 2.2 * mm + + bank_table = Table( + [ + [f"ИНН {issuer_inn}", f"КПП {issuer_kpp}", "", "Сч. №", bank_account], + [f"Получатель\n{issuer_name}", "", "", "", ""], + [f"Банк получателя\n{bank_name}", "", "", "БИК", bank_bik], + ["", "", "", "Сч. №", bank_corr_account], + ], + colWidths=[37 * mm, 34 * mm, 39 * mm, 25 * mm, 50 * mm], + ) + bank_table.setStyle( + TableStyle( + [ + ("FONT", (0, 0), (-1, -1), regular_font, 9), + ("FONT", (0, 0), (2, 0), bold_font, 8), + ("GRID", (0, 0), (-1, -1), 0.7, colors.black), + ("SPAN", (1, 0), (2, 0)), + ("SPAN", (0, 1), (2, 1)), + ("SPAN", (0, 2), (2, 3)), + ("SPAN", (3, 0), (3, 1)), + ("SPAN", (4, 0), (4, 1)), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("ALIGN", (3, 0), (3, -1), "CENTER"), + ("ALIGN", (4, 0), (4, -1), "LEFT"), + ("LEFTPADDING", (0, 0), (-1, -1), 4), + ("RIGHTPADDING", (0, 0), (-1, -1), 4), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ] + ) + ) + _, bank_table_height = bank_table.wrap(content_width, cursor_y) + bank_table.drawOn(pdf, left, cursor_y - bank_table_height) + cursor_y -= bank_table_height + 5.5 * mm + + pdf.setFont(bold_font, 13) + pdf.drawCentredString(page_width / 2, cursor_y, f"СЧЕТ № {invoice_number_display} от {issue_date_compact} года") + cursor_y -= 6.2 * mm + + details_table = Table( + [ + ["Исполнитель", issuer_name], + ["Адрес", issuer_address], + ["Телефон", issuer_phone], + ["Расчетный счет", bank_account], + ["Банк", bank_name], + ["БИК", bank_bik], + ["Корр. счет", bank_corr_account], + ["ИНН", issuer_inn], + ["КПП", issuer_kpp], + ["ОГРН", issuer_ogrn], + ], + colWidths=[30 * mm, content_width - 30 * mm], + ) + details_table.setStyle( + TableStyle( + [ + ("FONT", (0, 0), (-1, -1), regular_font, 9), + ("GRID", (0, 0), (-1, -1), 0.7, colors.black), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 4), + ("RIGHTPADDING", (0, 0), (-1, -1), 4), + ("TOPPADDING", (0, 0), (-1, -1), 3), + ("BOTTOMPADDING", (0, 0), (-1, -1), 3), + ] + ) + ) + _, details_table_height = details_table.wrap(content_width, cursor_y) + details_table.drawOn(pdf, left, cursor_y - details_table_height) + cursor_y -= details_table_height + 5 * mm + + pdf.line(left, cursor_y, page_width - left, cursor_y) + cursor_y -= 2.4 * mm + + item_name_width = 95 * mm - 8 + wrapped_service = "\n".join(simpleSplit(service_description, regular_font, 9, item_name_width) or [service_description]) + + item_table = Table( + [ + ["№\nПП", "Наименование", "Кол-во", "Цена\n(за единицу)", "ВСЕГО"], + ["1", wrapped_service, "1", amount_text, amount_text], + ["ВСЕГО", "", "", "", amount_text], + ], + colWidths=[13 * mm, 95 * mm, 18 * mm, 27 * mm, 28 * mm], + ) + item_table.setStyle( + TableStyle( + [ + ("FONT", (0, 0), (-1, -1), regular_font, 9), + ("FONT", (0, 0), (-1, 0), bold_font, 9), + ("FONT", (0, 2), (4, 2), bold_font, 9), + ("GRID", (0, 0), (-1, -1), 0.7, colors.black), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("ALIGN", (0, 0), (0, -1), "CENTER"), + ("ALIGN", (2, 0), (4, -1), "CENTER"), + ("ALIGN", (3, 1), (4, -1), "RIGHT"), + ("SPAN", (0, 2), (3, 2)), + ("ALIGN", (0, 2), (3, 2), "LEFT"), + ("LEFTPADDING", (0, 0), (-1, -1), 4), + ("RIGHTPADDING", (0, 0), (-1, -1), 4), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ] + ) + ) + _, item_table_height = item_table.wrap(content_width, cursor_y) + item_table.drawOn(pdf, left, cursor_y - item_table_height) + cursor_y -= item_table_height + 5.5 * mm + + pdf.setFont(regular_font, 9) + prefix = "Сумма прописью: " + pdf.drawString(left, cursor_y, prefix) + prefix_width = pdfmetrics.stringWidth(prefix, regular_font, 9) + pdf.setFont(bold_font, 10) + pdf.drawString(left + prefix_width, cursor_y, f"{amount_words} ({vat_note}).") + cursor_y -= 10 * mm + + block_width = min(155 * mm, content_width) + block_left = left + (content_width - block_width) / 2 + block_center_x = block_left + block_width / 2 + block_top = cursor_y + signature_name = director_name or _DEFAULT_DIRECTOR_NAME + + pdf.setFont(regular_font, 11) + pdf.drawString(block_left + 2 * mm, block_top, "С уважением,") + pdf.drawString(block_left + 2 * mm, block_top - 13 * mm, "Генеральный директор") + pdf.drawString(block_left + 2 * mm, block_top - 19 * mm, "ООО «АКБ»") + pdf.drawString(block_left + block_width - 35 * mm, block_top - 19 * mm, signature_name) + + if signature_stamp_image_path: + try: + stamp_image = ImageReader(signature_stamp_image_path) + img_w, img_h = stamp_image.getSize() + target_h = 40 * mm + target_w = target_h * (float(img_w) / max(float(img_h), 1.0)) + x = block_center_x - target_w / 2 + y = max(12 * mm, block_top - 43 * mm) + pdf.drawImage(stamp_image, x, y, width=target_w, height=target_h, mask="auto") + pdf.setFont(regular_font, 11) + pdf.drawString(x + target_w + 3 * mm, y + 6 * mm, "МП") + except Exception: + pdf.drawString(block_center_x + 28 * mm, block_top - 19 * mm, "МП") + else: + pdf.drawString(block_center_x + 28 * mm, block_top - 19 * mm, "МП") + + pdf.showPage() + pdf.save() + return buffer.getvalue() + + def build_invoice_pdf_bytes( *, invoice_number: str, @@ -43,6 +529,24 @@ def build_invoice_pdf_bytes( issued_by_name: str | None, requisites: dict[str, Any] | None, ) -> bytes: + if REPORTLAB_AVAILABLE: + try: + return _build_reportlab_invoice_pdf_bytes( + invoice_number=invoice_number, + amount=amount, + currency=currency, + status=status, + issued_at=issued_at, + paid_at=paid_at, + payer_display_name=payer_display_name, + request_track_number=request_track_number, + issued_by_name=issued_by_name, + requisites=requisites, + ) + except Exception: + # Safety fallback for environments without fonts/reportlab internals. + pass + lines = [ f"Invoice: {invoice_number}", f"Request: {request_track_number}", @@ -60,25 +564,4 @@ def build_invoice_pdf_bytes( lines.append(f"{key}: {req.get(key)}") else: lines.append("-") - - stream = _build_content_stream(lines) - objects = [ - b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n", - b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n", - b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj\n", - b"4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n", - f"5 0 obj << /Length {len(stream)} >> stream\n".encode("latin-1") + stream + b"\nendstream endobj\n", - ] - - body = b"%PDF-1.4\n" - offsets = [0] - for obj in objects: - offsets.append(len(body)) - body += obj - xref_offset = len(body) - body += f"xref\n0 {len(objects)+1}\n".encode("latin-1") - body += b"0000000000 65535 f \n" - for offset in offsets[1:]: - body += f"{offset:010d} 00000 n \n".encode("latin-1") - body += f"trailer << /Size {len(objects)+1} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode("latin-1") - return body + return _build_legacy_invoice_pdf_bytes(lines) diff --git a/app/web/admin.css b/app/web/admin.css index d1027d9..e05decf 100644 --- a/app/web/admin.css +++ b/app/web/admin.css @@ -355,6 +355,7 @@ margin-top: 0.3rem; font-size: 1.2rem; color: #f6dab0; + text-align: right; } .lawyer-dashboard-grid { @@ -1694,6 +1695,110 @@ margin-top: 0.2rem; } + .request-finance-actions { + margin-top: 0.65rem; + padding-top: 0.2rem; + } + + .request-finance-actions-inline { + margin-top: 0.1rem; + padding-top: 0; + } + + .request-finance-issue-form { + margin-top: 0.2rem; + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.55rem; + background: rgba(255, 255, 255, 0.02); + gap: 0.55rem; + } + + .request-finance-issue-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; + } + + .request-finance-invoices { + margin-top: 0.75rem; + border-top: 1px solid var(--line); + padding-top: 0.6rem; + max-height: min(42vh, 340px); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 0.45rem; + min-height: 0; + } + + .request-finance-invoices-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.45rem; + margin-bottom: 0.4rem; + } + + .request-finance-invoices-head h4 { + margin: 0; + font-size: 0.92rem; + } + + .request-finance-invoice-list { + display: grid; + gap: 0.45rem; + max-height: none; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding-right: 0.1rem; + } + + .request-finance-invoice-row { + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.5rem 0.55rem; + background: rgba(255, 255, 255, 0.02); + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + } + + .request-finance-invoice-meta { + display: grid; + gap: 0.3rem; + min-width: 0; + flex: 1 1 auto; + } + + .request-finance-invoice-number { + color: #d8e5f7; + font-size: 0.83rem; + } + + .request-finance-invoice-details { + display: flex; + flex-wrap: wrap; + gap: 0.45rem 0.65rem; + color: #b8c8db; + font-size: 0.79rem; + } + + .request-finance-empty { + margin: 0.1rem 0 0; + } + + .request-finance-invoice-download-btn { + width: 32px; + height: 32px; + border-radius: 8px; + font-size: 0.95rem; + line-height: 1; + padding: 0; + flex: 0 0 auto; + } + .request-data-modal { width: min(860px, 100%); } @@ -2558,6 +2663,14 @@ margin-bottom: 0.2rem; } + .chat-service-head { + font-size: 0.84rem; + font-weight: 800; + color: #ffe0a6; + margin-bottom: 0.2rem; + line-height: 1.3; + } + .chat-request-data-bubble.all-filled .chat-request-data-head { color: #d3f4dc; margin-bottom: 0.08rem; @@ -2688,6 +2801,22 @@ line-height: 1.2; } + .request-chat-list li.chat-empty-state { + align-self: center; + width: fit-content; + max-width: min(70%, 360px); + margin: 0.1rem 0 0; + padding: 0.36rem 0.72rem; + border-radius: 999px; + border: 1px solid rgba(131, 151, 178, 0.36); + background: rgba(46, 61, 84, 0.44); + color: #bfd0e6; + font-size: 0.78rem; + line-height: 1.2; + text-align: center; + white-space: nowrap; + } + .request-chat-composer-actions { display: flex; align-items: center; @@ -3026,6 +3155,10 @@ .request-main-column { order: 2; } + .request-finance-invoice-row { + flex-direction: column; + align-items: flex-start; + } } @media (max-width: 620px) { @@ -3082,4 +3215,7 @@ flex-direction: column; align-items: flex-start; } + .request-finance-issue-grid { + grid-template-columns: 1fr; + } } diff --git a/app/web/admin.html b/app/web/admin.html index 4581c53..7993b5a 100644 --- a/app/web/admin.html +++ b/app/web/admin.html @@ -5,12 +5,12 @@