Мера: о новой заявке узнаёт ответственный, а на дашборде видно, сколько оценок доходит до заявки #3551

Merged
bot-backend merged 1 commit from fix/lead-funnel into main 2026-09-17 09:21:48 +00:00
3 changed files with 170 additions and 6 deletions

View file

@ -133,6 +133,29 @@
}
},
{
"type": "timeseries",
"title": "Конверсия оценка → заявка, скользящие 7 суток",
"description": "Доля успешных оценок (outcome=ok), после которых оставили заявку (#1971): лиды за 7 суток / оценки ok за 7 суток. Знаменатель — только ok, потому что форма заявки показывается только при посчитанной оценке (insufficient_data формы не видит). Счёт по событиям, а не по людям: одна заявка после трёх пересчётов того же адреса даёт 33 %. Пусто вместо 0 % — за 7 суток не было ни одной успешной оценки.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 17 },
"targets": [
{
"refId": "A",
"expr": "sum(increase(mera_leads_total[7d])) / sum(increase(mera_estimates_total{outcome=\"ok\"}[7d]))",
"legendFormat": "заявки / оценки"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": []
}
},
{ "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
{

View file

@ -2,9 +2,16 @@
POST /api/v1/trade-in/lead контактная заявка с результата оценки:
телефон + явное согласие на обработку персональных данных. Persist в
trade_in_leads. Notification (Telegram/email) вне scope: нет существующей
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
persist + log; `notified_at` в таблице зарезервирован под будущую доставку.
trade_in_leads.
Уведомление ответственному (#1971): после ответа клиенту (BackgroundTasks) лид
уходит сообщением в support-топик Telegram тот же бот и топик, что у веб-чата
поддержки (`app.api.v1.support`). До этого заявки только ложились в таблицу, и
никто о них не узнавал. Успешная отправка проставляет `notified_at`; отказ
Telegram не трогает ни ответ, ни лид остаётся лог и пустой `notified_at` как
видимый след недоставки. Телефона в сообщении НЕТ: копия в Telegram не стирается
механизмом удаления ПДн (`data_erasure.py`), поэтому туда идут только id
телефон оператор берёт из trade_in_leads.
IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на
СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца любой
@ -25,15 +32,17 @@ from datetime import UTC, datetime, timedelta
from typing import Annotated, Any, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.v1.support import _bot_configured
from app.api.v1.trade_in import _assert_estimate_access
from app.core.config import settings
from app.core.db import get_db
from app.core.db import SessionLocal, get_db
from app.observability.metrics import LEADS
from app.services.tgbot.shared import get_telegram_client
logger = logging.getLogger(__name__)
@ -99,11 +108,49 @@ class TradeInLeadInput(BaseModel):
return value
async def _notify_new_lead(lead_id: str, estimate_id: UUID | None, username: str | None) -> None:
"""Сообщает о заявке в support-топик и отмечает `notified_at` (#1971).
Идёт после ответа клиенту, поэтому ретраи клиента штатные воркерные, без
интерактивного бюджета (как `app.tasks.glitchtip_alert_retry`). Сессия своя:
сессия запроса к этому моменту уже закрыта. Любой отказ только в лог.
"""
message = (
"Новая заявка на трейд-ин\n"
f"id: {lead_id}\n"
f"пользователь: {username or ''}\n"
f"оценка: {estimate_id or 'без привязки'}\n"
"Телефон — в trade_in_leads по id."
)
try:
await get_telegram_client().send_message(
chat_id=settings.telegram_support_chat_id,
text=message,
message_thread_id=settings.telegram_support_topic_id or None,
)
except Exception:
logger.exception("trade_in_lead: уведомление не доставлено id=%s", lead_id)
return
db = SessionLocal()
try:
db.execute(
text("UPDATE trade_in_leads SET notified_at = now() WHERE id = CAST(:id AS uuid)"),
{"id": lead_id},
)
db.commit()
except Exception:
logger.exception("trade_in_lead: уведомление ушло, notified_at не записан id=%s", lead_id)
finally:
db.close()
@router.post("/lead")
async def create_trade_in_lead(
payload: TradeInLeadInput,
request: Request,
db: Annotated[Session, Depends(get_db)],
background_tasks: BackgroundTasks,
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> dict[str, Any]:
"""Сохраняет лид (телефон + согласие) в trade_in_leads."""
@ -185,6 +232,15 @@ async def create_trade_in_lead(
_CONSENT_POLICY_VERSION,
)
if _bot_configured():
background_tasks.add_task(
_notify_new_lead, row["id"], payload.estimate_id, x_authenticated_user
)
else:
logger.warning(
"trade_in_lead: бот не настроен, уведомление о id=%s не отправлено", row["id"]
)
return {
"id": row["id"],
"created_at": row["created_at"].isoformat(),

View file

@ -21,7 +21,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
@ -285,3 +285,88 @@ def test_lead_source_landing_rejected_422(client: TestClient, db: MagicMock) ->
)
assert r.status_code == 422, r.text
assert not db.execute.called
# ── #1971: уведомление ответственному в support-топик ─────────────────────────
_NOTIFY_LEAD_ID = "00000000-0000-0000-0000-000000000001"
@pytest.fixture
def telegram(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace:
"""Бот настроен, клиент и сессия фоновой задачи подменены."""
from app.api.v1 import lead as lead_module
monkeypatch.setattr(lead_module.settings, "telegram_bot_token", "fake-token")
monkeypatch.setattr(lead_module.settings, "telegram_support_chat_id", -100123456789)
monkeypatch.setattr(lead_module.settings, "telegram_support_topic_id", 2)
fake_client = SimpleNamespace(send_message=AsyncMock(return_value={"message_id": 7}))
monkeypatch.setattr(lead_module, "get_telegram_client", lambda: fake_client)
notify_db = MagicMock()
monkeypatch.setattr(lead_module, "SessionLocal", lambda: notify_db)
return SimpleNamespace(client=fake_client, db=notify_db)
def test_lead_notifies_topic_without_phone_and_sets_notified_at(
client: TestClient, db: MagicMock, telegram: SimpleNamespace
) -> None:
db.execute.return_value = _insert_result(_NOTIFY_LEAD_ID)
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "+7 (912) 345-67-89", "consent": True},
headers={"X-Authenticated-User": "alice"},
)
assert r.status_code == 200, r.text
telegram.client.send_message.assert_awaited_once()
kwargs = telegram.client.send_message.await_args.kwargs
assert kwargs["chat_id"] == -100123456789
assert kwargs["message_thread_id"] == 2
assert _NOTIFY_LEAD_ID in kwargs["text"]
assert "alice" in kwargs["text"]
# Телефон в Telegram не уходит ни в каком написании.
assert "9123456789" not in "".join(ch for ch in kwargs["text"] if ch.isdigit())
sql, params = telegram.db.execute.call_args.args
assert "SET notified_at = now()" in str(sql)
assert params == {"id": _NOTIFY_LEAD_ID}
assert telegram.db.commit.called
assert telegram.db.close.called
def test_lead_telegram_failure_keeps_200_and_notified_at_empty(
client: TestClient, db: MagicMock, telegram: SimpleNamespace
) -> None:
from app.services.tgbot.client import TelegramError
db.execute.return_value = _insert_result(_NOTIFY_LEAD_ID)
telegram.client.send_message.side_effect = TelegramError("boom")
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "+79123456789", "consent": True},
headers={"X-Authenticated-User": "alice"},
)
assert r.status_code == 200, r.text
assert r.json()["id"] == _NOTIFY_LEAD_ID
assert db.commit.called # лид сохранён до попытки уведомления
telegram.client.send_message.assert_awaited_once()
assert not telegram.db.execute.called
def test_lead_bot_not_configured_skips_notification(
client: TestClient,
db: MagicMock,
telegram: SimpleNamespace,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.api.v1 import lead as lead_module
monkeypatch.setattr(lead_module.settings, "telegram_support_chat_id", 0)
db.execute.return_value = _insert_result(_NOTIFY_LEAD_ID)
r = client.post("/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": True})
assert r.status_code == 200, r.text
telegram.client.send_message.assert_not_awaited()
assert not telegram.db.execute.called