Мера: о новой заявке узнаёт ответственный, а на дашборде видно, сколько оценок доходит до заявки #3551
3 changed files with 170 additions and 6 deletions
|
|
@ -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 } },
|
||||
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue