Мера: о новой заявке узнаёт ответственный, а на дашборде видно, сколько оценок доходит до заявки #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 } },
|
{ "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue