Compare commits
2 Commits
e29e2984da
...
3e2e8919ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e2e8919ad | |||
| f06ecaabe3 |
+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) |
|
| Тип | Python FastAPI (Max Bot API) |
|
||||||
| FastAPI | порт **8001** |
|
| FastAPI | порт **8002** |
|
||||||
| PostgreSQL | общая БД `aegisone` (порт 5432), таблицы с префиксом `bot_` |
|
| PostgreSQL | общая БД `aegisone` (порт 5432), таблицы с префиксом `bot_` |
|
||||||
| Домен | `max.aegisone.ru` |
|
| Домен | `max.aegisone.ru` |
|
||||||
| Директория | `/opt/projects/aegisone-py/max_bot/` |
|
| Директория | `/opt/projects/aegisone-py/max_bot/` |
|
||||||
| Контейнер | `aegisone-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) |
|
| Токен бота | `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
|
# 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)
|
## 1.8.6 (02.06.2026)
|
||||||
### Новые функции
|
### Новые функции
|
||||||
- **sort-table.js:** универсальный модуль сортировки таблиц по столбцам (текст/число/дата), подключён глобально
|
- **sort-table.js:** универсальный модуль сортировки таблиц по столбцам (текст/число/дата), подключён глобально
|
||||||
|
|||||||
@@ -153,8 +153,8 @@ async def tech_access_page(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: dict = Depends(get_current_user),
|
user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
if user["role"] != "engineer":
|
if user["role"] not in ("engineer", "owner"):
|
||||||
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
|
return HTMLResponse(content="Доступ только для инженеров и руководителей", status_code=403)
|
||||||
|
|
||||||
perms = discover_docs()
|
perms = discover_docs()
|
||||||
editable_docs = [
|
editable_docs = [
|
||||||
@@ -170,8 +170,8 @@ async def tech_access_update(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: dict = Depends(get_current_user),
|
user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
if user["role"] != "engineer":
|
if user["role"] not in ("engineer", "owner"):
|
||||||
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
|
return HTMLResponse(content="Доступ только для инженеров и руководителей", status_code=403)
|
||||||
|
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
perms = discover_docs()
|
perms = discover_docs()
|
||||||
|
|||||||
@@ -113,8 +113,20 @@ async def seed_initial_data():
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
from app.models.models import Base
|
from app.models.models import Base
|
||||||
from app.database import engine
|
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:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
await seed_initial_data()
|
await seed_initial_data()
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
from app.calculations.engine import reload_coefficients
|
from app.calculations.engine import reload_coefficients
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
Миграционный раннер для 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 logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
logger = logging.getLogger("migrations")
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
await db.execute(text(query))
|
||||||
|
except Exception as query_err:
|
||||||
|
# Idempotent DDL: CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS,
|
||||||
|
# ALTER TABLE ADD COLUMN IF NOT EXISTS — пропускаем если объект уже существует
|
||||||
|
err_msg = str(query_err).lower()
|
||||||
|
is_idempotent = (
|
||||||
|
"already exists" in err_msg
|
||||||
|
or "duplicate key value violates unique constraint" in err_msg
|
||||||
|
)
|
||||||
|
if is_idempotent:
|
||||||
|
logger.info(
|
||||||
|
"Пропускаем (уже существует): %.100s", query[:100]
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Записываем в 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)
|
- users: сотрудники (owner/engineer/technician)
|
||||||
- customers: клиенты
|
- customers: клиенты
|
||||||
- objects: объекты обслуживания
|
- objects: объекты обслуживания
|
||||||
- tasks/ incidents: задачи и инциденты
|
- tasks / incidents: задачи и инциденты
|
||||||
|
- leads: лиды (обращения из бота, звонков, сайта, email)
|
||||||
|
- call_records: звонки (CDR из Yeastar S20)
|
||||||
|
- client_notifications: уведомления
|
||||||
|
- pbx_settings: настройки АТС
|
||||||
- blog/cases/ideas: контент
|
- blog/cases/ideas: контент
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -28,6 +32,7 @@ class User(Base):
|
|||||||
phone = Column(String(50), default="")
|
phone = Column(String(50), default="")
|
||||||
email = Column(String(255), default="")
|
email = Column(String(255), default="")
|
||||||
max_user_id = Column(BigInteger, nullable=True)
|
max_user_id = Column(BigInteger, nullable=True)
|
||||||
|
extension = Column(String(10), default="") # Внутренний номер АТС (1001, 1002...)
|
||||||
is_active = Column(Boolean, nullable=False, default=True)
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
@@ -51,17 +56,33 @@ class LoginAttempt(Base):
|
|||||||
|
|
||||||
|
|
||||||
class AuditLog(Base):
|
class AuditLog(Base):
|
||||||
|
"""Лог действий пользователей (аудит).
|
||||||
|
|
||||||
|
Записывает все изменения в системе: кто, что, когда изменил.
|
||||||
|
Используется для отслеживания дедупликации и ручных правок.
|
||||||
|
"""
|
||||||
__tablename__ = "audit_log"
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
action = Column(String(255), nullable=False)
|
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)
|
details = Column(Text)
|
||||||
ip_address = Column(String(45), default="")
|
ip_address = Column(String(45), default="")
|
||||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
|
|
||||||
|
|
||||||
class Customer(Base):
|
class Customer(Base):
|
||||||
|
"""Клиент (контрагент).
|
||||||
|
|
||||||
|
Хранит контактные данные, ИНН/КПП, статус.
|
||||||
|
Статус 'draft' — автосоздан системой из лида/звонка, ожидает заполнения.
|
||||||
|
Статус 'merged' — объединён с другим клиентом.
|
||||||
|
"""
|
||||||
__tablename__ = "customers"
|
__tablename__ = "customers"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -74,12 +95,20 @@ class Customer(Base):
|
|||||||
contact_email = Column(String(255), default="")
|
contact_email = Column(String(255), default="")
|
||||||
status = Column(String(20), nullable=False, default="active")
|
status = Column(String(20), nullable=False, default="active")
|
||||||
notes = Column(Text)
|
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_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
__table_args__ = (
|
__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")
|
objects = relationship("Object", back_populates="customer")
|
||||||
@@ -144,12 +173,19 @@ class ObjectAssignment(Base):
|
|||||||
|
|
||||||
|
|
||||||
class Task(Base):
|
class Task(Base):
|
||||||
|
"""Задача (привязана к объекту).
|
||||||
|
|
||||||
|
Owner/engineer создают и назначают на любого.
|
||||||
|
Technician может создать только себе напоминание.
|
||||||
|
lead_id — связь задачи с лидом (если задача создана из лида).
|
||||||
|
"""
|
||||||
__tablename__ = "tasks"
|
__tablename__ = "tasks"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
|
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
|
||||||
assigned_to = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
assigned_to = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
created_by = 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)
|
title = Column(String(500), nullable=False)
|
||||||
description = Column(Text)
|
description = Column(Text)
|
||||||
priority = Column(String(4), nullable=False, default="P3")
|
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
|
markdown==3.7
|
||||||
pydantic==2.10.3
|
pydantic==2.10.3
|
||||||
pydantic-settings==2.7.0
|
pydantic-settings==2.7.0
|
||||||
|
cryptography>=44.0.0
|
||||||
|
|
||||||
# Test
|
# Test
|
||||||
pytest>=9.0.0
|
pytest>=9.0.0
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.8.6
|
1.9.0
|
||||||
|
|||||||
Reference in New Issue
Block a user