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:
+554
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user