1531 lines
70 KiB
Markdown
1531 lines
70 KiB
Markdown
# План реализации: Лиды, Звонки, Единая карточка клиента
|
||
|
||
> Дата создания: 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С-Фреш). |