diff --git a/моя/sales-finder/salesfinder/adaudience.py b/моя/sales-finder/salesfinder/adaudience.py new file mode 100644 index 00000000..7839aefb --- /dev/null +++ b/моя/sales-finder/salesfinder/adaudience.py @@ -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 diff --git a/моя/sales-finder/salesfinder/portal.py b/моя/sales-finder/salesfinder/portal.py index 9658f59a..1405383c 100644 --- a/моя/sales-finder/salesfinder/portal.py +++ b/моя/sales-finder/salesfinder/portal.py @@ -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 {} diff --git a/моя/sales-finder/tests/test_adaudience.py b/моя/sales-finder/tests/test_adaudience.py new file mode 100644 index 00000000..0382e021 --- /dev/null +++ b/моя/sales-finder/tests/test_adaudience.py @@ -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("", idx) + assert "disabled" in html[tag_start:tag_end] diff --git a/моя/sales-finder/web/app.py b/моя/sales-finder/web/app.py index dda4b754..b4fdfe77 100644 --- a/моя/sales-finder/web/app.py +++ b/моя/sales-finder/web/app.py @@ -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 = ('

Не удалось отправить в прогрев

' + '

Связь с порталом не настроена (адрес/токен). ' + 'Ничего не отправлено.

' + f'← к списку
') + 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 = ('

Не отправлено — нет годных номеров

' + f'

У отмеченных фирм ({", ".join(without_phone)}) нет ни одного ' + 'живого мобильного номера — рекламу показывать некому. Соберите контакты ' + 'заново или отметьте другие фирмы.

' + f'← к списку
') + return HTMLResponse(ui.page("Нет годных номеров", body)) + + try: + PORTAL_SEND_AD_AUDIENCE(base, token, payloads) + except PortalError as e: + body = ('

Портал не принял фирмы

' + f'

{str(e)}. Ничего не отправлено — попробуйте ещё раз ' + 'через минуту.

' + f'← к списку
') + return HTMLResponse(ui.page("Ошибка отправки", body)) + + if without_phone: + body = ('

Отправлено частично

' + f'

В прогрев ушло {len(payloads)} фирм{"а" if len(payloads) == 1 else ""}. ' + f'Без годного мобильного номера пропущены: {", ".join(without_phone)}.

' + f'← к списку
') + 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) diff --git a/моя/sales-finder/web/static/app.js b/моя/sales-finder/web/static/app.js index 6c6e934e..95e1329e 100644 --- a/моя/sales-finder/web/static/app.js +++ b/моя/sales-finder/web/static/app.js @@ -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(); + } +}); diff --git a/моя/sales-finder/web/ui.py b/моя/sales-finder/web/ui.py index cbb888a3..2ad5a9bd 100644 --- a/моя/sales-finder/web/ui.py +++ b/моя/sales-finder/web/ui.py @@ -400,6 +400,8 @@ def results_html(run, managers=None): + "".join(rows) + '') # Кнопка «Отдать менеджеру» (Этап 2): выбор менеджера из портала + отправка отмеченных. # Портал недоступен (список пуст) → кнопка disabled, чтобы не отправлять в никуда. + # «Отправить в прогрев» (Task 9) — РЯДОМ, старую кнопку не убираем: решение владельца, + # это быстрая дорожка в обход прогрева для горячих фирм. if managers: opts = "".join(f'' for m in managers) @@ -407,15 +409,24 @@ def results_html(run, managers=None): f'{opts}' f'') + warmup = (f'') else: assign = ('') - # Одна форма с чекбоксами: обе кнопки шлют отмеченные фирмы, каждая на свой адрес (formaction). + warmup = ('') + # Одна форма с чекбоксами: все кнопки шлют отмеченные фирмы, каждая на свой адрес (formaction). # Таблица сдвинута отступом вправо (просьба владельца). + # ⚠ Обработчик подтверждения для #warmup-btn — во внешнем web/static/app.js, НЕ + # inline-onclick: CSP боевого портала (lk.liderra.ru) молча вырезает встроенные + # скрипты и атрибуты on*= (инцидент 15.07–18.07 — «Поиск клиентов» был мёртв 3 дня). collect = (f'
' f'
' - f'{assign}' + f'{assign}{warmup}' f'по ИНН → все телефоны и e-mail ' f'директора; 2 платных запроса на фирму
' f'
{table}
') @@ -426,4 +437,7 @@ def results_html(run, managers=None): 'с её сайта; сверьте перед звонком. Контакты (все телефоны и почты) собираются ' 'по ИНН → ФИО+дата рождения → базе контактов: это персональные данные, только по ' 'отмеченным галочкам, 2 платных запроса на фирму.') - return page("Список на прозвон — Лидерра", head + collect + note) + # ВНЕШНИЙ файл (не ' + return page("Список на прозвон — Лидерра", head + collect + note + script)