Files
site_aegisone/other/LEAD_IMPLEMENTATION_PLAN.md

1531 lines
70 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План реализации: Лиды, Звонки, Единая карточка клиента
> Дата создания: 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`
```sql
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`
```sql
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`
```sql
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`
```sql
ALTER TABLE tasks ADD COLUMN lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL;
```
---
## 2. Изменения в существующих таблицах
### 2.1 Таблица `customers`
```sql
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 бот передаёт:
```json
{
"title": "...",
"description": "...",
"status": "Новая",
"contact_name": "Иван Петров",
"contact_phone": "+79991234567",
"contact_email": "ivan@example.com",
"company_name": "ООО Ромашка",
"organization": "ООО Ромашка"
}
```
### 3.4 Email-канал: парсинг писем с сайта
**Важно:** Почта `mail@aegisone.ru` общая — приходят не только заявки, но и переписка с поставщиками. Парсинг идёт по теме письма.
#### Фактический формат темы (из PHP-кода сайта)
```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`
**Извлекаемое:**
- Тип услуги: `Риск-инжиниринг`, `Технический надзор` и т.д.
- Дата обращения
#### Алгоритм парсинга
```python
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-клиент для проверки почты
```python
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 Алгоритм автопривязки
При создании лида (из бота или вручную):
```python
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:
```sql
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: Перенос отдельных записей
В карточке звонка/лида — кнопка «Изменить клиента»:
```python
@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)
Если обнаружен дубликат клиента — кнопка «Слить с другим клиентом» в карточке:
```python
@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` (история изменений)
```sql
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 минут:
```python
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 Автопривязка звонка к клиенту
```python
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 уведомлений
```python
# Получить непрочитанные
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 Источники данных
```sql
-- Виртуальный запрос: объединение 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`
```sql
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 Шифрование паролей
```python
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 Проверка по кнопке
```python
@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 Миграция
```sql
ALTER TABLE users ADD COLUMN extension VARCHAR(10) DEFAULT '';
```
### 10.2 Привязка к расширению АТС
**Зачем:**
- Автоматическое определение кто ответил на звонок
- Уведомления о пропущенных звонках конкретному сотруднику
- Видно кто отвечает за какой объект
**Связь:**
```
users.extension ←→ call_records.callee_number (dst)
←→ CDR.dstchanurl (URL канала)
```
### 10.3 Автопривязка звонка к сотруднику
```python
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 Уведомления о пропущенных звонках
```python
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`
Добавить блок «Воронка продаж»:
```python
# 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:
```python
# 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 портала для бота
```python
# 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_Договоры` | Привязка к клиенту |
**Стратегия:**
1. Фаза 0: Настройка доступа (URL, login, password, tenant)
2. Фаза 1: Read-only синхронизация контрагентов → клиенты (dedup по ИНН)
3. Фаза 2: Синхронизация заказов → лиды
4. Фаза 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С-Фреш).