init: Parser v1 — Lead Generation Engine

Парсер лидов МБ РФ: Яндекс.Карты + HH.ru + обогащение DaData/ЕГРЮЛ/Rusprofile + Streamlit CRM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aks
2026-06-09 12:56:06 +03:00
commit f78f35fb3f
33 changed files with 9198 additions and 0 deletions
+554
View File
@@ -0,0 +1,554 @@
"""Streamlit CRM-приложение для работы с лидами парсера.
Запуск:
streamlit run app/app.py (из папки parser_v1)
либо двойной клик по launch_crm.bat
Работает над той же leads.db что и парсер — изменения видны
с обеих сторон сразу.
"""
import json
import sys
from pathlib import Path
import pandas as pd
import streamlit as st
# Чтобы импортировать config из родительской папки parser_v1/
PARENT = Path(__file__).parent.parent
sys.path.insert(0, str(PARENT))
import config # noqa: E402
import database # noqa: E402 (для автомиграции БД при первом запуске CRM)
import db_layer # noqa: E402 (рядом с app.py — Streamlit добавляет эту папку в sys.path)
# ─── Настройка страницы ──────────────────────────────────────────────
st.set_page_config(
page_title="CRM — 44AS Парсер лидов",
page_icon="🎯",
layout="wide",
initial_sidebar_state="expanded",
)
DB_PATH = PARENT / config.DB_PATH
# Автомиграция: если БД старая (без 4 CRM-колонок / outreach_events / lead_in_run) —
# init_db догонит схему через ALTER TABLE и CREATE TABLE IF NOT EXISTS.
# Идемпотентно — на свежей БД ничего не делает.
database.init_db(str(DB_PATH))
# ─── Лейблы для UI ──────────────────────────────────────────────────
CHANNEL_LABELS = {
"call": "📞 Звонок",
"email": "📧 Email",
"vk": "🔵 VK",
"telegram": "✈️ Telegram",
"whatsapp": "💬 WhatsApp",
"sms": "📩 SMS",
}
REACTION_LABELS = {
"no_answer": "❓ Не ответили",
"callback": "📞 Перезвонить позже",
"refused": "🚫 Отказ",
"agreed": "✅ Согласились",
"moved_to_tg": "✈️ Перешли в Telegram",
"not_target": "❌ Не наша ЦА",
"spam": "🗑️ Спам / нерелевантно",
}
STATUS_LABELS = {
"inbox": "📥 Inbox",
"triaged": "👀 Разобран",
"in_work": "⚙️ В работе",
"done": "✅ Готово",
"skip": "🚫 Пропустить",
}
# Источники — короткое отображение для таблицы и фильтров.
# Один лид может иметь несколько источников через запятую (после мержа дублей),
# например 'yandex_maps,hh'.
SOURCE_LABELS = {
"yandex_maps": "🗺 Я.Карты",
"hh": "💼 HH",
"2gis": "🗺 2ГИС",
"vk": "🔵 VK",
"telegram": "✈️ TG",
"avito": "🟢 Avito",
}
# Продукты 44AS — короткие имена для подсказки «с чем заходить» (скоринг v5).
PRODUCT_LABELS = {
"P1": "Text Agent",
"P2": "AI-Office",
"P3": "AI-Reputation",
"P4": "AI-Consultant",
"P10": "Smart Web",
"P12": "AI SMM",
}
BAND_LABELS = {"hot": "🔥 Hot", "warm": "🟡 Warm", "cold": "⚪ Cold"}
# ─── Форматтеры ─────────────────────────────────────────────────────
def fmt_status(s):
return STATUS_LABELS.get(s or "inbox", str(s) if s else "inbox")
def fmt_channel(c):
return CHANNEL_LABELS.get(c, str(c) if c else "")
def fmt_source(s):
"""Я.Карты + HH (через ' + ' если у лида несколько источников после мержа)."""
if not s:
return ""
parts = [SOURCE_LABELS.get(p.strip(), p.strip()) for p in s.split(",") if p.strip()]
return " + ".join(parts) if parts else ""
def fmt_reaction(r):
return REACTION_LABELS.get(r, str(r) if r else "")
def fmt_band(b):
return BAND_LABELS.get(b, "")
def _parse_pain(pp):
"""pain_products → list[(code, severity)] по убыванию. Принимает JSON-строку или dict."""
if not pp:
return []
if isinstance(pp, str):
try:
pp = json.loads(pp)
except (json.JSONDecodeError, TypeError):
return []
if isinstance(pp, dict):
return sorted(pp.items(), key=lambda kv: -kv[1])
return []
def fmt_pain_products(pp):
"""Короткая строка продуктов «с чем заходить» для таблицы: 'P4 · P10 · P12'."""
items = _parse_pain(pp)
return " · ".join(code for code, _ in items)
# ─── Деталь лида ────────────────────────────────────────────────────
def render_lead_detail(lead_id: int) -> None:
lead = db_layer.get_lead_detail(DB_PATH, lead_id)
if not lead:
st.error(f"Лид #{lead_id} не найден")
return
st.markdown(f"## 📋 {lead['name']}")
st.markdown(f"**Источник:** {fmt_source(lead.get('source'))} • ID #{lead_id}")
# Кнопка «открыть карточку источника» (Я.Карты / HH) — source_url из парсера.
src_url = lead.get("source_url")
if src_url:
src = (lead.get("source") or "").lower()
if "yandex" in src:
label = "🗺 Открыть в Яндекс.Картах"
elif "hh" in src:
label = "💼 Открыть на HH"
else:
label = "🔗 Открыть карточку источника"
st.link_button(label, src_url)
# 3 колонки: контакты | бизнес | статус
c1, c2, c3 = st.columns(3)
with c1:
st.markdown("**📞 Контакты**")
if lead.get("phone_primary"):
st.code(lead["phone_primary"], language=None)
if lead.get("email_primary"):
st.markdown(f"📧 `{lead['email_primary']}`")
if lead.get("website"):
st.markdown(f"🌐 [{lead['website']}]({lead['website']})")
if lead.get("vk_url"):
st.markdown(f"🔵 [VK]({lead['vk_url']})")
if lead.get("telegram_url"):
st.markdown(f"✈️ [Telegram]({lead['telegram_url']})")
# Доп. телефоны (подтверждённые — с Я.Карт)
phones = lead.get("phones")
if isinstance(phones, list) and len(phones) > 1:
st.caption("Доп. телефоны: " + ", ".join(phones[1:]))
# Телефоны, найденные на САЙТЕ — НЕ подтверждены (могут быть чужие).
extra = lead.get("phones_extra")
if isinstance(extra, list) and extra:
st.caption("⚠️ С сайта (проверить): " + ", ".join(extra))
with c2:
st.markdown("**🏢 Бизнес**")
st.markdown(f"📂 {lead.get('category') or ''}")
avg = lead.get("reviews_avg") or 0
cnt = lead.get("reviews_count") or 0
st.markdown(f"{avg:.1f} ({cnt} отзывов)")
if lead.get("director_name"):
st.markdown(f"👤 {lead['director_name']}")
if lead.get("inn"):
st.markdown(f"🏛️ ИНН `{lead['inn']}`")
if lead.get("registration_date"):
st.markdown(f"📅 Зарег.: {lead['registration_date']}")
if lead.get("employee_count") is not None:
st.markdown(f"👥 Сотрудников: {lead['employee_count']}")
if lead.get("revenue") is not None:
yr = f" ({lead['finance_year']})" if lead.get("finance_year") else ""
st.markdown(f"💰 Оборот: {int(lead['revenue']):,}{yr}".replace(",", " "))
if lead.get("address"):
st.caption(lead["address"])
with c3:
st.markdown("**🎯 Статус и оценка**")
score_val = lead.get("score", 0) or 0
st.markdown(f"### Score: **{score_val}** / 10 · {fmt_band(lead.get('band'))}")
cov = lead.get("diagnostic_coverage")
if cov is not None and cov < config.MIN_COVERAGE:
st.warning(f"⚠️ Низкая полнота диагностики ({cov:.0%}) — лиду нужно обогащение")
pains = _parse_pain(lead.get("pain_products"))
if pains:
st.markdown("**С чем заходить:**")
for code, sev in pains:
st.markdown(f"- **{code}** {PRODUCT_LABELS.get(code, '')} · _{sev:.1f}_")
st.markdown(f"📍 {lead.get('city') or ''} / {lead.get('district') or ''}")
st.markdown(f"📊 {fmt_status(lead.get('outreach_status'))}")
if lead.get("last_touched_at"):
st.markdown(f"📅 Последнее касание: `{lead['last_touched_at'][:16]}`")
if lead.get("last_action"):
st.markdown(
f"🎬 {fmt_channel(lead['last_action'])}{fmt_reaction(lead.get('last_reaction'))}"
)
breakdown = lead.get("score_breakdown")
if isinstance(breakdown, dict) and breakdown.get("reasons"):
with st.expander("Почему такой score"):
st.caption("Найденные проблемы:")
for r in breakdown["reasons"]:
st.text(f"{r}")
themes = breakdown.get("themes") or {}
if themes:
st.caption("Боль по темам: " + ", ".join(f"{t}={v}" for t, v in themes.items()))
st.caption(
f"ICP-fit ×{breakdown.get('icp_fit')} · raw={breakdown.get('pain_raw')} "
f"· coverage={breakdown.get('coverage')}"
)
st.divider()
# ─── Форма записи нового касания ─────────────────────────────────
st.markdown("### ➕ Записать касание")
with st.form(f"touch_form_{lead_id}", clear_on_submit=True):
fc1, fc2 = st.columns(2)
with fc1:
channel = st.selectbox(
"Действие",
options=list(CHANNEL_LABELS.keys()),
format_func=fmt_channel,
key=f"channel_{lead_id}",
)
with fc2:
reaction = st.selectbox(
"Реакция",
options=list(REACTION_LABELS.keys()),
format_func=fmt_reaction,
key=f"reaction_{lead_id}",
)
notes = st.text_area(
"Комментарий",
placeholder="Что узнал, что договорились…",
key=f"notes_{lead_id}",
)
new_status = st.selectbox(
"Перевести лида в статус",
options=["in_work", "done", "skip", "triaged", "inbox"],
format_func=fmt_status,
index=0,
help="После касания обычно 'в работе'. Закрываешь — 'готово'. Не наш — 'пропустить'.",
key=f"new_status_{lead_id}",
)
submit = st.form_submit_button(
"💾 Сохранить касание",
type="primary",
width="stretch",
)
if submit:
db_layer.record_touch(
DB_PATH, lead_id, channel,
reaction=reaction,
notes=(notes.strip() or None) if notes else None,
new_status=new_status,
)
st.success(f"Касание записано! Лид → {fmt_status(new_status)}")
st.rerun()
st.divider()
# ─── Свободные заметки ───────────────────────────────────────────
st.markdown("### 📝 Заметки о лиде")
current_comments = lead.get("comments") or ""
new_comments = st.text_area(
"Заметки",
value=current_comments,
height=100,
key=f"comments_input_{lead_id}",
label_visibility="collapsed",
placeholder="Свободный текст: что важно помнить об этом лиде…",
)
if st.button(
"💾 Сохранить заметку",
key=f"save_comm_{lead_id}",
disabled=(new_comments == current_comments),
):
db_layer.update_lead_comments(DB_PATH, lead_id, new_comments)
st.success("Заметка сохранена")
st.rerun()
st.divider()
# ─── История касаний ─────────────────────────────────────────────
st.markdown("### 📜 История касаний")
history = db_layer.get_outreach_history(DB_PATH, lead_id)
if not history:
st.info("Касаний ещё не было. Запиши первое выше ⬆️")
else:
for ev in history:
ch = fmt_channel(ev.get("channel"))
re_ = fmt_reaction(ev.get("reaction"))
ts = (ev.get("sent_at") or "")[:16]
title = f"{ch}{re_}{ts}"
with st.expander(title, expanded=False):
if ev.get("notes"):
st.write(ev["notes"])
else:
st.caption("Без комментария")
if ev.get("message_text"):
st.markdown("**Сообщение:**")
st.code(ev["message_text"])
# ─── 🗑 Удаление компании ────────────────────────────────────────
st.divider()
with st.expander("🗑 Удалить эту компанию"):
st.warning("Удаление необратимо — лид и вся его история касаний будут стёрты.")
confirm = st.checkbox("Да, удалить безвозвратно", key=f"del_confirm_{lead_id}")
if st.button(
"🗑 Удалить компанию",
type="primary",
disabled=not confirm,
key=f"del_btn_{lead_id}",
):
db_layer.delete_lead(DB_PATH, lead_id)
st.success("Компания удалена.")
st.rerun()
# ─── Главная страница ───────────────────────────────────────────────
def main():
st.title("🎯 CRM — Парсер лидов 44AS")
# ── Сайдбар: фильтры ────────────────────────────────────────────
st.sidebar.title("🔍 Фильтры")
hot_label = f"🔥 Hot (≥{config.HOT_LEAD_THRESHOLD})"
preset = st.sidebar.radio(
"Быстрый фильтр",
options=[
"📥 Inbox",
hot_label,
"⚙️ В работе",
"✅ Готовые",
"🚫 Пропущенные",
"📋 Все",
],
index=0,
)
preset_map = {
"📥 Inbox": {"statuses": ["inbox"], "min_score": 0},
hot_label: {"statuses": [], "min_score": config.HOT_LEAD_THRESHOLD},
"⚙️ В работе": {"statuses": ["in_work", "triaged"], "min_score": 0},
"✅ Готовые": {"statuses": ["done"], "min_score": 0},
"🚫 Пропущенные": {"statuses": ["skip"], "min_score": 0},
"📋 Все": {"statuses": [], "min_score": 0},
}
preset_cfg = preset_map[preset]
st.sidebar.divider()
sources = st.sidebar.multiselect(
"Источник",
options=db_layer.get_all_sources(DB_PATH),
placeholder="Любой",
)
regions = st.sidebar.multiselect(
"Регион",
options=db_layer.get_all_regions(DB_PATH),
placeholder="Любой",
)
district_search = st.sidebar.text_input("Район содержит", "")
categories = st.sidebar.multiselect(
"Категория",
options=db_layer.get_all_categories(DB_PATH),
placeholder="Любая",
)
statuses = st.sidebar.multiselect(
"Статус",
options=["inbox", "triaged", "in_work", "done", "skip"],
default=preset_cfg["statuses"],
format_func=fmt_status,
)
min_score, max_score = st.sidebar.slider(
"Диапазон score",
0, 10, (preset_cfg["min_score"], 10),
)
name_search = st.sidebar.text_input("Поиск по имени", "")
pain_filter = st.sidebar.multiselect(
"Боль под продукт",
options=list(PRODUCT_LABELS.keys()),
format_func=lambda p: f"{p} {PRODUCT_LABELS.get(p, '')}",
placeholder="Любой",
help="Показать лидов, у которых есть решаемая нами боль под этот продукт",
)
st.sidebar.divider()
st.sidebar.caption(f"БД: `{DB_PATH.name}`")
if st.sidebar.button("🔄 Обновить", width="stretch"):
st.rerun()
# ── Метрики наверху ─────────────────────────────────────────────
filters = {
"sources": sources,
"regions": regions,
"district_search": district_search,
"categories": categories,
"statuses": statuses,
"min_score": min_score,
"max_score": max_score,
"name_search": name_search,
"pain_products": pain_filter,
}
df = db_layer.get_leads(DB_PATH, filters)
m1, m2, m3, m4, m5 = st.columns(5)
m1.metric("Всего в БД", db_layer.count_total(DB_PATH))
m2.metric("📥 Inbox", db_layer.count_inbox(DB_PATH))
m3.metric("⚙️ В работе", db_layer.count_in_work(DB_PATH))
m4.metric("✅ Готовых", db_layer.count_done(DB_PATH))
m5.metric("Под фильтр", len(df))
st.divider()
# ── ➕ Добавить компанию вручную ────────────────────────────────
with st.expander("➕ Добавить компанию вручную"):
cat_options = sorted(set(config.CATEGORIES) | set(db_layer.get_all_categories(DB_PATH)))
city_options = list(config.CITIES.keys())
with st.form("add_company_form", clear_on_submit=True):
a1, a2, a3 = st.columns(3)
with a1:
f_name = st.text_input("Название *")
f_phone = st.text_input("Телефон")
f_email = st.text_input("Email")
f_website = st.text_input("Сайт")
with a2:
f_category = st.selectbox("Категория", options=cat_options or [""]) # 📋 меню
f_city = st.selectbox("Город", options=city_options) # 📋 меню
f_district = st.text_input("Район")
f_address = st.text_input("Адрес")
with a3:
f_inn = st.text_input("ИНН")
f_director = st.text_input("Директор (ФИО)")
f_source = st.selectbox("Источник", # 📋 меню
options=["manual", "yandex_maps", "hh", "2gis", "vk"])
f_status = st.selectbox("Статус", options=list(STATUS_LABELS.keys()), # 📋 меню
format_func=fmt_status)
add_submit = st.form_submit_button("💾 Добавить компанию", type="primary", width="stretch")
if add_submit:
if not f_name.strip():
st.error("Название обязательно.")
else:
data = {
"name": f_name.strip(),
"phones": [f_phone.strip()] if f_phone.strip() else [],
"phone_primary": f_phone.strip() or None,
"emails": [f_email.strip()] if f_email.strip() else [],
"email_primary": f_email.strip() or None,
"website": f_website.strip() or None,
"category": f_category,
"city": f_city,
"region": config.CITIES.get(f_city, {}).get("region", f_city),
"district": f_district.strip() or None,
"address": f_address.strip() or None,
"inn": f_inn.strip() or None,
"director_name": f_director.strip() or None,
"source": f_source,
"outreach_status": f_status,
}
try:
new_id = db_layer.add_lead_manual(DB_PATH, data)
st.success(f"✅ Добавлена: {f_name.strip()} (#{new_id})")
st.rerun()
except ValueError as e:
st.error(str(e))
# ── Таблица ─────────────────────────────────────────────────────
if len(df) == 0:
st.info(
"Под текущие фильтры лидов нет. "
"Ослабь критерии в левой панели или выбери другой быстрый фильтр сверху."
)
return
st.markdown(f"### 📋 Лиды ({len(df)})")
st.caption("👆 Кликни на строку — внизу раскроется детальная карточка лида.")
# Подготавливаем display
display = df.copy()
display["status_view"] = display["outreach_status"].apply(fmt_status)
display["source_view"] = display["source"].apply(fmt_source)
display["last_action_view"] = display["last_action"].apply(fmt_channel)
display["last_touched_short"] = (
display["last_touched_at"].fillna("").astype(str).str.slice(0, 16)
)
display["band_view"] = display["band"].apply(fmt_band)
display["pain_view"] = display["pain_products"].apply(fmt_pain_products)
show_cols = {
"id": "ID",
"name": "Имя",
"source_view": "Источник",
"category": "Категория",
"city": "Город",
"district": "Район",
"score": "Score",
"band_view": "Бэнд",
"pain_view": "С чем заходить",
"status_view": "Статус",
"phone_primary": "Телефон",
"email_primary": "Email",
"last_action_view": "Последнее действие",
"last_touched_short": "Касались",
}
show_df = display[list(show_cols.keys())].rename(columns=show_cols)
# NaN/None → пустая строка, чтобы в ячейках не было слова "None"
show_df = show_df.fillna("")
event = st.dataframe(
show_df,
width="stretch",
hide_index=True,
on_select="rerun",
selection_mode="single-row",
column_config={
"Score": st.column_config.NumberColumn(format="%d"),
},
)
# ── Деталь выбранного лида ──────────────────────────────────────
sel = event.selection.rows if event and event.selection else []
if sel:
st.divider()
selected_lead_id = int(df.iloc[sel[0]]["id"])
render_lead_detail(selected_lead_id)
# Streamlit запускает скрипт целиком на каждое действие — без if __name__
main()
+329
View File
@@ -0,0 +1,329 @@
"""DB-слой Streamlit-приложения.
Все запросы к leads.db инкапсулированы здесь. UI-код в app.py не делает
SQL напрямую — только через эти функции.
Стандарт: каждая функция сама открывает/закрывает соединение.
Streamlit перезапускает скрипт на каждое действие — глобальный коннект
держать не имеет смысла.
"""
import json
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Any
import pandas as pd
def _conn(db_path: Path | str) -> sqlite3.Connection:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
return conn
# ─── Опции для фильтров (что вообще есть в БД) ──────────────────────
def get_all_sources(db_path) -> list[str]:
conn = _conn(db_path)
rows = conn.execute(
"SELECT DISTINCT source FROM leads WHERE source IS NOT NULL ORDER BY source"
).fetchall()
conn.close()
# У некоторых лидов source может быть 'yandex_maps,hh' (мерж разных источников) — раскладываем
out: set[str] = set()
for r in rows:
for part in (r["source"] or "").split(","):
part = part.strip()
if part:
out.add(part)
return sorted(out)
def get_all_regions(db_path) -> list[str]:
conn = _conn(db_path)
rows = conn.execute(
"SELECT DISTINCT region FROM leads WHERE region IS NOT NULL AND region != '' ORDER BY region"
).fetchall()
conn.close()
return [r["region"] for r in rows]
def get_all_categories(db_path) -> list[str]:
conn = _conn(db_path)
rows = conn.execute(
"SELECT DISTINCT category FROM leads WHERE category IS NOT NULL AND category != '' ORDER BY category"
).fetchall()
conn.close()
return [r["category"] for r in rows]
# ─── Загрузка лидов с фильтрами ──────────────────────────────────────
def get_leads(db_path, filters: dict) -> pd.DataFrame:
"""Получить таблицу лидов с применением фильтров. Возвращает DataFrame.
filters: {
sources, regions, district_search, categories, statuses,
min_score, max_score, name_search
}
Все ключи опциональные.
"""
where: list[str] = []
params: list = []
if filters.get("sources"):
clauses = []
for s in filters["sources"]:
clauses.append("source LIKE ?")
params.append(f"%{s}%")
where.append("(" + " OR ".join(clauses) + ")")
if filters.get("regions"):
placeholders = ", ".join("?" for _ in filters["regions"])
where.append(f"region IN ({placeholders})")
params.extend(filters["regions"])
if filters.get("district_search"):
where.append("district LIKE ?")
params.append(f"%{filters['district_search']}%")
if filters.get("categories"):
placeholders = ", ".join("?" for _ in filters["categories"])
where.append(f"category IN ({placeholders})")
params.extend(filters["categories"])
if filters.get("statuses"):
# 'inbox' совмещаем с 'new' (старые лиды до миграции имели default 'new')
normalized = []
for s in filters["statuses"]:
if s == "inbox":
normalized.append("inbox")
normalized.append("new")
else:
normalized.append(s)
placeholders = ", ".join("?" for _ in normalized)
where.append(f"COALESCE(outreach_status, 'new') IN ({placeholders})")
params.extend(normalized)
# COALESCE(score, 0): лиды со score=NULL (напр. добавленные вручную) иначе
# отсеиваются, т.к. в SQL `NULL >= 0` не истинно. Считаем NULL за 0.
if "min_score" in filters:
where.append("COALESCE(score, 0) >= ?")
params.append(filters["min_score"])
if "max_score" in filters:
where.append("COALESCE(score, 0) <= ?")
params.append(filters["max_score"])
if filters.get("name_search"):
where.append("name LIKE ?")
params.append(f"%{filters['name_search']}%")
# Фильтр «есть боль под продукт»: pain_products хранит JSON {"P4":3.0,...}.
# Матчим по подстроке "P4" (в кавычках, чтобы P1 не ловил P10).
if filters.get("pain_products"):
clauses = []
for p in filters["pain_products"]:
clauses.append("pain_products LIKE ?")
params.append(f'%"{p}"%')
where.append("(" + " OR ".join(clauses) + ")")
where_sql = " AND ".join(where) if where else "1=1"
cols = """
id, name, inn, director_name, phone_primary, email_primary, phones, emails,
website, vk_url, telegram_url, instagram_url, youtube_url,
address, city, region, district, category,
reviews_count, reviews_avg, score, score_breakdown,
pain_products, diagnostic_coverage, band,
outreach_status, comments, last_action, last_reaction, last_touched_at,
source, parsed_at
"""
query = f"""
SELECT {cols}
FROM leads
WHERE {where_sql}
ORDER BY score DESC, id
"""
conn = _conn(db_path)
df = pd.read_sql_query(query, conn, params=params)
conn.close()
# Нормализуем outreach_status: NULL/'new' → 'inbox' для отображения
if "outreach_status" in df.columns:
df["outreach_status"] = df["outreach_status"].fillna("inbox").replace({"new": "inbox"})
return df
# ─── Один лид ────────────────────────────────────────────────────────
def get_lead_detail(db_path, lead_id: int) -> dict | None:
conn = _conn(db_path)
row = conn.execute("SELECT * FROM leads WHERE id = ?", (lead_id,)).fetchone()
conn.close()
if not row:
return None
lead = dict(row)
# Парсим JSON-поля
for f in ("phones", "phones_extra", "emails", "score_breakdown", "pain_products"):
if lead.get(f):
try:
lead[f] = json.loads(lead[f])
except (json.JSONDecodeError, TypeError):
pass
return lead
# ─── История касаний ────────────────────────────────────────────────
def get_outreach_history(db_path, lead_id: int) -> list[dict]:
conn = _conn(db_path)
rows = conn.execute("""
SELECT * FROM outreach_events
WHERE lead_id = ?
ORDER BY COALESCE(sent_at, '0000') DESC, id DESC
""", (lead_id,)).fetchall()
conn.close()
return [dict(r) for r in rows]
# ─── Запись нового касания ──────────────────────────────────────────
def record_touch(
db_path,
lead_id: int,
channel: str,
reaction: str | None = None,
notes: str | None = None,
new_status: str | None = None,
message_text: str | None = None,
) -> int:
"""Записать касание лида.
- Создаёт строку в outreach_events
- Обновляет last_action / last_reaction / last_touched_at у лида
- Опционально меняет outreach_status
Возвращает id новой строки в outreach_events.
"""
now = datetime.now().isoformat(timespec="seconds")
conn = _conn(db_path)
cursor = conn.execute("""
INSERT INTO outreach_events
(lead_id, channel, message_text, sent_at, reaction, notes)
VALUES (?, ?, ?, ?, ?, ?)
""", (lead_id, channel, message_text, now, reaction, notes))
event_id = cursor.lastrowid
updates = ["last_action = ?", "last_reaction = ?", "last_touched_at = ?"]
values: list[Any] = [channel, reaction, now]
if new_status:
updates.append("outreach_status = ?")
values.append(new_status)
values.append(lead_id)
conn.execute(f"UPDATE leads SET {', '.join(updates)} WHERE id = ?", values)
conn.commit()
conn.close()
return event_id
# ─── Обновление полей лида ──────────────────────────────────────────
def update_lead_status(db_path, lead_id: int, status: str) -> None:
conn = _conn(db_path)
conn.execute("UPDATE leads SET outreach_status = ? WHERE id = ?", (status, lead_id))
conn.commit()
conn.close()
def update_lead_comments(db_path, lead_id: int, comments: str) -> None:
conn = _conn(db_path)
conn.execute("UPDATE leads SET comments = ? WHERE id = ?", (comments, lead_id))
conn.commit()
conn.close()
# ─── Метрики для дашборда ───────────────────────────────────────────
def count_inbox(db_path) -> int:
conn = _conn(db_path)
n = conn.execute(
"SELECT COUNT(*) FROM leads WHERE COALESCE(outreach_status, 'new') IN ('inbox', 'new')"
).fetchone()[0]
conn.close()
return n
def count_in_work(db_path) -> int:
conn = _conn(db_path)
n = conn.execute(
"SELECT COUNT(*) FROM leads WHERE outreach_status IN ('in_work', 'triaged')"
).fetchone()[0]
conn.close()
return n
def count_done(db_path) -> int:
conn = _conn(db_path)
n = conn.execute(
"SELECT COUNT(*) FROM leads WHERE outreach_status = 'done'"
).fetchone()[0]
conn.close()
return n
def count_total(db_path) -> int:
conn = _conn(db_path)
n = conn.execute("SELECT COUNT(*) FROM leads").fetchone()[0]
conn.close()
return n
# ─── Ручное добавление / удаление компаний (из CRM) ──────────────────
def add_lead_manual(db_path, data: dict) -> int:
"""Добавить компанию вручную из CRM. Пишет в ту же leads.db.
Использует database._prepare_lead — те же dedup-ключи / нормализация /
has_website / parsed_at, что и у парсера (консистентность).
Возвращает id нового лида.
Бросает ValueError при дубле (UNIQUE inn / phone_dedup_key) — UI покажет.
"""
import database # parser_v1/database.py (PARENT уже в sys.path из app.py)
prepared = database._prepare_lead(data)
if prepared.get("score") is None:
prepared["score"] = 0 # иначе NULL-score лид невидим в таблице (фильтр score)
fields = list(database.WRITABLE_FIELDS) + ["parsed_at"]
if prepared.get("outreach_status"): # не входит в WRITABLE_FIELDS — добавляем явно
fields.append("outreach_status")
cols = ", ".join(fields)
placeholders = ", ".join("?" for _ in fields)
values = [prepared.get(f) for f in fields]
conn = _conn(db_path)
try:
cur = conn.execute(
f"INSERT INTO leads ({cols}) VALUES ({placeholders})", values
)
conn.commit()
return cur.lastrowid
except sqlite3.IntegrityError as e:
conn.rollback()
msg = str(e).lower()
if "inn" in msg:
raise ValueError(f"Компания с таким ИНН уже есть в базе ({data.get('inn')}).") from e
if "phone" in msg:
raise ValueError(f"Компания с таким телефоном уже есть в базе.") from e
raise ValueError(f"Не удалось добавить (дубль): {e}") from e
finally:
conn.close()
def delete_lead(db_path, lead_id: int) -> None:
"""Удалить компанию из CRM + её историю касаний и связи с прогонами."""
conn = _conn(db_path)
conn.execute("DELETE FROM outreach_events WHERE lead_id = ?", (lead_id,))
conn.execute("DELETE FROM lead_in_run WHERE lead_id = ?", (lead_id,))
conn.execute("DELETE FROM leads WHERE id = ?", (lead_id,))
conn.commit()
conn.close()