Files
site_aegisone/other/LEAD_IMPLEMENTATION_PLAN.md
T

70 KiB
Raw Blame History

План реализации: Лиды, Звонки, Единая карточка клиента

Дата создания: 03.06.2026 Статус: ПЛАН (код не редактируется)


0. Краткое описание

Реализация Service Desk + CRM системы по модели из voronka.md:

  • Лиды — обращения из бота, телефонии, сайта → единая точка входа
  • Звонки — CDR из MySQL → привязка к клиентам → уведомления о пропущенных
  • Единая карточка клиента — всё об одном клиенте в одном месте
  • Воронка продаж — визуализация для руководителя
  • Уведомления — автоматическая фиксация событий без участия пользователя

Ключевые решения

Решение Выбор
Лид = отдельная таблица в py_service Да
Автопривязка лида к клиенту ИНН → телефон → email → создание draft
Дедупликация 3 уровня: draft (автопривязка), перенос записей, слияние дубликатов + уведомление
Транскрипция (уровень 4) Автопривязка ≥ 85% + уведомление ответившему для подтверждения; фаза 6
Email-канал Тема Заявка с сайта ({тип услуги}) — {дата}; парсинг типа услуги + контактов из тела; общая почта — фильтр по теме
Название компании Не уникально; ИНН — уникально, но не всегда доступен
Входящий поток Единая страница /service/inbox (виртуальный запрос из 3 источников)
Настройки АТС Отдельная страница, пароли шифруются, серийник проверяется
Внутренний номер Привязка к расширению АТС → автодетект звонка → уведомления
KPI/воронка Только для owner (руководителя)
Задачи Owner/engineer создают → делегируют техникам; техник может только создать себе напоминание
Транскрибация Будет позже (нужен доступ к Yandex SpeechKit) — фаза 6
Записи WAV Будет позже (нужен SSH/FTP к файлам) — фаза 6
1С:УНФ интеграция OData API → синхронизация контрагентов/заказов — фаза 7

1. Новые сущности (миграции)

1.1 Таблица leads

