Merge pull request 'Мера: о новой заявке узнаёт ответственный, а на дашборде видно, сколько оценок доходит до заявки' (#3551) from fix/lead-funnel into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
Deploy Metrics / server (push) Successful in 1m12s
Deploy Metrics / agent-apps (push) Successful in 36s
Deploy Metrics / agent-infra (push) Successful in 33s

This commit is contained in:
bot-backend 2026-09-17 09:21:44 +00:00
commit 99f8525b1c
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 } }, { "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
{ {

View file

@ -2,9 +2,16 @@
POST /api/v1/trade-in/lead контактная заявка с результата оценки: POST /api/v1/trade-in/lead контактная заявка с результата оценки:
телефон + явное согласие на обработку персональных данных. Persist в телефон + явное согласие на обработку персональных данных. Persist в
trade_in_leads. Notification (Telegram/email) вне scope: нет существующей trade_in_leads.
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
persist + log; `notified_at` в таблице зарезервирован под будущую доставку. Уведомление ответственному (#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` раньше только проверялся на IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на
СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца любой СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца любой
@ -25,15 +32,17 @@ from datetime import UTC, datetime, timedelta
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from uuid import UUID 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 pydantic import BaseModel, Field, field_validator
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session 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.api.v1.trade_in import _assert_estimate_access
from app.core.config import settings 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.observability.metrics import LEADS
from app.services.tgbot.shared import get_telegram_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -99,11 +108,49 @@ class TradeInLeadInput(BaseModel):
return value 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") @router.post("/lead")
async def create_trade_in_lead( async def create_trade_in_lead(
payload: TradeInLeadInput, payload: TradeInLeadInput,
request: Request, request: Request,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
background_tasks: BackgroundTasks,
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Сохраняет лид (телефон + согласие) в trade_in_leads.""" """Сохраняет лид (телефон + согласие) в trade_in_leads."""
@ -185,6 +232,15 @@ async def create_trade_in_lead(
_CONSENT_POLICY_VERSION, _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 { return {
"id": row["id"], "id": row["id"],
"created_at": row["created_at"].isoformat(), "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 datetime import UTC, datetime
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import MagicMock from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4 from uuid import uuid4
import pytest 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 r.status_code == 422, r.text
assert not db.execute.called 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