70 KiB
План реализации: Лиды, Звонки, Единая карточка клиента
Дата создания: 03.06.2026 Статус: ПЛАН (код не редактируется)
0. Краткое описание
Реализация Service Desk + CRM системы по модели из voronka.md:
- Лиды — обращения из бота, телефонии, сайта → единая точка входа
- Звонки — CDR из MySQL → привязка к клиентам → уведомления о пропущенных
- Единая карточка клиента — всё об одном клиенте в одном месте
- Воронка продаж — визуализация для руководителя
- Уведомления — автоматическая фиксация событий без участия пользователя
Ключевые решения
| Решение | Выбор |
|---|---|
| Лид = отдельная таблица в py_service | ✅ Да |
| Автопривязка лида к клиенту | ИНН → телефон → email → создание draft |
| Дедупликация | 3 уровня: draft (автопривязка), перенос записей, слияние дубликатов + уведомление |
| Транскрипция (уровень 4) | Автопривязка ≥ 85% + уведомление ответившему для подтверждения; фаза 6 |
| Email-канал | Тема Заявка с сайта ({тип услуги}) — {дата}; парсинг типа услуги + контактов из тела; общая почта — фильтр по теме |
| Название компании | Не уникально; ИНН — уникально, но не всегда доступен |
| Входящий поток | Единая страница /service/inbox (виртуальный запрос из 3 источников) |
| Настройки АТС | Отдельная страница, пароли шифруются, серийник проверяется |
| Внутренний номер | Привязка к расширению АТС → автодетект звонка → уведомления |
| KPI/воронка | Только для owner (руководителя) |
| Задачи | Owner/engineer создают → делегируют техникам; техник может только создать себе напоминание |
| Транскрибация | Будет позже (нужен доступ к Yandex SpeechKit) — фаза 6 |
| Записи WAV | Будет позже (нужен SSH/FTP к файлам) — фаза 6 |
| 1С:УНФ интеграция | OData API → синхронизация контрагентов/заказов — фаза 7 |
1. Новые сущности (миграции)
1.1 Таблица leads
CREATE TABLE leads (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
-- Источник обращения
source VARCHAR(20) NOT NULL DEFAULT 'bot',
-- bot | phone_in | phone_out | site | email | manual
-- Данные клиента (заполняются из бота/звонка)
company_name VARCHAR(255) DEFAULT '',
contact_name VARCHAR(255) DEFAULT '',
contact_phone VARCHAR(50) DEFAULT '',
contact_email VARCHAR(255) DEFAULT '',
inn VARCHAR(12) DEFAULT '',
-- Содержание обращения
service_type VARCHAR(255) DEFAULT '', -- какую услугу ищет
description TEXT DEFAULT '', -- текст обращения / краткое описание
urgency VARCHAR(10) DEFAULT 'medium', -- critical | high | medium | low
-- Статус
status VARCHAR(30) DEFAULT 'new',
-- new | contact | qualification | proposal | negotiation | approved | won | lost | merged
-- Привязка
assigned_to INTEGER REFERENCES users(id), -- кто обрабатывает
bot_ticket_id BIGINT, -- ID заявки из бота (ссылка на max_bot DB)
-- Воронка
deal_amount NUMERIC(12, 2) DEFAULT 0, -- сумма сделки (для KPI)
expected_close_date DATE, -- ожидаемая дата закрытия
-- Мета
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
1.2 Таблица call_records
CREATE TABLE call_records (
id SERIAL PRIMARY KEY,
-- Данные из CDR
cdr_uniqueid VARCHAR(32) UNIQUE, -- уникальный ID из CDR (для дедупликации)
call_datetime TIMESTAMP NOT NULL,
caller_number VARCHAR(80) DEFAULT '', -- номер звонящего (src)
callee_number VARCHAR(80) DEFAULT '', -- номер получателя (dst)
trunk VARCHAR(80) DEFAULT '', -- транк (Mango, Tele2)
duration INTEGER DEFAULT 0, -- общая длительность (сек)
billable_seconds INTEGER DEFAULT 0, -- биллируемая (сек)
disposition VARCHAR(30) DEFAULT '', -- ANSWERED, NO ANSWER, BUSY, VOICEMAIL
call_type VARCHAR(20) DEFAULT '', -- Inbound, Outbound, Internal
queue_name VARCHAR(30) DEFAULT '', -- очередь (6700, 6701, 6702)
-- Привязка к клиенту
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL,
-- Инженер/техник кто принял звонок
answered_by INTEGER REFERENCES users(id), -- расширение (1001, 1002)
answered_by_name VARCHAR(100) DEFAULT '', -- имя (из CDR personalcontact)
-- Запись
has_recording BOOLEAN DEFAULT FALSE,
recording_path VARCHAR(500) DEFAULT '', -- путь на PBX (/tmp/media/mmc1/...)
recording_filename VARCHAR(200) DEFAULT '', -- имя WAV-файла
-- Уведомления
notification_sent BOOLEAN DEFAULT FALSE, -- отправлено ли уведомление
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_call_records_customer ON call_records(customer_id);
CREATE INDEX idx_call_records_datetime ON call_records(call_datetime);
CREATE INDEX idx_call_records_caller ON call_records(caller_number);
CREATE INDEX idx_call_records_disposition ON call_records(disposition);
1.3 Таблица client_notifications
CREATE TABLE client_notifications (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL,
call_record_id INTEGER REFERENCES call_records(id) ON DELETE SET NULL,
type VARCHAR(30) NOT NULL,
-- missed_call | new_lead | lead_status_change | sla_breach | task_overdue
title VARCHAR(500) NOT NULL,
message TEXT DEFAULT '',
is_read BOOLEAN DEFAULT FALSE,
read_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_notifications_unread ON client_notifications(is_read, created_at);
1.4 Связь task → lead
ALTER TABLE tasks ADD COLUMN lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL;
2. Изменения в существующих таблицах
2.1 Таблица customers
ALTER TABLE customers ADD COLUMN lead_source VARCHAR(20) DEFAULT '';
ALTER TABLE customers ADD COLUMN last_call_at TIMESTAMP;
ALTER TABLE customers ADD COLUMN total_calls INTEGER DEFAULT 0;
ALTER TABLE customers ADD COLUMN total_leads INTEGER DEFAULT 0;
2.2 Таблица bot_tickets (max_bot)
Без изменений. BotTicket остаётся как «сырой» источник. При создании BotTicket → автоматически создаётся Lead в py_service.
3. Бот: сбор данных
3.1 Изменение flow
Текущий flow бота:
greeting → awaiting_input → [ticket intent] → awaiting_consent
→ awaiting_contact → awaiting_inquiry → completed
Новый flow (добавляется шаг сбора компании):
greeting → awaiting_input → [ticket intent] → awaiting_consent
→ awaiting_contact → awaiting_company → awaiting_inquiry → completed
3.2 Шаг «awaiting_company»
После получения контакта бот спрашивает:
Бот: Из какой вы компании?
[Не могу сказать] ← кнопка
- Если пользователь пишет название → сохраняется в
BotUser.organization - Если нажал «Не могу сказать» → поле пустое, руководитель заполнит позже
- Важно: название компании не уникально (может быть 10 ООО «Ромашка»)
3.3 API для передачи данных в py_service
При создании BotTicket бот передаёт:
{
"title": "...",
"description": "...",
"status": "Новая",
"contact_name": "Иван Петров",
"contact_phone": "+79991234567",
"contact_email": "ivan@example.com",
"company_name": "ООО Ромашка",
"organization": "ООО Ромашка"
}
3.4 Email-канал: парсинг писем с сайта
Важно: Почта mail@aegisone.ru общая — приходят не только заявки, но и переписка с поставщиками. Парсинг идёт по теме письма.
Фактический формат темы (из PHP-кода сайта)
define('SITE_MAIL_FROM', 'no-reply@aegisone.ru');
define('SITE_MAIL_SUBJECT_PREFIX', 'Заявка с сайта');
Формат темы: Заявка с сайта ({тип услуги}) — {дата}
Примеры:
Заявка с сайта (Риск-инжиниринг) — 18.05.2026 11:54:55Заявка с сайта (Технический надзор) — 16.05.2026 16:26:32
Извлекаемое:
- Тип услуги:
Риск-инжиниринг,Технический надзори т.д. - Дата обращения
Алгоритм парсинга
import re
from email.header import decode_header
# Паттерн для определения заявки с сайта
# Фактический формат: "Заявка с сайта ({тип услуги}) — {дата}"
# Пример: "Заявка с сайта (Риск-инжиниринг) — 18.05.2026 11:54:55"
SITE_REQUEST_PATTERN = re.compile(
r'^Заявка с сайта\s*\((.+?)\)\s*—\s*(\d{2}\.\d{2}\.\d{4}\s+\d{2}:\d{2}:\d{2})$',
re.IGNORECASE
)
async def parse_email_to_lead(email_message):
"""Парсинг email-сообщения в Lead.
Определяет по теме письма:
- Является ли письмо заявкой с сайта
- Тип услуги (из скобок)
- Дату обращения
Контактные данные извлекаются из тела письма.
"""
subject = decode_header(email_message['Subject'])[0][0]
if isinstance(subject, bytes):
subject = subject.decode('utf-8')
match = SITE_REQUEST_PATTERN.match(subject.strip())
if not match:
return None # Не заявка — пропускаем
service_type = match.group(1).strip() # "Риск-инжиниринг"
date_str = match.group(2).strip() # "18.05.2026 11:54:55"
# Извлекаем данные из тела письма
body = extract_body(email_message)
contact_data = extract_contact_from_body(body)
lead = Lead(
source="email",
title=f"Заявка с сайта: {service_type}",
service_type=service_type,
description=body,
company_name=contact_data.get("company", ""),
contact_name=contact_data.get("name", ""),
contact_phone=contact_data.get("phone", ""),
contact_email=email_message.get("From", ""),
)
return lead
def extract_contact_from_body(body: str) -> dict:
"""Извлечение контактов из тела email.
Ожидаемый формат тела (автоматически сгенерирован формой):
---
Имя: Иван Петров
Телефон: +7 (999) 123-45-67
Email: ivan@example.com
Компания: ООО Ромашка
Сообщение: ...
---
"""
result = {}
for line in body.split('\n'):
if ':' in line:
key, value = line.split(':', 1)
key = key.strip().lower()
value = value.strip()
if key == 'имя':
result['name'] = value
elif key == 'телефон':
result['phone'] = value
elif key == 'email':
result['email'] = value
elif key == 'компания':
result['company'] = value
return result
IMAP-клиент для проверки почты
import imaplib
import email
IMAP_SERVER = "mail.aegisone.ru" # уточнить у хостинга
IMAP_PORT = 993
IMAP_USER = "mail@aegisone.ru"
IMAP_PASS = "из .env"
async def check_email_for_leads():
"""Периодическая проверка почты на новые заявки.
Запускается каждые 5 минут (как CDR-синхронизация).
Обрабатывает только непрочитанные письма с темой 'Заявка с сайта ('.
"""
conn = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT)
conn.login(IMAP_USER, IMAP_PASS)
conn.select("INBOX")
# Ищем непрочитанные с темой "Заявка с сайта ("
_, msg_nums = conn.search(None, '(UNSEEN SUBJECT "Заявка с сайта (")')
for num in msg_nums[0].split():
_, msg_data = conn.fetch(num, "(RFC822)")
msg = email.message_from_bytes(msg_data[0][1])
lead = await parse_email_to_lead(msg)
if lead:
await save_lead(lead)
# Помечаем как прочитанное
conn.store(num, "+FLAGS", "\\Seen")
conn.logout()
Приоритет: сначала телефон, потом email
| Этап | Источник | Статус |
|---|---|---|
| Фаза 1 | Телефон (CDR из Yeastar S20) | Текущий приоритет |
| Фаза 2 | Email (парсинг по теме письма) | Позже |
Причина: Телефон — основной канал коммуникации. Email требует настройки IMAP + парсинг темы, что сложнее прямого доступа к MySQL CDR.
4. Автопривязка: Lead → Customer
4.1 Алгоритм автопривязки
При создании лида (из бота или вручную):
async def auto_link_lead(lead, db):
customer = None
# 1. Поиск по ИНН (если есть) — самый точный
if lead.inn:
customer = await db.execute(
select(Customer).where(Customer.inn == lead.inn)
).scalar_one_or_none()
# 2. Поиск по телефону (нормализованный)
if not customer and lead.contact_phone:
normalized = normalize_phone(lead.contact_phone)
customer = await db.execute(
select(Customer).where(
func.regexp_replace(Customer.contact_phone, '[^0-9]', '', 'g') LIKE f'%{normalized}%'
)
).scalar_one_or_none()
# 3. Поиск по email
if not customer and lead.contact_email:
customer = await db.execute(
select(Customer).where(
func.lower(Customer.contact_email) == lead.contact_email.lower()
)
).scalar_one_or_none()
# 4. Если не найден — создать «Черновик клиента»
if not customer:
customer = Customer(
name=lead.company_name or f"Клиент {lead.contact_phone or lead.contact_email}",
contact_person=lead.contact_name,
contact_phone=lead.contact_phone,
contact_email=lead.contact_email,
status="draft", -- черновик, руководитель заполнит
created_by=lead.created_by
)
db.add(customer)
await db.flush()
lead.customer_id = customer.id
4.2 Новый статус клиента: draft
Добавить в CHECK-constraint:
ALTER TABLE customers DROP CONSTRAINT ck_customers_status;
ALTER TABLE customers ADD CONSTRAINT ck_customers_status
CHECK (status IN ('active', 'inactive', 'prospect', 'draft'));
draft = автосоздан системой, ожидает заполнения руководителем.
4.3 Коррекция привязки (дедупликация)
Проблема: Телефон +79991234567 принадлежит секретарю ООО "Ромашка", но он звонит и от имени ООО "Василёк". Автопривязка привяжет звонок к "Ромашка", хотя заявка от "Василёк".
Решение — 3 уровня коррекции:
Уровень 1: Автопривязка + статус draft
- Автопривязка по телефону/email работает автоматически
- Если клиент создан автоматически (черновик) — статус
draft - Руководитель проверяет и корректирует при необходимости
- Черновики выделяются визуально (серый фон в таблице)
Уровень 2: Перенос отдельных записей
В карточке звонка/лида — кнопка «Изменить клиента»:
@router.post("/service/calls/reassign")
async def reassign_call(
call_id: int,
new_customer_id: int,
user: dict = Depends(get_current_user),
):
"""Перенос звонка к другому клиенту.
Сохраняет audit-запись о переносе.
"""
call = await db.get(CallRecord, call_id)
old_customer_id = call.customer_id
call.customer_id = new_customer_id
# Audit-запись для истории
await db.execute(
insert(AuditLog).values(
entity_type="call_record",
entity_id=call_id,
field="customer_id",
old_value=str(old_customer_id),
new_value=str(new_customer_id),
changed_by=user["id"],
)
)
await db.commit()
UI: В таблице звонков/лидов — колонка «Клиент» кликабельна → выпадающий список для выбора другого клиента.
Уровень 3: Слияние дубликатов (Merge)
Если обнаружен дубликат клиента — кнопка «Слить с другим клиентом» в карточке:
@router.post("/service/customers/merge")
async def merge_customers(
source_id: int, # дубликат (удаляется)
target_id: int, # основной (остаётся)
user: dict = Depends(get_current_user),
):
"""Слияние двух клиентов.
Все связанные данные переносятся на target.
Исходный клиент помечается как 'merged'.
"""
# Перенос лидов
await db.execute(
update(Lead).where(Lead.customer_id == source_id).values(customer_id=target_id)
)
# Перенос звонков
await db.execute(
update(CallRecord).where(CallRecord.customer_id == source_id).values(customer_id=target_id)
)
# Перенос задач
await db.execute(
update(Task).where(Task.customer_id == source_id).values(customer_id=target_id)
)
# Перенос инцидентов
await db.execute(
update(Incident).where(Incident.customer_id == source_id).values(customer_id=target_id)
)
# Помечаем исходный как merged
source = await db.get(Customer, source_id)
source.status = "merged"
source.merged_into = target_id
await db.commit()
UI: В карточке клиента — кнопка «Слить с другим» → выбор клиента → подтверждение → перенос всех данных + визуальное уведомление.
4.4 Таблица audit_log (история изменений)
CREATE TABLE audit_log (
id SERIAL PRIMARY KEY,
entity_type VARCHAR(50) NOT NULL, -- customer, lead, call_record, task
entity_id INTEGER NOT NULL,
field VARCHAR(100) NOT NULL,
old_value TEXT DEFAULT '',
new_value TEXT DEFAULT '',
changed_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_audit_entity ON audit_log(entity_type, entity_id);
5. Звонки из MySQL CDR
5.1 Периодический опрос
Каждые 5 минут:
async def sync_cdr():
last_sync = get_last_cdr_sync_time()
conn = get_pbx_mysql_connection()
cursor = conn.cursor()
# Определяем текущую таблицу (cdr или cdr_YYYYMM)
table = f"cdr_{datetime.now():%Y%m}"
cursor.execute(f"""
SELECT datetime, src, dst, duration, billable, disposition,
calltype, srctrunk, uniqueid, recordfile, recordpath,
personalcontact, dstchanurl
FROM cdr.{table}
WHERE datetime > %s
ORDER BY datetime ASC
""", (last_sync,))
for row in cursor.fetchall():
# Создаём call_record
call = create_call_record(row)
# Автопривязка к клиенту
auto_link_call_to_customer(call)
# Уведомление о пропущенном
if call.disposition in ('NO ANSWER', 'BUSY'):
send_missed_call_notification(call)
update_last_cdr_sync_time()
5.2 Автопривязка звонка к клиенту
async def auto_link_call_to_customer(call):
customer = None
# Нормализуем номер
normalized = normalize_phone(call.caller_number)
# Ищем клиента по номеру телефона
customer = await db.execute(
select(Customer).where(
func.regexp_replace(Customer.contact_phone, '[^0-9]', '', 'g') LIKE f'%{normalized}%'
)
).scalar_one_or_none()
# Если не найден — создать «Черновик клиента» (как при лиде)
if not customer:
customer = create_draft_customer(call.caller_number)
call.customer_id = customer.id
5.3 Уведомления
| Событие | Тип уведомления | Кому |
|---|---|---|
| Пропущенный звонок (NO ANSWER) | missed_call |
Owner + инженер |
| Входящий звонок на пустую очередь | missed_call |
Owner + инженер |
| Новый лид от бота | new_lead |
Owner + инженер |
| SLA по задаче нарушен | sla_breach |
Owner + инженер |
| Задача просрочена | task_overdue |
Owner + инженер |
6. Страницы в портале
6.1 Страница «Лиды» (/service/leads)
Маршруты:
GET /service/leads— список лидовPOST /service/leads/create— создать лид вручнуюPOST /service/leads/update— изменить статус/данныеPOST /service/leads/delete— удалить лид
Фильтры:
- По статусу (воронка)
- По источнику (bot, phone, manual)
- По менеджеру (assigned_to)
- По дате
- Поиск по названию/телефону/контакту
Колонки таблицы:
| # | Столбец | Описание |
|---|---|---|
| 1 | ID | Номер лида |
| 2 | Источник | bot / phone / site / manual |
| 3 | Клиент | Компания (ссылка на карточку) |
| 4 | Контакт | Имя + телефон |
| 5 | Услуга | Что ищет клиент |
| 6 | Критичность | Critical / High / Medium / Low |
| 7 | Статус | Воронка |
| 8 | Менеджер | Назначенный инженер |
| 9 | Сумма | Ожидаемая сумма сделки |
| 10 | Дата | Дата создания |
| 11 | Действия | Статус, редактирование, удаление |
Страница «Воронка» (вкладка на странице лидов для owner):
Новые: 5 Связаться: 3 Квалификация: 2
КП: 1 Переговоры: 0 Согласование: 0
Выиграно: 4 Проиграно: 1
6.2 Страница «Звонки» (/service/calls)
Маршруты:
GET /service/calls— история звонков
Фильтры:
- По дате (от/до)
- По номеру телефона
- По клиенту
- По типу (входящий/исходящий)
- По статусу (отвечен/пропущен/занято)
Колонки таблицы:
| # | Столбец | Описание |
|---|---|---|
| 1 | Дата/время | Когда звонил |
| 2 | Тип | Входящий / Исходящий |
| 3 | Номер | Откуда / Куда |
| 4 | Клиент | Привязанный клиент |
| 5 | Принял | Кто ответил (расширение) |
| 6 | Длительность | Секунды |
| 7 | Статус | Отвечен / Пропущен / Занято |
| 8 | Транк | Mango / Tele2 |
| 9 | Запись | 🎧 (будет доступна позже) |
6.3 Расширенная карточка клиента (/service/customers/{id})
Текущая карточка — модальное окно. Нужно перевести на отдельную страницу.
Вкладки (tabs):
| Вкладка | Содержимое |
|---|---|
| Общее | Контакты, ИНН, адрес, статус, заметки (текущая карточка) |
| Звонки | Таблица звонков этого клиента |
| Заявки | Лиды этого клиента |
| Задачи | Задачи, привязанные к клиенту (через объект или лид) |
| Инциденты | Инциденты этого клиента (через объект) |
| Файлы | Документы клиента (будущее) |
| История | Лог действий (audit_log) |
7. Уведомления
7.1 API уведомлений
# Получить непрочитанные
GET /service/api/notifications?unread=true
# Пометить как прочитанное
POST /service/api/notifications/{id}/read
# Количество непрочитанных (для иконки в шапке)
GET /service/api/notifications/count
7.2 UI уведомлений
- 🔔 Иконка в шапке с бейджем количества непрочитанных
- Выпадающий список последних 10 уведомлений
- Ссылка «Все уведомления» → отдельная страница
7.3 Типы уведомлений
| Тип | Описание | Кому | Действие |
|---|---|---|---|
missed_call |
Пропущенный звонок | Owner, assigned engineer | → Карточка клиента |
new_lead |
Новая заявка от бота | Owner | → Список лидов |
lead_status_change |
Статус лида изменён | Assigned user | → Карточка лида |
sla_breach |
Нарушен SLA по задаче | Owner, assigned | → Карточка задачи |
task_overdue |
Задача просрочена | Owner, assigned | → Карточка задачи |
8. Единая страница входящих /service/inbox
8.1 Концепция
Единая точка входа для owner и engineer. Все обращения из разных каналов в одном месте.
| Параметр | Значение |
|---|---|
| URL | /service/inbox |
| Доступ | owner, engineer |
| Обновление | По кнопке "Обновить" (AJAX) |
| Источник | Виртуальный запрос (без отдельной таблицы) |
8.2 Источники данных
-- Виртуальный запрос: объединение 3 каналов
(
-- 1. Заявки от бота (не обработанные)
SELECT
'bot' as channel,
bt.id as source_id,
bt.contact_name,
bt.contact_phone,
bt.company_name,
bt.description,
bt.status,
bt.created_at
FROM bot_tickets bt
WHERE bt.status = 'Новая'
UNION ALL
-- 2. Пропущенные звонки (не обработанные)
SELECT
'phone' as channel,
cr.id as source_id,
'' as contact_name,
cr.caller_number as contact_phone,
'' as company_name,
CONCAT(cr.disposition, ' — ', cr.callee_number) as description,
'missed' as status,
cr.call_datetime as created_at
FROM call_records cr
WHERE cr.disposition IN ('NO ANSWER', 'BUSY')
AND cr.customer_id IS NULL -- ещё не привязан к клиенту
UNION ALL
-- 3. Заявки с сайта (email, не обработанные)
SELECT
'email' as channel,
l.id as source_id,
l.contact_name,
l.contact_phone,
l.company_name,
l.description,
l.status,
l.created_at
FROM leads l
WHERE l.source = 'email'
AND l.status = 'new'
)
ORDER BY created_at DESC;
8.3 UI
┌─────────────────────────────────────────────────────────────────┐
│ Неразобранное (3) [Обновить] 🔔 │
├─────────────────────────────────────────────────────────────────┤
│ 📱 Бот (2) │ Иван Петров │ +7999... │ "Нужен насос" │
│ 📞 Звонок (1) │ +7912345678 │ — │ Пропущенный 14:32 │
│ 📧 Email (0) │ — │ — │ — │
├─────────────────────────────────────────────────────────────────┤
│ [Обработать] → Открывает карточку/лид │
└─────────────────────────────────────────────────────────────────┘
Действия:
- Кнопка "Обработать" → переход к созданию лида/клиента
- Фильтр по каналу (бот/звонок/email)
- Сортировка по времени
9. Настройки АТС (Yeastar S20)
9.1 Таблица pbx_settings
CREATE TABLE pbx_settings (
id SERIAL PRIMARY KEY,
-- Подключение
mysql_host VARCHAR(255) DEFAULT '192.168.1.150',
mysql_port INTEGER DEFAULT 3306,
mysql_user VARCHAR(100) DEFAULT '',
mysql_password VARCHAR(255) DEFAULT '', -- шифрование
mysql_database VARCHAR(100) DEFAULT 'cdr',
ami_host VARCHAR(255) DEFAULT '192.168.1.150',
ami_port INTEGER DEFAULT 5038,
ami_user VARCHAR(100) DEFAULT '',
ami_password VARCHAR(255) DEFAULT '', -- шифрование
ftp_host VARCHAR(255) DEFAULT '192.168.1.150',
ftp_port INTEGER DEFAULT 21,
ftp_user VARCHAR(100) DEFAULT '',
ftp_password VARCHAR(255) DEFAULT '', -- шифрование
web_host VARCHAR(255) DEFAULT '192.168.1.150',
web_port INTEGER DEFAULT 8088,
web_user VARCHAR(100) DEFAULT '',
web_password VARCHAR(255) DEFAULT '', -- шифрование
-- Информация
serial_number VARCHAR(50) DEFAULT '', -- ручной ввод
actual_serial VARCHAR(50) DEFAULT '', -- из FTP
-- Статус подключения (кэш)
mysql_status VARCHAR(20) DEFAULT 'unknown', -- ok/error/unknown
ami_status VARCHAR(20) DEFAULT 'unknown',
ftp_status VARCHAR(20) DEFAULT 'unknown',
serial_match BOOLEAN DEFAULT FALSE,
-- Синхронизация
last_sync_at TIMESTAMP,
sync_interval INTEGER DEFAULT 5, -- минут
updated_at TIMESTAMP DEFAULT NOW()
);
-- Всегда одна запись
INSERT INTO pbx_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
9.2 Шифрование паролей
from cryptography.fernet import Fernet
import os
# Ключ шифрования (из .env)
ENCRYPTION_KEY = os.getenv("PBX_ENCRYPTION_KEY")
cipher = Fernet(ENCRYPTION_KEY.encode())
def encrypt_password(plain: str) -> str:
"""Шифрование пароля для хранения в БД."""
return cipher.encrypt(plain.encode()).decode()
def decrypt_password(encrypted: str) -> str:
"""Дешифрование пароля из БД."""
return cipher.decrypt(encrypted.encode()).decode()
9.3 Страница настроек
| Вкладка | Поля |
|---|---|
| MySQL | Host, Port, User, Password, Database |
| AMI | Host, Port, User, Password |
| FTP | Host, Port, User, Password |
| Веб-интерфейс | Host, Port, User, Password |
| Информация | Серийный номер (ручной ввод), Интервал синхронизации |
| Статус | Индикаторы состояния, Время последней синхронизации |
9.4 Индикаторы состояния
| Цвет | Состояние | Описание |
|---|---|---|
| 🔴 Красный | Подключение отсутствует | connect() failed |
| 🟡 Жёлтый | Не все данные | Есть пустые обязательные поля |
| 🟢 Зелёный | Всё OK | Подключение успешно, данные получены |
9.5 Проверка по кнопке
@router.post("/service/api/pbx-settings/test")
async def test_pbx_connection():
"""Тест подключения к АТС по всем протоколам."""
settings = await db.get(PbxSettings, 1)
results = {}
# MySQL test
try:
conn = mysql.connector.connect(
host=settings.mysql_host,
port=settings.mysql_port,
user=settings.mysql_user,
password=decrypt_password(settings.mysql_password),
database=settings.mysql_database,
charset='utf8',
)
cursor = conn.cursor()
cursor.execute("SELECT 1")
cursor.fetchone()
conn.close()
results["mysql"] = {"status": "ok", "message": "Подключение успешно"}
except Exception as e:
results["mysql"] = {"status": "error", "message": str(e)}
# AMI test
try:
ami = AMIClient(address=f"{settings.ami_host}:{settings.ami_port}")
ami.login(settings.ami_user, decrypt_password(settings.ami_password))
ami.send_action({"Action": "Ping"})
ami.logoff()
results["ami"] = {"status": "ok", "message": "Подключение успешно"}
except Exception as e:
results["ami"] = {"status": "error", "message": str(e)}
# FTP test
try:
ftp = ftplib.FTP()
ftp.connect(settings.ftp_host, settings.ftp_port)
ftp.login(settings.ftp_user, decrypt_password(settings.ftp_password))
ftp.quit()
results["ftp"] = {"status": "ok", "message": "Подключение успешно"}
except Exception as e:
results["ftp"] = {"status": "error", "message": str(e)}
# Serial number validation
try:
ftp = ftplib.FTP()
ftp.connect(settings.ftp_host, settings.ftp_port)
ftp.login(settings.ftp_user, decrypt_password(settings.ftp_password))
ftp.cwd("/support/tmp")
lines = []
ftp.retrlines("RETR deviceinfo.txt", lines.append)
ftp.quit()
actual_serial = extract_serial_from_info(lines)
settings.actual_serial = actual_serial
settings.serial_match = (actual_serial == settings.serial_number)
results["serial"] = {
"status": "match" if settings.serial_match else "mismatch",
"actual": actual_serial,
"configured": settings.serial_number,
}
except Exception as e:
results["serial"] = {"status": "error", "message": str(e)}
# Save status
settings.mysql_status = results["mysql"]["status"]
settings.ami_status = results["ami"]["status"]
settings.ftp_status = results["ftp"]["status"]
await db.commit()
return results
10. Внутренний номер сотрудника
10.1 Миграция
ALTER TABLE users ADD COLUMN extension VARCHAR(10) DEFAULT '';
10.2 Привязка к расширению АТС
Зачем:
- Автоматическое определение кто ответил на звонок
- Уведомления о пропущенных звонках конкретному сотруднику
- Видно кто отвечает за какой объект
Связь:
users.extension ←→ call_records.callee_number (dst)
←→ CDR.dstchanurl (URL канала)
10.3 Автопривязка звонка к сотруднику
async def auto_link_call_to_employee(call, db):
"""Привязка звонка к сотруднику по внутреннему номеру."""
# Нормализуем номер получателя
dst_normalized = normalize_phone(call.callee_number)
# Ищем сотрудника по внутреннему номеру
employee = await db.execute(
select(User).where(
User.extension == dst_normalized,
User.is_active == True,
)
).scalar_one_or_none()
if employee:
call.answered_by = employee.id
return employee
10.4 Уведомления о пропущенных звонках
async def send_missed_call_notification(call, db):
"""Уведомление о пропущенном звонке."""
# Кому: владелец номера + руководитель
if call.answered_by:
recipients = [call.answered_by] # владелец номера
else:
recipients = [] # только owner
# Добавляем owner
owner = await db.execute(
select(User).where(User.role == "owner")
).scalar_one_or_none()
if owner and owner.id not in recipients:
recipients.append(owner.id)
for user_id in recipients:
await db.execute(
insert(ClientNotification).values(
user_id=user_id,
type="missed_call",
title=f"Пропущенный звонок от {call.caller_number}",
message=f"Время: {call.call_datetime}, Номер: {call.callee_number}",
call_record_id=call.id,
)
)
await db.commit()
11. Задачи: уточнение workflow
Текущая модель (без изменений)
Owner/Engineer:
✅ Видит ВСЕ задачи
✅ Создаёт задачи
✅ Назначает на любого (engineer/technician)
✅ Закрывает любые
Technician:
✅ Видит ТОЛЬКО свои задачи (WHERE assigned_to = user.id)
❌ НЕ может создавать задачи для других
✅ МОЖЕТ создать задачу себе (напоминание)
✅ Закрывает ТОЛЬКО свои задачи
Добавления (в плане)
| Изменение | Описание |
|---|---|
lead_id FK в tasks |
Связь задачи с лидом |
created_by = assigned_to для техника |
Если техник создаёт — автоматически назначает на себя |
| Вкладка «Задачи» в карточке клиента | Задачи, связанные через лид или объект |
| Кнопка «Создать задачу» в карточке лида | Быстрое создание задачи из лида |
9. Воронка и KPI (только для owner)
9.1 Расширение /service/charts
Добавить блок «Воронка продаж»:
# KPI данные
GET /service/api/charts/funnel
GET /service/api/charts/kpi
9.2 Воронка (визуализация)
Новые лиды ████████████████ 100
Квалификация ██████████ 65
КП отправлено ██████ 40
Переговоры ███ 22
Сделка █ 11
9.3 KPI
| Метрика | Формула |
|---|---|
| Новые лиды | COUNT(leads WHERE status='new' AND date=today) |
| Обработано сегодня | COUNT(leads WHERE updated_at::date = today) |
| Конверсия | won / total * 100 |
| Средний чек | AVG(deal_amount) WHERE status='won' |
| Активные проекты | COUNT(tasks WHERE status IN ('open','in_progress')) |
10. Интеграция бота и портала
10.1 Создание лида при BotTicket
При создании BotTicket в max_bot → POST в py_service:
# max_bot: inquiry.py
async def create_ticket(...):
# ... существующий код создания BotTicket ...
# НОВОЕ: создать Lead в py_service
await create_lead_via_api({
"source": "bot",
"bot_ticket_id": ticket.id,
"company_name": user.organization or "",
"contact_name": user.full_name,
"contact_phone": user.phone,
"contact_email": user.email,
"description": inquiry_text,
"service_type": detected_service,
})
10.2 API портала для бота
# py_service: новый роутер для API бота
POST /api/internal/leads # создать лид
PATCH /api/internal/leads/{id} # обновить лид
POST /api/internal/calls/sync # синхронизация CDR
11. Фазы реализации
Фаза 1: Лиды (базовая)
- Миграция: таблица
leads - Миграция: статус
draftдля customers - Страница
/service/leads(CRUD) - Автопривязка Lead → Customer (ИНН → телефон → email → создание)
- Интеграция с BotTicket (автосоздание лида)
- Добавить вопрос о компании в бота
Фаза 2: Звонки
- Миграция: таблица
call_records - Синхронизация CDR из MySQL (каждые 5 мин)
- Автопривязка звонков к клиентам
- Страница
/service/calls - Уведомления о пропущенных звонках
Фаза 3: Единая карточка клиента
- Перевод карточки клиента на отдельную страницу
- Вкладки: общее, звонки, заявки, задачи, инциденты
- Кнопка «Создать задачу» из карточки лида
- Связь
task → lead
Фаза 4: Уведомления
- Миграция: таблица
client_notifications - API уведомлений (count, list, read)
- UI: иконка 🔔 в шапке с бейджем
- Все типы уведомлений
Фаза 5: Воронка и KPI
- API воронки и KPI
- Визуализация воронки на
/service/charts - Блок KPI для руководителя
Фаза 6 (будущее): Транскрибация + Записи
- SSH/FTP доступ к WAV-файлам
- Интеграция с Yandex SpeechKit
- Автотранскрибация звонков
- Плеер для прослушивания
17. Миграции (SQL-файлы)
| Файл | Описание |
|---|---|
migrations/002_leads_and_calls.sql |
Таблицы leads, call_records, client_notifications |
migrations/003_lead_task_relation.sql |
lead_id FK в tasks |
migrations/004_customer_draft_status.sql |
Статус draft + доп. поля в customers |
migrations/005_pbx_settings.sql |
Таблица pbx_settings (настройки АТС) |
migrations/006_user_extension.sql |
Поле extension в users (внутренний номер) |
migrations/007_audit_log_enhanced.sql |
Расширенная таблица audit_log (для дедупликации) |
13. Зависимости и риски
| Риск | Описание | Решение |
|---|---|---|
| MySQL доступ | 1cuser/1csecret, БД cdr | ✅ Работает, тестировать |
| Проброс портов | 43306→3306, 45038→5038 | ✅ Настроен |
| FTP/SSH | Записи WAV | ❌ Нет логина SSH — позже |
| Yandex SpeechKit | Транскрибация | ❌ Нет API-ключа — позже |
| Кириллица в CDR | CP1251 garbage | Конвертация при чтении |
| Два DB | py_service и max_bot | API-интеграция через HTTP |
| Шифрование паролей | Пароли АТС в открытом виде | cryptography.fernet + ключ в .env |
| Привязка extension | Несоответствие номеров | Ручной ввод + проверка при синхронизации |
| Серийник АТС | Несовпадение serial_number | Сравнение при каждой синхронизации |
| Общая почта | Письма не-заявки | Фильтр по теме Заявка с сайта: |
Зависимости (обновить requirements.txt)
cryptography>=44.0.0 # для шифрования паролей АТС
19. Связь с существующими разделами меню
Текущая структура
Панель: Дашборд, Графики, CEO
Управление: Клиенты, Объекты, SLA, Опросник, Паспорта
Работа: Сотрудники, Назначение, Документация, Задачи, Отчёты, Инциденты, Чек-лист
Чат-бот MAX: Согласия, Заявки, База знаних, Неизвестные, Диалоги
Контент: Блог, Кейсы
Настройки: Опросник, Формулы, Портал, Роли, Бот, Идеи
Новая структура (после реализации)
Панель: Дашборд, Графики (+ воронка, KPI), CEO
Управление: Клиенты (+ вкладки), Объекты, Лиды (НОВОЕ), SLA, Опросник, Паспорта
Работа: Неразобранное (НОВОЕ), Сотрудники (+ внутр. номер), Назначение, Документация,
Задачи (+ связь с лидами), Отчёты, Инциденты, Чек-лист, Звонки (НОВОЕ)
Чат-бот MAX: Согласия, Заявки, База знаних, Неизвестные, Диалоги
Контент: Блог, Кейсы
Настройки: Опросник, Формулы, Портал, Роли, Бот, Настройки АТС (НОВОЕ), Идеи
Пункты меню
| Раздел | Пункт | Действие |
|---|---|---|
| Панель | Графики (РАСШИРЕНИЕ) | + воронка + KPI |
| Управление | Лиды (НОВОЕ) | /service/leads |
| Работа | Неразобранное (НОВОЕ) | /service/inbox |
| Работа | Звонки (НОВОЕ) | /service/calls |
| Настройки | Настройки АТС (НОВОЕ) | /service/pbx-settings |
20. Итоговая архитектура
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Бот MAX │ │ Телефон │ │ Сайт │ │ Email │
│ (заявка) │ │ (CDR/AMI) │ │ (форма) │ │ (парсинг) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │ │
└───────────────────┼───────────────────┼───────────────────┘
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ ЛИДЫ │ │ ЗВОНКИ │ │ ЗАЯВКИ │
│ (leads) │ │(call_records)│ │ (leads) │
│ Автопривязка│ │ Автопривязка│ │ Парсинг темы│
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└───────────────────┼───────────────────┘
│
┌──────▼──────┐
│ КЛИЕНТ │
│ (customers) │
└──────┬──────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ ЗАДАЧИ │ │ ИНЦИДЕНТЫ │ │ УВЕДОМЛЕНИЯ │
│ (tasks) │ │(incidents) │ │(notific.) │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ НАСТРОЙКИ АТС (pbx_settings) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ MySQL │ │ AMI │ │ FTP │ │ Web │ │ Серийник │ │
│ │ 🔴🟡🟢 │ │ 🔴🟡🟢 │ │ 🔴🟡🟢 │ │ 🔴🟡🟢 │ │ ✅❌ │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ СОТРУДНИКИ (users) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ ФИО │ │ Роль │ │ Внутр. │ │ Привязка │ │
│ │ │ │ │ │ номер │ │ к АТС │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Потоки данных
| Источник | Формат | Метод | Периодичность |
|---|---|---|---|
| Бот MAX | JSON API | BotTicket → Lead | Реальное время |
| Yeastar S20 | MySQL CDR | call_records | Каждые 5 мин |
| Сайт (форма) | Email (SMTP) | Тема Заявка с сайта ({тип}) — {дата} → Lead |
Каждые 5 мин (IMAP) |
| Руководитель | Ручной ввод | CRUD лидов | По мере необходимости |
Привязка сотрудников к АТС
users.extension ←→ call_records.callee_number (dst)
←→ CDR.dstchanurl (URL канала)
При звонке:
1. Определяемextension (номер получателя)
2. Ищем сотрудника в users WHERE extension = dst
3. Привязываем звонок к сотруднику
4. Если пропущенный → уведомляем сотрудника + owner
21. Приоритеты реализации
Фаза 1: Лиды + Базовая инфраструктура
- Миграция: таблица
leads - Миграция: статус
draftдля customers - Миграция: таблица
audit_log - Миграция: таблица
pbx_settings - Миграция: поле
extensionв users - Страница
/service/leads(CRUD) - Автопривязка Lead → Customer (ИНН → телефон → email → создание)
- Интеграция с BotTicket (автосоздание лида)
- Добавить вопрос о компании в бота ("Не могу сказать")
- Коррекция привязки (перенос отдельных записей)
- Страница
/service/pbx-settings(настройки АТС) - Шифрование паролей в pbx_settings
- Тест подключения (MySQL, AMI, FTP)
- Проверка серийного номера
- Добавить поле "Внутренний номер" в /service/users
Фаза 2: Звонки + Email + Входящий поток
- Миграция: таблица
call_records - Синхронизация CDR из MySQL (каждые 5 мин)
- Автопривязка звонков к клиентам
- Автопривязка звонков к сотрудникам (по extension)
- Страница
/service/calls - Страница
/service/inbox(виртуальный запрос) - Уведомления о пропущенных звонках (конкретному сотруднику)
- IMAP-клиент для проверки почты
- Парсинг темы письма → Lead
- Периодическая проверка почты (каждые 5 мин)
Фаза 3: Единая карточка клиента
- Перевод карточки клиента на отдельную страницу
- Вкладки: общее, звонки, заявки, задачи, инциденты
- Кнопка «Создать задачу» из карточки лида
- Связь
task → lead - Слияние дубликатов (Merge)
Фаза 4: Уведомления
- Миграция: таблица
client_notifications - API уведомлений (count, list, read)
- UI: иконка 🔔 в шапке с бейджем
- Все типы уведомлений
- Уведомления о пропущенных звонках (конкретному сотруднику)
Фаза 5: Воронка и KPI
- API воронки и KPI
- Визуализация воронки на
/service/charts - Блок KPI для руководителя
Фаза 6 (будущее): Транскрибация + Записи
- SSH/FTP доступ к WAV-файлам
- Интеграция с Yandex SpeechKit
- Автотранскрибация звонков
- Извлечение названия компании из транскрипции (уровень 4 дедупликации)
- Автопривязка по транскрипции (≥ 85%) + уведомление для подтверждения
- Плеер для прослушивания
Фаза 7 (будущее): Интеграция с 1С:УНФ 3.0
Статус: ИССЛЕДОВАНИЕ (документ
other/1C_UNF_INTEGRATION.md)
Возможная интеграция с 1С:УНФ 3.0 в облаке 1С-Фреш через OData API.
Что можно получить из 1С:УНФ:
| Сущность 1С | OData URL | Наша сущность |
|---|---|---|
| Контрагенты | Catalog_Контрагенты |
Клиенты |
| Контактные лица | Catalog_КонтактныеЛица |
Контакты клиента |
| Заказ покупателя | Document_ЗаказКлиента |
Лиды/Заказы |
| Номенклатура | Catalog_Номенклатура |
Каталог услуг |
| Договоры | Catalog_Договоры |
Привязка к клиенту |
Стратегия:
- Фаза 0: Настройка доступа (URL, login, password, tenant)
- Фаза 1: Read-only синхронизация контрагентов → клиенты (dedup по ИНН)
- Фаза 2: Синхронизация заказов → лиды
- Фаза 3: Bidirectional (portal → 1С) — опционально
Задачи:
- Получить доступ к 1С-Фреш (URL, login, password, tenant)
- Проверить доступность OData (
$metadata) - Создать таблицу
onec_settings(настройки подключения) - Создать таблицу
onec_sync_log(логирование синхронизации) - Реализовать
OneCFreshClient(Python-клиент OData) - Задача синхронизации контрагентов (cron)
- Дедупликация по ИНН при синхронизации
- Задача синхронизации заказов (cron)
- UI: Индикатор последней синхронизации в настройках
Весь план не реализован. Ни одной миграции, модели, страницы или API для лидов/звонков/уведомлений не существует.
План работы по фазам Фаза 0: Инфраструктура (предусловие)
Задача Зависимости Сложность
0.1 Создать py_service/migrations/ + migrations_runner.py + таблица _applied_migrations Нет Низкая 0.2 Проверить/установить cryptography в requirements.txt Нет Низкая 0.3 Добавить в models.py колонки: User.extension, Customer.lead_source/last_call_at/total_calls/total_leads, Task.lead_id 0.1 Средняя 0.4 Расширить AuditLog: добавить entity_type, entity_id (сейчас есть entity_type, но нет entity_id) 0.1 Низкая 0.5 Добавить статус draft в CHECK constraint customers 0.1 Низкая Результат: Миграционная система работает, существующие таблицы расширены.
Фаза 1: Лиды + Настройки АТС
Задача Зависимости Сложность
1.1 Миграция 002_leads.sql: таблица leads 0.1 Низкая 1.2 Миграция 003_pbx_settings.sql: таблица pbx_settings 0.1 Низкая 1.3 Миграция 006_user_extension.sql: поле extension в users 0.1 Низкая 1.4 Миграция 007_audit_log_enhanced.sql: расширенный audit_log 0.1 Низкая 1.5 Model Lead в models.py 1.1 Низкая 1.6 Model PbxSettings в models.py 1.2 Низкая 1.7 Утилита шифрования (pbx_encryption.py): encrypt_password, decrypt_password 0.2 Низкая 1.8 Страница /service/leads (шаблон + CRUD API) 1.5 Высокая 1.9 Автопривязка Lead → Customer (ИНН → телефон → email → draft) 1.5 Средняя 1.10 Интеграция BotTicket → Lead (POST API в py_service) 1.5, 1.9 Средняя 1.11 Вопрос о компании в бота (awaiting_company + "Не могу.say") 1.10 Средняя 1.12 Страница /service/pbx-settings (шаблон + API + тест подключения) 1.6, 1.7 Высокая 1.13 Поле "Внутренний номер" в /service/users 1.3 Низкая 1.14 Коррекция привязки (кнопка "Изменить клиента" в звонках/лидах) 1.9 Средняя Результат: Лиды создаются из бота и вручную, настройки АТС сохраняются, пароли зашифрованы.
Фаза 2: Звонки + Email + Входящий поток
Задача Зависимости Сложность
2.1 Миграция 002_leads.sql (расширение): таблица call_records 1.1 Низкая 2.2 Model CallRecord в models.py 2.1 Низкая 2.3 CDR-синхронизация (фоновая задача, MySQL → call_records, каждые 5 мин) 1.6, 2.2 Высокая 2.4 Автопривязка звонков к клиентам (по номеру телефона) 2.3, 1.9 Средняя 2.5 Автопривязка звонков к сотрудникам (по extension) 2.3, 1.3 Средняя 2.6 Страница /service/calls (шаблон + API + фильтры) 2.2 Высокая 2.7 Страница /service/inbox (виртуальный запрос: bot_tickets + call_records + leads) 1.5, 2.2 Средняя 2.8 Уведомления о пропущенных звонках (конкретному сотруднику + owner) 2.3, 2.5 Средняя 2.9 IMAP-клиент (проверка почты mail@aegisone.ru) Нет Средняя 2.10 Парсинг темы письма → Lead (regex Заявка с сайта ({тип}) — {дата}) 2.9, 1.5 Средняя 2.11 Периодическая проверка почты (каждые 5 мин) 2.9, 2.10 Низкая Результат: Звонки синхронизируются из MySQL, привязываются к клиентам/сотрудникам, email-заявки создаются как лиды.
Фаза 3: Единая карточка клиента
Задача Зависимости Сложность
3.1 Миграция 003_lead_task_relation.sql: lead_id FK в tasks 1.1 Низкая 3.2 Страница /service/customers/{id} (отдельная страница с вкладками) 1.5 Высокая 3.3 Вкладка "Звонки" в карточке клиента 3.2, 2.2 Средняя 3.4 Вкладка "Заявки" в карточке клиента 3.2, 1.5 Средняя 3.5 Вкладка "Задачи" в карточке клиента (через лид или объект) 3.2, 3.1 Средняя 3.6 Вкладка "Инциденты" в карточке клиента 3.2 Низкая 3.7 Вкладка "История" (audit_log) 3.2, 0.4 Средняя 3.8 Кнопка "Создать задачу" из карточки лида 3.1 Низкая 3.9 Слияние дубликатов (Merge: перенос лидов/звонков/задач + статус merged) 1.5, 0.4 Высокая Результат: Полная карточка клиента со всеми связанными данными.
Фаза 4: Уведомления
Задача Зависимости Сложность
4.1 Миграция 002_leads.sql (расширение): таблица client_notifications 1.1 Низкая 4.2 Model ClientNotification в models.py 4.1 Низкая 4.3 API уведомлений: count, list, read 4.2 Средняя 4.4 UI: иконка 🔔 в шапке с бейджем + выпадающий список 4.3 Средняя 4.5 Страница /service/notifications (все уведомления) 4.3 Средняя 4.6 Все типы уведомлений: missed_call, new_lead, lead_status_change, sla_breach, task_overdue 4.2 Средняя Результат: Автоматические уведомления о событиях, UI для просмотра.
Фаза 5: Воронка и KPI
Задача Зависимости Сложность
5.1 API воронки: GET /service/api/charts/funnel 1.5 Средняя 5.2 API KPI: GET /service/api/charts/kpi 1.5 Средняя 5.3 Визуализация воронки на /service/charts 5.1 Средняя 5.4 Блок KPI для руководителя 5.2 Низкая Результат: Визуализация воронки продаж и KPI для owner.
Фаза 6 (будущее): Транскрибация + Записи
Задача Зависимости Сложность
6.1 SSH/FTP доступ к WAV-файлам (найти логин SSH) Нет Неизвестная 6.2 Интеграция с Yandex SpeechKit 6.1 Высокая 6.3 Автотранскрибация звонков 6.2 Средняя 6.4 Уровень 4 дедупликации (из транскрипции) 6.3, 1.9 Высокая 6.5 Плеер для прослушивания 6.1 Средняя Фаза 7 (будущее): Интеграция с 1С:УНФ 3.0
Задача Зависимости Сложность
7.1 Получить доступ к 1С-Фреш (URL, login, password, tenant) Внешний Неизвестная 7.2 Проверить доступность OData ($metadata) 7.1 Низкая 7.3 Таблица onec_settings + onec_sync_log 7.2 Низкая 7.4 Python-клиент OneCFreshClient (OData) 7.2 Средняя 7.5 Синхронизация контрагентов → клиенты (dedup по ИНН) 7.4, 1.5 Высокая 7.6 Синхронизация заказов → лиды 7.5, 1.5 Высокая 7.7 UI: индикатор последней синхронизации 7.5 Низкая Рекомендуемый порядок выполнения Фаза 0 (инфра) → Фаза 1 (лиды + АТС) → Фаза 2 (звонки + email) ↓ Фаза 3 (карточка клиента) ↓ Фаза 4 (уведомления) ↓ Фаза 5 (воронка/KPI) Фазы 6 и 7 — будущее, зависят от внешних факторов (SSH-логин, доступ к 1С-Фреш).