v1.9.0: Phase 0 — инфраструктура CRM/лидов, миграции БД, расширение моделей
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
1.8.6
|
||||
1.9.0
|
||||
|
||||
@@ -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://<server>/a/unf/<tenant>/odata/standard.odata/<Entity>
|
||||
```
|
||||
|
||||
| Параметр | Описание | Пример |
|
||||
|----------|----------|--------|
|
||||
| `server` | Адрес сервера 1С-Фреш | `https://xxx.1cfresh.com` |
|
||||
| `app` | Код приложения | `unf` (для УНФ) |
|
||||
| `tenant` | Номер области данных | `34` |
|
||||
| `Entity` | Имя объекта метаданных | `Catalog_Контрагенты` |
|
||||
|
||||
### 3.2 Аутентификация
|
||||
|
||||
```
|
||||
Authorization: Basic <base64(login:password)>
|
||||
```
|
||||
|
||||
Используется сервисный пользователь 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 <auth>
|
||||
```
|
||||
|
||||
### 5.2 Поиск контрагента по ИНН
|
||||
|
||||
```http
|
||||
GET /odata/standard.odata/Catalog_Контрагенты?$format=json
|
||||
&$select=Ref_Key,Description,ИНН,КПП
|
||||
&$filter=(ИНН eq '2310031540')
|
||||
Authorization: Basic <auth>
|
||||
```
|
||||
|
||||
### 5.3 Поиск контрагента по наименованию
|
||||
|
||||
```http
|
||||
GET /odata/standard.odata/Catalog_Контрагенты?$format=json
|
||||
&$select=Ref_Key,Description,ИНН
|
||||
&$filter=like(Description, 'Аегис%')
|
||||
Authorization: Basic <auth>
|
||||
```
|
||||
|
||||
### 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 <auth>
|
||||
```
|
||||
|
||||
### 5.5 Контактные лица контрагента
|
||||
|
||||
```http
|
||||
GET /odata/standard.odata/Catalog_КонтактныеЛица?$format=json
|
||||
&$select=Ref_Key,Description,Должность,Владелец_Key
|
||||
&$filter=Владелец_Key eq guid'...'
|
||||
Authorization: Basic <auth>
|
||||
```
|
||||
|
||||
### 5.6 Номенклатура (услуги)
|
||||
|
||||
```http
|
||||
GET /odata/standard.odata/Catalog_Номенклатура?$format=json
|
||||
&$expand=ЕдиницаИзмерения
|
||||
&$select=Ref_Key,Description,Артикул,ВидНоменклатуры
|
||||
&$filter=not (IsFolder)
|
||||
&$orderby=Description
|
||||
Authorization: Basic <auth>
|
||||
```
|
||||
|
||||
### 5.7 Создание нового контрагента (POST)
|
||||
|
||||
```http
|
||||
POST /odata/standard.odata/Catalog_Контрагенты
|
||||
Content-Type: application/json
|
||||
Authorization: Basic <auth>
|
||||
|
||||
{
|
||||
"Description": "ООО Ромашка",
|
||||
"ИНН": "2310031540",
|
||||
"КПП": "231001001",
|
||||
"ЮридическоеФизическоеЛицо": "ЮридическоеЛицо"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.8 Обновление контрагента (PATCH)
|
||||
|
||||
```http
|
||||
PATCH /odata/standard.odata/Catalog_Контрагенты(guid'...')
|
||||
Content-Type: application/json
|
||||
Authorization: Basic <auth>
|
||||
|
||||
{
|
||||
"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)
|
||||
@@ -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) |
|
||||
|
||||
**Структура:**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) |
|
||||
@@ -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
|
||||
```
|
||||
@@ -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:** универсальный модуль сортировки таблиц по столбцам (текст/число/дата), подключён глобально
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.8.6
|
||||
1.9.0
|
||||
|
||||
Reference in New Issue
Block a user