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