feat(finder): кнопка «Отправить в прогрев» рядом с «Отдать менеджеру»

Task 9 плана 2026-07-19-reklamnaya-auditoriya-v2: начальник в «Поиске
клиентов» отмечает фирмы галочками и отправляет их в прогрев рекламой
Яндекса. Старая кнопка «Отдать менеджеру» остаётся рядом — решение
владельца: быстрая дорожка в обход прогрева для горячих фирм.

- salesfinder/adaudience.py — pick_ad_phones: из размеченных номеров
  (ДаДата+HLR) отбирает живой/молчащий мобильный, отбрасывает городской,
  мусор и «не существует».
- salesfinder/portal.py — send_ad_audience: тот же транспорт и заголовок
  X-Sales-Token, что у send_prospects, POST на /api/sales/integration/ad-audience.
- web/app.py — POST /view/{rid}/warmup: отмеченные фирмы без единого
  годного мобильного пропускаются с честным сообщением, остальные летят
  в портал полным снимком (payload=asdict(f)) вместе с отобранными номерами.
- web/ui.py — кнопка рядом с «Отдать менеджеру», деактивируется вместе
  с ней, когда портал недоступен.
- web/static/app.js — подтверждение перед отправкой ВО ВНЕШНЕМ файле
  (не onclick/inline-<script>): CSP боевого портала lk.liderra.ru такое
  молча вырезает (инцидент 15.07–18.07 — «Поиск клиентов» был мёртв 3 дня).

