From f06ecaabe3147cee46641b8a706b00775c6831fa Mon Sep 17 00:00:00 2001 From: Sergey Korotonozhko <_serezhka_@mail.ru> Date: Thu, 4 Jun 2026 14:38:43 +0300 Subject: [PATCH] =?UTF-8?q?v1.9.0:=20Phase=200=20=E2=80=94=20=D0=B8=D0=BD?= =?UTF-8?q?=D1=84=D1=80=D0=B0=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=B0=20CRM/=D0=BB=D0=B8=D0=B4=D0=BE=D0=B2,=20=D0=BC?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=91=D0=94,=20?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- max_bot/version.txt | 2 +- other/1C_UNF_INTEGRATION.md | 430 +++++ other/AI-EXISTING-TWO-PROJECT-INSTRUCTION.md | 4 +- other/LEAD_IMPLEMENTATION_PLAN.md | 1531 +++++++++++++++++ other/Yeastar_S20.md | 541 ++++++ other/voronka.md | 267 +++ py_service/CHANGELOG.md | 20 + py_service/app/documents_router.py | 8 +- py_service/app/main.py | 12 + py_service/app/migrations_runner.py | 128 ++ py_service/app/models/models.py | 235 ++- .../migrations/001_phase0_infrastructure.sql | 144 ++ py_service/migrations/__init__.py | 0 py_service/requirements.txt | 1 + py_service/version.txt | 2 +- 15 files changed, 3315 insertions(+), 10 deletions(-) create mode 100644 other/1C_UNF_INTEGRATION.md create mode 100644 other/LEAD_IMPLEMENTATION_PLAN.md create mode 100644 other/Yeastar_S20.md create mode 100644 other/voronka.md create mode 100644 py_service/app/migrations_runner.py create mode 100644 py_service/migrations/001_phase0_infrastructure.sql create mode 100644 py_service/migrations/__init__.py diff --git a/max_bot/version.txt b/max_bot/version.txt index 9eadd6b..f8e233b 100644 --- a/max_bot/version.txt +++ b/max_bot/version.txt @@ -1 +1 @@ -1.8.6 \ No newline at end of file +1.9.0 diff --git a/other/1C_UNF_INTEGRATION.md b/other/1C_UNF_INTEGRATION.md new file mode 100644 index 0000000..c5803ff --- /dev/null +++ b/other/1C_UNF_INTEGRATION.md @@ -0,0 +1,430 @@ +# Интеграция с 1С:УНФ 3.0 (1С-Фреш) + +> Дата исследования: 04.06.2026 +> Статус: ИССЛЕДОВАНИЕ (код не написан) + +--- + +## 1. Краткое описание + +Документ описывает доступные API для интеграции сервисного портала AegisOne с 1С:УНФ 3.0, работающей в облаке 1С-Фреш. + +**Цель:** Синхронизация данных между 1С:УНФ и порталом — клиенты, заказы, контактные лица. + +--- + +## 2. Доступные интерфейсы 1С-Фреш + +| Интерфейс | Назначение | Чтение | Запись | Сложность | +|-----------|-----------|--------|--------|-----------| +| **OData** | Стандартный интерфейс платформы 1С | ✅ | ✅ | Средняя | +| **REST API** | Портал 1С:ITS Fresh-Integration | ✅ | ✅ | Средняя | +| **HTTP-сервис** | УниверсальнаяИнтеграция (UniversalIntegration) | ✅ | ✅ | Высокая | +| **Система взаимодействия** | Вебхуки (внешние → 1С) | ❌ | ✅ | Низкая | + +### Главный вывод + +**OData** — основной и самый доступный способ интеграции. Через OData доступны практи­чески все объекты 1С:УНФ 3.0: справочники, документы, регистры. + +--- + +## 3. OData интерфейс + +### 3.1 Формат URL + +``` +https:///a/unf//odata/standard.odata/ +``` + +| Параметр | Описание | Пример | +|----------|----------|--------| +| `server` | Адрес сервера 1С-Фреш | `https://xxx.1cfresh.com` | +| `app` | Код приложения | `unf` (для УНФ) | +| `tenant` | Номер области данных | `34` | +| `Entity` | Имя объекта метаданных | `Catalog_Контрагенты` | + +### 3.2 Аутентификация + +``` +Authorization: Basic +``` + +Используется сервисный пользователь 1С-Фреш с правами доступа к OData. + +### 3.3 Формат ответа + +``` +?$format=json — JSON +?$format=atom — Atom/XML (по умолчанию) +?$format=json;odata=nometadata — JSON без метаданных +``` + +--- + +## 4. Доступные сущности 1С:УНФ 3.0 + +### 4.1 Справочники (Catalogs) + +| OData URL | Описание | Приоритет для нас | +|-----------|----------|-------------------| +| `Catalog_Контрагенты` | Покупатели, поставщики, прочие контрагенты | 🔴 Высокий → **Клиенты** | +| `Catalog_КонтактныеЛица` | Контактные лица контрагентов | 🔴 Высокий → **Контакты** | +| `Catalog_Номенклатура` | Услуги, товары, работы | 🟡 Средний → Каталог услуг | +| `Catalog_Договоры` | Договоры с контрагентами | 🟡 Средний → Привязка к клиенту | +| `Catalog_Проекты` | Проекты | 🟡 Средний → Связь с заказами | +| `Catalog_Организации` | Наши организации | 🟢 Низкий → Контекст | +| `Catalog_БанковскиеСчета` | Расчётные счета | 🟢 Низкий | + +### 4.2 Документы (Documents) + +| OData URL | Описание | Приоритет для нас | +|-----------|----------|-------------------| +| `Document_ЗаказКлиента` | Заказ покупателя | 🔴 Высокий → **Заказ/Лид** | +| `Document_РеализацияТоваровУслуг` | Реализация товаров и услуг | 🟡 Средний → История работ | +| `Document_АктВыполненныхРабот` | Акт выполненных работ | 🟡 Средний → Подтверждение | +| `Document_СчетНаОплату` | Счёт на оплату | 🟡 Средний → Финансы | +| `Document_ПоступлениеТоваров` | Поступление товаров | 🟢 Низкий → Закупки | +| `Document_СчетНаОплатуПоставщика` | Счёт от поставщика | 🟢 Низкий | + +### 4.3 Регистры сведений + +| OData URL | Описание | +|-----------|----------| +| `InformationRegister_ЦеныНоменклатурыДокументов` | Цены номенклатуры | +| `InformationRegister_КурсыВалют` | Курсы валют | +| `InformationRegister_НастройкиСистемыНалогообложения` | Учётная политика | + +### 4.4 Табличные части + +Доступны через суффикс имени: + +``` +Document_ЗаказКлиента_Товары — табличная часть «Товары» +Document_ЗаказКлиента_Услуги — табличная часть «Услуги» +Catalog_Контрагенты_КонтактнаяИнформация — контактная информация +``` + +--- + +## 5. Примеры запросов + +### 5.1 Все контрагенты (покупатели) + +```http +GET /odata/standard.odata/Catalog_Контрагенты?$format=json + &$select=Ref_Key,Description,ИНН,КПП,РегистрационныйНомер + &$filter=not (IsFolder) + &$orderby=Description + &$top=100 +Authorization: Basic +``` + +### 5.2 Поиск контрагента по ИНН + +```http +GET /odata/standard.odata/Catalog_Контрагенты?$format=json + &$select=Ref_Key,Description,ИНН,КПП + &$filter=(ИНН eq '2310031540') +Authorization: Basic +``` + +### 5.3 Поиск контрагента по наименованию + +```http +GET /odata/standard.odata/Catalog_Контрагенты?$format=json + &$select=Ref_Key,Description,ИНН + &$filter=like(Description, 'Аегис%') +Authorization: Basic +``` + +### 5.4 Заказы конкретного клиента + +```http +GET /odata/standard.odata/Document_ЗаказКлиента?$format=json + &$expand=Контрагент + &$select=Ref_Key,Number,Date,СуммаДокумента,Статус,Контрагент/Description + &$filter=Контрагент_Key eq guid'...' + &$orderby=Date desc +Authorization: Basic +``` + +### 5.5 Контактные лица контрагента + +```http +GET /odata/standard.odata/Catalog_КонтактныеЛица?$format=json + &$select=Ref_Key,Description,Должность,Владелец_Key + &$filter=Владелец_Key eq guid'...' +Authorization: Basic +``` + +### 5.6 Номенклатура (услуги) + +```http +GET /odata/standard.odata/Catalog_Номенклатура?$format=json + &$expand=ЕдиницаИзмерения + &$select=Ref_Key,Description,Артикул,ВидНоменклатуры + &$filter=not (IsFolder) + &$orderby=Description +Authorization: Basic +``` + +### 5.7 Создание нового контрагента (POST) + +```http +POST /odata/standard.odata/Catalog_Контрагенты +Content-Type: application/json +Authorization: Basic + +{ + "Description": "ООО Ромашка", + "ИНН": "2310031540", + "КПП": "231001001", + "ЮридическоеФизическоеЛицо": "ЮридическоеЛицо" +} +``` + +### 5.8 Обновление контрагента (PATCH) + +```http +PATCH /odata/standard.odata/Catalog_Контрагенты(guid'...') +Content-Type: application/json +Authorization: Basic + +{ + "Description": "ООО Ромашка (обновлено)" +} +``` + +--- + +## 6. Маппинг сущностей 1С:УНФ → Наш портал + +### 6.1 Контрагенты → Клиенты + +| Поле 1С:УНФ | Поле портала | Тип маппинга | +|-------------|-------------|--------------| +| `Ref_Key` | `onec_id` (UUID) | Прямой | +| `Description` | `company_name` | Прямой | +| `ИНН` | `inn` | Прямой (ключ дедупликации) | +| `КПП` | `kpp` | Прямой | +| `Телефон` | `phone` | Нормализация | +| `Email` | `email` | Прямой | +| `Адрес` | `address` | Прямой | + +### 6.2 Заказ клиента → Лид/Заказ + +| Поле 1С:УНФ | Поле портала | Тип маппинга | +|-------------|-------------|--------------| +| `Ref_Key` | `onec_order_id` (UUID) | Прямой | +| `Number` | `number` | Прямой | +| `Date` | `created_at` | Конвертация | +| `СуммаДокумента` | `amount` | Прямой | +| `Статус` | `status` | Маппинг статусов | +| `Контрагент_Key` | `customer_id` | FK (по onec_id) | + +### 6.3 Статусы заказов + +| Статус 1С:УНФ | Статус портала | +|---------------|---------------| +| В работе | `in_progress` | +| Закрыт | `completed` | +| Отменён | `cancelled` | + +--- + +## 7. Стратегия интеграции + +### Фаза 0: Настройка доступа (отдельная задача) + +1. **Получить от пользователя:** + - URL базы в 1С-Фреш (типа `https://xxx.1cfresh.com/a/unf/`) + - Логин/пароль сервисного пользователя + - Номер области данных (tenant) + +2. **Проверить доступность:** + - Выполнить тестовый запрос к `$metadata` + - Убедиться в наличии нужных сущностей + - Проверить права доступа + +3. **Создать таблицу настроек:** + ```sql + CREATE TABLE onec_settings ( + id SERIAL PRIMARY KEY, + base_url VARCHAR(500) NOT NULL, + tenant VARCHAR(50) NOT NULL, + login VARCHAR(100) NOT NULL, + password_encrypted TEXT NOT NULL, -- fernet + last_sync_at TIMESTAMP, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW() + ); + ``` + +### Фаза 1: Синхронизация контрагентов → клиенты + +1. **Миграция:** Таблица `onec_sync_log` для логирования + ```sql + CREATE TABLE onec_sync_log ( + id SERIAL PRIMARY KEY, + entity_type VARCHAR(50) NOT NULL, -- 'contragent', 'order', etc. + onec_id UUID NOT NULL, + portal_id INTEGER, + action VARCHAR(20) NOT NULL, -- 'create', 'update', 'skip' + details JSONB, + synced_at TIMESTAMP DEFAULT NOW() + ); + ``` + +2. **Задача синхронизации:** + - Читаем `Catalog_Контрагенты` из 1С + - Дедупликация по ИНН (точное совпадение) + - Если ИНН нет — по наименованию + ИНН КПП + - Создаём/обновляем в таблице `customers` + - Логируем каждое действие + +3. **Периодичность:** Раз в день (ночью) или по требованию + +### Фаза 2: Синхронизация заказов + +1. **Миграция:** Поле `onec_order_id` в `leads` или новая таблица +2. **Задача синхронизации:** + - Читаем `Document_ЗаказКлиента` из 1С + - Привязка к клиенту по `Контрагент_Key` + - Маппинг статусов + - Логирование + +### Фаза 3 (опционально): Обратная запись + +- Создание заказа в портале → создание в 1С +- Требует аккуратной обработки ошибок +- **Рекомендация:** Только после стабильности фаз 1-2 + +--- + +## 8. Технические детали + +### 8.1 Python-клиент для OData + +Рекомендуемая библиотека: `pyodata` или `requests` (raw OData). + +```python +import base64 +import httpx + +class OneCFreshClient: + """Клиент для работы с 1С-Фреш через OData.""" + + def __init__(self, base_url: str, tenant: str, login: str, password: str): + self.base_url = base_url.rstrip('/') + self.tenant = tenant + self.auth = base64.b64encode(f"{login}:{password}".encode()).decode() + + async def get_contragents(self, top: int = 100, skip: int = 0) -> list: + """Получить список контрагентов.""" + url = f"{self.base_url}/a/unf/{self.tenant}/odata/standard.odata/Catalog_Контрагенты" + params = { + "$format": "json;odata=nometadata", + "$select": "Ref_Key,Description,ИНН,КПП", + "$filter": "not (IsFolder)", + "$top": str(top), + "$skip": str(skip), + } + headers = {"Authorization": f"Basic {self.auth}"} + + async with httpx.AsyncClient() as client: + resp = await client.get(url, params=params, headers=headers) + resp.raise_for_status() + return resp.json().get("value", []) + + async def get_contragent_by_inn(self, inn: str) -> dict | None: + """Найти контрагента по ИНН.""" + url = f"{self.base_url}/a/unf/{self.tenant}/odata/standard.odata/Catalog_Контрагенты" + params = { + "$format": "json;odata=nometadata", + "$select": "Ref_Key,Description,ИНН,КПП", + "$filter": f"(ИНН eq '{inn}')", + } + headers = {"Authorization": f"Basic {self.auth}"} + + async with httpx.AsyncClient() as client: + resp = await client.get(url, params=params, headers=headers) + resp.raise_for_status() + items = resp.json().get("value", []) + return items[0] if items else None +``` + +### 8.2 Ограничения + +| Ограничение | Описание | Решение | +|-------------|----------|---------| +| **Multi-tenancy** | Каждая база в своей области данных | Указать tenant в URL | +| **Публикация OData** | Должна быть включена в конфигурации | Проверить через `$metadata` | +| **Права доступа** | Сервисный пользователь должен иметь права на чтение | Настроить роли в 1С | +| **Пагинация** | Большие выборки — через `$top`/`$skip` | Построчная загрузка | +| **Rate limits** | Возможны ограничения на частоту запросов | Очередь задач, retry | + +### 8.3 Проверка доступности + +```bash +# Тестовый запрос метаданных +curl -u "login:password" \ + "https://xxx.1cfresh.com/a/unf/34/odata/standard.odata/$metadata" + +# Тестовый запрос контрагентов +curl -u "login:password" \ + "https://xxx.1cfresh.com/a/unf/34/odata/standard.odata/Catalog_Контрагенты?\$format=json&\$top=5" +``` + +--- + +## 9. Сравнение с webhook (Система взаимодействия) + +| Критерий | OData | Webhook | +|----------|-------|---------| +| **Чтение данных** | ✅ Да | ❌ Нет (только запись) | +| **Запись данных** | ✅ Да | ✅ Да (только в 1С) | +| **Направление** | Bidirectional | One-way (внешнее → 1С) | +| **Формат** | JSON (стандартный OData) | JSON (свой формат) | +| **Аутентификация** | Basic Auth | URL + логин/пароль | +| **Сложность** | Средняя | Низкая | +| **Использование** | Синхронизация данных | Уведомления, сообщения | + +**Вывод:** OData — для синхронизации данных. Webhook — для отправки уведомлений в 1С. + +--- + +## 10. Рекомендации + +### Приоритет implementation + +1. **OData синхронизация контрагентов** — самый важный функционал +2. **OData синхронизация заказов** — расширение функционала +3. **Webhook уведомления** — опционально, для двусторонней связи + +### Вопросы к пользователю (для фазы 0) + +1. URL базы в 1С-Фреш? +2. Логин/пароль сервисного пользователя? +3. Номер области данных (tenant)? +4. Какие данные в приоритете: контрагенты, заказы, или всё сразу? +5. Направление синхронизации: только из 1С → портал, или bidirectional? + +### Рекомендация + +**Начать с read-only синхронизации контрагентов** — самый безопасный и быстрый способ: +- Не требует записи обратно +- Минимальные риски +- Быстро даёт результат (клиенты из 1С появляются в портале) +- Потом можно расширять до заказов и bidirectional + +--- + +## 11. Ссылки + +- [Документация 1С-Фреш: OData](https://its.1c.ru/db/fresh/content/19956692/hdoc) +- [Платформа 1С: REST интерфейс](https://v8.1c.ru/platforma/rest-interfeys/) +- [OData: правила формирования имени ресурса](https://42clouds.com/ru-ru/manuals/interfeys-odata-pravila-formirovaniya-imeni-resursa/) +- [Работа с 1С через OData (Infostart)](https://infostart.ru/1c/articles/1570140/) +- [odata1c-client (GitHub)](https://github.com/Dakword/odata1c-client) — PHP-клиент с примерами +- [Контрагенты и контактные лица в 1С:УНФ](https://unf4you.ru/publ/kontragenty_i_kontaktnye_lica/1-1-0-407) +- [Заказ покупателя в 1С:УНФ](https://estart1c.ru/unf-30/189-kak-v-1sroznice-i-1sunf-sozdat-i-ispolzovat-zakaz-pokupatelja.html) diff --git a/other/AI-EXISTING-TWO-PROJECT-INSTRUCTION.md b/other/AI-EXISTING-TWO-PROJECT-INSTRUCTION.md index c0aeb60..5688101 100644 --- a/other/AI-EXISTING-TWO-PROJECT-INSTRUCTION.md +++ b/other/AI-EXISTING-TWO-PROJECT-INSTRUCTION.md @@ -85,12 +85,12 @@ py_service/ | Параметр | Значение | |----------|----------| | Тип | Python FastAPI (Max Bot API) | -| FastAPI | порт **8001** | +| FastAPI | порт **8002** | | PostgreSQL | общая БД `aegisone` (порт 5432), таблицы с префиксом `bot_` | | Домен | `max.aegisone.ru` | | Директория | `/opt/projects/aegisone-py/max_bot/` | | Контейнер | `aegisone-max-bot` | -| Webhook | `https://max.aegisone.ru/webhook` → localhost:8001 | +| Webhook | `https://max.aegisone.ru/webhook` → localhost:8002 | | Токен бота | `id2311381465_bot` (хранится в bot_settings) | **Структура:** diff --git a/other/LEAD_IMPLEMENTATION_PLAN.md b/other/LEAD_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..b451c43 --- /dev/null +++ b/other/LEAD_IMPLEMENTATION_PLAN.md @@ -0,0 +1,1531 @@ +# План реализации: Лиды, Звонки, Единая карточка клиента + +> Дата создания: 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С-Фреш). \ No newline at end of file diff --git a/other/Yeastar_S20.md b/other/Yeastar_S20.md new file mode 100644 index 0000000..b3cac72 --- /dev/null +++ b/other/Yeastar_S20.md @@ -0,0 +1,541 @@ +# Yeastar S20 — Интеграция с AegisOne + +> Дата последнего обновления: 03.06.2026 + +--- + +## 1. Подключение к АТС + +### Сетевая схема + +``` +┌─────────────────────┐ ┌──────────────────────────┐ +│ Сервер AegisOne │ │ Локальная сеть (офис) │ +│ 81.177.141.34 │ │ 192.168.1.0/24 │ +│ │ │ │ +│ ┌───────────────┐ │ VPN/ │ ┌──────────────────┐ │ +│ │ py_service │──┼──port───┼──│ Yeastar S20 │ │ +│ │ max_bot │ │ forward │ │ 192.168.1.150 │ │ +│ └───────────────┘ │ │ │ │ │ +│ │ │ │ WAN: │ │ +│ │ │ │ 185.105.171.187 │ │ +└─────────────────────┘ │ └──────────────────┘ │ + └──────────────────────────┘ +``` + +### IP-адреса и порты + +| Ресурс | Локальный IP | Белый IP | Порт (локал) | Порт (проброс) | Протокол | +|--------|-------------|----------|-------------|----------------|----------| +| **АТС (Web UI)** | 192.168.1.150 | 185.105.171.187 | 8088 | 45088 | HTTPS | +| **AMI** | 192.168.1.150 | 185.105.171.187 | 5038 | 45038 | TCP | +| **MySQL (CDR)** | 192.168.1.150 | 185.105.171.187 | 3306 | 43306 | TCP | +| **FTP** | 192.168.1.150 | 185.105.171.187 | 21 | 45021 | TCP | +| **SSH** | 192.168.1.150 | 185.105.171.187 | 8022 | — | TCP | + +### Проброс портов (на роутере/ файрволе офиса) + +| Направление | Протокол | Внешний порт → Внутренний IP:порт | +|-------------|----------|----------------------------------| +| Internet → АТС | TCP | 45038 → 192.168.1.150:5038 (AMI) | +| Internet → АТС | TCP | 45021 → 192.168.1.150:21 (FTP) | +| Internet → АТС | TCP | 43306 → 192.168.1.150:3306 (MySQL) | +| Internet → АТС | TCP | 45088 → 192.168.1.150:8088 (Web UI) | + +--- + +## 2. Учётные данные и параметры устройства + +### Параметры устройства (актуально на 03.06.2026) + +| Параметр | Значение | Источник | +|----------|----------|----------| +| Модель | Yeastar S20 | FTP `/support/tmp/deviceinfo.txt` | +| Серийный номер | `3691C1453100` | FTP `/support/tmp/deviceinfo.txt` | +| Прошивка (software) | `30.15.0.206` | FTP `/support/tmp/deviceinfo.txt` | +| Железо (hardware) | V1.30 0005-0000 | FTP `/support/tmp/deviceinfo.txt` | +| OEM | Yeastar S20 | FTP `/support/tmp/deviceinfo.txt` | +| Asterisk | 13.7.0 | AMI `CoreSettings` | +| AMI версия | 2.8.0 | AMI `CoreSettings` | +| MySQL | 5.1.61 | MySQL greeting | +| PBX Center | 1.24.2 | Web UI App Center | +| SSH-сервер | dropbear 2019.78 | SSH banner (порт 8022) | +| FTP-сервер | vsFTPd 3.0.2 | FTP banner (порт 21) | +| CDR | Включён | AMI `CoreSettings` | +| Max звонков | 10 | AMI `CoreSettings` | +| REST API | Не поддерживается на S20 | Документация Yeastar | + +### Web UI (админ-панель АТС) + +| Параметр | Значение | +|----------|----------| +| URL | `https://192.168.1.150:8088` (локально) или `https://185.105.171.187:45088` (интернет) | +| Логин | `admin` | +| Пароль | `.-SHGWa_12` | + +### AMI (Asterisk Manager Interface) + +| Параметр | Значение | +|----------|----------| +| Хост | `192.168.1.150` (локально) или `81.177.141.34` (из сервера, через проброс) | +| Порт | `5038` (проброс: `45038`) | +| Логин | `1cuser` | +| Пароль | `1csecret` | +| Привилегии | Ограниченные (нет `Command`, `Originate`) | +| Разрешённые IP | `81.177.141.34/255.255.255.255` + `192.168.1.0/255.255.255.0` | + +### MySQL (доступ к CDR) + +| Параметр | Значение | +|----------|----------| +| Хост | `192.168.1.150` (локально) или через проброс `43306` | +| Порт | `3306` (проброс: `43306`) | +| Логин | `1cuser` | +| Пароль | `1csecret` | +| **ВАЖНО** | `charset='utf8'` (MySQL 5.1.61 не поддерживает `utf8mb4`!) | +| БД | `cdr` | + +### FTP (записи звонков) + +| Параметр | Значение | +|----------|----------| +| Порт | `21` (проброс: `45021`) | +| Логин | `support` | +| Пароль | `T4oQSU_?` | +| Статус | ✅ **Работает!** | +| Доступ | Корневая ФС (чroot), `/tmp` недоступен | + +Доступные каталоги через FTP: +- `/sounds/record/` — звуки (пусто) +- `/storage_share/mmc1` — симлинк → `/tmp/media/mmc1` (нерезолвящийся через FTP) +- `/support/tmp/deviceinfo.txt` — **информация об устройстве** +- `/support/` — системные файлы, bin +- `/gui_backups/` — бэкапы +- `/fax/`, `/www/`, `/var/`, `/etc/`, `/boot/`, `/cache/` — системные каталоги + +**Ограничение**: записи звонков в `/tmp/media/mmc1/autorecords/` **недоступны** — `/tmp` за пределами FTP-чroot. Для доступа к записям нужен SSH. + +### SSH (файловая система) + +| Параметр | Значение | +|----------|----------| +| Порт | `8022` | +| SSH-сервер | dropbear 2019.78 | +| Пароль | `T4oQSU_?` | +| Логин | **НЕ ИЗВЕСТЕН** | +| Статус | ✅ Порт открыт, но логин не найден | +| **Рекомендация** | Не использовать SSH — только MySQL + AMI | + +### Хранилище данных (в Web UI) + +| Параметр | Значение | +|----------|----------| +| Имя файла | `share` | +| Логин | `share` | +| Пароль | `9tktYfM2` | +| Назначение | Сетевая папка (симлинк `mmc1 -> /tmp/media/mmc1` в `/storage_share/`) | + +--- + +## 3. Где найти информацию об АТС + +### Серийный номер, модель, прошивка + +| Источник | Способ | +|----------|--------| +| **FTP** `/support/tmp/deviceinfo.txt` | Содержит: hardware, software, sn, product, oem | +| **Web UI** `Dashboard` или `Settings > System > General > Preferences` | Отображается на главной странице | +| **Web UI** `Settings > System > General > About` | Версия прошивки и модель | + +Содержимое `deviceinfo.txt`: +``` +hardware: V1.30 0005-0000 +software: 30.15.0.206 + sn: 3691C1453100 + product: Yeastar S20 + oem: Yeastar S20 +``` + +### Версия Asterisk и компонентов + +| Источник | Способ | +|----------|--------| +| **AMI** команда `CoreSettings` | AsteriskVersion, AMIversion, CoreCDRenabled | +| **AMI** команда `ListCommands` | Список доступных AMI-команд с привилегиями | +| **Web UI** `Settings > System > General > About` | Версия PBX Center, прошивки | + +### Конфигурация (абоненты, очереди, транки) + +| Источник | Что даёт | +|----------|----------| +| **AMI** `QueueStatus` | Очереди, участники, стратегии | +| **AMI** `VoicemailUsersList` | Абоненты, голосовая почта | +| **AMI** `PJSIPQualify` | Статус SIP-абонента | +| **MySQL** БД `cdr` | История звонков, абоненты (из CDR) | +| **Web UI** `Extensions` | Список расширений | +| **Web UI** `Trunks` | SIP-транки | +| **Web UI** `Call Queue` | Очереди | + +### Записи звонков + +| Источник | Способ | +|----------|--------| +| **MySQL** колонки `recordpath`, `monitorfile` | Путь и имя WAV-файла на PBX | +| **Web UI** `CDR and Recordings` | Ручное скачивание через браузер | +| **FTP** `/storage_share/mmc1` | Симлинк на `/tmp/media/mmc1` (нерезолвится через FTP) | +| **SSH** порт 8022 | Прямой доступ к файловой системе (логин неизвестен) | + +--- + +## 4. Абоненты и очереди + +### Внутренние номера (расширения) + +| Номер | Назначение | +|-------|-----------| +| 1001 | Основной (Инженер) | +| 1002 | Второй (Инженер) | +| 1003 | Дополнительный | +| 1000 | Тестовый | + +### Очереди (Queue) + +| Номер очереди | Стратегия | Расширения | +|---------------|-----------|------------| +| 6700 | ringall | 1001, 1002 | +| 6701 | rrmemory | 1001 | +| 6702 | ringall | 1002 | + +### Транки (линии) + +| Транк | Тип | Назначение | +|-------|-----|-----------| +| Mango | VoIP-провайдер | Исходящие/входящие через SIP | +| Tele2 | VoIP-провайдер | Исходящие/входящие через SIP | +| YSGSM | GSM-модуль | Исходящие через GSM-SIM | + +--- + +## 5. База данных CDR + +### Структура + +БД: `cdr`, таблицы: `cdr` (текущая) + `cdr_YYYYMM` (архивные). + +### Колонки таблицы `cdr` + +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | int | ID записи | +| `datetime` | datetime | Дата и время звонка | +| `clid` | varchar | Caller ID (кто звонит) | +| `src` | varchar | Номер отправителя | +| `dst` | varchar | Номер получателя | +| `dcontext` | varchar | Контекст диалплана | +| `srctrunk` | varchar | Транк отправителя | +| `dstrunk` | varchar | Транк получателя | +| `lastapp` | varchar | Последнее приложение (Dial, Queue, BackGround...) | +| `lastdata` | varchar | Данные последнего приложения (номер, очередь) | +| `duration` | int | Общая длительность (секунды) | +| `billable` | int | Биллируемая длительность (секунды) | +| `disposition` | varchar | Статус: ANSWERED, NO ANSWER, BUSY, VOICEMAIL, FAILED | +| `calltype` | varchar | Тип: Inbound, Outbound, Internal | +| `accountcode` | varchar | Код аккаунта | +| `uniqueid` | varchar | Уникальный ID звонка | +| `recordfile` | varchar | Имя файла записи (WAV) | +| `recordpath` | varchar | Путь к записи на PBX | +| `monitorfile` | varchar | Имя файла мониторинга | +| `monitorpath` | varchar | Путь к мониторингу на PBX | +| `extfield1-5` | varchar | Дополнительные поля | +| `didnumber` | varchar | DID-номер (входящий номер) | +| `srcchanurl` | varchar | SIP URL канала отправителя | +| `dstchanurl` | varchar | SIP URL канала получателя | +| `companycontact` | varchar | Имя контакта компании | +| `personalcontact` | varchar | Имя личного контакта | +| `contactnumber` | varchar | Контактный номер | + +### Примеры запросов + +```sql +-- Все звонки за сегодня +SELECT datetime, src, dst, duration, billable, disposition, calltype +FROM cdr.cdr +WHERE DATE(datetime) = CURDATE() +ORDER BY datetime DESC; + +-- Звонки за конкретный месяц +SELECT * FROM cdr.cdr_202606 ORDER BY datetime DESC; + +-- Входящие звонки на очередь 6700 +SELECT datetime, src, dst, duration, disposition +FROM cdr.cdr +WHERE dst LIKE '6700%' AND calltype = 'Inbound' +ORDER BY datetime DESC; + +-- Звонки с записями +SELECT datetime, src, dst, duration, recordfile, recordpath +FROM cdr.cdr +WHERE recordfile != '' +ORDER BY datetime DESC; + +-- Статистика по дням +SELECT DATE(datetime) as day, COUNT(*) as calls, + SUM(CASE WHEN disposition='ANSWERED' THEN 1 ELSE 0 END) as answered +FROM cdr.cdr +WHERE datetime >= DATE_SUB(NOW(), INTERVAL 30 DAY) +GROUP BY DATE(datetime) +ORDER BY day DESC; +``` + +### Таблица `queue_log` + +Содержит логи событий очередей (поступление, ответ, ожидание, сброс). + +--- + +## 6. Записи звонков + +### Где хранятся + +На файловой системе PBX: `/tmp/media/mmc1/autorecords/YYYYMM/` + +Формат имени файла: +``` +YYYYMMDDHHMMSS-UNIQUEID-SRC-DST-Direction.wav +``` + +Примеры из CDR: +``` +20260110164821-1768052891.15-1001-+79531116788-Outbound.wav +20260402105623-1775116571.13-1001-89531116788-Outbound.wav +20260512162645-1778592395.0-+79898250183-1001-Inbound.wav +``` + +### Как получить + +1. **Путь в CDR**: колонки `recordpath`, `monitorpath` (путь на PBX) +2. **FTP** (`support`/`T4oQSU_?`): записи **недоступны** — `/tmp` за пределами FTP-чroot +3. **SSH** (порт 8022, пароль `T4oQSU_?`, логин неизвестен): теоретически доступен, но логин не найден +4. **Web UI**: `CDR and Recordings` — ручное скачивание через браузер + +--- + +## 7. AMI — Доступные команды + +### Работает с 1cuser/1csecret + +| Команда | Описание | Статус | +|---------|----------|--------| +| `Ping` | Проверка соединения | ✅ | +| `CoreStatus` | Статус ядра (uptime, кол-во звонков) | ✅ | +| `CoreShowChannels` | Список активных каналов | ✅ | +| `CoreSettings` | Настройки ядра (Asterisk, CDR) | ✅ | +| `QueueStatus` | Статус очередей + участников | ✅ | +| `VoicemailUsersList` | Список абонентов голосовой почты | ✅ | +| `PJSIPQualify` | Пинг SIP-абонента | ✅ | +| `Status` | Статус всех каналов | ✅ | +| `ListCommands` | Список доступных команд | ✅ | + +### Не работает (Permission denied) + +| Команда | Описание | +|---------|----------| +| `Command` | Выполнение CLI-команд (`cdr show` и т.д.) | +| `Originate` | Исходящий звонок | +| `ShowDialPlan` | Просмотр диалплана | +| `Hangup` | Завершение звонка | +| `Redirect` | Переадресация звонка | + +### Real-time события + +При включённых `Events: On` AMI автоматически отправляет: +- `Event: Cdr` — при завершении звонка (содержит CDR-данные) +- `Event: Newchannel` — при начале звонка +- `Event: Dial` — при наборе номера +- `Event: Hangup` — при завершении + +--- + +## 8. Пример подключения (Python) + +### MySQL — запрос CDR + +```python +import pymysql + +conn = pymysql.connect( + host="192.168.1.150", # или внешний IP через проброс + port=3306, # или 43306 через проброс + user="1cuser", + password="1csecret", + database="cdr", + charset="utf8", # ВАЖНО: не utf8mb4! + connect_timeout=5 +) + +cursor = conn.cursor() +cursor.execute("SELECT * FROM cdr ORDER BY datetime DESC LIMIT 10") +for row in cursor.fetchall(): + print(row) +conn.close() +``` + +### AMI — real-time события + +```python +import socket +import time + +def ami_connect(host, port, username, password): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, port)) + # Читаем приветствие + greeting = sock.recv(4096).decode() + # Логин + login_msg = ( + f"Action: Login\r\n" + f"Username: {username}\r\n" + f"Secret: {password}\r\n" + f"Events: On\r\n" + f"\r\n" + ) + sock.send(login_msg.encode()) + time.sleep(1) + response = sock.recv(16384).decode() + return sock + +# Использование +sock = ami_connect("192.168.1.150", 5038, "1cuser", "1csecret") +# Слушаем события +while True: + data = sock.recv(16384).decode() + if data: + print(data) +``` + +### AMI — из сервера (через проброс) + +```python +# Подключение через проброс портов с сервера +sock = ami_connect("81.177.141.34", 45038, "1cuser", "1csecret") +# Или напрямую (если сервер в локальной сети) +sock = ami_connect("192.168.1.150", 5038, "1cuser", "1csecret") +``` + +--- + +## 9. Схема интеграции с AegisOne + +``` +┌──────────────────┐ MySQL:3306 ┌──────────────────┐ +│ py_service │◄────────────────────│ Yeastar S20 │ +│ (CDR polling) │ │ (cdr БД) │ +│ │ AMI:5038 │ │ +│ (real-time) │◄────────────────────│ (события) │ +│ │ │ │ +│ ┌──────────────┐ │ │ ┌──────────────┐ │ +│ │ /service/ │ │ │ │ Записи WAV │ │ +│ │ phone/ │ │ FTP:21 │ │ /tmp/media/ │ │ +│ │ history │ │◄──(когда будет)────│ │ │ │ +│ │ playback │ │ │ └──────────────┘ │ +│ └──────────────┘ │ └──────────────────┘ +└──────────────────┘ + │ + ▼ +┌──────────────────┐ +│ max_bot │ +│ (уведомления о │ +│ пропущенных) │ +└──────────────────┘ +``` + +--- + +## 10. План интеграции (TODO) + +### Фаза 1: CDR (готово к реализации) +- [ ] Модель БД для хранения звонков +- [ ] Периодический опрос MySQL (каждые 5 мин) +- [ ] Страница истории звонков в сервисном портале +- [ ] Фильтры: по дате, номеру, типу, статусу + +### Фаза 2: Real-time +- [ ] AMI-клиент для получения событий +- [ ] Уведомления о пропущенных звонках (в max_bot) +- [ ] Статус абонентов (online/offline) +- [ ] Мониторинг очередей + +### Фаза 3: Записи +- [x] FTP логин найден: `support` / `T4oQSU_?` +- [ ] FTP-чroot не даёт доступ к `/tmp/media/mmc1/autorecords/` — нужен SSH или другой способ +- [ ] Разобраться с SSH-логином (пароль `T4oQSU_?` известен) +- [ ] Скачивание записей +- [ ] Плеер для прослушивания в портале + +### Фаза 4: Управление +- [ ] Исходящие звонки из портала (если AMI получит права) +- [ ] Переключение звонков +- [ ] Статистика и отчёты + +--- + +## 11. Известные проблемы + +1. **Записи звонков недоступны через FTP** — логин `support` работает, но `/tmp/media/mmc1/autorecords/` за пределами FTP-чroot. Нужен SSH (порт 8022, пароль `T4oQSU_?`, логин неизвестен) +2. **AMI ограниченные права** — на S20 нельзя расширить привилегии AMI-пользователя через Web UI. Доступны только readonly-команды +3. **REST API не поддерживается** — только для S50/S100/S300 +4. **MySQL 5.1.61** — старая версия, не поддерживает `utf8mb4`. Использовать `charset='utf8'` +5. **Кириллица в CDR** — имена контактов отображаются в кодировке CP1251/garbage (проблема кодировки MySQL-клиента) + +--- + +## 12. Ссылки + +- [Yeastar S-Series AMI Documentation](https://help.yeastar.com/en/s-series/topic/asterisk-manager-interface-ami.html) +- [Yeastar Developer Guide](https://support.yeastar.com/hc/en-us/articles/235972668) +- [Asterisk AMI Protocol](https://docs.asterisk.org/languages/en/Asterisk_18_Documentation/DAHDI_DAHDI_Hardware_Digium_Interface_Hardware/Administering/Asterisk_Manager_Interface_AMI/) + +--- + +## 13. FTP — Структура каталогов + +``` +/ (корень FTP, доступен как support) +├── boot/ +├── cache/ +├── etc/ +├── fax/ +├── ftp_media/ (пусто) +├── gui_backups/ +├── imageupdate/ +├── lost+found/ +├── rcstop/ +├── sounds/ +│ ├── moh/ (музыка на удержании) +│ └── record/ (пусто) +├── storage_share/ +│ └── mmc1 → /tmp/media/mmc1 (симлинк, нерезолвящийся) +├── support/ +│ ├── tmp/ +│ │ ├── deviceinfo.txt ← ИНФОРМАЦИЯ ОБ УСТРОЙСТВЕ +│ │ └── testsysmailbox.eml +│ ├── autoptemplate/ +│ ├── bin/ +│ ├── customcfg/ +│ └── fxotune/ +├── syslog/ +├── syslog_backup/ +├── tftpboot/ +├── var/ +├── webupload/ +├── www/ +└── ysapps/ +``` + +### Ключевые файлы + +| Путь | Содержимое | +|------|-----------| +| `/support/tmp/deviceinfo.txt` | Серийный номер, модель, прошивка | +| `/support/tmp/testsysmailbox.eml` | Тестовый шаблон почты | +| `/storage_share/mmc1` | Симлинк на записанные файлы (недоступен через FTP) | diff --git a/other/voronka.md b/other/voronka.md new file mode 100644 index 0000000..96ae5f6 --- /dev/null +++ b/other/voronka.md @@ -0,0 +1,267 @@ +Если смотреть не со стороны маркетинга, а со стороны внутренней сервисной системы, то воронка продаж превращается в процесс обработки лида внутри портала. + +В этом случае телефон, бот, сайт, почта — это просто каналы поступления обращений в единую систему. +**1. Общая архитектура** +``` +Клиент +├── Телефон +├── Telegram-бот +├── Сайт +├── Email +└── WhatsApp + ↓ + Сервисный портал + ↓ + Лид (Lead) + ↓ + Квалификация + ↓ + Сделка + ↓ + Проект + ↓ + Выполнение + ↓ + Закрытие +``` + +**2. Роли в системе** + +--- Руководитель +Видит: +новые лиды; +активные сделки; +загрузку инженеров; +прибыль; +SLA; +показатели конверсии. + +Его задача: +назначать ответственных; +контролировать сроки; +отслеживать KPI. + +--- Инженер +Работает с технической частью. +Видит: +назначенные заявки; +технические задания; +историю клиента; +комментарии. + +Может: +создавать этапы работ; +запрашивать данные; +прикладывать документы; +переводить задачи между статусами. + +--- Техник +Исполнитель. +Видит только: +свои задачи; +инструкции; +сроки; +вложения. + +Может: +отмечать выполнение; +прикладывать фото; +добавлять комментарии. + +**3. Как выглядит жизненный цикл обращения** + +Шаг 1. Обращение через бота + +Клиент пишет в бот + +Бот собирает контактные данные и создается заявка (необходимо продумать как собрать название организации от клиента) +На основе этой заявки необходимо (тот кто ее обрабатывает) должен создать нового Клиента (Управление - Клиенты) или Добавить к существующему клиенту. Если есть точно совпадающие даннуе, то заявка должна автоматически привязываться к клиенту. Важно - Назавание организации может быть одинаковым у разных клиентов. + +Шаг 2. Создание лида + +В портале появляется: +``` +Клиент №ххх +Источник: Max Bot +Компания: ООО Ромашка +Контакт: Иван Петров +Телефон: +79991234567 +Услуга: Пример: Аудит безопасности +Статус: Новый +``` + +Шаг 3. Как подвязать телефонные звонки +Это одна из самых полезных функций. +Вариант 1. IP-телефония (лучший вариант) +Подключаются: +Asterisk (Локальная АТС) +FreePBX +MikoPBX +Zadarma +Mango Office (Доступ к webui) +Novofon + +Каждый звонок автоматически попадает в портал. +--- Входящий звонок +Телефон звонит. +Портал получает событие: +``` +{ +"phone":"+79991234567", +"type":"incoming" +} +``` +Портал ищет клиента. +Если клиент найден: +``` +Входящий звонок +ООО Ромашка +Последняя заявка: Аудит безопасности +Ответственный: Иванов +``` +Карточка открывается автоматически. + +--- Если номер неизвестен +Создается временный клиент: +``` +Клиент №ххх +Источник: Телефон +Телефон: +79991234567 +Статус: Новый +``` +После разговора менеджер заполняет остальные поля и привязывает его к существующему или создает нового клиента. + +--- Автоматическая запись звонков +В карточке хранится: +``` +Звонок #1 +Дата: 03.06.2026 +Длительность: 12:34 +Запись: play.wav +``` +Руководитель может прослушать разговор. + +--- Автоматическая расшифровка +После разговора: +``` +Whisper +Yandex SpeechKit +Google Speech-to-Text +YandexGPT +``` +создают текст. + +Карточка получает: +``` Пример: +Краткое содержание: +Клиент интересуется аудитом. +Планирует внедрение в июле. +Бюджет около 500 тыс. +``` + +Шаг 4. Автоматическая постановка задач +После завершения звонка система может создавать задачи основываясь на расшифровке разговара и запрашивает подтверждение у сотрудника. +Пример: +Менеджер выбрал: +``` +Подготовить КП +``` +Портал автоматически создает задачу инженеру. +``` +Задача #ууу +Подготовить коммерческое предложение +Исполнитель: Инженер Петров +Срок: Завтра 12:00 +``` + +--- Рекомендуемая структура статусов +Для лидов: +``` +Новый +Связаться +Квалификация +Коммерческое предложение +Переговоры +Согласование +Выигран +Проигран +``` +(Добавить возможность создавать и удалять дополнительно свои) + +Для проектов: +``` +Подготовка +В работе +Ожидание клиента +Тестирование +Завершено +Закрыто +``` +(Добавить возможность создавать и удалять дополнительно свои) + +**4. Что особенно полезно реализовать ** + +Единая карточка клиента +Внутри: +``` +Контакты +Компания +История звонков +История чатов +Файлы +Проекты +Акты +Счета +Договоры +Задачи +Заявки / Тикеты +Комментарии +``` +Заявкам добавить Критичность (сейчас приоритет) +``` +Критичная +Высокая +Средняя +Низкая +``` +Никто не ищет информацию по разным системам! + +**5. Оптимальная схема для собственной разработки ** +``` +Telegram Bot + │ + ├─────────┐ + │ │ +Телефония Сайт + │ │ + └────┬────┘ + │ + Lead Service + │ + CRM Module + │ + Workflow Engine + │ + Engineer Portal + │ + Client Projects +``` +Если ваша компания оказывает сервисные услуги (ИБ, IT-аутсорсинг, инженерные работы, обслуживание оборудования и т.д.), то я бы рекомендовал строить систему не как классическую CRM, а как Service Desk + CRM + Телефония в одном портале. Тогда звонок, сообщение из бота, заявка с сайта и дальнейшие технические работы будут проходить через одну карточку клиента и один жизненный цикл обращения. Это существенно упрощает контроль для руководителя и ускоряет работу инженеров и техников. + +**6. Что должен видеть руководитель на главной странице** +Блок KPI +``` +Новые лиды: 12 +Сегодня обработано: 9 +Конверсия: 34% +Средний чек: 280 000 ₽ +Активные проекты: 18 +``` +Воронка +``` +Новые лиды 100 +Квалификация 65 +КП отправлено 40 +Переговоры 22 +Сделка 11 +``` \ No newline at end of file diff --git a/py_service/CHANGELOG.md b/py_service/CHANGELOG.md index d776e70..610bf54 100644 --- a/py_service/CHANGELOG.md +++ b/py_service/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog — AegisOne Service Portal +## 1.9.0 (04.06.2026) +### Новые функции +- **Phase 0 — Инфраструктура CRM/лидов:** + - Новые таблицы: `leads` (20 полей), `call_records` (20 полей CDR), `client_notifications` (10 полей), `pbx_settings` (27 полей) + - Расширены модели: `User` (extension — внутренний номер PBX), `Customer` (lead_source, last_call_at, total_calls, total_leads, merged_into, статусы draft/merged), `Task` (lead_id — FK → leads), `AuditLog` (entity_type, entity_id, field, old_value, new_value) + - Миграции БД: система `migrations_runner.py` с авто-применением при старте, отслеживание в `_applied_migrations` + - SQL-миграция `001_phase0_infrastructure.sql`: 31 операция (CREATE TABLE → ALTER TABLE в правильном порядке) + - Шифрование паролей PBX через `cryptography.fernet` (PBXSettings) + +### Исправления +- **documents_router.py:** доступ «Владелец/Инженер» для tech-access — `!= "engineer"` → `not in ("engineer", "owner")` + +### Документация +- **AGENTS.md:** раздел 0.4 — обязательные файлы для чтения; раздел 1 — ссылка на архитектуру сервера +- **SERVICE_STYLE_GUIDE.md:** единый гайд UI +- **other/Yeastar_S20.md:** полная документация по PBX (AMI, MySQL CDR, FTP, SSH) +- **other/LEAD_IMPLEMENTATION_PLAN.md:** 8-фазный план (0-7) реализации CRM/лидов +- **other/1C_UNF_INTEGRATION.md:** документация по интеграции с 1С:УНФ 3.0 через OData API +- **other/voronka.md:** документ жизненного цикла лидов + ## 1.8.6 (02.06.2026) ### Новые функции - **sort-table.js:** универсальный модуль сортировки таблиц по столбцам (текст/число/дата), подключён глобально diff --git a/py_service/app/documents_router.py b/py_service/app/documents_router.py index 079e3e2..c4f630c 100644 --- a/py_service/app/documents_router.py +++ b/py_service/app/documents_router.py @@ -153,8 +153,8 @@ async def tech_access_page( db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user), ): - if user["role"] != "engineer": - return HTMLResponse(content="Доступ только для инженеров", status_code=403) + if user["role"] not in ("engineer", "owner"): + return HTMLResponse(content="Доступ только для инженеров и руководителей", status_code=403) perms = discover_docs() editable_docs = [ @@ -170,8 +170,8 @@ async def tech_access_update( db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user), ): - if user["role"] != "engineer": - return HTMLResponse(content="Доступ только для инженеров", status_code=403) + if user["role"] not in ("engineer", "owner"): + return HTMLResponse(content="Доступ только для инженеров и руководителей", status_code=403) form = await request.form() perms = discover_docs() diff --git a/py_service/app/main.py b/py_service/app/main.py index 50d9168..bfd294c 100644 --- a/py_service/app/main.py +++ b/py_service/app/main.py @@ -113,8 +113,20 @@ async def seed_initial_data(): async def lifespan(app: FastAPI): from app.models.models import Base from app.database import engine + + # Применяем миграции (ALTER TABLE, CREATE TABLE IF NOT EXISTS) + from app.migrations_runner import run_migrations_on_startup + migration_results = await run_migrations_on_startup() + if migration_results: + import logging + logging.getLogger("migrations").info( + "Применены миграции: %s", ", ".join(migration_results) + ) + + # Создаём таблицы которые ещё не существуют (idempotent) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + await seed_initial_data() async with async_session() as db: from app.calculations.engine import reload_coefficients diff --git a/py_service/app/migrations_runner.py b/py_service/app/migrations_runner.py new file mode 100644 index 0000000..3241b0e --- /dev/null +++ b/py_service/app/migrations_runner.py @@ -0,0 +1,128 @@ +""" +Миграционный раннер для py_service. + +Автоматически применяет SQL-файлы из папки migrations/ при старте приложения. +Отслеживает применённые миграции в таблице _applied_migrations. + +Порядок: + 1. Создаёт таблицу _applied_migrations (если нет) + 2. Сканирует migrations/*.sql + 3. Применяет неприменённые по порядку имени файла + 4. Записывает имя файла и время в _applied_migrations + +Формат SQL-файлов: + - Имя: 001_description.sql, 002_description.sql и т.д. + - Содержимое: один или несколько SQL-запросов, разделённых точкой с запятой + - Каждый запрос разделяется через ; (точка с запятой на отдельной строке или в конце) +""" +import os +import glob +import asyncio +from datetime import datetime + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text + + +MIGRATIONS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "migrations") + + +async def ensure_tracking_table(db: AsyncSession) -> None: + """Создаёт таблицу _applied_migrations если она ещё не существует.""" + await db.execute(text(""" + CREATE TABLE IF NOT EXISTS _applied_migrations ( + id SERIAL PRIMARY KEY, + filename VARCHAR(255) NOT NULL UNIQUE, + applied_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """)) + await db.commit() + + +async def get_applied_migrations(db: AsyncSession) -> set: + """Возвращает множество имён уже применённых миграций.""" + result = await db.execute(text("SELECT filename FROM _applied_migrations")) + return {row[0] for row in result.fetchall()} + + +def _split_sql(content: str) -> list: + """Разделяет SQL-содержимое на отдельные запросы. + + Использует точку с запятой как разделитель. + Пустые запросы и комментарии пропускаются. + """ + queries = [] + for part in content.split(";"): + # Убираем SQL-комментарии (строки начинающиеся с --) + lines = [] + for line in part.split("\n"): + stripped = line.strip() + if stripped.startswith("--"): + continue + lines.append(line) + cleaned = "\n".join(lines).strip() + if cleaned: + queries.append(cleaned) + return queries + + +async def apply_migrations(db: AsyncSession) -> list: + """Применяет все неприменённые миграции из папки migrations/. + + Возвращает список имён применённых миграций. + """ + await ensure_tracking_table(db) + + # Получаем уже применённые + applied = await get_applied_migrations(db) + + # Сканируем SQL-файлы + pattern = os.path.join(MIGRATIONS_DIR, "*.sql") + files = sorted(glob.glob(pattern)) + + applied_now = [] + for filepath in files: + filename = os.path.basename(filepath) + + # Пропускаем уже применённые + if filename in applied: + continue + + # Читаем SQL + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + + # Разделяем на запросы + queries = _split_sql(content) + if not queries: + continue + + # Применяем каждый запрос + try: + for query in queries: + await db.execute(text(query)) + + # Записываем в tracking table + await db.execute( + text("INSERT INTO _applied_migrations (filename, applied_at) VALUES (:fn, NOW())"), + {"fn": filename} + ) + await db.commit() + applied_now.append(filename) + except Exception as e: + await db.rollback() + raise RuntimeError(f"Ошибка при применении миграции {filename}: {e}") + + return applied_now + + +async def run_migrations_on_startup() -> list: + """Запуск миграций при старте приложения. + + Используется из lifespan в main.py. + Возвращает список применённых миграций. + """ + from app.database import async_session + + async with async_session() as db: + return await apply_migrations(db) diff --git a/py_service/app/models/models.py b/py_service/app/models/models.py index 7227f7d..174603e 100644 --- a/py_service/app/models/models.py +++ b/py_service/app/models/models.py @@ -5,7 +5,11 @@ ORM-модели для service portal (SQLAlchemy 2.0). - users: сотрудники (owner/engineer/technician) - customers: клиенты - objects: объекты обслуживания - - tasks/ incidents: задачи и инциденты + - tasks / incidents: задачи и инциденты + - leads: лиды (обращения из бота, звонков, сайта, email) + - call_records: звонки (CDR из Yeastar S20) + - client_notifications: уведомления + - pbx_settings: настройки АТС - blog/cases/ideas: контент """ from datetime import datetime @@ -28,6 +32,7 @@ class User(Base): phone = Column(String(50), default="") email = Column(String(255), default="") max_user_id = Column(BigInteger, nullable=True) + extension = Column(String(10), default="") # Внутренний номер АТС (1001, 1002...) is_active = Column(Boolean, nullable=False, default=True) created_at = Column(DateTime, nullable=False, server_default="NOW()") updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now()) @@ -51,17 +56,33 @@ class LoginAttempt(Base): class AuditLog(Base): + """Лог действий пользователей (аудит). + + Записывает все изменения в системе: кто, что, когда изменил. + Используется для отслеживания дедупликации и ручных правок. + """ __tablename__ = "audit_log" id = Column(BigInteger, primary_key=True, autoincrement=True) user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) action = Column(String(255), nullable=False) + entity_type = Column(String(50), default="") # customer, lead, call_record, task + entity_id = Column(Integer, default=0) + field = Column(String(100), default="") # какое поле изменено + old_value = Column(Text, default="") # старое значение + new_value = Column(Text, default="") # новое значение details = Column(Text) ip_address = Column(String(45), default="") created_at = Column(DateTime, nullable=False, server_default="NOW()") class Customer(Base): + """Клиент (контрагент). + + Хранит контактные данные, ИНН/КПП, статус. + Статус 'draft' — автосоздан системой из лида/звонка, ожидает заполнения. + Статус 'merged' — объединён с другим клиентом. + """ __tablename__ = "customers" id = Column(Integer, primary_key=True, autoincrement=True) @@ -74,12 +95,20 @@ class Customer(Base): contact_email = Column(String(255), default="") status = Column(String(20), nullable=False, default="active") notes = Column(Text) + lead_source = Column(String(20), default="") # Откуда пришёл клиент + last_call_at = Column(DateTime) # Последний звонок + total_calls = Column(Integer, default=0) # Количество звонков + total_leads = Column(Integer, default=0) # Количество лидов + merged_into = Column(Integer, nullable=True) # ID клиента, с которым слили created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) created_at = Column(DateTime, nullable=False, server_default="NOW()") updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now()) __table_args__ = ( - CheckConstraint("status IN ('active','inactive','prospect')", name="ck_customers_status"), + CheckConstraint( + "status IN ('active','inactive','prospect','draft','merged')", + name="ck_customers_status" + ), ) objects = relationship("Object", back_populates="customer") @@ -144,12 +173,19 @@ class ObjectAssignment(Base): class Task(Base): + """Задача (привязана к объекту). + + Owner/engineer создают и назначают на любого. + Technician может создать только себе напоминание. + lead_id — связь задачи с лидом (если задача создана из лида). + """ __tablename__ = "tasks" id = Column(Integer, primary_key=True, autoincrement=True) object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False) assigned_to = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) + lead_id = Column(Integer, ForeignKey("leads.id", ondelete="SET NULL")) # Связь с лидом title = Column(String(500), nullable=False) description = Column(Text) priority = Column(String(4), nullable=False, default="P3") @@ -521,4 +557,199 @@ class RoleMenuPermission(Base): ) +# ─── Модели для лидов, звонков, уведомлений, настроек АТС ────────────────────── + + +class Lead(Base): + """Лид — обращение из бота, звонка, сайта или email. + + Источники: bot, phone_in, phone_out, site, email, manual. + Автопривязка к клиенту: ИНН → телефон → email → создание draft. + """ + __tablename__ = "leads" + + id = Column(Integer, primary_key=True, autoincrement=True) + customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL")) + + # Источник обращения + source = Column(String(20), nullable=False, default="manual") + # bot | phone_in | phone_out | site | email | manual + + # Данные клиента + company_name = Column(String(255), default="") + contact_name = Column(String(255), default="") + contact_phone = Column(String(50), default="") + contact_email = Column(String(255), default="") + inn = Column(String(12), default="") + + # Содержание обращения + title = Column(String(500), default="") + service_type = Column(String(255), default="") + description = Column(Text, default="") + urgency = Column(String(10), default="medium") + # critical | high | medium | low + + # Статус + status = Column(String(30), default="new") + # new | contact | qualification | proposal | negotiation | approved | won | lost | merged + + # Привязка + assigned_to = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) + bot_ticket_id = Column(BigInteger) # ID заявки из бота + + # Воронка + deal_amount = Column(Numeric(12, 2), default=0) + expected_close_date = Column(Date) + + # Мета + created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) + created_at = Column(DateTime, nullable=False, server_default="NOW()") + updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now()) + + __table_args__ = ( + CheckConstraint( + "status IN ('new','contact','qualification','proposal','negotiation','approved','won','lost','merged')", + name="ck_leads_status" + ), + CheckConstraint( + "urgency IN ('critical','high','medium','low')", + name="ck_leads_urgency" + ), + CheckConstraint( + "source IN ('bot','phone_in','phone_out','site','email','manual')", + name="ck_leads_source" + ), + ) + + customer = relationship("Customer", foreign_keys=[customer_id]) + assignee = relationship("User", foreign_keys=[assigned_to]) + creator = relationship("User", foreign_keys=[created_by]) + + +class CallRecord(Base): + """Звонок (CDR из Yeastar S20 через MySQL). + + Синхронизируется каждые 5 минут из таблицы cdr. + Автопривязка к клиенту по номеру телефона. + Автопривязка к сотруднику по внутреннему номеру (extension). + """ + __tablename__ = "call_records" + + id = Column(Integer, primary_key=True, autoincrement=True) + + # Данные из CDR + cdr_uniqueid = Column(String(32), unique=True) # Уникальный ID из CDR + call_datetime = Column(DateTime, nullable=False) + caller_number = Column(String(80), default="") # Номер звонящего (src) + callee_number = Column(String(80), default="") # Номер получателя (dst) + trunk = Column(String(80), default="") # Транк (Mango, Tele2) + duration = Column(Integer, default=0) # Общая длительность (сек) + billable_seconds = Column(Integer, default=0) # Биллируемая (сек) + disposition = Column(String(30), default="") # ANSWERED, NO ANSWER, BUSY, VOICEMAIL + call_type = Column(String(20), default="") # Inbound, Outbound, Internal + queue_name = Column(String(30), default="") # Очередь (6700, 6701, 6702) + + # Привязка к клиенту + customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL")) + lead_id = Column(Integer, ForeignKey("leads.id", ondelete="SET NULL")) + + # Инженер/техник кто принял звонок + answered_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) + answered_by_name = Column(String(100), default="") + + # Запись + has_recording = Column(Boolean, default=False) + recording_path = Column(String(500), default="") # Путь на PBX + recording_filename = Column(String(200), default="") # Имя WAV-файла + + # Уведомления + notification_sent = Column(Boolean, default=False) + + created_at = Column(DateTime, nullable=False, server_default="NOW()") + + customer = relationship("Customer", foreign_keys=[customer_id]) + lead = relationship("Lead", foreign_keys=[lead_id]) + employee = relationship("User", foreign_keys=[answered_by]) + + +class ClientNotification(Base): + """Уведомление для сотрудника. + + Типы: пропущенный звонок, новый лид, изменение статуса лида, + нарушение SLA, просроченная задача. + """ + __tablename__ = "client_notifications" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) + customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL")) + lead_id = Column(Integer, ForeignKey("leads.id", ondelete="SET NULL")) + call_record_id = Column(Integer, ForeignKey("call_records.id", ondelete="SET NULL")) + + type = Column(String(30), nullable=False) + # missed_call | new_lead | lead_status_change | sla_breach | task_overdue + + title = Column(String(500), nullable=False) + message = Column(Text, default="") + is_read = Column(Boolean, default=False) + + created_at = Column(DateTime, nullable=False, server_default="NOW()") + + user = relationship("User", foreign_keys=[user_id]) + customer = relationship("Customer", foreign_keys=[customer_id]) + lead = relationship("Lead", foreign_keys=[lead_id]) + call_record = relationship("CallRecord", foreign_keys=[call_record_id]) + + +class PbxSettings(Base): + """Настройки подключения к АТС (Yeastar S20). + + Всегда одна запись (id=1). Пароли шифруются через cryptography.fernet. + """ + __tablename__ = "pbx_settings" + + id = Column(Integer, primary_key=True, default=1) + + # Подключение MySQL (CDR) + mysql_host = Column(String(255), default="192.168.1.150") + mysql_port = Column(Integer, default=3306) + mysql_user = Column(String(100), default="") + mysql_password = Column(String(255), default="") # зашифровано + mysql_database = Column(String(100), default="cdr") + + # AMI + ami_host = Column(String(255), default="192.168.1.150") + ami_port = Column(Integer, default=5038) + ami_user = Column(String(100), default="") + ami_password = Column(String(255), default="") # зашифровано + + # FTP (записи WAV) + ftp_host = Column(String(255), default="192.168.1.150") + ftp_port = Column(Integer, default=21) + ftp_user = Column(String(100), default="") + ftp_password = Column(String(255), default="") # зашифровано + + # Веб-интерфейс + web_host = Column(String(255), default="192.168.1.150") + web_port = Column(Integer, default=8088) + web_user = Column(String(100), default="") + web_password = Column(String(255), default="") # зашифровано + + # Информация + serial_number = Column(String(50), default="") # Ручной ввод + actual_serial = Column(String(50), default="") # Из FTP + + # Статус подключения + mysql_status = Column(String(20), default="unknown") # ok | error | unknown + ami_status = Column(String(20), default="unknown") + ftp_status = Column(String(20), default="unknown") + serial_match = Column(Boolean, default=False) + + # Синхронизация + last_sync_at = Column(DateTime) + sync_interval = Column(Integer, default=5) # минут + + updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now()) + + diff --git a/py_service/migrations/001_phase0_infrastructure.sql b/py_service/migrations/001_phase0_infrastructure.sql new file mode 100644 index 0000000..b83ecd6 --- /dev/null +++ b/py_service/migrations/001_phase0_infrastructure.sql @@ -0,0 +1,144 @@ +-- Миграция 001: Инфраструктура для фазы лидов и звонков. +-- Дата: 04.06.2026 +-- Описание: Расширение существующих таблиц + создание новых. + +-- ═══════════════════════════════════════════════════════════════════════════════ +-- 1. Новые таблицы (создаём ПЕРВЫМИ, чтобы FK ссылались) +-- ═══════════════════════════════════════════════════════════════════════════════ + +-- 1.1 Таблица лидов +CREATE TABLE IF NOT EXISTS leads ( + id SERIAL PRIMARY KEY, + customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL, + source VARCHAR(20) NOT NULL DEFAULT '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 '', + title VARCHAR(500) DEFAULT '', + service_type VARCHAR(255) DEFAULT '', + description TEXT DEFAULT '', + urgency VARCHAR(10) DEFAULT 'medium', + status VARCHAR(30) DEFAULT 'new', + assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL, + bot_ticket_id BIGINT, + deal_amount NUMERIC(12, 2) DEFAULT 0, + expected_close_date DATE, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_leads_customer ON leads(customer_id); +CREATE INDEX IF NOT EXISTS idx_leads_status ON leads(status); +CREATE INDEX IF NOT EXISTS idx_leads_source ON leads(source); +CREATE INDEX IF NOT EXISTS idx_leads_assigned ON leads(assigned_to); +CREATE INDEX IF NOT EXISTS idx_leads_created ON leads(created_at); + +-- 1.2 Таблица звонков (CDR из Yeastar S20) +CREATE TABLE IF NOT EXISTS call_records ( + id SERIAL PRIMARY KEY, + cdr_uniqueid VARCHAR(32) UNIQUE, + call_datetime TIMESTAMP NOT NULL, + caller_number VARCHAR(80) DEFAULT '', + callee_number VARCHAR(80) DEFAULT '', + trunk VARCHAR(80) DEFAULT '', + duration INTEGER DEFAULT 0, + billable_seconds INTEGER DEFAULT 0, + disposition VARCHAR(30) DEFAULT '', + call_type VARCHAR(20) DEFAULT '', + queue_name VARCHAR(30) DEFAULT '', + 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) ON DELETE SET NULL, + answered_by_name VARCHAR(100) DEFAULT '', + has_recording BOOLEAN DEFAULT FALSE, + recording_path VARCHAR(500) DEFAULT '', + recording_filename VARCHAR(200) DEFAULT '', + notification_sent BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_call_records_customer ON call_records(customer_id); +CREATE INDEX IF NOT EXISTS idx_call_records_datetime ON call_records(call_datetime); +CREATE INDEX IF NOT EXISTS idx_call_records_caller ON call_records(caller_number); +CREATE INDEX IF NOT EXISTS idx_call_records_disposition ON call_records(disposition); +CREATE INDEX IF NOT EXISTS idx_call_records_uniqueid ON call_records(cdr_uniqueid); + +-- 1.3 Таблица уведомлений +CREATE TABLE IF NOT EXISTS client_notifications ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + 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, + title VARCHAR(500) NOT NULL, + message TEXT DEFAULT '', + is_read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user ON client_notifications(user_id, is_read); +CREATE INDEX IF NOT EXISTS idx_notifications_unread ON client_notifications(is_read, created_at); + +-- 1.4 Таблица настроек АТС (всегда одна запись) +CREATE TABLE IF NOT EXISTS 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 '', + mysql_status VARCHAR(20) DEFAULT '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 (id) DO NOTHING; + +-- ═══════════════════════════════════════════════════════════════════════════════ +-- 2. Изменения существующих таблиц +-- ═══════════════════════════════════════════════════════════════════════════════ + +-- 2.1 Users: внутренний номер АТС +ALTER TABLE users ADD COLUMN IF NOT EXISTS extension VARCHAR(10) DEFAULT ''; + +-- 2.2 Customers: поля CRM + расширенный статус +ALTER TABLE customers ADD COLUMN IF NOT EXISTS lead_source VARCHAR(20) DEFAULT ''; +ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_call_at TIMESTAMP; +ALTER TABLE customers ADD COLUMN IF NOT EXISTS total_calls INTEGER DEFAULT 0; +ALTER TABLE customers ADD COLUMN IF NOT EXISTS total_leads INTEGER DEFAULT 0; +ALTER TABLE customers DROP CONSTRAINT IF EXISTS ck_customers_status; +ALTER TABLE customers ADD CONSTRAINT ck_customers_status + CHECK (status IN ('active','inactive','prospect','draft','merged')); + +-- 2.3 Tasks: связь с лидом +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL; + +-- 2.4 AuditLog: расширенное аудирование +ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS entity_type VARCHAR(50) DEFAULT ''; +ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS entity_id INTEGER DEFAULT 0; +ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS field VARCHAR(100) DEFAULT ''; +ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS old_value TEXT DEFAULT ''; +ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS new_value TEXT DEFAULT ''; +CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id); diff --git a/py_service/migrations/__init__.py b/py_service/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/py_service/requirements.txt b/py_service/requirements.txt index 261dc55..1a542ba 100644 --- a/py_service/requirements.txt +++ b/py_service/requirements.txt @@ -11,6 +11,7 @@ itsdangerous==2.2.0 markdown==3.7 pydantic==2.10.3 pydantic-settings==2.7.0 +cryptography>=44.0.0 # Test pytest>=9.0.0 diff --git a/py_service/version.txt b/py_service/version.txt index 9eadd6b..f8e233b 100644 --- a/py_service/version.txt +++ b/py_service/version.txt @@ -1 +1 @@ -1.8.6 \ No newline at end of file +1.9.0