Law/app/services/billing_flow.py
2026-03-03 14:13:59 +03:00

291 lines
9 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from string import Formatter
from typing import Any
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import inspect
from sqlalchemy.exc import SQLAlchemyError
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"
STATUS_KIND_PAID = "PAID"
ALLOWED_STATUS_KINDS = {STATUS_KIND_DEFAULT, STATUS_KIND_INVOICE, STATUS_KIND_PAID}
INVOICE_STATUS_WAITING = "WAITING_PAYMENT"
INVOICE_STATUS_PAID = "PAID"
FALLBACK_INVOICE_CODES = {"INVOICE", "BILLING", "WAITING_PAYMENT"}
FALLBACK_PAID_CODES = {"PAID", "ОПЛАЧЕНО"}
DEFAULT_INVOICE_TEMPLATE = (
"Счет по заявке {track_number}. "
"Клиент: {client_name}. "
"Тема: {topic_code}. "
"Сумма: {amount} RUB."
)
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
def _to_float(value: Any) -> float | None:
if value is None:
return None
if isinstance(value, Decimal):
return float(value)
try:
return float(value)
except (TypeError, ValueError):
return None
def _actor_uuid_or_none(admin: dict[str, Any] | None) -> UUID | None:
if not admin:
return None
try:
return UUID(str(admin.get("sub") or ""))
except ValueError:
return None
def _normalize_kind(raw: str | None) -> str:
value = str(raw or STATUS_KIND_DEFAULT).strip().upper()
if value not in ALLOWED_STATUS_KINDS:
return STATUS_KIND_DEFAULT
return value
def normalize_status_kind_or_400(raw: str | None) -> str:
value = str(raw or STATUS_KIND_DEFAULT).strip().upper()
if value not in ALLOWED_STATUS_KINDS:
raise HTTPException(status_code=400, detail='Поле "kind" должно быть одним из: DEFAULT, INVOICE, PAID')
return value
def _table_exists(db: Session, table_name: str) -> bool:
try:
bind = db.get_bind()
if bind is None:
return False
return table_name in set(inspect(bind).get_table_names())
except SQLAlchemyError:
return False
def _status_kind(db: Session, status_code: str) -> str:
code = str(status_code or "").strip()
if not code:
return STATUS_KIND_DEFAULT
row = db.query(Status.kind).filter(Status.code == code).first()
if row and row[0]:
return _normalize_kind(row[0])
upper = code.upper()
if upper in FALLBACK_INVOICE_CODES:
return STATUS_KIND_INVOICE
if upper in FALLBACK_PAID_CODES:
return STATUS_KIND_PAID
return STATUS_KIND_DEFAULT
def _status_template(db: Session, status_code: str) -> str | None:
code = str(status_code or "").strip()
if not code:
return None
row = db.query(Status.invoice_template).filter(Status.code == code).first()
if row is None:
return None
value = str(row[0] or "").strip()
return value or None
def _safe_render_template(template: str, values: dict[str, Any]) -> str:
source = str(template or "").strip() or DEFAULT_INVOICE_TEMPLATE
allowed = {
"request_id",
"track_number",
"client_name",
"client_phone",
"topic_code",
"from_status",
"to_status",
"effective_rate",
"invoice_amount",
"amount",
}
formatter = Formatter()
out = source
for _, field_name, _, _ in formatter.parse(source):
if not field_name:
continue
if field_name not in allowed:
raise HTTPException(status_code=400, detail=f'Шаблон счета содержит недопустимый placeholder: "{field_name}"')
try:
out = source.format_map({key: values.get(key) for key in allowed})
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Ошибка рендера шаблона счета: {exc}")
return out
def _create_waiting_invoice(
db: Session,
*,
req: Request,
to_status: str,
from_status: str,
admin: dict[str, Any] | None,
responsible: str,
) -> str:
waiting = (
db.query(Invoice)
.filter(Invoice.request_id == req.id, Invoice.status == INVOICE_STATUS_WAITING)
.order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc())
.first()
)
if waiting is not None:
return waiting.invoice_number
base_amount = _to_float(req.invoice_amount)
if base_amount is None or base_amount <= 0:
base_amount = _to_float(req.effective_rate)
amount = round(float(base_amount or 0.0), 2)
template = _status_template(db, to_status) or DEFAULT_INVOICE_TEMPLATE
rendered_template = _safe_render_template(
template,
{
"request_id": str(req.id),
"track_number": req.track_number,
"client_name": req.client_name,
"client_phone": req.client_phone,
"topic_code": req.topic_code,
"from_status": from_status,
"to_status": to_status,
"effective_rate": _to_float(req.effective_rate),
"invoice_amount": _to_float(req.invoice_amount),
"amount": amount,
},
)
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=generate_invoice_number(db, issued_at),
status=INVOICE_STATUS_WAITING,
amount=amount,
currency="RUB",
payer_display_name=str(req.client_name or "").strip() or "Клиент",
payer_details_encrypted=encrypt_requisites(
{
"template_rendered": rendered_template,
"request_track_number": req.track_number,
"topic_code": req.topic_code,
}
),
issued_by_admin_user_id=actor,
issued_by_role=role,
issued_at=issued_at,
paid_at=None,
responsible=responsible,
)
db.add(invoice)
if req.invoice_amount is None:
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
def _mark_waiting_invoice_paid_or_400(
db: Session,
*,
req: Request,
admin: dict[str, Any] | None,
responsible: str,
) -> tuple[str, float]:
actor = _actor_uuid_or_none(admin)
role = str((admin or {}).get("role") or "").strip().upper()
if role != "ADMIN":
raise HTTPException(status_code=403, detail='Статус "Оплачено" может поставить только администратор')
waiting = (
db.query(Invoice)
.filter(Invoice.request_id == req.id, Invoice.status == INVOICE_STATUS_WAITING)
.order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc())
.first()
)
if waiting is None:
raise HTTPException(status_code=400, detail='Для перехода в статус "Оплачено" нужен счет в статусе "Ожидает оплату"')
waiting.status = INVOICE_STATUS_PAID
waiting.paid_at = _now_utc()
waiting.responsible = responsible
db.add(waiting)
req.invoice_amount = waiting.amount
req.paid_at = waiting.paid_at
req.paid_by_admin_id = str(actor) if actor else None
req.responsible = responsible
db.add(req)
return waiting.invoice_number, round(float(_to_float(waiting.amount) or 0.0), 2)
def apply_billing_transition_effects(
db: Session,
*,
req: Request,
from_status: str,
to_status: str,
admin: dict[str, Any] | None,
responsible: str,
) -> str | None:
if not _table_exists(db, "invoices"):
return None
from_kind = _status_kind(db, from_status)
to_kind = _status_kind(db, to_status)
if to_kind == STATUS_KIND_INVOICE and from_kind != STATUS_KIND_INVOICE:
number = _create_waiting_invoice(
db,
req=req,
to_status=to_status,
from_status=from_status,
admin=admin,
responsible=responsible,
)
return f"Выставлен счет {number}"
if to_kind == STATUS_KIND_PAID:
number, amount = _mark_waiting_invoice_paid_or_400(
db,
req=req,
admin=admin,
responsible=responsible,
)
return f"Оплачен счет {number} на сумму {amount:.2f}"
return None