12 новых тестов (TDD, падали до реализации), полный прогон службы
tests/ — 321 passed, регрессий нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-19 11:45:26 +03:00
parent c43ed7b283
commit d2bae45429
6 changed files with 383 additions and 3 deletions
@@ -0,0 +1,23 @@
"""Отбор номеров, годных для рекламы в Яндексе.
Годится живой или молчащий мобильный. Не годится городской (реклама по нему
не найдёт человека) и мусор. «Не существует» (dead) отбрасываем — деньги на ветер.
"""
from typing import Any
def pick_ad_phones(firm: dict[str, Any]) -> list[str]:
checked = firm.get("contact_phones_checked") or []
picked: list[str] = []
for row in checked:
if row.get("kind") != "mobile":
continue
if row.get("alive") == "dead":
continue
phone = str(row.get("phone") or "")
if len(phone) == 11 and phone.startswith("7") and phone not in picked:
picked.append(phone)
return picked
+22
View File
@@ -60,3 +60,25 @@ def send_prospects(base, token, manager_id, firms, post=None):
if not (200 <= r.status_code < 300):
raise PortalError(f"портал вернул {r.status_code}")
return r.json() or {}
def send_ad_audience(base, token, firms, post=None):
"""Отправить фирмы в прогрев рекламой — портал заводит их в аудиторию Яндекса.
`firms` — уже собранные словари по контракту POST /api/sales/integration/ad-audience
(firm_name/firm_inn/.../phones), номера уже отобраны и нормализованы вызывающим
кодом (salesfinder.adaudience.pick_ad_phones) — тут только транспорт и заголовок.
Возвращает {added, renewed, phones}.
"""
if post is None:
import requests
post = requests.post
url = base.rstrip("/") + "/api/sales/integration/ad-audience"
body = {"firms": firms}
try:
r = post(url, json=body, headers=_headers(token), timeout=_TIMEOUT)
except Exception as e:
raise PortalError(f"портал недоступен: {e}") from e
if not (200 <= r.status_code < 300):
raise PortalError(f"портал вернул {r.status_code}")
return r.json() or {}
@@ -0,0 +1,225 @@
"""TDD «Отправить в прогрев» (Task 9): отбор номеров, отправка в портал, кнопка в UI.
Старая кнопка «Отдать менеджеру» рядом ОСТАЁТСЯ (решение владельца) — быстрая дорожка
в обход прогрева для горячих фирм. Портал/сеть инъектируются — тесты бьют по стабам,
секреты сюда не попадают. ПДн: только фиктивные номера 7999000000X.
"""
import os
from dataclasses import asdict
import pytest
from fastapi.testclient import TestClient
from salesfinder.adaudience import pick_ad_phones
from salesfinder.models import Firm
from salesfinder.portal import PortalError
# ── Шаг 1: отбор номеров, годных для рекламы ────────────────────────────────
def test_берём_только_живые_мобильные():
firm = {
"contact_phones_checked": [
{"phone": "79990000001", "kind": "mobile", "alive": "alive"},
{"phone": "79990000002", "kind": "mobile", "alive": "dead"},
{"phone": "74950000003", "kind": "landline", "alive": "alive"},
{"phone": "79990000004", "kind": "junk", "alive": "alive"},
]
}
assert pick_ad_phones(firm) == ["79990000001"]
def test_номер_гасшего_абонента_всё_равно_годится():
# «выключен» — не «не существует»: рекламу такому показать можно.
firm = {"contact_phones_checked": [{"phone": "79990000005", "kind": "mobile", "alive": "gone"}]}
assert pick_ad_phones(firm) == ["79990000005"]
def test_фирма_без_размеченных_номеров_даёт_пустой_список():
assert pick_ad_phones({}) == []
# ── Шаг 5: отправка в портал (транспорт) ────────────────────────────────────
class _Resp:
def __init__(self, status, data):
self.status_code = status
self._data = data
def json(self):
return self._data
def test_send_ad_audience_posts_firms_with_token_header():
from salesfinder import portal
sent = {}
def fake_post(url, json=None, headers=None, timeout=None):
sent["url"] = url
sent["json"] = json
sent["headers"] = headers
return _Resp(200, {"added": 1, "renewed": 0, "phones": 1})
firms = [{"firm_name": "ООО Ромашка", "phones": ["79990000001"]}]
res = portal.send_ad_audience("https://p", "TOK", firms, post=fake_post)
assert res == {"added": 1, "renewed": 0, "phones": 1}
assert sent["url"].endswith("/api/sales/integration/ad-audience")
assert sent["headers"]["X-Sales-Token"] == "TOK"
assert sent["json"] == {"firms": firms}
def test_send_ad_audience_non_2xx_raises():
from salesfinder import portal
def fake_post(url, json=None, headers=None, timeout=None):
return _Resp(422, {})
with pytest.raises(portal.PortalError):
portal.send_ad_audience("https://p", "TOK", [{"firm_name": "X"}], post=fake_post)
# ── Шаг 6: обработчик /view/{rid}/warmup + кнопка в UI ──────────────────────
def make_client(tmp_path):
os.environ["SF_DB"] = str(tmp_path / "runs.sqlite")
from web import app as appmod
appmod._portal_auth = lambda: None
return TestClient(appmod.app)
def _seed_run(appmod, firms, rubric="stom"):
rid = appmod.STORE.create(rubric, "moscow")
appmod.STORE.save_result(rid, firms)
appmod.STORE.set_status(rid, "done")
return rid
def _firm_with_mobile(**over):
base = dict(
name="ООО Ромашка", inn="7709918259", city="Москва", domain="romashka.ru",
contact_phones_checked=[{"phone": "79990000001", "kind": "mobile", "alive": "alive"}],
)
base.update(over)
return Firm(**base)
def test_warmup_sends_picked_firms_with_their_phones(tmp_path):
c = make_client(tmp_path)
from web import app as appmod
appmod._portal_cfg = lambda: ("https://p", "TOK")
sent = {}
def stub_send(base, token, firms):
sent["base"] = base
sent["token"] = token
sent["firms"] = firms
return {"added": len(firms), "renewed": 0, "phones": sum(len(f["phones"]) for f in firms)}
appmod.PORTAL_SEND_AD_AUDIENCE = stub_send
rid = _seed_run(appmod, [
_firm_with_mobile(name="A"),
_firm_with_mobile(name="B"),
])
r = c.post(f"/view/{rid}/warmup", data={"sel": ["0", "1"]})
assert r.status_code == 200
assert len(sent["firms"]) == 2
assert sent["firms"][0]["firm_name"] == "A"
assert sent["firms"][0]["phones"] == ["79990000001"]
assert sent["firms"][0]["firm_inn"] == "7709918259"
def test_warmup_skips_firm_without_mobile_and_reports_it(tmp_path):
c = make_client(tmp_path)
from web import app as appmod
appmod._portal_cfg = lambda: ("https://p", "TOK")
sent = {}
def stub_send(base, token, firms):
sent["firms"] = firms
return {"added": len(firms), "renewed": 0, "phones": 1}
appmod.PORTAL_SEND_AD_AUDIENCE = stub_send
rid = _seed_run(appmod, [
Firm(name="Без телефона", contact_phones_checked=[]),
_firm_with_mobile(name="С телефоном"),
])
r = c.post(f"/view/{rid}/warmup", data={"sel": ["0", "1"]})
assert r.status_code == 200
assert "Без телефона" in r.text # честно назвали, кого пропустили
assert len(sent["firms"]) == 1
assert sent["firms"][0]["firm_name"] == "С телефоном"
def test_warmup_all_without_mobile_sends_nothing(tmp_path):
c = make_client(tmp_path)
from web import app as appmod
appmod._portal_cfg = lambda: ("https://p", "TOK")
called = {"n": 0}
def stub_send(base, token, firms):
called["n"] += 1
return {"added": 0, "renewed": 0, "phones": 0}
appmod.PORTAL_SEND_AD_AUDIENCE = stub_send
rid = _seed_run(appmod, [Firm(name="Без телефона", contact_phones_checked=[])])
r = c.post(f"/view/{rid}/warmup", data={"sel": ["0"]})
assert r.status_code == 200
assert called["n"] == 0 # портал вообще не дёргаем — нечего слать
assert "Без телефона" in r.text
def test_warmup_portal_error_sends_nothing_marked(tmp_path):
c = make_client(tmp_path)
from web import app as appmod
appmod._portal_cfg = lambda: ("https://p", "TOK")
def boom(base, token, firms):
raise PortalError("портал недоступен")
appmod.PORTAL_SEND_AD_AUDIENCE = boom
rid = _seed_run(appmod, [_firm_with_mobile(name="A")])
r = c.post(f"/view/{rid}/warmup", data={"sel": ["0"]})
assert r.status_code == 200
assert "портал" in r.text.lower()
def test_warmup_no_portal_config_sends_nothing(tmp_path):
c = make_client(tmp_path)
from web import app as appmod
appmod._portal_cfg = lambda: ("", "")
called = {"n": 0}
appmod.PORTAL_SEND_AD_AUDIENCE = lambda base, token, firms: called.__setitem__("n", called["n"] + 1) or {}
rid = _seed_run(appmod, [_firm_with_mobile(name="A")])
r = c.post(f"/view/{rid}/warmup", data={"sel": ["0"]})
assert r.status_code == 200
assert called["n"] == 0
def test_results_html_has_warmup_button_next_to_assign():
from web import ui
run = {"id": 5, "rubric": "stom", "city": "moscow", "status": "done",
"firms": [asdict(_firm_with_mobile(name="A", director_inn="1"))]}
html = ui.results_html(run, managers=[{"id": 7, "name": "Иванов"}])
assert "Отправить в прогрев" in html
assert "Отдать менеджеру" in html # старая кнопка осталась рядом
def test_results_html_no_managers_disables_warmup_too():
from web import ui
run = {"id": 5, "rubric": "stom", "city": "moscow", "status": "done",
"firms": [asdict(_firm_with_mobile(name="A", director_inn="1"))]}
html = ui.results_html(run, managers=[])
idx = html.index("Отправить в прогрев")
# атрибут disabled должен стоять на том же кнопочном теге
tag_start = html.rindex("<button", 0, idx)
tag_end = html.index(">", idx)
assert "disabled" in html[tag_start:tag_end]
+82
View File
@@ -208,6 +208,7 @@ from salesfinder import portal as _portal
from salesfinder.portal import PortalError
PORTAL_SEND = _portal.send_prospects
PORTAL_MANAGERS = _portal.get_managers
PORTAL_SEND_AD_AUDIENCE = _portal.send_ad_audience # Task 9: «Отправить в прогрев»
def _portal_cfg():
@@ -401,6 +402,87 @@ def assign_manager(rid: int, sel: list = Form(default=[]), manager_id: str = For
return RedirectResponse(url=_u(f"/view/{rid}"), status_code=303)
@app.post("/view/{rid}/warmup")
def send_to_warmup(rid: int, sel: list = Form(default=[])):
"""Отправить отмеченные фирмы в прогрев рекламой (Яндекс.Аудитории через портал).
Кнопка «Отдать менеджеру» рядом ОСТАЁТСЯ — решение владельца: быстрая дорожка
в обход прогрева для горячих фирм. Фирма без единого годного мобильного номера
в прогрев не берётся — сообщаем об этом честно, а не теряем молча."""
from dataclasses import asdict
from salesfinder.adaudience import pick_ad_phones
from salesfinder.models import Firm
from salesfinder.ranker import heat_label
from salesfinder.rubrics import label_for as rubric_label
r = STORE.get(rid)
if not r or r.get("status") != "done":
return RedirectResponse(url=_u(f"/view/{rid}"), status_code=303)
base, token = _portal_cfg()
if not base or not token:
body = ('<div class=card><h1>Не удалось отправить в прогрев</h1>'
'<p class=lead>Связь с порталом не настроена (адрес/токен). '
'Ничего не отправлено.</p>'
f'<a class="btn" href="{_u(f"/view/{rid}")}">← к списку</a></div>')
return HTMLResponse(ui.page("Портал недоступен", body))
firms = [Firm(**row) for row in r["firms"]]
picked = sorted({int(s) for s in sel if str(s).isdigit()})
chosen = [firms[i] for i in picked if 0 <= i < len(firms)]
if not chosen:
return RedirectResponse(url=_u(f"/view/{rid}"), status_code=303)
rubric_ru = rubric_label(r.get("rubric") or "") or (r.get("rubric") or "")
payloads = []
without_phone = []
for f in chosen:
phones = pick_ad_phones(asdict(f))
if not phones:
without_phone.append(f.name)
continue
contacts = [{"name": f.director, "phones": phones}] if f.director else []
payloads.append({
"firm_name": f.name,
"firm_inn": f.inn,
"legal_name": f.legal_name,
"city": f.city,
"rubric": rubric_ru,
"site": f.domain,
"phone": f.phone,
"rating_label": heat_label(f.hotness or 0),
"payload": asdict(f),
"contacts": contacts,
"phones": phones,
})
if not payloads:
body = ('<div class=card><h1>Не отправлено — нет годных номеров</h1>'
f'<p class=lead>У отмеченных фирм ({", ".join(without_phone)}) нет ни одного '
'живого мобильного номера — рекламу показывать некому. Соберите контакты '
'заново или отметьте другие фирмы.</p>'
f'<a class="btn" href="{_u(f"/view/{rid}")}">← к списку</a></div>')
return HTMLResponse(ui.page("Нет годных номеров", body))
try:
PORTAL_SEND_AD_AUDIENCE(base, token, payloads)
except PortalError as e:
body = ('<div class=card><h1>Портал не принял фирмы</h1>'
f'<p class=lead>{str(e)}. Ничего не отправлено — попробуйте ещё раз '
'через минуту.</p>'
f'<a class="btn" href="{_u(f"/view/{rid}")}">← к списку</a></div>')
return HTMLResponse(ui.page("Ошибка отправки", body))
if without_phone:
body = ('<div class=card><h1>Отправлено частично</h1>'
f'<p class=lead>В прогрев ушло {len(payloads)} фирм{"а" if len(payloads) == 1 else ""}. '
f'Без годного мобильного номера пропущены: {", ".join(without_phone)}.</p>'
f'<a class="btn" href="{_u(f"/view/{rid}")}">← к списку</a></div>')
return HTMLResponse(ui.page("Отправлено частично", body))
return RedirectResponse(url=_u(f"/view/{rid}"), status_code=303)
@app.post("/run")
def start_run(background: BackgroundTasks, rubric: str = Form(...), city: str = Form(...)):
rid = STORE.create(rubric, city)
+14
View File
@@ -148,3 +148,17 @@
}
} catch (e) { /* мусор в памяти браузера не должен ломать страницу */ }
})();
/* «Отправить в прогрев» (страница /view/{id}) — с этого момента директору начинает
показываться реклама Лидерры, поэтому спрашиваем подтверждение перед отправкой
(тот же приём, что у подтверждения удаления списка выше — событие submit,
не onsubmit-атрибут, чтобы CSP боевого портала его не вырезал). Кнопка живёт
в форме результатов, а не на главной, поэтому это отдельный top-level блок,
не завязанный на #cascade. */
document.addEventListener("submit", function (e) {
var btn = e.submitter;
if (btn && btn.id === "warmup-btn" &&
!confirm("Отправить отмеченные фирмы в прогрев рекламой?")) {
e.preventDefault();
}
});
+17 -3
View File
@@ -400,6 +400,8 @@ def results_html(run, managers=None):
+ "".join(rows) + '</table>')
# Кнопка «Отдать менеджеру» (Этап 2): выбор менеджера из портала + отправка отмеченных.
# Портал недоступен (список пуст) → кнопка disabled, чтобы не отправлять в никуда.
# «Отправить в прогрев» (Task 9) — РЯДОМ, старую кнопку не убираем: решение владельца,
# это быстрая дорожка в обход прогрева для горячих фирм.
if managers:
opts = "".join(f'<option value="{_esc(m.get("id"))}">{_esc(m.get("name"))}</option>'
for m in managers)
@@ -407,15 +409,24 @@ def results_html(run, managers=None):
f'{opts}</select>'
f'<button class=btn formaction="{_u(f"/view/{_esc(rid)}/assign")}" '
f'style="margin-left:8px">Отдать менеджеру</button>')
warmup = (f'<button class="btn ghost" id=warmup-btn '
f'formaction="{_u(f"/view/{_esc(rid)}/warmup")}" '
f'style="margin-left:8px">Отправить в прогрев</button>')
else:
assign = ('<button class=btn disabled '
'title="портал недоступен — список менеджеров не получен" '
'style="margin-left:16px">Отдать менеджеру</button>')
# Одна форма с чекбоксами: обе кнопки шлют отмеченные фирмы, каждая на свой адрес (formaction).
warmup = ('<button class="btn ghost" disabled '
'title="портал недоступен — список менеджеров не получен" '
'style="margin-left:8px">Отправить в прогрев</button>')
# Одна форма с чекбоксами: все кнопки шлют отмеченные фирмы, каждая на свой адрес (formaction).
# Таблица сдвинута отступом вправо (просьба владельца).
# ⚠ Обработчик подтверждения для #warmup-btn — во внешнем web/static/app.js, НЕ
# inline-onclick: CSP боевого портала (lk.liderra.ru) молча вырезает встроенные
# скрипты и атрибуты on*= (инцидент 15.07–18.07 — «Поиск клиентов» был мёртв 3 дня).
collect = (f'<form method=post action="{_u(f"/view/{_esc(rid)}/contacts")}">'
f'<div style="margin:0 0 12px"><button class=btn>Собрать контакты по отмеченным</button>'
f'{assign}'
f'{assign}{warmup}'
f'<span class=dim style="margin-left:12px">по ИНН → все телефоны и e-mail '
f'директора; 2 платных запроса на фирму</span></div>'
f'<div style="padding-left:24px">{table}</div></form>')
@@ -426,4 +437,7 @@ def results_html(run, managers=None):
'с её сайта; сверьте перед звонком. <b>Контакты</b> (все телефоны и почты) собираются '
'по ИНН → ФИО+дата рождения → базе контактов: это персональные данные, только по '
'отмеченным галочкам, 2 платных запроса на фирму.</div>')
return page("Список на прозвон — Лидерра", head + collect + note)
# ВНЕШНИЙ файл (не <script> в теле) — CSP боевого портала встроенный скрипт
# молча вырезает; он даёт подтверждение перед «Отправить в прогрев».
script = f'<script src="{_esc(_u("/app.js"))}" defer></script>'
return page("Список на прозвон — Лидерра", head + collect + note + script)