CREATE TABLE leads (
    id SERIAL PRIMARY KEY,
    customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,

    -- Источник обращения
    source VARCHAR(20) NOT NULL DEFAULT 'bot',
    -- bot | phone_in | phone_out | site | email | manual

    -- Данные клиента (заполняются из бота/звонка)
    company_name VARCHAR(255) DEFAULT '',
    contact_name VARCHAR(255) DEFAULT '',
    contact_phone VARCHAR(50) DEFAULT '',
    contact_email VARCHAR(255) DEFAULT '',
    inn VARCHAR(12) DEFAULT '',

    -- Содержание обращения
    service_type VARCHAR(255) DEFAULT '',    -- какую услугу ищет
    description TEXT DEFAULT '',             -- текст обращения / краткое описание
    urgency VARCHAR(10) DEFAULT 'medium',   -- critical | high | medium | low

    -- Статус
    status VARCHAR(30) DEFAULT 'new',
    -- new | contact | qualification | proposal | negotiation | approved | won | lost | merged

    -- Привязка
    assigned_to INTEGER REFERENCES users(id),  -- кто обрабатывает
    bot_ticket_id BIGINT,                       -- ID заявки из бота (ссылка на max_bot DB)

    -- Воронка
    deal_amount NUMERIC(12, 2) DEFAULT 0,      -- сумма сделки (для KPI)
    expected_close_date DATE,                   -- ожидаемая дата закрытия

    -- Мета
    created_by INTEGER REFERENCES users(id),
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

1.2 Таблица call_records

CREATE TABLE call_records (
    id SERIAL PRIMARY KEY,

    -- Данные из CDR
    cdr_uniqueid VARCHAR(32) UNIQUE,           -- уникальный ID из CDR (для дедупликации)
    call_datetime TIMESTAMP NOT NULL,
    caller_number VARCHAR(80) DEFAULT '',       -- номер звонящего (src)
    callee_number VARCHAR(80) DEFAULT '',       -- номер получателя (dst)
    trunk VARCHAR(80) DEFAULT '',               -- транк (Mango, Tele2)
    duration INTEGER DEFAULT 0,                 -- общая длительность (сек)
    billable_seconds INTEGER DEFAULT 0,         -- биллируемая (сек)
    disposition VARCHAR(30) DEFAULT '',         -- ANSWERED, NO ANSWER, BUSY, VOICEMAIL
    call_type VARCHAR(20) DEFAULT '',           -- Inbound, Outbound, Internal
    queue_name VARCHAR(30) DEFAULT '',          -- очередь (6700, 6701, 6702)

    -- Привязка к клиенту
    customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
    lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL,

    -- Инженер/техник кто принял звонок
    answered_by INTEGER REFERENCES users(id),   -- расширение (1001, 1002)
    answered_by_name VARCHAR(100) DEFAULT '',   -- имя (из CDR personalcontact)

    -- Запись
    has_recording BOOLEAN DEFAULT FALSE,
    recording_path VARCHAR(500) DEFAULT '',     -- путь на PBX (/tmp/media/mmc1/...)
    recording_filename VARCHAR(200) DEFAULT '', -- имя WAV-файла

    -- Уведомления
    notification_sent BOOLEAN DEFAULT FALSE,    -- отправлено ли уведомление

    created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_call_records_customer ON call_records(customer_id);
CREATE INDEX idx_call_records_datetime ON call_records(call_datetime);
CREATE INDEX idx_call_records_caller ON call_records(caller_number);
CREATE INDEX idx_call_records_disposition ON call_records(disposition);

1.3 Таблица client_notifications

CREATE TABLE client_notifications (
    id SERIAL PRIMARY KEY,
    customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
    lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL,
    call_record_id INTEGER REFERENCES call_records(id) ON DELETE SET NULL,

    type VARCHAR(30) NOT NULL,
    -- missed_call | new_lead | lead_status_change | sla_breach | task_overdue

    title VARCHAR(500) NOT NULL,
    message TEXT DEFAULT '',
    is_read BOOLEAN DEFAULT FALSE,
    read_by INTEGER REFERENCES users(id),

    created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_notifications_unread ON client_notifications(is_read, created_at);

1.4 Связь task → lead

ALTER TABLE tasks ADD COLUMN lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL;

2. Изменения в существующих таблицах

2.1 Таблица customers

ALTER TABLE customers ADD COLUMN lead_source VARCHAR(20) DEFAULT '';
ALTER TABLE customers ADD COLUMN last_call_at TIMESTAMP;
ALTER TABLE customers ADD COLUMN total_calls INTEGER DEFAULT 0;
ALTER TABLE customers ADD COLUMN total_leads INTEGER DEFAULT 0;

2.2 Таблица bot_tickets (max_bot)

Без изменений. BotTicket остаётся как «сырой» источник. При создании BotTicket → автоматически создаётся Lead в py_service.


3. Бот: сбор данных

3.1 Изменение flow

Текущий flow бота:

greeting → awaiting_input → [ticket intent] → awaiting_consent
→ awaiting_contact → awaiting_inquiry → completed

Новый flow (добавляется шаг сбора компании):

greeting → awaiting_input → [ticket intent] → awaiting_consent
→ awaiting_contact → awaiting_company → awaiting_inquiry → completed

3.2 Шаг «awaiting_company»

После получения контакта бот спрашивает:

Бот: Из какой вы компании?
[Не могу сказать]  ← кнопка
  • Если пользователь пишет название → сохраняется в BotUser.organization
  • Если нажал «Не могу сказать» → поле пустое, руководитель заполнит позже
  • Важно: название компании не уникально (может быть 10 ООО «Ромашка»)

3.3 API для передачи данных в py_service

При создании BotTicket бот передаёт:

{
  "title": "...",
  "description": "...",
  "status": "Новая",
  "contact_name": "Иван Петров",
  "contact_phone": "+79991234567",
  "contact_email": "ivan@example.com",
  "company_name": "ООО Ромашка",
  "organization": "ООО Ромашка"
}

3.4 Email-канал: парсинг писем с сайта

Важно: Почта mail@aegisone.ru общая — приходят не только заявки, но и переписка с поставщиками. Парсинг идёт по теме письма.

Фактический формат темы (из PHP-кода сайта)

define('SITE_MAIL_FROM', 'no-reply@aegisone.ru');
define('SITE_MAIL_SUBJECT_PREFIX', 'Заявка с сайта');

Формат темы: Заявка с сайта ({тип услуги}) — {дата}

Примеры:

  • Заявка с сайта (Риск-инжиниринг) — 18.05.2026 11:54:55
  • Заявка с сайта (Технический надзор) — 16.05.2026 16:26:32

Извлекаемое:

  • Тип услуги: Риск-инжиниринг, Технический надзор и т.д.
  • Дата обращения

Алгоритм парсинга

import re
from email.header import decode_header

# Паттерн для определения заявки с сайта
# Фактический формат: "Заявка с сайта ({тип услуги}) — {дата}"
# Пример: "Заявка с сайта (Риск-инжиниринг) — 18.05.2026 11:54:55"
SITE_REQUEST_PATTERN = re.compile(
    r'^Заявка с сайта\s*\((.+?)\)\s*—\s*(\d{2}\.\d{2}\.\d{4}\s+\d{2}:\d{2}:\d{2})$',
    re.IGNORECASE
)

async def parse_email_to_lead(email_message):
    """Парсинг email-сообщения в Lead.

    Определяет по теме письма:
    - Является ли письмо заявкой с сайта
    - Тип услуги (из скобок)
    - Дату обращения

    Контактные данные извлекаются из тела письма.
    """
    subject = decode_header(email_message['Subject'])[0][0]
    if isinstance(subject, bytes):
        subject = subject.decode('utf-8')

    match = SITE_REQUEST_PATTERN.match(subject.strip())
    if not match:
        return None  # Не заявка — пропускаем

    service_type = match.group(1).strip()   # "Риск-инжиниринг"
    date_str = match.group(2).strip()       # "18.05.2026 11:54:55"

    # Извлекаем данные из тела письма
    body = extract_body(email_message)
    contact_data = extract_contact_from_body(body)

    lead = Lead(
        source="email",
        title=f"Заявка с сайта: {service_type}",
        service_type=service_type,
        description=body,
        company_name=contact_data.get("company", ""),
        contact_name=contact_data.get("name", ""),
        contact_phone=contact_data.get("phone", ""),
        contact_email=email_message.get("From", ""),
    )

    return lead


def extract_contact_from_body(body: str) -> dict:
    """Извлечение контактов из тела email.

    Ожидаемый формат тела (автоматически сгенерирован формой):
    ---
    Имя: Иван Петров
    Телефон: +7 (999) 123-45-67
    Email: ivan@example.com
    Компания: ООО Ромашка
    Сообщение: ...
    ---
    """
    result = {}
    for line in body.split('\n'):
        if ':' in line:
            key, value = line.split(':', 1)
            key = key.strip().lower()
            value = value.strip()
            if key == 'имя':
                result['name'] = value
            elif key == 'телефон':
                result['phone'] = value
            elif key == 'email':
                result['email'] = value
            elif key == 'компания':
                result['company'] = value
    return result

IMAP-клиент для проверки почты

import imaplib
import email

IMAP_SERVER = "mail.aegisone.ru"  # уточнить у хостинга
IMAP_PORT = 993
IMAP_USER = "mail@aegisone.ru"
IMAP_PASS = "из .env"

async def check_email_for_leads():
    """Периодическая проверка почты на новые заявки.

    Запускается каждые 5 минут (как CDR-синхронизация).
    Обрабатывает только непрочитанные письма с темой 'Заявка с сайта ('.
    """
    conn = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT)
    conn.login(IMAP_USER, IMAP_PASS)
    conn.select("INBOX")

    # Ищем непрочитанные с темой "Заявка с сайта ("
    _, msg_nums = conn.search(None, '(UNSEEN SUBJECT "Заявка с сайта (")')

    for num in msg_nums[0].split():
        _, msg_data = conn.fetch(num, "(RFC822)")
        msg = email.message_from_bytes(msg_data[0][1])

        lead = await parse_email_to_lead(msg)
        if lead:
            await save_lead(lead)
            # Помечаем как прочитанное
            conn.store(num, "+FLAGS", "\\Seen")

    conn.logout()

Приоритет: сначала телефон, потом email

Этап Источник Статус
Фаза 1 Телефон (CDR из Yeastar S20) Текущий приоритет
Фаза 2 Email (парсинг по теме письма) Позже

Причина: Телефон — основной канал коммуникации. Email требует настройки IMAP + парсинг темы, что сложнее прямого доступа к MySQL CDR.


4. Автопривязка: Lead → Customer

4.1 Алгоритм автопривязки

При создании лида (из бота или вручную):

async def auto_link_lead(lead, db):
    customer = None

    # 1. Поиск по ИНН (если есть) — самый точный
    if lead.inn:
        customer = await db.execute(
            select(Customer).where(Customer.inn == lead.inn)
        ).scalar_one_or_none()

    # 2. Поиск по телефону (нормализованный)
    if not customer and lead.contact_phone:
        normalized = normalize_phone(lead.contact_phone)
        customer = await db.execute(
            select(Customer).where(
                func.regexp_replace(Customer.contact_phone, '[^0-9]', '', 'g') LIKE f'%{normalized}%'
            )
        ).scalar_one_or_none()

    # 3. Поиск по email
    if not customer and lead.contact_email:
        customer = await db.execute(
            select(Customer).where(
                func.lower(Customer.contact_email) == lead.contact_email.lower()
            )
        ).scalar_one_or_none()

    # 4. Если не найден — создать «Черновик клиента»
    if not customer:
        customer = Customer(
            name=lead.company_name or f"Клиент {lead.contact_phone or lead.contact_email}",
            contact_person=lead.contact_name,
            contact_phone=lead.contact_phone,
            contact_email=lead.contact_email,
            status="draft",  -- черновик, руководитель заполнит
            created_by=lead.created_by
        )
        db.add(customer)
        await db.flush()

    lead.customer_id = customer.id

4.2 Новый статус клиента: draft

Добавить в CHECK-constraint:

ALTER TABLE customers DROP CONSTRAINT ck_customers_status;
ALTER TABLE customers ADD CONSTRAINT ck_customers_status
    CHECK (status IN ('active', 'inactive', 'prospect', 'draft'));

draft = автосоздан системой, ожидает заполнения руководителем.

4.3 Коррекция привязки (дедупликация)

Проблема: Телефон +79991234567 принадлежит секретарю ООО "Ромашка", но он звонит и от имени ООО "Василёк". Автопривязка привяжет звонок к "Ромашка", хотя заявка от "Василёк".

Решение — 3 уровня коррекции:

Уровень 1: Автопривязка + статус draft

  • Автопривязка по телефону/email работает автоматически
  • Если клиент создан автоматически (черновик) — статус draft
  • Руководитель проверяет и корректирует при необходимости
  • Черновики выделяются визуально (серый фон в таблице)

Уровень 2: Перенос отдельных записей

В карточке звонка/лида — кнопка «Изменить клиента»:

@router.post("/service/calls/reassign")
async def reassign_call(
    call_id: int,
    new_customer_id: int,
    user: dict = Depends(get_current_user),
):
    """Перенос звонка к другому клиенту.

    Сохраняет audit-запись о переносе.
    """
    call = await db.get(CallRecord, call_id)
    old_customer_id = call.customer_id
    call.customer_id = new_customer_id

    # Audit-запись для истории
    await db.execute(
        insert(AuditLog).values(
            entity_type="call_record",
            entity_id=call_id,
            field="customer_id",
            old_value=str(old_customer_id),
            new_value=str(new_customer_id),
            changed_by=user["id"],
        )
    )
    await db.commit()

UI: В таблице звонков/лидов — колонка «Клиент» кликабельна → выпадающий список для выбора другого клиента.

Уровень 3: Слияние дубликатов (Merge)

Если обнаружен дубликат клиента — кнопка «Слить с другим клиентом» в карточке:

@router.post("/service/customers/merge")
async def merge_customers(
    source_id: int,  # дубликат (удаляется)
    target_id: int,  # основной (остаётся)
    user: dict = Depends(get_current_user),
):
    """Слияние двух клиентов.

    Все связанные данные переносятся на target.
    Исходный клиент помечается как 'merged'.
    """
    # Перенос лидов
    await db.execute(
        update(Lead).where(Lead.customer_id == source_id).values(customer_id=target_id)
    )
    # Перенос звонков
    await db.execute(
        update(CallRecord).where(CallRecord.customer_id == source_id).values(customer_id=target_id)
    )
    # Перенос задач
    await db.execute(
        update(Task).where(Task.customer_id == source_id).values(customer_id=target_id)
    )
    # Перенос инцидентов
    await db.execute(
        update(Incident).where(Incident.customer_id == source_id).values(customer_id=target_id)
    )

    # Помечаем исходный как merged
    source = await db.get(Customer, source_id)
    source.status = "merged"
    source.merged_into = target_id
    await db.commit()

UI: В карточке клиента — кнопка «Слить с другим» → выбор клиента → подтверждение → перенос всех данных + визуальное уведомление.

4.4 Таблица audit_log (история изменений)

CREATE TABLE audit_log (
    id SERIAL PRIMARY KEY,
    entity_type VARCHAR(50) NOT NULL,  -- customer, lead, call_record, task
    entity_id INTEGER NOT NULL,
    field VARCHAR(100) NOT NULL,
    old_value TEXT DEFAULT '',
    new_value TEXT DEFAULT '',
    changed_by INTEGER REFERENCES users(id),
    created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_audit_entity ON audit_log(entity_type, entity_id);

5. Звонки из MySQL CDR

5.1 Периодический опрос

Каждые 5 минут:

async def sync_cdr():
    last_sync = get_last_cdr_sync_time()
    conn = get_pbx_mysql_connection()
    cursor = conn.cursor()

    # Определяем текущую таблицу (cdr или cdr_YYYYMM)
    table = f"cdr_{datetime.now():%Y%m}"
    cursor.execute(f"""
        SELECT datetime, src, dst, duration, billable, disposition,
               calltype, srctrunk, uniqueid, recordfile, recordpath,
               personalcontact, dstchanurl
        FROM cdr.{table}
        WHERE datetime > %s
        ORDER BY datetime ASC
    """, (last_sync,))

    for row in cursor.fetchall():
        # Создаём call_record
        call = create_call_record(row)
        # Автопривязка к клиенту
        auto_link_call_to_customer(call)
        # Уведомление о пропущенном
        if call.disposition in ('NO ANSWER', 'BUSY'):
            send_missed_call_notification(call)

    update_last_cdr_sync_time()

5.2 Автопривязка звонка к клиенту

async def auto_link_call_to_customer(call):
    customer = None

    # Нормализуем номер
    normalized = normalize_phone(call.caller_number)

    # Ищем клиента по номеру телефона
    customer = await db.execute(
        select(Customer).where(
            func.regexp_replace(Customer.contact_phone, '[^0-9]', '', 'g') LIKE f'%{normalized}%'
        )
    ).scalar_one_or_none()

    # Если не найден — создать «Черновик клиента» (как при лиде)
    if not customer:
        customer = create_draft_customer(call.caller_number)

    call.customer_id = customer.id

5.3 Уведомления

Событие Тип уведомления Кому
Пропущенный звонок (NO ANSWER) missed_call Owner + инженер
Входящий звонок на пустую очередь missed_call Owner + инженер
Новый лид от бота new_lead Owner + инженер
SLA по задаче нарушен sla_breach Owner + инженер
Задача просрочена task_overdue Owner + инженер

6. Страницы в портале

6.1 Страница «Лиды» (/service/leads)

Маршруты:

  • GET /service/leads — список лидов
  • POST /service/leads/create — создать лид вручную
  • POST /service/leads/update — изменить статус/данные
  • POST /service/leads/delete — удалить лид

Фильтры:

  • По статусу (воронка)
  • По источнику (bot, phone, manual)
  • По менеджеру (assigned_to)
  • По дате
  • Поиск по названию/телефону/контакту

Колонки таблицы:

# Столбец Описание
1 ID Номер лида
2 Источник bot / phone / site / manual
3 Клиент Компания (ссылка на карточку)
4 Контакт Имя + телефон
5 Услуга Что ищет клиент
6 Критичность Critical / High / Medium / Low
7 Статус Воронка
8 Менеджер Назначенный инженер
9 Сумма Ожидаемая сумма сделки
10 Дата Дата создания
11 Действия Статус, редактирование, удаление

Страница «Воронка» (вкладка на странице лидов для owner):

Новые: 5          Связаться: 3         Квалификация: 2
КП: 1             Переговоры: 0        Согласование: 0
Выиграно: 4       Проиграно: 1

6.2 Страница «Звонки» (/service/calls)

Маршруты:

  • GET /service/calls — история звонков

Фильтры:

  • По дате (от/до)
  • По номеру телефона
  • По клиенту
  • По типу (входящий/исходящий)
  • По статусу (отвечен/пропущен/занято)

Колонки таблицы:

# Столбец Описание
1 Дата/время Когда звонил
2 Тип Входящий / Исходящий
3 Номер Откуда / Куда
4 Клиент Привязанный клиент
5 Принял Кто ответил (расширение)
6 Длительность Секунды
7 Статус Отвечен / Пропущен / Занято
8 Транк Mango / Tele2
9 Запись 🎧 (будет доступна позже)

6.3 Расширенная карточка клиента (/service/customers/{id})

Текущая карточка — модальное окно. Нужно перевести на отдельную страницу.

Вкладки (tabs):

Вкладка Содержимое
Общее Контакты, ИНН, адрес, статус, заметки (текущая карточка)
Звонки Таблица звонков этого клиента
Заявки Лиды этого клиента
Задачи Задачи, привязанные к клиенту (через объект или лид)
Инциденты Инциденты этого клиента (через объект)
Файлы Документы клиента (будущее)
История Лог действий (audit_log)

7. Уведомления

7.1 API уведомлений

# Получить непрочитанные
GET /service/api/notifications?unread=true

# Пометить как прочитанное
POST /service/api/notifications/{id}/read

# Количество непрочитанных (для иконки в шапке)
GET /service/api/notifications/count

7.2 UI уведомлений

  • 🔔 Иконка в шапке с бейджем количества непрочитанных
  • Выпадающий список последних 10 уведомлений
  • Ссылка «Все уведомления» → отдельная страница

7.3 Типы уведомлений

Тип Описание Кому Действие
missed_call Пропущенный звонок Owner, assigned engineer → Карточка клиента
new_lead Новая заявка от бота Owner → Список лидов
lead_status_change Статус лида изменён Assigned user → Карточка лида
sla_breach Нарушен SLA по задаче Owner, assigned → Карточка задачи
task_overdue Задача просрочена Owner, assigned → Карточка задачи

8. Единая страница входящих /service/inbox

8.1 Концепция

Единая точка входа для owner и engineer. Все обращения из разных каналов в одном месте.

Параметр Значение
URL /service/inbox
Доступ owner, engineer
Обновление По кнопке "Обновить" (AJAX)
Источник Виртуальный запрос (без отдельной таблицы)

8.2 Источники данных

-- Виртуальный запрос: объединение 3 каналов
(
    -- 1. Заявки от бота (не обработанные)
    SELECT 
        'bot' as channel,
        bt.id as source_id,
        bt.contact_name,
        bt.contact_phone,
        bt.company_name,
        bt.description,
        bt.status,
        bt.created_at
    FROM bot_tickets bt
    WHERE bt.status = 'Новая'
    
    UNION ALL
    
    -- 2. Пропущенные звонки (не обработанные)
    SELECT 
        'phone' as channel,
        cr.id as source_id,
        '' as contact_name,
        cr.caller_number as contact_phone,
        '' as company_name,
        CONCAT(cr.disposition, ' — ', cr.callee_number) as description,
        'missed' as status,
        cr.call_datetime as created_at
    FROM call_records cr
    WHERE cr.disposition IN ('NO ANSWER', 'BUSY')
      AND cr.customer_id IS NULL  -- ещё не привязан к клиенту
    
    UNION ALL
    
    -- 3. Заявки с сайта (email, не обработанные)
    SELECT 
        'email' as channel,
        l.id as source_id,
        l.contact_name,
        l.contact_phone,
        l.company_name,
        l.description,
        l.status,
        l.created_at
    FROM leads l
    WHERE l.source = 'email'
      AND l.status = 'new'
)
ORDER BY created_at DESC;

8.3 UI

┌─────────────────────────────────────────────────────────────────┐
│  Неразобранное (3)                              [Обновить] 🔔  │
├─────────────────────────────────────────────────────────────────┤
│  📱 Бот (2)    │ Иван Петров  │ +7999... │ "Нужен насос"     │
│  📞 Звонок (1) │ +7912345678  │ —        │ Пропущенный 14:32  │
│  📧 Email (0)  │ —            │ —        │ —                  │
├─────────────────────────────────────────────────────────────────┤
│  [Обработать] → Открывает карточку/лид                        │
└─────────────────────────────────────────────────────────────────┘

Действия:

  • Кнопка "Обработать" → переход к созданию лида/клиента
  • Фильтр по каналу (бот/звонок/email)
  • Сортировка по времени

9. Настройки АТС (Yeastar S20)

9.1 Таблица pbx_settings

CREATE TABLE pbx_settings (
    id SERIAL PRIMARY KEY,
    
    -- Подключение
    mysql_host VARCHAR(255) DEFAULT '192.168.1.150',
    mysql_port INTEGER DEFAULT 3306,
    mysql_user VARCHAR(100) DEFAULT '',
    mysql_password VARCHAR(255) DEFAULT '',  -- шифрование
    mysql_database VARCHAR(100) DEFAULT 'cdr',
    
    ami_host VARCHAR(255) DEFAULT '192.168.1.150',
    ami_port INTEGER DEFAULT 5038,
    ami_user VARCHAR(100) DEFAULT '',
    ami_password VARCHAR(255) DEFAULT '',    -- шифрование
    
    ftp_host VARCHAR(255) DEFAULT '192.168.1.150',
    ftp_port INTEGER DEFAULT 21,
    ftp_user VARCHAR(100) DEFAULT '',
    ftp_password VARCHAR(255) DEFAULT '',    -- шифрование
    
    web_host VARCHAR(255) DEFAULT '192.168.1.150',
    web_port INTEGER DEFAULT 8088,
    web_user VARCHAR(100) DEFAULT '',
    web_password VARCHAR(255) DEFAULT '',    -- шифрование
    
    -- Информация
    serial_number VARCHAR(50) DEFAULT '',    -- ручной ввод
    actual_serial VARCHAR(50) DEFAULT '',    -- из FTP
    
    -- Статус подключения (кэш)
    mysql_status VARCHAR(20) DEFAULT 'unknown',  -- ok/error/unknown
    ami_status VARCHAR(20) DEFAULT 'unknown',
    ftp_status VARCHAR(20) DEFAULT 'unknown',
    serial_match BOOLEAN DEFAULT FALSE,
    
    -- Синхронизация
    last_sync_at TIMESTAMP,
    sync_interval INTEGER DEFAULT 5,         -- минут
    
    updated_at TIMESTAMP DEFAULT NOW()
);

-- Всегда одна запись
INSERT INTO pbx_settings (id) VALUES (1) ON CONFLICT DO NOTHING;

9.2 Шифрование паролей

from cryptography.fernet import Fernet
import os

# Ключ шифрования (из .env)
ENCRYPTION_KEY = os.getenv("PBX_ENCRYPTION_KEY")
cipher = Fernet(ENCRYPTION_KEY.encode())

def encrypt_password(plain: str) -> str:
    """Шифрование пароля для хранения в БД."""
    return cipher.encrypt(plain.encode()).decode()

def decrypt_password(encrypted: str) -> str:
    """Дешифрование пароля из БД."""
    return cipher.decrypt(encrypted.encode()).decode()

9.3 Страница настроек

Вкладка Поля
MySQL Host, Port, User, Password, Database
AMI Host, Port, User, Password
FTP Host, Port, User, Password
Веб-интерфейс Host, Port, User, Password
Информация Серийный номер (ручной ввод), Интервал синхронизации
Статус Индикаторы состояния, Время последней синхронизации

9.4 Индикаторы состояния

Цвет Состояние Описание
🔴 Красный Подключение отсутствует connect() failed
🟡 Жёлтый Не все данные Есть пустые обязательные поля
🟢 Зелёный Всё OK Подключение успешно, данные получены

9.5 Проверка по кнопке

@router.post("/service/api/pbx-settings/test")
async def test_pbx_connection():
    """Тест подключения к АТС по всем протоколам."""
    settings = await db.get(PbxSettings, 1)
    results = {}
    
    # MySQL test
    try:
        conn = mysql.connector.connect(
            host=settings.mysql_host,
            port=settings.mysql_port,
            user=settings.mysql_user,
            password=decrypt_password(settings.mysql_password),
            database=settings.mysql_database,
            charset='utf8',
        )
        cursor = conn.cursor()
        cursor.execute("SELECT 1")
        cursor.fetchone()
        conn.close()
        results["mysql"] = {"status": "ok", "message": "Подключение успешно"}
    except Exception as e:
        results["mysql"] = {"status": "error", "message": str(e)}
    
    # AMI test
    try:
        ami = AMIClient(address=f"{settings.ami_host}:{settings.ami_port}")
        ami.login(settings.ami_user, decrypt_password(settings.ami_password))
        ami.send_action({"Action": "Ping"})
        ami.logoff()
        results["ami"] = {"status": "ok", "message": "Подключение успешно"}
    except Exception as e:
        results["ami"] = {"status": "error", "message": str(e)}
    
    # FTP test
    try:
        ftp = ftplib.FTP()
        ftp.connect(settings.ftp_host, settings.ftp_port)
        ftp.login(settings.ftp_user, decrypt_password(settings.ftp_password))
        ftp.quit()
        results["ftp"] = {"status": "ok", "message": "Подключение успешно"}
    except Exception as e:
        results["ftp"] = {"status": "error", "message": str(e)}
    
    # Serial number validation
    try:
        ftp = ftplib.FTP()
        ftp.connect(settings.ftp_host, settings.ftp_port)
        ftp.login(settings.ftp_user, decrypt_password(settings.ftp_password))
        ftp.cwd("/support/tmp")
        lines = []
        ftp.retrlines("RETR deviceinfo.txt", lines.append)
        ftp.quit()
        
        actual_serial = extract_serial_from_info(lines)
        settings.actual_serial = actual_serial
        settings.serial_match = (actual_serial == settings.serial_number)
        results["serial"] = {
            "status": "match" if settings.serial_match else "mismatch",
            "actual": actual_serial,
            "configured": settings.serial_number,
        }
    except Exception as e:
        results["serial"] = {"status": "error", "message": str(e)}
    
    # Save status
    settings.mysql_status = results["mysql"]["status"]
    settings.ami_status = results["ami"]["status"]
    settings.ftp_status = results["ftp"]["status"]
    await db.commit()
    
    return results

10. Внутренний номер сотрудника

10.1 Миграция

ALTER TABLE users ADD COLUMN extension VARCHAR(10) DEFAULT '';

10.2 Привязка к расширению АТС

Зачем:

  • Автоматическое определение кто ответил на звонок
  • Уведомления о пропущенных звонках конкретному сотруднику
  • Видно кто отвечает за какой объект

Связь:

users.extension  ←→  call_records.callee_number (dst)
                  ←→  CDR.dstchanurl (URL канала)

10.3 Автопривязка звонка к сотруднику

async def auto_link_call_to_employee(call, db):
    """Привязка звонка к сотруднику по внутреннему номеру."""
    # Нормализуем номер получателя
    dst_normalized = normalize_phone(call.callee_number)
    
    # Ищем сотрудника по внутреннему номеру
    employee = await db.execute(
        select(User).where(
            User.extension == dst_normalized,
            User.is_active == True,
        )
    ).scalar_one_or_none()
    
    if employee:
        call.answered_by = employee.id
    
    return employee

10.4 Уведомления о пропущенных звонках

async def send_missed_call_notification(call, db):
    """Уведомление о пропущенном звонке."""
    # Кому: владелец номера + руководитель
    if call.answered_by:
        recipients = [call.answered_by]  # владелец номера
    else:
        recipients = []  # только owner
    
    # Добавляем owner
    owner = await db.execute(
        select(User).where(User.role == "owner")
    ).scalar_one_or_none()
    if owner and owner.id not in recipients:
        recipients.append(owner.id)
    
    for user_id in recipients:
        await db.execute(
            insert(ClientNotification).values(
                user_id=user_id,
                type="missed_call",
                title=f"Пропущенный звонок от {call.caller_number}",
                message=f"Время: {call.call_datetime}, Номер: {call.callee_number}",
                call_record_id=call.id,
            )
        )
    await db.commit()

11. Задачи: уточнение workflow

Текущая модель (без изменений)

Owner/Engineer:
  ✅ Видит ВСЕ задачи
  ✅ Создаёт задачи
  ✅ Назначает на любого (engineer/technician)
  ✅ Закрывает любые

Technician:
  ✅ Видит ТОЛЬКО свои задачи (WHERE assigned_to = user.id)
  ❌ НЕ может создавать задачи для других
  ✅ МОЖЕТ создать задачу себе (напоминание)
  ✅ Закрывает ТОЛЬКО свои задачи

Добавления (в плане)

Изменение Описание
lead_id FK в tasks Связь задачи с лидом
created_by = assigned_to для техника Если техник создаёт — автоматически назначает на себя
Вкладка «Задачи» в карточке клиента Задачи, связанные через лид или объект
Кнопка «Создать задачу» в карточке лида Быстрое создание задачи из лида

9. Воронка и KPI (только для owner)

9.1 Расширение /service/charts

Добавить блок «Воронка продаж»:

# KPI данные
GET /service/api/charts/funnel
GET /service/api/charts/kpi

9.2 Воронка (визуализация)

Новые лиды          ████████████████  100
Квалификация         ██████████        65
КП отправлено        ██████            40
Переговоры           ███               22
Сделка               █                 11

9.3 KPI

Метрика Формула
Новые лиды COUNT(leads WHERE status='new' AND date=today)
Обработано сегодня COUNT(leads WHERE updated_at::date = today)
Конверсия won / total * 100
Средний чек AVG(deal_amount) WHERE status='won'
Активные проекты COUNT(tasks WHERE status IN ('open','in_progress'))

10. Интеграция бота и портала

10.1 Создание лида при BotTicket

При создании BotTicket в max_bot → POST в py_service:

# max_bot: inquiry.py
async def create_ticket(...):
    # ... существующий код создания BotTicket ...

    # НОВОЕ: создать Lead в py_service
    await create_lead_via_api({
        "source": "bot",
        "bot_ticket_id": ticket.id,
        "company_name": user.organization or "",
        "contact_name": user.full_name,
        "contact_phone": user.phone,
        "contact_email": user.email,
        "description": inquiry_text,
        "service_type": detected_service,
    })

10.2 API портала для бота

# py_service: новый роутер для API бота
POST /api/internal/leads          # создать лид
PATCH /api/internal/leads/{id}    # обновить лид
POST /api/internal/calls/sync     # синхронизация CDR

11. Фазы реализации

Фаза 1: Лиды (базовая)

  • Миграция: таблица leads
  • Миграция: статус draft для customers
  • Страница /service/leads (CRUD)
  • Автопривязка Lead → Customer (ИНН → телефон → email → создание)
  • Интеграция с BotTicket (автосоздание лида)
  • Добавить вопрос о компании в бота

Фаза 2: Звонки

  • Миграция: таблица call_records
  • Синхронизация CDR из MySQL (каждые 5 мин)
  • Автопривязка звонков к клиентам
  • Страница /service/calls
  • Уведомления о пропущенных звонках

Фаза 3: Единая карточка клиента

  • Перевод карточки клиента на отдельную страницу
  • Вкладки: общее, звонки, заявки, задачи, инциденты
  • Кнопка «Создать задачу» из карточки лида
  • Связь task → lead

Фаза 4: Уведомления

  • Миграция: таблица client_notifications
  • API уведомлений (count, list, read)
  • UI: иконка 🔔 в шапке с бейджем
  • Все типы уведомлений

Фаза 5: Воронка и KPI

  • API воронки и KPI
  • Визуализация воронки на /service/charts
  • Блок KPI для руководителя

Фаза 6 (будущее): Транскрибация + Записи

  • SSH/FTP доступ к WAV-файлам
  • Интеграция с Yandex SpeechKit
  • Автотранскрибация звонков
  • Плеер для прослушивания

17. Миграции (SQL-файлы)

Файл Описание
migrations/002_leads_and_calls.sql Таблицы leads, call_records, client_notifications
migrations/003_lead_task_relation.sql lead_id FK в tasks
migrations/004_customer_draft_status.sql Статус draft + доп. поля в customers
migrations/005_pbx_settings.sql Таблица pbx_settings (настройки АТС)
migrations/006_user_extension.sql Поле extension в users (внутренний номер)
migrations/007_audit_log_enhanced.sql Расширенная таблица audit_log (для дедупликации)

13. Зависимости и риски

Риск Описание Решение
MySQL доступ 1cuser/1csecret, БД cdr Работает, тестировать
Проброс портов 43306→3306, 45038→5038 Настроен
FTP/SSH Записи WAV Нет логина SSH — позже
Yandex SpeechKit Транскрибация Нет API-ключа — позже
Кириллица в CDR CP1251 garbage Конвертация при чтении
Два DB py_service и max_bot API-интеграция через HTTP
Шифрование паролей Пароли АТС в открытом виде cryptography.fernet + ключ в .env
Привязка extension Несоответствие номеров Ручной ввод + проверка при синхронизации
Серийник АТС Несовпадение serial_number Сравнение при каждой синхронизации
Общая почта Письма не-заявки Фильтр по теме Заявка с сайта:

Зависимости (обновить requirements.txt)

cryptography>=44.0.0  # для шифрования паролей АТС

19. Связь с существующими разделами меню

Текущая структура

Панель: Дашборд, Графики, CEO
Управление: Клиенты, Объекты, SLA, Опросник, Паспорта
Работа: Сотрудники, Назначение, Документация, Задачи, Отчёты, Инциденты, Чек-лист
Чат-бот MAX: Согласия, Заявки, База знаних, Неизвестные, Диалоги
Контент: Блог, Кейсы
Настройки: Опросник, Формулы, Портал, Роли, Бот, Идеи

Новая структура (после реализации)

Панель: Дашборд, Графики (+ воронка, KPI), CEO
Управление: Клиенты (+ вкладки), Объекты, Лиды (НОВОЕ), SLA, Опросник, Паспорта
Работа: Неразобранное (НОВОЕ), Сотрудники (+ внутр. номер), Назначение, Документация, 
        Задачи (+ связь с лидами), Отчёты, Инциденты, Чек-лист, Звонки (НОВОЕ)
Чат-бот MAX: Согласия, Заявки, База знаних, Неизвестные, Диалоги
Контент: Блог, Кейсы
Настройки: Опросник, Формулы, Портал, Роли, Бот, Настройки АТС (НОВОЕ), Идеи

Пункты меню

Раздел Пункт Действие
Панель Графики (РАСШИРЕНИЕ) + воронка + KPI
Управление Лиды (НОВОЕ) /service/leads
Работа Неразобранное (НОВОЕ) /service/inbox
Работа Звонки (НОВОЕ) /service/calls
Настройки Настройки АТС (НОВОЕ) /service/pbx-settings

20. Итоговая архитектура

┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│  Бот MAX    │     │   Телефон   │     │    Сайт    │     │   Email     │
│  (заявка)   │     │  (CDR/AMI)  │     │  (форма)   │     │  (парсинг)  │
└──────┬──────┘     └──────┬──────┘     └──────┬──────┘     └──────┬──────┘
       │                   │                   │                   │
       └───────────────────┼───────────────────┼───────────────────┘
                           │                   │                   │
                    ┌──────▼──────┐     ┌──────▼──────┐     ┌──────▼──────┐
                    │   ЛИДЫ      │     │  ЗВОНКИ     │     │  ЗАЯВКИ     │
                    │  (leads)    │     │(call_records)│    │  (leads)    │
                    │  Автопривязка│    │  Автопривязка│    │  Парсинг темы│
                    └──────┬──────┘     └──────┬──────┘     └──────┬──────┘
                           │                   │                   │
                           └───────────────────┼───────────────────┘
                                               │
                                        ┌──────▼──────┐
                                        │   КЛИЕНТ    │
                                        │ (customers) │
                                        └──────┬──────┘
                                               │
                          ┌────────────────────┼────────────────────┐
                          │                    │                    │
                   ┌──────▼──────┐      ┌──────▼──────┐      ┌──────▼──────┐
                   │   ЗАДАЧИ    │      │  ИНЦИДЕНТЫ  │      │ УВЕДОМЛЕНИЯ │
                   │   (tasks)   │      │(incidents)  │      │(notific.)   │
                   └─────────────┘      └─────────────┘      └─────────────┘

┌─────────────────────────────────────────────────────────────────────────────┐
│  НАСТРОЙКИ АТС (pbx_settings)                                              │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐   │
│  │  MySQL   │  │   AMI    │  │   FTP    │  │   Web    │  │ Серийник │   │
│  │ 🔴🟡🟢  │  │ 🔴🟡🟢  │  │ 🔴🟡🟢  │  │ 🔴🟡🟢  │  │   ✅❌   │   │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘  └──────────┘   │
└─────────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────────┐
│  СОТРУДНИКИ (users)                                                        │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐                   │
│  │  ФИО     │  │   Роль   │  │ Внутр.   │  │ Привязка │                   │
│  │          │  │          │  │  номер   │  │ к АТС    │                   │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘                   │
└─────────────────────────────────────────────────────────────────────────────┘

Потоки данных

Источник Формат Метод Периодичность
Бот MAX JSON API BotTicket → Lead Реальное время
Yeastar S20 MySQL CDR call_records Каждые 5 мин
Сайт (форма) Email (SMTP) Тема Заявка с сайта ({тип}) — {дата} → Lead Каждые 5 мин (IMAP)
Руководитель Ручной ввод CRUD лидов По мере необходимости

Привязка сотрудников к АТС

users.extension  ←→  call_records.callee_number (dst)
                  ←→  CDR.dstchanurl (URL канала)

При звонке:
1. Определяемextension (номер получателя)
2. Ищем сотрудника в users WHERE extension = dst
3. Привязываем звонок к сотруднику
4. Если пропущенный → уведомляем сотрудника + owner

21. Приоритеты реализации

Фаза 1: Лиды + Базовая инфраструктура

  • Миграция: таблица leads
  • Миграция: статус draft для customers
  • Миграция: таблица audit_log
  • Миграция: таблица pbx_settings
  • Миграция: поле extension в users
  • Страница /service/leads (CRUD)
  • Автопривязка Lead → Customer (ИНН → телефон → email → создание)
  • Интеграция с BotTicket (автосоздание лида)
  • Добавить вопрос о компании в бота ("Не могу сказать")
  • Коррекция привязки (перенос отдельных записей)
  • Страница /service/pbx-settings (настройки АТС)
  • Шифрование паролей в pbx_settings
  • Тест подключения (MySQL, AMI, FTP)
  • Проверка серийного номера
  • Добавить поле "Внутренний номер" в /service/users

Фаза 2: Звонки + Email + Входящий поток

  • Миграция: таблица call_records
  • Синхронизация CDR из MySQL (каждые 5 мин)
  • Автопривязка звонков к клиентам
  • Автопривязка звонков к сотрудникам (по extension)
  • Страница /service/calls
  • Страница /service/inbox (виртуальный запрос)
  • Уведомления о пропущенных звонках (конкретному сотруднику)
  • IMAP-клиент для проверки почты
  • Парсинг темы письма → Lead
  • Периодическая проверка почты (каждые 5 мин)

Фаза 3: Единая карточка клиента

  • Перевод карточки клиента на отдельную страницу
  • Вкладки: общее, звонки, заявки, задачи, инциденты
  • Кнопка «Создать задачу» из карточки лида
  • Связь task → lead
  • Слияние дубликатов (Merge)

Фаза 4: Уведомления

  • Миграция: таблица client_notifications
  • API уведомлений (count, list, read)
  • UI: иконка 🔔 в шапке с бейджем
  • Все типы уведомлений
  • Уведомления о пропущенных звонках (конкретному сотруднику)

Фаза 5: Воронка и KPI

  • API воронки и KPI
  • Визуализация воронки на /service/charts
  • Блок KPI для руководителя

Фаза 6 (будущее): Транскрибация + Записи

  • SSH/FTP доступ к WAV-файлам
  • Интеграция с Yandex SpeechKit
  • Автотранскрибация звонков
  • Извлечение названия компании из транскрипции (уровень 4 дедупликации)
  • Автопривязка по транскрипции (≥ 85%) + уведомление для подтверждения
  • Плеер для прослушивания

Фаза 7 (будущее): Интеграция с 1С:УНФ 3.0

Статус: ИССЛЕДОВАНИЕ (документ other/1C_UNF_INTEGRATION.md)

Возможная интеграция с 1С:УНФ 3.0 в облаке 1С-Фреш через OData API.

Что можно получить из 1С:УНФ:

Сущность 1С OData URL Наша сущность
Контрагенты Catalog_Контрагенты Клиенты
Контактные лица Catalog_КонтактныеЛица Контакты клиента
Заказ покупателя Document_ЗаказКлиента Лиды/Заказы
Номенклатура Catalog_Номенклатура Каталог услуг
Договоры Catalog_Договоры Привязка к клиенту

Стратегия:

  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С-Фреш).