Compare commits

...

12 Commits

Author SHA1 Message Date
angel 3e2e8919ad v1.9.0: fix migration runner — идемпотентный DDL, обработка 'уже существует'
Tests / test (push) Has been cancelled
Tests / test-max-bot (push) Has been cancelled
2026-06-04 14:47:46 +03:00
angel f06ecaabe3 v1.9.0: Phase 0 — инфраструктура CRM/лидов, миграции БД, расширение моделей 2026-06-04 14:38:43 +03:00
angel e29e2984da v1.8.8: AGENTS.md — добавлены обязательные файлы для чтения (серверная архитектура, UI-стили)
Tests / test (push) Has been cancelled
Tests / test-max-bot (push) Has been cancelled
2026-06-04 01:45:55 +03:00
angel 95ac20987f v1.8.7: favicon — лого-щит AegisOne вместо текстового AO
Tests / test (push) Has been cancelled
Tests / test-max-bot (push) Has been cancelled
2026-06-02 21:53:14 +03:00
angel 1199407a72 v1.8.6: сортировка таблиц, спиннеры number input, единый стиль — fix broken HTML, modal-actions → form-actions, card-wrapper
Tests / test (push) Has been cancelled
Tests / test-max-bot (push) Has been cancelled
2026-06-02 21:25:12 +03:00
angel b2a270cb2e v1.8.5: fix onupdate NOW() → func.now(), удалён main-header, правила по Идеям, синхронизация версии в контейнере
Tests / test (push) Has been cancelled
Tests / test-max-bot (push) Has been cancelled
2026-06-02 20:57:46 +03:00
angel e184e5fe79 v1.8.4: единая стилистика — замена confirm/alert/prompt на кастомные модалки, SERVICE_STYLE_GUIDE.md, обновлен AGENTS.md
Tests / test (push) Has been cancelled
Tests / test-max-bot (push) Has been cancelled
2026-06-02 20:40:24 +03:00
angel df34048e2a v1.8.3: скрыт main-header на странице Диалоги (дублирующий заголовок с датой)
Tests / test (push) Has been cancelled
Tests / test-max-bot (push) Has been cancelled
2026-06-02 19:48:00 +03:00
angel 5bb9ffd459 v1.8.3: fix migrations_runner — split SQL on ; for asyncpg compatibility
Tests / test (push) Has been cancelled
Tests / test-max-bot (push) Has been cancelled
2026-06-02 19:04:34 +03:00
angel 8c1486da8b v1.8.3: добавлена директория migrations/ и migrations_runner.py для SQL-миграций
Tests / test (push) Has been cancelled
Tests / test-max-bot (push) Has been cancelled
2026-06-02 18:56:32 +03:00
angel e7f670f21c v1.8.3: обновлены version.txt и CHANGELOG.md
Tests / test (push) Has been cancelled
Tests / test-max-bot (push) Has been cancelled
2026-06-02 18:54:39 +03:00
angel a7a39bdd5d v1.8.3: добавлены docstrings и комментарии по AGENTS.md ко всем ключевым файлам 2026-06-02 18:53:21 +03:00
62 changed files with 4763 additions and 153 deletions
+37
View File
@@ -10,6 +10,16 @@
0.2. **Любое общение только на русском языке.** Код, комментарии, коммиты, CHANGELOG — всё по-русски. 0.2. **Любое общение только на русском языке.** Код, комментарии, коммиты, CHANGELOG — всё по-русски.
0.3. **Если предлагаешь варианты — обязательно указывай рекомендацию и пояснение.** 0.3. **Если предлагаешь варианты — обязательно указывай рекомендацию и пояснение.**
### 0.4. Обязательные файлы для чтения
Перед началом работы агент ОБЯЗАН прочитать:
| Файл | Содержание | Когда читать |
|------|------------|--------------|
| `AGENTS.md` | Правила проекта (этот файл) | Всегда, перед началом работы |
| `other/AI-EXISTING-TWO-PROJECT-INSTRUCTION.md` | Архитектура сервера, порты, Docker-проекты, команды | Перед работой с сервером, деплоем, диагностикой |
| `py_service/SERVICE_STYLE_GUIDE.md` | Стили UI, CSS-классы, паттерны шаблонов | Перед работой с фронтендом портала |
--- ---
## 1. Архитектура проекта ## 1. Архитектура проекта
@@ -22,6 +32,8 @@
**ВАЖНО:** Публичная часть полностью готова и не подлежит изменениям! **ВАЖНО:** Публичная часть полностью готова и не подлежит изменениям!
**Подробная архитектура сервера:** `other/AI-EXISTING-TWO-PROJECT-INSTRUCTION.md` — порты, Docker-проекты, команды, troubleshooting.
--- ---
## 2. Версионирование ## 2. Версионирование
@@ -223,3 +235,28 @@ docker logs aegisone-app --tail 10
- Следуй существующему стилю кода - Следуй существующему стилю кода
- Удаляй неиспользуемый код при изменениях - Удаляй неиспользуемый код при изменениях
- Не добавляй лишних зависимостей без необходимости - Не добавляй лишних зависимостей без необходимости
---
## 11. Стили UI (сервисный портал)
- **Единый гайд:** `py_service/SERVICE_STYLE_GUIDE.md` — читать перед работой с фронтендом
- **Нельзя** использовать `confirm()`, `alert()`, `prompt()` — только `showNotification()` и кастомные модалки
- **Удаление:** простая модалка (Variant A), кнопки: **Действие** (слева) → **Отмена** (справа)
- **Иконки:** `` = удалить, `` = редактировать, `+` = создать
- **Модалки:** `.modal-overlay` + `.modal`, открытие через `classList.add('open')`
- **Структура страницы:** `card` > `card-header` > `filter-bar` > `table-wrap` > `table`
- **CSS классы кнопок:** `.btn-primary` (создать), `.btn-secondary` (редактировать), `.btn-danger` (удалить), `.btn-success` (выполнено)
- **Сортировка таблиц:** `sort-table.js` подключён глобально в `base.html`, автоматически сортирует все `<table>` по клику на `<th>` (текст/число/дата)
- **Number input:** спиннеры скрыты глобально через CSS (`service.css`), `type="number"` выглядит как обычный input
---
## 12. Идеи
- **Страница:** `/service/ideas`
- **Агент может:** видеть список идей, добавлять новые, менять статус, предлагать к реализации, реализовывать
- **Статусы:** `normal` → `in_progress` → `completed` → `normal`
- **API:** POST `/service/ideas/create` (title, description), POST `/service/ideas/status` (id), POST `/service/ideas/delete` (id)
- Агент может добавить идею по просьбе пользователя через API
- Агент может реализовать идею, создав/изменив код, и предложить к реализации
+9
View File
@@ -1,3 +1,12 @@
"""
Конфигурация max_bot (Pydantic Settings).
Читает из .env с префиксом BOT_:
- database_url: PostgreSQL URL
- max_token: токен MAX Platform
- webhook_url/webhook_secret: настройки webhook
- config_php_path: путь к config.php сайта
"""
from typing import List, Optional from typing import List, Optional
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
+6
View File
@@ -1,3 +1,9 @@
"""
Чтение конфигурации из config.php (AegisOne сайт).
Парсит PHP define() константы и возвращает их как dict.
Fallback на hardcoded значения если файл не найден.
"""
from __future__ import annotations from __future__ import annotations
import re import re
import os import os
+6
View File
@@ -1,3 +1,9 @@
"""
Асинхронное подключение к PostgreSQL через SQLAlchemy 2.0.
engine: create_async_engine с pool_size=10
async_session: async_sessionmaker для создания сессий
"""
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.config import settings from app.config import settings
+5
View File
@@ -1,3 +1,8 @@
"""
Отправка email через sendmail (с fallback на логирование в файл).
Используется для уведомлений о заявках.
"""
import subprocess import subprocess
import os import os
import logging import logging
+20
View File
@@ -1,3 +1,11 @@
"""
Обработчик согласия на обработку персональных данных.
Управляет жизненным циклом согласия:
- запрос согласия → да/нет
- отзыв согласия (consent_revoked_date)
- повторное согласие после отказа
"""
import datetime import datetime
import re import re
import logging import logging
@@ -12,6 +20,12 @@ logger = logging.getLogger(__name__)
def _matches_any(text: str, words: list) -> bool: def _matches_any(text: str, words: list) -> bool:
"""
Проверяет, содержит ли текст хотя бы одно из указанных слов.
Использует split для точного совпадения коротких слов (≤3 символов)
и подстроку для длинных (>3 символов).
"""
text_words = set(text.split()) text_words = set(text.split())
for w in words: for w in words:
if w in text_words: if w in text_words:
@@ -22,6 +36,7 @@ def _matches_any(text: str, words: list) -> bool:
async def handle_consent_callback(user_id: int, conv_id: int, callback_id: str) -> None: async def handle_consent_callback(user_id: int, conv_id: int, callback_id: str) -> None:
"""Маршрутизация callback-кнопок согласия (да/нет)."""
if callback_id == "consent_yes": if callback_id == "consent_yes":
await handle_consent_yes(user_id, conv_id) await handle_consent_yes(user_id, conv_id)
elif callback_id == "consent_no": elif callback_id == "consent_no":
@@ -29,6 +44,7 @@ async def handle_consent_callback(user_id: int, conv_id: int, callback_id: str)
async def handle_consent_response(user_id: int, conv_id: int, text: str) -> None: async def handle_consent_response(user_id: int, conv_id: int, text: str) -> None:
"""Обработка текстового ответа на запрос согласия (да/нет/другое)."""
text_lower = text.strip().lower() text_lower = text.strip().lower()
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие"] consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие"]
refuse_words = ["нет", "не даю", "отказ", "no", "не согласен", "не согласна"] refuse_words = ["нет", "не даю", "отказ", "no", "не согласен", "не согласна"]
@@ -48,6 +64,7 @@ async def handle_consent_response(user_id: int, conv_id: int, text: str) -> None
async def handle_consent_yes(user_id: int, conv_id: int) -> None: async def handle_consent_yes(user_id: int, conv_id: int) -> None:
"""Обработка согласия: устанавливает consent_given=True, переводит в awaiting_contact."""
async with async_session() as db: async with async_session() as db:
result = await db.execute(select(BotUser).where(BotUser.id == user_id)) result = await db.execute(select(BotUser).where(BotUser.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@@ -78,6 +95,7 @@ async def handle_consent_yes(user_id: int, conv_id: int) -> None:
async def handle_consent_no(user_id: int, conv_id: int) -> None: async def handle_consent_no(user_id: int, conv_id: int) -> None:
"""Обработка отказа: устанавливает consent_refused, показывает контакты."""
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id) select(BotConversation).where(BotConversation.id == conv_id)
@@ -106,6 +124,7 @@ async def handle_consent_no(user_id: int, conv_id: int) -> None:
async def handle_refused_again(user_id: int, conv_id: int, text: str) -> None: async def handle_refused_again(user_id: int, conv_id: int, text: str) -> None:
"""Повторная обработка после отказа: позволяет дать согласие или показывает контакты."""
text_lower = text.strip().lower() text_lower = text.strip().lower()
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие", "передумал", "согласен дать"] consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие", "передумал", "согласен дать"]
refuse_words = ["нет", "не даю", "не хочу", "отказ", "отмена", "no", "cancel", "я отказался"] refuse_words = ["нет", "не даю", "не хочу", "отказ", "отмена", "no", "cancel", "я отказался"]
@@ -145,6 +164,7 @@ async def handle_refused_again(user_id: int, conv_id: int, text: str) -> None:
async def handle_revoke_consent(user_id: int, conv_id: int) -> None: async def handle_revoke_consent(user_id: int, conv_id: int) -> None:
"""Отзыв согласия: устанавливает consent_given=False, consent_revoked_date=now."""
async with async_session() as db: async with async_session() as db:
user = await db.get(BotUser, user_id) user = await db.get(BotUser, user_id)
if user and user.consent_given: if user and user.consent_given:
+9
View File
@@ -1,3 +1,10 @@
"""
Обработчик контактов пользователя.
Принимает контакты (телефон, email) через:
- sharing контакта из мессенджера (VCF)
- ручной ввод текстом
"""
import datetime import datetime
import re import re
import logging import logging
@@ -13,6 +20,7 @@ EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
async def handle_contact_received(user_id: int, conv_id: int, contact_text: str) -> None: async def handle_contact_received(user_id: int, conv_id: int, contact_text: str) -> None:
"""Обработка контакта из мессенджера: парсинг телефона/email, переход к заявке."""
async with async_session() as db: async with async_session() as db:
result = await db.execute(select(BotUser).where(BotUser.id == user_id)) result = await db.execute(select(BotUser).where(BotUser.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@@ -37,6 +45,7 @@ async def handle_contact_received(user_id: int, conv_id: int, contact_text: str)
async def handle_manual_contact(user_id: int, conv_id: int, text: str) -> None: async def handle_manual_contact(user_id: int, conv_id: int, text: str) -> None:
"""Обработка ручного ввода контакта: валидация телефона/email, отказ → consent_refused."""
text = text.strip() text = text.strip()
text_lower = text.lower() text_lower = text.lower()
+35
View File
@@ -1,3 +1,13 @@
"""
Обработчик приветствий и основных сообщений бота.
Управляет жизненным циклом диалога:
- приветствие → создание пользователя и диалога
- маршрутизация по состояниям (state machine)
- анализ намерений через YandexGPT
- быстрые паттерны (заявка/приветствие) до NLP
- контекст из последних 10 минут
"""
import logging import logging
import datetime import datetime
from typing import Optional from typing import Optional
@@ -7,6 +17,7 @@ from app.database import async_session
MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3)) MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3))
def moscow_now(): def moscow_now():
"""Возвращает текущее время в UTC+3 (Москва) без tzinfo."""
return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None) return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None)
from app.models import BotUser, BotConversation, BotMessage, BotKnowledgeBase, BotCategory, BotUnknownQuestion from app.models import BotUser, BotConversation, BotMessage, BotKnowledgeBase, BotCategory, BotUnknownQuestion
from app.max_api import max_api from app.max_api import max_api
@@ -26,6 +37,7 @@ async def handle_greeting(
last_name: str = "", last_name: str = "",
username: str = "", username: str = "",
) -> None: ) -> None:
"""Обработка приветствия: создаёт/обновляет пользователя, создаёт диалог, отправляет приветствие."""
assistant_name = await settings_cache.get("assistant_name", "София") assistant_name = await settings_cache.get("assistant_name", "София")
greeting_text = ( greeting_text = (
@@ -94,6 +106,14 @@ async def handle_greeting(
async def handle_message(user_id: int, text: str) -> None: async def handle_message(user_id: int, text: str) -> None:
"""
Основной обработчик сообщений. Маршрутизирует по состояниям:
- greeting/analyzing/awaiting_input → analyze_and_respond
- awaiting_consent/consent_refused → обработка согласия
- awaiting_contact → ручной ввод контакта
- awaiting_inquiry → обработка заявки
- completed/escalated → повторный анализ (escape hatch)
"""
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
select(BotConversation) select(BotConversation)
@@ -199,6 +219,7 @@ async def handle_message(user_id: int, text: str) -> None:
async def _get_user_history_context(user_id: int) -> str: async def _get_user_history_context(user_id: int) -> str:
"""Формирует контекст из последних сообщений (10 мин) и открытых заявок пользователя."""
from app.models import BotTicket from app.models import BotTicket
async with async_session() as db: async with async_session() as db:
ten_min_ago = moscow_now() - datetime.timedelta(minutes=10) ten_min_ago = moscow_now() - datetime.timedelta(minutes=10)
@@ -237,12 +258,20 @@ async def _get_user_history_context(user_id: int) -> str:
async def _select_ai(): async def _select_ai():
"""Выбор AI-провайдера (только YandexGPT)."""
if await yandex_gpt.is_available(): if await yandex_gpt.is_available():
return yandex_gpt return yandex_gpt
return None return None
async def analyze_and_respond(user_id: int, conv_id: int, text: str) -> None: async def analyze_and_respond(user_id: int, conv_id: int, text: str) -> None:
"""
Анализ намерений и генерация ответа.
Быстрые паттерны (заявка/приветствие) проверяются ПЕРЕД NLP.
Затем: analyze_intent → ticket/question/unknown → обработка.
clarify_intent вызывается не более 1 раза за диалог.
"""
try: try:
logger.info(f"analyze_and_respond: user={user_id} conv={conv_id} text={text[:80]}") logger.info(f"analyze_and_respond: user={user_id} conv={conv_id} text={text[:80]}")
@@ -359,6 +388,7 @@ async def analyze_and_respond(user_id: int, conv_id: int, text: str) -> None:
async def _limited_mode(user_id: int, conv_id: int) -> None: async def _limited_mode(user_id: int, conv_id: int) -> None:
"""Режим без AI: показывает контакты компании."""
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id) select(BotConversation).where(BotConversation.id == conv_id)
@@ -372,6 +402,7 @@ async def _limited_mode(user_id: int, conv_id: int) -> None:
async def _send_contacts(user_id: int, conv_id: int = None) -> None: async def _send_contacts(user_id: int, conv_id: int = None) -> None:
"""Отправка контактных данных компании (телефон, email)."""
phones = config_reader.get_phones() phones = config_reader.get_phones()
email = config_reader.get_site_mail() email = config_reader.get_site_mail()
@@ -387,6 +418,7 @@ async def _send_contacts(user_id: int, conv_id: int = None) -> None:
async def _get_knowledge_base_context(query: str) -> str: async def _get_knowledge_base_context(query: str) -> str:
"""Формирует контекст из базы знаний по ключевым словам (ранжирование по score)."""
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
select(BotKnowledgeBase) select(BotKnowledgeBase)
@@ -433,6 +465,7 @@ async def _get_knowledge_base_context(query: str) -> str:
def _texts_match(text_a: str, text_b: str) -> bool: def _texts_match(text_a: str, text_b: str) -> bool:
"""Проверка схожести двух текстов по пересечению слов (≥50%)."""
stopwords = {"какой", "какая", "какие", "какое", "ваш", "ваша", "ваши", stopwords = {"какой", "какая", "какие", "какое", "ваш", "ваша", "ваши",
"где", "когда", "сколько", "что", "это", "или", "для", "при", "где", "когда", "сколько", "что", "это", "или", "для", "при",
"подскажите", "расскажите", "объясните", "пожалуйста"} "подскажите", "расскажите", "объясните", "пожалуйста"}
@@ -445,6 +478,7 @@ def _texts_match(text_a: str, text_b: str) -> bool:
async def _auto_generate_and_add_kb(db, question_record): async def _auto_generate_and_add_kb(db, question_record):
"""Автогенерация карточки KB через YandexGPT при 3+ обращениях."""
card_data = await yandex_gpt.generate_kb_card([question_record.question_text]) card_data = await yandex_gpt.generate_kb_card([question_record.question_text])
if not card_data: if not card_data:
return return
@@ -466,6 +500,7 @@ async def _auto_generate_and_add_kb(db, question_record):
async def _save_unknown_question(user_id: int, question_text: str) -> None: async def _save_unknown_question(user_id: int, question_text: str) -> None:
"""Сохранение неизвестного вопроса с дедупликацией и автогенерацией при 3+ обращениях."""
normalized_input = question_text.lower().strip() normalized_input = question_text.lower().strip()
async with async_session() as db: async with async_session() as db:
+7
View File
@@ -1,3 +1,8 @@
"""
Обработчик callback-кнопок и шаринга контактов.
Маршрутизирует callback-и по типу (consent_* и др.).
"""
import datetime import datetime
import logging import logging
from sqlalchemy import select from sqlalchemy import select
@@ -10,6 +15,7 @@ logger = logging.getLogger(__name__)
async def handle_callback(user_id: int, callback_id: str, callback_data: dict) -> None: async def handle_callback(user_id: int, callback_id: str, callback_data: dict) -> None:
"""Маршрутизация callback-кнопок (consent_* → consent handler)."""
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
select(BotConversation) select(BotConversation)
@@ -40,6 +46,7 @@ async def handle_contact_share(
vcf_data: dict = None, vcf_data: dict = None,
contact_hash: str = "", contact_hash: str = "",
) -> None: ) -> None:
"""Обработка шаринга контакта из мессенджера: парсинг VCF, сохранение в BotUser."""
if vcf_data is None: if vcf_data is None:
vcf_data = {} vcf_data = {}
async with async_session() as db: async with async_session() as db:
+8
View File
@@ -1,3 +1,9 @@
"""
Обработчик заявок (inquiry).
Создаёт BotTicket с контактными данными пользователя,
отправляет уведомление на email, подтверждает пользователю.
"""
import datetime import datetime
import logging import logging
from sqlalchemy import select from sqlalchemy import select
@@ -12,10 +18,12 @@ logger = logging.getLogger(__name__)
MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3)) MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3))
def moscow_now(): def moscow_now():
"""Возвращает текущее время в UTC+3 (Москва) без tzinfo."""
return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None) return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None)
async def handle_inquiry(user_id: int, conv_id: int, text: str) -> None: async def handle_inquiry(user_id: int, conv_id: int, text: str) -> None:
"""Обработка заявки: создаёт.ticket, отправляет email, подтверждает пользователю."""
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id) select(BotConversation).where(BotConversation.id == conv_id)
+9
View File
@@ -1,3 +1,12 @@
"""
Клавиатуры для MAX Platform бота.
Типы кнопок:
- callback: callback-кнопка (consent_yes/consent_no)
- link: ссылка (политика конфиденциальности)
- request_contact: запрос контакта из мессенджера
- message: текстовая кнопка
"""
from typing import List, Dict, Any from typing import List, Dict, Any
POLITICA_URL = "https://aegisone.ru/politica.php" POLITICA_URL = "https://aegisone.ru/politica.php"
+24
View File
@@ -1,3 +1,11 @@
"""
Главный модуль max_bot FastAPI-приложения.
Эндпоинты:
- /webhook: входящие webhook-и от MAX Platform
- /health: healthcheck
- /api/bot/*: CRUD для настроек, KB, заявок, диалогов, пользователей
"""
from __future__ import annotations from __future__ import annotations
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -33,6 +41,11 @@ async def lifespan(app: FastAPI):
logger.info("Database tables ensured.") logger.info("Database tables ensured.")
await _apply_migrations() await _apply_migrations()
# Применение SQL-миграций из директории migrations/
from app.migrations_runner import run_migrations
await run_migrations()
await _seed_default_settings() await _seed_default_settings()
await _register_webhook() await _register_webhook()
@@ -54,6 +67,7 @@ app.add_middleware(
async def _apply_migrations(): async def _apply_migrations():
"""Автоматические миграции: добавление колонок/таблиц при старте."""
async with engine.begin() as conn: async with engine.begin() as conn:
try: try:
await conn.execute(text(""" await conn.execute(text("""
@@ -101,6 +115,7 @@ async def _apply_migrations():
async def _add_column_if_not_exists(conn, table: str, column: str, col_type: str): async def _add_column_if_not_exists(conn, table: str, column: str, col_type: str):
"""Безопасное добавление колонки (idempotent)."""
from sqlalchemy import text as sa_text from sqlalchemy import text as sa_text
_validate_sql_name(table) _validate_sql_name(table)
_validate_sql_name(column) _validate_sql_name(column)
@@ -121,6 +136,7 @@ async def _add_column_if_not_exists(conn, table: str, column: str, col_type: str
async def _create_table_if_not_exists(conn, table: str, create_sql: str): async def _create_table_if_not_exists(conn, table: str, create_sql: str):
"""Безопасное создание таблицы (idempotent)."""
from sqlalchemy import text as sa_text from sqlalchemy import text as sa_text
try: try:
await conn.execute(sa_text(f""" await conn.execute(sa_text(f"""
@@ -138,12 +154,14 @@ async def _create_table_if_not_exists(conn, table: str, create_sql: str):
def _validate_sql_name(name: str) -> None: def _validate_sql_name(name: str) -> None:
"""Валидация SQL-идентификатора (только буквы, цифры, подчёркивания)."""
import re import re
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_ ]*$', name): if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_ ]*$', name):
raise ValueError(f"Invalid SQL identifier: {name}") raise ValueError(f"Invalid SQL identifier: {name}")
def _validate_sql_type(col_type: str) -> None: def _validate_sql_type(col_type: str) -> None:
"""Валидация SQL-типа колонки (разрешённые типы)."""
import re import re
allowed = r'^(VARCHAR\(\d+\)|TEXT|INTEGER( DEFAULT \d+)?|BOOLEAN( DEFAULT (TRUE|FALSE))?|TIMESTAMP)$' allowed = r'^(VARCHAR\(\d+\)|TEXT|INTEGER( DEFAULT \d+)?|BOOLEAN( DEFAULT (TRUE|FALSE))?|TIMESTAMP)$'
if not re.match(allowed, col_type.strip()): if not re.match(allowed, col_type.strip()):
@@ -151,6 +169,7 @@ def _validate_sql_type(col_type: str) -> None:
async def _log_processing(user_id: int, step: str, status: str = "ok", message: str = None, detail: str = None, conversation_id: int = None): async def _log_processing(user_id: int, step: str, status: str = "ok", message: str = None, detail: str = None, conversation_id: int = None):
"""Запись лога обработки в bot_processing_logs."""
try: try:
async with async_session() as db: async with async_session() as db:
db.add(BotProcessingLog( db.add(BotProcessingLog(
@@ -167,6 +186,7 @@ async def _log_processing(user_id: int, step: str, status: str = "ok", message:
async def _seed_default_settings(): async def _seed_default_settings():
"""Заполнение настроек по умолчанию при первом запуске."""
defaults = { defaults = {
"assistant_name": "София", "assistant_name": "София",
"assistant_role": "Представитель компании AegisOne Engineering, помогающий клиентам с запросами по безопасности", "assistant_role": "Представитель компании AegisOne Engineering, помогающий клиентам с запросами по безопасности",
@@ -185,6 +205,7 @@ async def _seed_default_settings():
async def _register_webhook(): async def _register_webhook():
"""Регистрация webhook-а в MAX Platform."""
try: try:
subs = await max_api.get_subscriptions() subs = await max_api.get_subscriptions()
logger.info(f"Current subscriptions: {subs}") logger.info(f"Current subscriptions: {subs}")
@@ -199,6 +220,7 @@ async def _register_webhook():
def _verify_secret(request: Request) -> bool: def _verify_secret(request: Request) -> bool:
"""Проверка webhook-секрета (пустой заголовок = OK для обратной совместимости)."""
secret = request.headers.get("X-Max-Bot-Api-Secret", "") secret = request.headers.get("X-Max-Bot-Api-Secret", "")
if not secret: if not secret:
return True return True
@@ -206,6 +228,7 @@ def _verify_secret(request: Request) -> bool:
async def _get_active_conv_id(user_id: int) -> int | None: async def _get_active_conv_id(user_id: int) -> int | None:
"""Получение ID активного диалога пользователя."""
try: try:
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
@@ -798,6 +821,7 @@ async def api_delete_conversation(conv_id: int):
@app.delete("/api/bot/users/{user_id}") @app.delete("/api/bot/users/{user_id}")
async def api_delete_bot_user(user_id: int): async def api_delete_bot_user(user_id: int):
"""Каскадное удаление пользователя: BotUser + BotConversation + BotMessage + BotProcessingLog. Заявки остаются."""
from app.models import BotMessage, BotProcessingLog from app.models import BotMessage, BotProcessingLog
from sqlalchemy import delete as sa_delete from sqlalchemy import delete as sa_delete
async with async_session() as db: async with async_session() as db:
+10
View File
@@ -1,3 +1,11 @@
"""
API-клиент для MAX Platform (Мой Мир / VK).
Основные методы:
- send_message: отправка сообщений (с логированием outgoing BotMessage)
- subscribe_webhook: регистрация webhook-а
- answer_callback: ответ на callback-кнопки
"""
import logging import logging
import httpx import httpx
from typing import Optional from typing import Optional
@@ -9,6 +17,7 @@ TEST_USER_THRESHOLD = 999999000
class MaxAPI: class MaxAPI:
"""Клиент MAX Platform API с поддержкой mock-реплик для тестов."""
def __init__(self): def __init__(self):
self.base_url = settings.max_api_base self.base_url = settings.max_api_base
self.token = settings.max_token self.token = settings.max_token
@@ -65,6 +74,7 @@ class MaxAPI:
notify: bool = True, notify: bool = True,
conversation_id: int = None, conversation_id: int = None,
) -> dict: ) -> dict:
"""Отправка сообщения. Если conversation_id — сохраняет outgoing BotMessage."""
if user_id >= TEST_USER_THRESHOLD: if user_id >= TEST_USER_THRESHOLD:
logger.info(f"MOCK send_message to test user {user_id}: {text[:50]}") logger.info(f"MOCK send_message to test user {user_id}: {text[:50]}")
self.mock_replies.append(text) self.mock_replies.append(text)
+84
View File
@@ -0,0 +1,84 @@
"""
Автоматические миграции БД для max_bot.
При старте приложения:
1. Создаёт таблицу _applied_migrations (если не существует)
2. Проверяет какие SQL-файлы уже применены
3. Применяет новые миграции по порядку
ВАЖНО: asyncpg не поддерживает несколько SQL-команд в одном text().
Поэтому каждый SQL-файл разбивается на отдельные команды по ';' .
"""
import os
import re
import logging
from sqlalchemy import text
from app.database import engine
logger = logging.getLogger(__name__)
MIGRATIONS_DIR = os.path.join(os.path.dirname(__file__), "migrations")
def _split_sql(sql: str) -> list:
"""Разбивает SQL-файл на отдельные команды, пропуская комментарии и пустые строки."""
statements = []
for stmt in sql.split(";"):
# Убираем комментарии (-- ...) и пустые строки
lines = []
for line in stmt.split("\n"):
stripped = line.strip()
if stripped.startswith("--"):
continue
if stripped:
lines.append(stripped)
clean = " ".join(lines)
if clean:
statements.append(clean)
return statements
async def run_migrations():
"""Запуск всех неприменённых миграций из директории migrations/."""
async with engine.begin() as conn:
# Создаём таблицу для отслеживания миграций
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS _applied_migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL,
applied_at TIMESTAMP DEFAULT NOW()
)
"""))
# Получаем список уже применённых миграций
result = await conn.execute(text("SELECT filename FROM _applied_migrations"))
applied = {row[0] for row in result.all()}
# Сканируем директорию миграций
if not os.path.isdir(MIGRATIONS_DIR):
logger.info("Директория migrations/ не найдена, пропускаем")
return
sql_files = sorted(f for f in os.listdir(MIGRATIONS_DIR) if f.endswith(".sql"))
for filename in sql_files:
if filename in applied:
continue
filepath = os.path.join(MIGRATIONS_DIR, filename)
with open(filepath, "r", encoding="utf-8") as f:
sql = f.read()
statements = _split_sql(sql)
logger.info(f"Применение миграции: {filename} ({len(statements)} команд)")
try:
for stmt in statements:
await conn.execute(text(stmt))
await conn.execute(
text("INSERT INTO _applied_migrations (filename) VALUES (:fn)"),
{"fn": filename}
)
logger.info(f"Миграция применена: {filename}")
except Exception as e:
logger.warning(f"Миграция {filename} уже применена или ошибка: {e}")
+18
View File
@@ -1,3 +1,20 @@
"""
ORM-модели для PostgreSQL (SQLAlchemy 2.0).
Таблицы:
- bot_settings: настройки бота (ключ-значение)
- bot_features: фичи бота (вкл/выкл)
- bot_categories: категории базы знаний
- bot_knowledge_base: карточки базы знаний
- bot_users: пользователи бота (контакты, согласие)
- bot_conversations: диалоги (state machine)
- bot_messages: сообщения (incoming/outgoing)
- bot_tickets: заявки
- bot_ticket_messages: сообщения заявок
- bot_processing_logs: логи обработки
- bot_ticket_statuses: история смены статусов заявок
- bot_unknown_questions: неизвестные вопросы (автогенерация KB)
"""
from __future__ import annotations from __future__ import annotations
import datetime import datetime
from sqlalchemy import Column, Integer, BigInteger, String, Text, Boolean, DateTime, ForeignKey, ARRAY from sqlalchemy import Column, Integer, BigInteger, String, Text, Boolean, DateTime, ForeignKey, ARRAY
@@ -53,6 +70,7 @@ class BotKnowledgeBase(Base):
class BotUser(Base): class BotUser(Base):
"""Пользователь бота: профиль, контакты, согласие на обработку ПД."""
__tablename__ = "bot_users" __tablename__ = "bot_users"
id = Column(BigInteger, primary_key=True) id = Column(BigInteger, primary_key=True)
first_name = Column(String(255), nullable=True) first_name = Column(String(255), nullable=True)
+6
View File
@@ -1,3 +1,9 @@
"""
In-memory кэш настроек бота с синхронизацией с БД.
При первом обращении загружает все настройки из bot_settings.
При set() обновляет и БД, и кэш.
"""
import asyncio import asyncio
from typing import Optional, Dict from typing import Optional, Dict
from sqlalchemy import select from sqlalchemy import select
+13
View File
@@ -1,3 +1,9 @@
"""
YandexGPT интеграция для анализа намерений и генерации ответов.
Модель: yandexgpt-lite
API: https://llm.api.cloud.yandex.net/foundationModels/v1/completion
"""
import httpx import httpx
import json import json
import logging import logging
@@ -14,6 +20,7 @@ YANDEX_GPT_MODEL = "yandexgpt-lite"
class YandexGPT: class YandexGPT:
"""Клиент YandexGPT для анализа намерений и генерации ответов."""
def __init__(self): def __init__(self):
self._api_key: Optional[str] = None self._api_key: Optional[str] = None
self._folder_id: Optional[str] = None self._folder_id: Optional[str] = None
@@ -27,6 +34,7 @@ class YandexGPT:
return await self._load_credentials() return await self._load_credentials()
async def _request(self, prompt: str, system_prompt: str = "", temperature: float = 0.3, max_tokens: int = 1000) -> Optional[str]: async def _request(self, prompt: str, system_prompt: str = "", temperature: float = 0.3, max_tokens: int = 1000) -> Optional[str]:
"""Базовый запрос к YandexGPT API."""
if not await self._load_credentials(): if not await self._load_credentials():
return None return None
@@ -65,11 +73,13 @@ class YandexGPT:
return None return None
def _sanitize_input(self, text: str) -> str: def _sanitize_input(self, text: str) -> str:
"""Очистка и обрезка входного текста (макс. 2000 символов)."""
text = text[:MAX_INPUT_LENGTH] text = text[:MAX_INPUT_LENGTH]
text = text.replace("\r\n", "\n").replace("\r", "\n") text = text.replace("\r\n", "\n").replace("\r", "\n")
return text return text
async def analyze_intent(self, text: str, history_context: str = "") -> str: async def analyze_intent(self, text: str, history_context: str = "") -> str:
"""Анализ намерения: ticket/question/unknown. Aggressive ticket detection для услуг."""
safe_text = self._sanitize_input(text) safe_text = self._sanitize_input(text)
safe_history = self._sanitize_input(history_context) if history_context else "" safe_history = self._sanitize_input(history_context) if history_context else ""
system_prompt = ( system_prompt = (
@@ -97,6 +107,7 @@ class YandexGPT:
return "unknown" return "unknown"
async def generate_answer(self, question: str, context: str, history_context: str = "") -> Optional[str]: async def generate_answer(self, question: str, context: str, history_context: str = "") -> Optional[str]:
"""Генерация ответа на вопрос с контекстом из базы знаний."""
safe_question = self._sanitize_input(question) safe_question = self._sanitize_input(question)
safe_history = self._sanitize_input(history_context) if history_context else "" safe_history = self._sanitize_input(history_context) if history_context else ""
system_prompt = ( system_prompt = (
@@ -116,6 +127,7 @@ class YandexGPT:
return await self._request(user_prompt, system_prompt) return await self._request(user_prompt, system_prompt)
async def clarify_intent(self, text: str, history_context: str = "") -> str: async def clarify_intent(self, text: str, history_context: str = "") -> str:
"""Уточнение намерения: если есть слова-маркеры — отвечает без 'уточните'."""
safe_text = self._sanitize_input(text) safe_text = self._sanitize_input(text)
safe_history = self._sanitize_input(history_context) if history_context else "" safe_history = self._sanitize_input(history_context) if history_context else ""
system_prompt = ( system_prompt = (
@@ -159,6 +171,7 @@ class YandexGPT:
return result or "Подскажите, чем я могу помочь? Вы хотите задать вопрос об услугах AegisOne или оставить заявку?" return result or "Подскажите, чем я могу помочь? Вы хотите задать вопрос об услугах AegisOne или оставить заявку?"
async def generate_kb_card(self, user_questions: list) -> Optional[dict]: async def generate_kb_card(self, user_questions: list) -> Optional[dict]:
"""Генерация карточки базы знаний из вопросов пользователей (JSON)."""
joined = "\n".join(f"- {q}" for q in user_questions) joined = "\n".join(f"- {q}" for q in user_questions)
system_prompt = ( system_prompt = (
"Ты — редактор базы знаний компании AegisOne Engineering. " "Ты — редактор базы знаний компании AegisOne Engineering. "
@@ -0,0 +1,22 @@
-- Миграция: consent_revoked_date + bot_processing_logs
-- Дата: 02.06.2026
-- Описание: Добавление поля consent_revoked_date в bot_users и создание таблицы bot_processing_logs
-- 1. Consent revoked date
ALTER TABLE bot_users ADD COLUMN IF NOT EXISTS consent_revoked_date TIMESTAMP NULL;
-- 2. BotProcessingLog table
CREATE TABLE IF NOT EXISTS bot_processing_logs (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES bot_users(id) ON DELETE CASCADE,
conversation_id BIGINT REFERENCES bot_conversations(id) ON DELETE SET NULL,
step VARCHAR(50) NOT NULL,
status VARCHAR(20) DEFAULT 'ok',
message TEXT,
detail TEXT,
created_at TIMESTAMP DEFAULT now()
);
-- Индексы для производительности
CREATE INDEX IF NOT EXISTS idx_bot_processing_logs_user_id ON bot_processing_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_bot_processing_logs_conversation_id ON bot_processing_logs(conversation_id);
+1 -1
View File
@@ -1 +1 @@
1.8.2 1.9.0
+430
View File
@@ -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)
+2 -2
View File
@@ -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
+541
View File
@@ -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) |
+267
View File
@@ -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
```
+68
View File
@@ -1,5 +1,73 @@
# 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)
### Новые функции
- **sort-table.js:** универсальный модуль сортировки таблиц по столбцам (текст/число/дата), подключён глобально
- **Number input:** спиннеры скрыты через CSS (`appearance: textfield`) на всех страницах
### Исправления
- **customers.html:** восстановлена сломанная HTML-структура — `.card` > `.card-header`, поиск в `.filter-bar`
- **bot_dialogues.html:** обёрнут в `.card` > `.card-header`, фильтры в `.filter-bar`, удалены кастомные `.page-header`/`.header-controls`
- **checklist.html:** `<h3>` обёрнут в `.card-header`
- **18 страниц:** `modal-actions``form-actions` (единый класс для кнопок в модалках)
- **charts.html:** `btn-xs``btn-sm`
- **assignments.html:** добавлена модалка подтверждения отвязки сотрудника
- **4 AJAX-страницы:** `sortTables()` вызывается после загрузки данных (bot_kb, bot_unknown, bot_consent, bot_tickets)
## 1.8.5 (02.06.2026)
### Новые функции
- **AGENTS.md (раздел 12):** правила по Идеям — агент может видеть, добавлять, менять статус, предлагать к реализации, реализовывать
### Исправления
- **models.py:** `onupdate="NOW()"``onupdate=func.now()` — fix Internal Server Error при смене статуса Идеи (14 моделей)
- **page.html:** удалён `main-header` (заголовок + дата) — больше не дублируется на всех страницах
- **dashboard.html:** удалён `main-header`
- **bot_dialogues.html:** удалён CSS `.main-header { display: none !important; }` (больше не нужен)
- **version.txt / CHANGELOG.md:** синхронизация версии в контейнере aegisone-app
## 1.8.4 (02.06.2026)
### Новые функции
- **SERVICE_STYLE_GUIDE.md:** единый гайд стилей сервисного портала
- **AGENTS.md (раздел 11):** правила стилей UI — см. SERVICE_STYLE_GUIDE.md
- **CSS `.modal-actions`:** общий класс для кнопок в модалках
### Исправления
- **bot_dialogues.html:** скрыт `main-header` (дублирующий заголовок с датой)
- **bot_kb.html:** замена кастомных классов на `.modal-overlay`/`.modal`, `confirm()` → кастомная модалка, иконки ✎/✕
- **bot_unknown.html:** `.modal-box``.modal`, `confirm()` → кастомная модалка, иконки ✎/✕
- **bot_consent.html:** `confirm()` → кастомная модалка удаления пользователя
- **bot_test.html:** `confirm()` → кастомная модалка очистки тестовых данных
- **charts.html:** `prompt()` → кастомная модалка ввода имени графика, `confirm()` → кастомная модалка удаления
- **role_settings.html:** `alert()``showNotification()`
- **10 простых страниц:** blog, cases, coefficients, customers, formulas, ideas, objects, questionnaire_config, tasks, users — `confirm()` → кастомные модалки с уникальными ID
- **migrations_runner.py:** split SQL на `;` для совместимости с asyncpg
## 1.8.3 (02.06.2026)
### Новые функции
- **Docstrings:** добавлены docstrings и комментарии по AGENTS.md ко всем ключевым Python-файлам
- **File headers:** каждый файл имеет шапку с описанием назначения
- **Function docstrings:** каждая функция имеет docstring на русском языке
## 1.8.2 (02.06.2026) ## 1.8.2 (02.06.2026)
### Новые функции ### Новые функции
- **AGENTS.md:** правила проекта для AI-агентов (корень репозитория) - **AGENTS.md:** правила проекта для AI-агентов (корень репозитория)
+232
View File
@@ -0,0 +1,232 @@
# Сервисный портал AegisOne — Единый стиль
> Этот документ ОБЯЗАН прочитать каждый AI-агент перед изменением шаблонов/CSS сервисного портала.
---
## 1. Структура страницы
Каждая страница наследует `page.html` и реализует блок `page_content`.
### Порядок элементов в правой части (main):
```
page.html main-header:
left: h1 (заголовок) + breadcrumb
right: page_actions block + дата {{ now }}
page_content:
1. div.card — основной контент
├── div.card-header — заголовок + кнопка действия
├── div.filter-bar — фильтры (если нужны)
└── div.table-wrap > table — таблица данных
2. div.modal-overlay#modal-xxx — модалка создания/редактирования
3. div.modal-overlay#confirm-delete-xxx — модалка подтверждения удаления
```
### Card Header:
```html
<div class="card-header">
<h2>Название секции</h2>
<button class="btn btn-primary btn-sm" onclick="openModal()">+ Действие</button>
</div>
```
---
## 2. Модалки
### Единые CSS-классы
| Класс | Назначение |
|-------|-----------|
| `.modal-overlay` | Затемнённый фон (position: fixed, z-index: 200) |
| `.modal-overlay.open` | Показать модалку (display: flex) |
| `.modal` | Контейнер модалки (фон, рамка, скругление) |
| `.modal-close` | Кнопка закрытия (✕) в правом верхнем углу |
| `.form-actions` | Блок кнопок внизу модалки (display: flex, gap: 10px) |
### Открытие/закрытие
```javascript
// Открыть
document.getElementById('modal-xxx').classList.add('open');
// Закрыть
document.getElementById('modal-xxx').classList.remove('open');
// Или изнутри кнопки
this.closest('.modal-overlay').classList.remove('open');
```
**ЗАПРЕЩЕНО:** использовать `style.display = 'flex'` / `style.display = 'none'`
### Модалка создания/редактирования (стандарт)
```html
<div class="modal-overlay" id="modal-xxx">
<div class="modal">
<button type="button" class="modal-close"
onclick="this.closest('.modal-overlay').classList.remove('open')">✕</button>
<h2>Название</h2>
<form method="post" action="/service/xxx/create">
<div class="form-row">
<div class="form-group"><label>Поле</label><input type="text" name="field"></div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn"
onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button>
</div>
</form>
</div>
</div>
```
### Модалка подтверждения удаления (единый шаблон)
**ЗАПРЕЩЕНЫ:** `confirm()`, `alert()`, `prompt()` — только кастомные модалки!
```html
<div class="modal-overlay" id="confirm-delete-xxx">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Удалить [объект]?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
<strong id="delete-xxx-name"></strong> будет удалён навсегда.
Это действие нельзя отменить.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmDeleteXxx()">Удалить</button>
<button type="button" class="btn" onclick="closeConfirmDeleteXxx()">Отмена</button>
</div>
</div>
</div>
```
### Порядок кнопок в модалках
```
[ Действие (btn-danger / btn-primary) ] [ Отмена (btn) ]
слева справа
```
---
## 3. Кнопки
### Иконки
| Иконка | Назначение | CSS-класс |
|--------|-----------|-----------|
| `+` | Создание (перед текстом) | `btn btn-primary btn-sm` |
| `✎` | Редактирование | `btn btn-secondary btn-sm` |
| `✕` | Удаление | `btn btn-danger btn-sm` |
| `✓` | Завершение/включение | `btn btn-success btn-sm` |
| `🗑` | Удаление (только в карточках) | `btn btn-danger btn-sm` |
### CSS-классы кнопок
| Класс | Назначение |
|-------|-----------|
| `.btn` | Базовая кнопка |
| `.btn-primary` | Основное действие (синий) |
| `.btn-secondary` | Вторичное действие (серый) |
| `.btn-danger` | Удаление/опасное действие (красный) |
| `.btn-success` | Успех/завершение (зелёный) |
| `.btn-warning` | Предупреждение (жёлтый) |
| `.btn-sm` | Маленький размер |
### Таблицы — колонка действий
```html
<th class="actions"></th>
<td class="actions">
<button class="btn btn-secondary btn-sm" onclick="editXxx(...)" title="Редактировать">✎</button>
<button class="btn btn-danger btn-sm" onclick="openConfirmDeleteXxx(...)" title="Удалить">✕</button>
</td>
```
---
## 4. Уведомления
### Показ уведомлений
```javascript
showNotification('Текст уведомления', 'success'); // зелёное
showNotification('Текст уведомления', 'error'); // красное
showNotification('Текст уведомления', 'warning'); // жёлтое
showNotification('Текст уведомления', 'info'); // синее
```
**ЗАПРЕЩЕНЫ:** `alert()`, `prompt()`, `confirm()` — только `showNotification()` и кастомные модалки!
---
## 5. Фильтры
```html
<div class="filter-bar">
<input type="text" id="search-input" placeholder="Поиск..." oninput="filterItems()">
<select id="status-filter" onchange="filterItems()" class="filter-select">
<option value="">Все статусы</option>
</select>
</div>
```
---
## 6. Таблицы
```html
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Колонка 1</th>
<th>Колонка 2</th>
<th class="actions"></th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.field }}</td>
<td>{{ item.field2 }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" onclick="editXxx(...)">✎</button>
<button class="btn btn-danger btn-sm" onclick="openConfirmDeleteXxx(...)">✕</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
```
---
## 7. Страницы с JS-рендерингом
Для страниц, где данные загружаются через `fetch()` (bot_consent, bot_dialogues, bot_kb, bot_tickets, bot_unknown, charts):
- Таблица рендерится через `renderXxx(data)` в JavaScript
- Фильтрация через `filterXxx()` на клиенте
- Модалки подтверждения удаления — те же HTML-шаблоны
---
## 8. Чек-лист перед коммитом
- [ ] Нет `confirm()`, `alert()`, `prompt()` в коде
- [ ] Модалки открываются через `classList.add('open')`
- [ ] Модалки закрываются через `classList.remove('open')`
- [ ] Кнопки удаления: `✕` (btn-danger)
- [ ] Кнопки редактирования: `✎` (btn-secondary)
- [ ] Порядок кнопок в модалках: Действие → Отмена
- [ ] Уведомления через `showNotification()`
- [ ] Таблицы используют `sort-table.js` (автоматически через base.html)
- [ ] Number input без спиннеров (CSS в service.css)
- [ ] CSS-классы модалок: `.modal-overlay` / `.modal`
+9
View File
@@ -1,3 +1,12 @@
"""
Конфигурация service portal (Pydantic Settings).
Читает из .env:
- DATABASE_URL: PostgreSQL URL
- SECRET_KEY: ключ сессий
- SESSION_TTL: время жизни сессии (сек)
- DOCS_DIR/UPLOADS_DIR: пути к документам
"""
import os import os
from pathlib import Path from pathlib import Path
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
+4 -4
View File
@@ -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()
+22
View File
@@ -1,3 +1,13 @@
"""
Главный модуль service portal FastAPI-приложения.
Портал для управления:
- Сотрудниками (owner/engineer/technician)
- Клиентами и объектами
- Задачами и инцидентами
- Настройками бота и базой знаний
- Документами и отчётами
"""
import os import os
import asyncio import asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -103,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
+145
View File
@@ -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)
+257 -16
View File
@@ -1,6 +1,20 @@
"""
ORM-модели для service portal (SQLAlchemy 2.0).
Основные таблицы:
- users: сотрудники (owner/engineer/technician)
- customers: клиенты
- objects: объекты обслуживания
- tasks / incidents: задачи и инциденты
- leads: лиды (обращения из бота, звонков, сайта, email)
- call_records: звонки (CDR из Yeastar S20)
- client_notifications: уведомления
- pbx_settings: настройки АТС
- blog/cases/ideas: контент
"""
from datetime import datetime from datetime import datetime
from sqlalchemy import ( from sqlalchemy import (
Column, Integer, String, Boolean, Text, DateTime, Numeric, Date, BigInteger, SmallInteger, JSON, ForeignKey, CheckConstraint Column, Integer, String, Boolean, Text, DateTime, Numeric, Date, BigInteger, SmallInteger, JSON, ForeignKey, CheckConstraint, func
) )
from sqlalchemy.orm import relationship, declarative_base from sqlalchemy.orm import relationship, declarative_base
@@ -18,9 +32,10 @@ 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="NOW()") updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = ( __table_args__ = (
CheckConstraint("role IN ('owner','engineer','technician')", name="ck_users_role"), CheckConstraint("role IN ('owner','engineer','technician')", name="ck_users_role"),
@@ -41,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)
@@ -64,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="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")
@@ -99,7 +138,7 @@ class Object(Base):
region_factor = Column(Numeric(3, 2), default=1.00) region_factor = Column(Numeric(3, 2), default=1.00)
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="NOW()") updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = ( __table_args__ = (
CheckConstraint("status IN ('active','inactive','audit','prospective')", name="ck_objects_status"), CheckConstraint("status IN ('active','inactive','audit','prospective')", name="ck_objects_status"),
@@ -134,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")
@@ -153,7 +199,7 @@ class Task(Base):
result_notes = Column(Text) result_notes = Column(Text)
closed_at = Column(DateTime) closed_at = Column(DateTime)
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="NOW()") updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = ( __table_args__ = (
CheckConstraint("priority IN ('P1','P2','P3','P4')", name="ck_tasks_priority"), CheckConstraint("priority IN ('P1','P2','P3','P4')", name="ck_tasks_priority"),
@@ -217,7 +263,7 @@ class Report(Base):
status = Column(String(20), nullable=False, default="draft") status = Column(String(20), nullable=False, default="draft")
client_confirmed = Column(Boolean, default=False) client_confirmed = Column(Boolean, default=False)
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="NOW()") updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = ( __table_args__ = (
CheckConstraint("report_type IN ('inspection','service','emergency','audit','monthly')", name="ck_reports_type"), CheckConstraint("report_type IN ('inspection','service','emergency','audit','monthly')", name="ck_reports_type"),
@@ -245,7 +291,7 @@ class QuestionnaireSession(Base):
object_index = Column(Numeric(5, 2), default=0) object_index = Column(Numeric(5, 2), default=0)
sla_price_monthly = Column(Numeric(12, 2), default=0) sla_price_monthly = Column(Numeric(12, 2), default=0)
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="NOW()") updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = ( __table_args__ = (
CheckConstraint("status IN ('draft','completed','cancelled')", name="ck_qsessions_status"), CheckConstraint("status IN ('draft','completed','cancelled')", name="ck_qsessions_status"),
@@ -283,7 +329,7 @@ class QuestionnaireItem(Base):
is_active = Column(Boolean, nullable=False, default=True) is_active = Column(Boolean, nullable=False, default=True)
help_text = Column(String(500), default="") help_text = Column(String(500), default="")
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="NOW()") updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = ( __table_args__ = (
CheckConstraint("type IN ('text','number','select','radio','checkbox','textarea')", name="ck_qitems_type"), CheckConstraint("type IN ('text','number','select','radio','checkbox','textarea')", name="ck_qitems_type"),
@@ -330,7 +376,7 @@ class SLAContract(Base):
status = Column(String(20), nullable=False, default="negotiation") status = Column(String(20), nullable=False, default="negotiation")
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="NOW()") updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = ( __table_args__ = (
CheckConstraint("service_level IN ('start','business','enterprise')", name="ck_sla_level"), CheckConstraint("service_level IN ('start','business','enterprise')", name="ck_sla_level"),
@@ -359,7 +405,7 @@ class Incident(Base):
sla_breached = Column(Boolean, default=False) sla_breached = Column(Boolean, default=False)
resolved_at = Column(DateTime) resolved_at = Column(DateTime)
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="NOW()") updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = ( __table_args__ = (
CheckConstraint("severity IN ('P1','P2','P3')", name="ck_incidents_severity"), CheckConstraint("severity IN ('P1','P2','P3')", name="ck_incidents_severity"),
@@ -428,7 +474,7 @@ class BlogPost(Base):
status = Column(String(20), nullable=False, default="draft") status = Column(String(20), nullable=False, default="draft")
published_at = Column(DateTime) published_at = Column(DateTime)
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="NOW()") updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = ( __table_args__ = (
CheckConstraint("category IN ('audit','sla','incident','supervision','documentation','risk','cases')", name="ck_blog_category"), CheckConstraint("category IN ('audit','sla','incident','supervision','documentation','risk','cases')", name="ck_blog_category"),
@@ -448,7 +494,7 @@ class FormulaCoefficient(Base):
formula_ref = Column(String(255), default="") formula_ref = Column(String(255), default="")
is_active = Column(Boolean, nullable=False, default=True) is_active = Column(Boolean, nullable=False, default=True)
updated_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) updated_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()") updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
updater = relationship("User") updater = relationship("User")
@@ -463,7 +509,7 @@ class Case(Base):
sort_order = Column(Integer, nullable=False, default=0) sort_order = Column(Integer, nullable=False, default=0)
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="NOW()") updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
class Idea(Base): class Idea(Base):
@@ -475,7 +521,7 @@ class Idea(Base):
status = Column(String(20), nullable=False, default="normal") status = Column(String(20), nullable=False, default="normal")
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="NOW()") updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = ( __table_args__ = (
CheckConstraint("status IN ('normal','in_progress','completed')", name="ck_ideas_status"), CheckConstraint("status IN ('normal','in_progress','completed')", name="ck_ideas_status"),
@@ -493,7 +539,7 @@ class QuickMenuItem(Base):
url = Column(String(500), nullable=False) url = Column(String(500), nullable=False)
sort_order = Column(Integer, nullable=False, default=0) sort_order = Column(Integer, nullable=False, default=0)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()") updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
creator = relationship("User") creator = relationship("User")
@@ -511,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())
+3
View File
@@ -1293,6 +1293,7 @@ BOT_SERVICE_URL = "http://127.0.0.1:8002"
async def _proxy(method: str, path: str, body=None): async def _proxy(method: str, path: str, body=None):
"""Прокси запросов от service portal к max_bot API."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
if body is not None: if body is not None:
resp = await client.request(method, f"{BOT_SERVICE_URL}{path}", json=body) resp = await client.request(method, f"{BOT_SERVICE_URL}{path}", json=body)
@@ -1431,11 +1432,13 @@ async def proxy_bot_conversation_logs(conv_id: int, user: dict = Depends(get_cur
@router.delete("/api/bot/conversations/{conv_id}") @router.delete("/api/bot/conversations/{conv_id}")
async def proxy_delete_conversation(conv_id: int, request: Request, user: dict = Depends(require_role("owner"))): async def proxy_delete_conversation(conv_id: int, request: Request, user: dict = Depends(require_role("owner"))):
"""Удаление диалога (каскадно: сообщения + логи). Доступно только owner."""
return await _proxy("DELETE", f"/api/bot/conversations/{conv_id}") return await _proxy("DELETE", f"/api/bot/conversations/{conv_id}")
@router.delete("/api/bot/users/{user_id}") @router.delete("/api/bot/users/{user_id}")
async def proxy_delete_bot_user(user_id: int, request: Request, user: dict = Depends(require_role("owner"))): async def proxy_delete_bot_user(user_id: int, request: Request, user: dict = Depends(require_role("owner"))):
"""Удаление пользователя бота (каскадно: диалоги + сообщения + логи). Заявки остаются. Доступно только owner."""
return await _proxy("DELETE", f"/api/bot/users/{user_id}") return await _proxy("DELETE", f"/api/bot/users/{user_id}")
+17
View File
@@ -164,6 +164,17 @@ img { max-width:100%; height:auto; }
resize: vertical; resize: vertical;
} }
/* Скрытие стандартных спиннеров на number input */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
.btn { .btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -576,6 +587,12 @@ table .actions button {
cursor: pointer; cursor: pointer;
} }
.modal-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
/* ---------- TABS ---------- */ /* ---------- TABS ---------- */
.tabs { .tabs {
display: flex; display: flex;
+22 -1
View File
@@ -1 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="4" fill="#0A1628"/><text x="16" y="22" font-size="18" font-weight="bold" fill="#00ADEF" text-anchor="middle" font-family="sans-serif">AO</text></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<!-- Голубой круг позади щита -->
<circle cx="128" cy="128" r="118" fill="#00B4D8"/>
<!-- Внешний серый щит -->
<path d="M128 18 L230 62 L230 140 Q230 200 128 242 Q26 200 26 140 L26 62 Z" fill="#6B7280"/>
<!-- Белый контур-разделитель -->
<path d="M128 28 L220 67 L220 140 Q220 194 128 232 Q36 194 36 140 L36 67 Z" fill="none" stroke="#FFFFFF" stroke-width="3"/>
<!-- Средний серый щит -->
<path d="M128 42 L208 74 L208 140 Q208 186 128 222 Q48 186 48 140 L48 74 Z" fill="#8B929A"/>
<!-- Белый контур-разделитель -->
<path d="M128 52 L198 80 L198 140 Q198 180 128 212 Q58 180 58 140 L58 80 Z" fill="none" stroke="#FFFFFF" stroke-width="3"/>
<!-- Внутренний серый щит -->
<path d="M128 64 L188 88 L188 140 Q188 174 128 202 Q68 174 68 140 L68 88 Z" fill="#6B7280"/>
<!-- Белый контур-разделитель -->
<path d="M128 74 L178 94 L178 140 Q178 168 128 192 Q78 168 78 140 L78 94 Z" fill="none" stroke="#FFFFFF" stroke-width="3"/>
<!-- Центральный тёмный щит -->
<path d="M128 86 L168 104 L168 140 Q168 162 128 182 Q88 162 88 140 L88 104 Z" fill="#374151"/>
<!-- Чёрный треугольник в центре -->
<polygon points="128,100 158,130 128,155 98,130" fill="#000000"/>
<!-- Чёрная полоса внизу щита -->
<rect x="90" y="218" width="76" height="14" rx="2" fill="#000000"/>
</svg>

Before

Width:  |  Height:  |  Size: 243 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+164
View File
@@ -0,0 +1,164 @@
/**
* sort-table.js Универсальный модуль сортировки таблиц.
*
* Автоматически находит все <table> на странице и делает
* заголовки <th> кликабельными для сортировки по столбцам.
*
* Использование:
* 1. Подключить <script src="/service/static/js/sort-table.js"></script>
* 2. Таблицы автоматически получают сортировку при загрузке
* 3. Для AJAX-таблиц: вызвать sortTables() после заполнения <tbody>
*/
(function(){
'use strict';
var SORT_CSS = [
'.sort-th { cursor:pointer; user-select:none; position:relative; padding-right:18px !important; }',
'.sort-th:hover { color:var(--accent); }',
'.sort-th::after { content:""; position:absolute; right:6px; top:50%; transform:translateY(-50%); width:0; height:0; border-left:4px solid transparent; border-right:4px solid transparent; border-top:5px solid var(--text-muted); opacity:0.4; }',
'.sort-th.sort-asc::after { border-top:none; border-bottom:5px solid var(--accent); opacity:1; }',
'.sort-th.sort-desc::after { border-top:5px solid var(--accent); opacity:1; }'
].join('\n');
/* Встраиваем стили один раз */
function injectStyles(){
if(document.getElementById('sort-table-styles')) return;
var style = document.createElement('style');
style.id = 'sort-table-styles';
style.textContent = SORT_CSS;
document.head.appendChild(style);
}
/* Определение типа значения ячейки */
function getCellValue(tr, idx){
var td = tr.children[idx];
if(!td) return '';
var text = (td.textContent || '').trim();
return text;
}
/* Парсинг числа из текста */
function parseNumber(text){
if(!text || text === '—' || text === '-') return NaN;
/* Убираем пробелы,.currency-символы, % */
var cleaned = text.replace(/[^\d,\.\-]/g, '').replace(',', '.');
var num = parseFloat(cleaned);
return isNaN(num) ? NaN : num;
}
/* Парсинг даты из текста (DD.MM.YYYY или YYYY-MM-DD) */
function parseDate(text){
if(!text) return NaN;
/* DD.MM.YYYY HH:MM */
var m = text.match(/(\d{2})\.(\d{2})\.(\d{4})/);
if(m) return new Date(m[3], m[2]-1, m[1]).getTime();
/* YYYY-MM-DD */
m = text.match(/(\d{4})-(\d{2})-(\d{2})/);
if(m) return new Date(m[1], m[2]-1, m[3]).getTime();
return NaN;
}
/* Сравнение двух значений */
function compare(a, b, type){
if(type === 'number'){
var na = parseNumber(a), nb = parseNumber(b);
if(isNaN(na) && isNaN(nb)) return 0;
if(isNaN(na)) return 1;
if(isNaN(nb)) return -1;
return na - nb;
}
if(type === 'date'){
var da = parseDate(a), db = parseDate(b);
if(isNaN(da) && isNaN(db)) return 0;
if(isNaN(da)) return 1;
if(isNaN(db)) return -1;
return da - db;
}
/* Текст */
return a.localeCompare(b, 'ru');
}
/* Определение типа столбца по первым строкам */
function detectColumnType(tbody, idx){
var rows = tbody.children;
var samples = [];
var limit = Math.min(rows.length, 10);
for(var i = 0; i < limit; i++){
samples.push(getCellValue(rows[i], idx));
}
var numCount = 0, dateCount = 0;
for(var j = 0; j < samples.length; j++){
if(parseNumber(samples[j]) !== NaN && samples[j] !== '') numCount++;
if(parseDate(samples[j]) !== NaN && samples[j] !== '') dateCount++;
}
if(samples.length > 0 && numCount / samples.length > 0.6) return 'number';
if(samples.length > 0 && dateCount / samples.length > 0.6) return 'date';
return 'text';
}
/* Привязка сортировки к таблице */
function attachSort(table){
if(table.dataset.sortAttached) return;
table.dataset.sortAttached = '1';
var thead = table.querySelector('thead');
var tbody = table.querySelector('tbody');
if(!thead || !tbody) return;
var headers = thead.querySelectorAll('th');
headers.forEach(function(th, idx){
/* Пропускаем колонку действий */
if(th.classList.contains('actions')) return;
th.classList.add('sort-th');
th.dataset.sortIdx = idx;
th.dataset.sortDir = 'none';
th.addEventListener('click', function(){
var colIdx = parseInt(this.dataset.sortIdx);
var currentDir = this.dataset.sortDir;
var newDir = currentDir === 'asc' ? 'desc' : 'asc';
/* Сброс других столбцов */
headers.forEach(function(h){ h.classList.remove('sort-asc', 'sort-desc'); h.dataset.sortDir = 'none'; });
this.classList.remove('sort-asc', 'sort-desc');
this.classList.add('sort-' + newDir);
this.dataset.sortDir = newDir;
/* Определение типа данных */
var colType = detectColumnType(tbody, colIdx);
/* Сбор строк с значениями */
var rowsArray = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
rowsArray.sort(function(rowA, rowB){
var valA = getCellValue(rowA, colIdx);
var valB = getCellValue(rowB, colIdx);
var result = compare(valA, valB, colType);
return newDir === 'asc' ? result : -result;
});
/* Перестановка строк */
rowsArray.forEach(function(row){ tbody.appendChild(row); });
});
});
}
/* Публичная функция: инициализация/переинициализация всех таблиц */
function sortTables(){
injectStyles();
var tables = document.querySelectorAll('table');
for(var i = 0; i < tables.length; i++){
attachSort(tables[i]);
}
}
/* Автозапуск после загрузки DOM */
if(document.readyState === 'loading'){
document.addEventListener('DOMContentLoaded', sortTables);
} else {
sortTables();
}
/* Экспорт в глобальную область */
window.sortTables = sortTables;
})();
+2 -1
View File
@@ -8,8 +8,8 @@
<title>{% block title %}Service Portal — AegisOne Engineering{% endblock %}</title> <title>{% block title %}Service Portal — AegisOne Engineering{% endblock %}</title>
<link rel="stylesheet" href="/service/static/css/service.css"> <link rel="stylesheet" href="/service/static/css/service.css">
<link rel="stylesheet" href="/service/static/css/service-theme.css"> <link rel="stylesheet" href="/service/static/css/service-theme.css">
<link rel="icon" type="image/x-icon" href="/service/static/img/favicons/favicon.ico">
<link rel="icon" type="image/svg+xml" href="/service/static/img/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/service/static/img/favicon.svg">
<link rel="icon" type="image/x-icon" href="/service/static/img/favicons/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/service/static/img/favicons/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="32x32" href="/service/static/img/favicons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/service/static/img/favicons/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="/service/static/img/favicons/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/service/static/img/favicons/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="/service/static/img/favicons/apple-touch-icon.png">
@@ -55,6 +55,7 @@
<div class="toast-container" id="toast-container"></div> <div class="toast-container" id="toast-container"></div>
<script src="/service/static/js/theme.js"></script> <script src="/service/static/js/theme.js"></script>
<script src="/service/static/js/role-switcher.js"></script> <script src="/service/static/js/role-switcher.js"></script>
<script src="/service/static/js/sort-table.js"></script>
<script> <script>
function showNotification(msg, type){ function showNotification(msg, type){
type = type || 'success'; type = type || 'success';
-10
View File
@@ -5,16 +5,6 @@
<div class="layout"> <div class="layout">
{% include "_sidebar.html" %} {% include "_sidebar.html" %}
<main class="main"> <main class="main">
<div class="main-header">
<div>
<h1>Дашборд</h1>
<div class="breadcrumb"></div>
</div>
<div class="actions">
<span style="font-size:12px;color:var(--text-muted);">{{ now }}</span>
</div>
</div>
{% if shs_alert %} {% if shs_alert %}
<div class="shs-alert critical">⚠ {{ shs_alert }}</div> <div class="shs-alert critical">⚠ {{ shs_alert }}</div>
{% endif %} {% endif %}
-12
View File
@@ -5,18 +5,6 @@
<div class="layout"> <div class="layout">
{% include "_sidebar.html" %} {% include "_sidebar.html" %}
<main class="main"> <main class="main">
<div class="main-header">
<div>
<h1>{% block page_title %}{{ title|default(page_name|capitalize) }}{% endblock %}</h1>
<div class="breadcrumb">
{% block breadcrumb %}{% endblock %}
</div>
</div>
<div class="actions">
{% block page_actions %}{% endblock %}
<span style="font-size:12px;color:var(--text-muted);">{{ now }}</span>
</div>
</div>
{% block page_content %}{% endblock %} {% block page_content %}{% endblock %}
</main> </main>
</div> </div>
@@ -10,10 +10,7 @@
<td><span class="badge badge-{{ a.role }}">{{ {'engineer':'Инженер','technician':'Техник'}[a.role] }}</span></td> <td><span class="badge badge-{{ a.role }}">{{ {'engineer':'Инженер','technician':'Техник'}[a.role] }}</span></td>
<td>{{ a.assigned_at.strftime('%d.%m.%Y %H:%M') if a.assigned_at else '' }}</td> <td>{{ a.assigned_at.strftime('%d.%m.%Y %H:%M') if a.assigned_at else '' }}</td>
<td class="actions"> <td class="actions">
<form method="post" action="/service/assignments/unassign" style="display:inline"> <button class="btn btn-danger btn-sm" onclick="openConfirmUnassign({{ a.id }},'{{ (a.user.full_name if a.user else '')|e }}','{{ (a.object.name if a.object else '')|e }}')" title="Отвязать"></button>
<input type="hidden" name="id" value="{{ a.id }}">
<button class="btn btn-danger btn-sm">Отвязать</button>
</form>
</td> </td>
</tr>{% endfor %}</tbody></table> </tr>{% endfor %}</tbody></table>
</div> </div>
@@ -25,7 +22,38 @@
<div class="form-group"><label>Объект</label><select name="object_id" required>{% for o in objects %}<option value="{{ o.id }}">{{ o.name }}</option>{% endfor %}</select></div> <div class="form-group"><label>Объект</label><select name="object_id" required>{% for o in objects %}<option value="{{ o.id }}">{{ o.name }}</option>{% endfor %}</select></div>
<div class="form-group"><label>Сотрудник</label><select name="user_id" required>{% for u in users %}<option value="{{ u.id }}">{{ u.full_name }} ({{ u.role }})</option>{% endfor %}</select></div> <div class="form-group"><label>Сотрудник</label><select name="user_id" required>{% for u in users %}<option value="{{ u.id }}">{{ u.full_name }} ({{ u.role }})</option>{% endfor %}</select></div>
<div class="form-actions"><button type="submit" class="btn btn-primary">Назначить</button> <div class="form-actions"><button type="submit" class="btn btn-primary">Назначить</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div> <button type="button" class="btn" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form> </form>
</div></div> </div></div>
<div class="modal-overlay" id="confirm-unassign">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Отвязать сотрудника?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
Назначение <strong id="unassign-info"></strong> будет удалено. Это действие нельзя отменить.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmUnassign()">Отвязать</button>
<button type="button" class="btn" onclick="document.getElementById('confirm-unassign').classList.remove('open')">Отмена</button>
</div>
</div>
</div>
<script>
var unassignId = null;
function openConfirmUnassign(id, userName, objectName){
unassignId = id;
document.getElementById('unassign-info').textContent = userName + ' → ' + objectName;
document.getElementById('confirm-unassign').classList.add('open');
}
function confirmUnassign(){
if(!unassignId) return;
var form = document.createElement('form');
form.method = 'POST';
form.action = '/service/assignments/unassign';
var input = document.createElement('input');
input.type = 'hidden'; input.name = 'id'; input.value = unassignId;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
}
</script>
{% endblock %} {% endblock %}
+28 -1
View File
@@ -11,7 +11,7 @@
<td><small>{{ p.created_at.strftime('%d.%m.%Y') if p.created_at else '' }}</small></td> <td><small>{{ p.created_at.strftime('%d.%m.%Y') if p.created_at else '' }}</small></td>
<td class="actions"> <td class="actions">
<button class="btn btn-secondary btn-sm" onclick="editBlog({{ p.id }},'{{ p.title|e }}','{{ p.slug|e }}','{{ p.category|e }}','{{ p.status }}','{{ p.excerpt|e }}','{{ p.content|e }}')"></button> <button class="btn btn-secondary btn-sm" onclick="editBlog({{ p.id }},'{{ p.title|e }}','{{ p.slug|e }}','{{ p.category|e }}','{{ p.status }}','{{ p.excerpt|e }}','{{ p.content|e }}')"></button>
<form method="post" action="/service/blog/delete" style="display:inline" onsubmit="return confirm('Удалить запись?')"><input type="hidden" name="id" value="{{ p.id }}"><button class="btn btn-danger btn-sm"></button></form> <form method="post" action="/service/blog/delete" style="display:inline" id="delete-blog-form-{{ p.id }}"><input type="hidden" name="id" value="{{ p.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteBlog({{ p.id }},'{{ p.title|e }}')" title="Удалить"></button></form>
</td> </td>
</tr>{% endfor %}</tbody></table> </tr>{% endfor %}</tbody></table>
</div> </div>
@@ -33,7 +33,34 @@
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div> <button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form> </form>
</div></div> </div></div>
<div class="modal-overlay" id="confirm-delete-blog">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Удалить запись?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
<strong id="delete-blog-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmDeleteBlog()">Удалить</button>
<button type="button" class="btn" onclick="closeConfirmDeleteBlog()">Отмена</button>
</div>
</div>
</div>
<script> <script>
var deleteBlogId = null;
function openConfirmDeleteBlog(id, name){
deleteBlogId = id;
document.getElementById('delete-blog-name').textContent = name || '#' + id;
document.getElementById('confirm-delete-blog').classList.add('open');
}
function closeConfirmDeleteBlog(){
document.getElementById('confirm-delete-blog').classList.remove('open');
deleteBlogId = null;
}
function confirmDeleteBlog(){
if(!deleteBlogId) return;
document.getElementById('delete-blog-form-' + deleteBlogId).submit();
closeConfirmDeleteBlog();
}
function editBlog(id,title,slug,category,status,excerpt,content){ function editBlog(id,title,slug,category,status,excerpt,content){
document.getElementById('modal-blog').classList.add('open'); document.getElementById('modal-blog').classList.add('open');
document.getElementById('modal-blog-title').textContent = 'Редактировать запись'; document.getElementById('modal-blog-title').textContent = 'Редактировать запись';
@@ -31,6 +31,14 @@
.confirm-modal .confirm-actions { display:flex; gap:10px; justify-content:center; } .confirm-modal .confirm-actions { display:flex; gap:10px; justify-content:center; }
.confirm-modal .confirm-actions .btn { min-width:140px; } .confirm-modal .confirm-actions .btn { min-width:140px; }
.timer-danger { color:var(--danger) !important; } .timer-danger { color:var(--danger) !important; }
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:1000; align-items:center; justify-content:center; }
.modal-overlay.open { display:flex; }
.modal { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:24px; width:420px; max-width:90vw; }
.modal h2 { color:var(--text-primary); }
.modal p { color:var(--text-secondary); }
.form-actions { display:flex; gap:10px; }
.btn-danger { background:var(--danger, #dc3545); color:#fff; border:none; padding:8px 16px; border-radius:6px; cursor:pointer; font-weight:600; }
.btn { background:var(--bg-input); color:var(--text-primary); border:1px solid var(--border); padding:8px 16px; border-radius:6px; cursor:pointer; }
</style> </style>
<div class="card"> <div class="card">
@@ -93,10 +101,27 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="confirm-delete-user">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Удалить пользователя?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
<strong id="delete-user-name"></strong> (ID: <span id="delete-user-id"></span>) будет удалён навсегда.
Все его диалоги и сообщения будут удалены. Заявки останутся.
Это действие нельзя отменить.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmDeleteUser()">Удалить</button>
<button type="button" class="btn" onclick="closeConfirmDeleteUser()">Отмена</button>
</div>
</div>
</div>
<script> <script>
var allConsents = []; var allConsents = [];
var confirmTimer = null; var confirmTimer = null;
var confirmSeconds = 10; var confirmSeconds = 10;
var pendingDeleteUserId = null;
var pendingDeleteUserName = null;
function loadConsents(){ function loadConsents(){
var tbody = document.getElementById('consent-table-body'); var tbody = document.getElementById('consent-table-body');
@@ -135,11 +160,12 @@ function renderConsents(list){
'<td class="consent-yes">Да</td>' + '<td class="consent-yes">Да</td>' +
'<td style="font-size:12px;color:var(--text-muted);">' + dateStr + '</td>'; '<td style="font-size:12px;color:var(--text-muted);">' + dateStr + '</td>';
if(USER_ROLE === 'owner'){ if(USER_ROLE === 'owner'){
html += '<td><button class="btn btn-danger btn-sm" onclick="confirmDeleteUser(' + u.id + ', \'' + escapeHtml(name).replace(/'/g, "\\'") + '\')" title="Удалить пользователя">🗑</button></td>'; html += '<td><button class="btn btn-danger btn-sm" onclick="openConfirmDeleteUser(' + u.id + ', \'' + escapeHtml(name).replace(/'/g, "\\'") + '\')" title="Удалить пользователя">🗑</button></td>';
} }
html += '</tr>'; html += '</tr>';
}); });
tbody.innerHTML = html; tbody.innerHTML = html;
if(typeof sortTables === 'function') sortTables();
} }
function filterConsents(){ function filterConsents(){
@@ -222,8 +248,24 @@ function escapeHtml(s){
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
} }
function confirmDeleteUser(userId, userName){ function openConfirmDeleteUser(userId, userName){
if(!confirm('Удалить пользователя ' + userName + ' (ID: ' + userId + ')?\n\nВсе его диалоги и сообщения будут удалены навсегда. Заявки останутся.')) return; pendingDeleteUserId = userId;
pendingDeleteUserName = userName;
document.getElementById('delete-user-name').textContent = userName;
document.getElementById('delete-user-id').textContent = userId;
document.getElementById('confirm-delete-user').classList.add('open');
}
function closeConfirmDeleteUser(){
document.getElementById('confirm-delete-user').classList.remove('open');
pendingDeleteUserId = null;
pendingDeleteUserName = null;
}
function confirmDeleteUser(){
var userId = pendingDeleteUserId;
var userName = pendingDeleteUserName;
closeConfirmDeleteUser();
fetch('/api/bot/users/' + userId, { method: 'DELETE' }) fetch('/api/bot/users/' + userId, { method: 'DELETE' })
.then(function(r){ return r.json() }) .then(function(r){ return r.json() })
.then(function(d){ .then(function(d){
@@ -1,30 +1,29 @@
{% extends "page.html" %} {% extends "page.html" %}
{% block page_content %} {% block page_content %}
<div class="page-header"> <div class="card">
<h1>Диалоги чат-бота</h1> <div class="card-header"><h2>Диалоги чат-бота</h2>
<div class="header-controls"> <button onclick="loadConversations()" class="btn btn-primary btn-sm">Обновить</button>
<input type="text" id="search-input" placeholder="Поиск по ID или имени..." oninput="filterConversations()" class="filter-input"> </div>
<button onclick="loadConversations()" class="btn btn-primary">Обновить</button> <div class="filter-bar">
<input type="text" id="search-input" placeholder="Поиск по ID или имени..." oninput="filterConversations()">
<select id="status-filter" onchange="filterConversations()" class="filter-select">
<option value="">Все статусы</option>
<option value="greeting">Приветствие</option>
<option value="collecting_name">Сбор имени</option>
<option value="collecting_phone">Сбор телефона</option>
<option value="collecting_email">Сбор email</option>
<option value="ticket_creation">Создание заявки</option>
<option value="question">Вопрос</option>
<option value="done">Завершён</option>
</select>
</div>
<script>var USER_ROLE = '{{ user.role }}';</script>
<div id="conversations-container">
<div class="loading"><div class="spinner"></div></div>
</div> </div>
</div> </div>
<script>var USER_ROLE = '{{ user.role }}';</script>
<div style="margin-bottom:12px;display:flex;gap:8px;flex-wrap:wrap;">
<select id="status-filter" onchange="filterConversations()" class="filter-select">
<option value="">Все статусы</option>
<option value="greeting">Приветствие</option>
<option value="collecting_name">Сбор имени</option>
<option value="collecting_phone">Сбор телефона</option>
<option value="collecting_email">Сбор email</option>
<option value="ticket_creation">Создание заявки</option>
<option value="question">Вопрос</option>
<option value="done">Завершён</option>
</select>
</div>
<div id="conversations-container">
<div class="loading"><div class="spinner"></div></div>
</div>
<div class="modal-overlay" id="dialog-modal" style="display:none;"> <div class="modal-overlay" id="dialog-modal">
<div class="modal" style="max-width:800px;width:90%;"> <div class="modal" style="max-width:800px;width:90%;">
<button type="button" class="modal-close" onclick="closeDialog()"></button> <button type="button" class="modal-close" onclick="closeDialog()"></button>
<h2 id="dialog-title">Диалог</h2> <h2 id="dialog-title">Диалог</h2>
@@ -44,9 +43,9 @@
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;"> <p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
Диалог с <strong id="delete-conv-name"></strong> будет удалён навсегда. Это действие нельзя отменить. Диалог с <strong id="delete-conv-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
</p> </p>
<div class="modal-actions" style="justify-content:flex-end;"> <div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn" onclick="closeDeleteModal()">Отмена</button>
<button type="button" class="btn btn-danger" onclick="confirmDelete()">Удалить</button> <button type="button" class="btn btn-danger" onclick="confirmDelete()">Удалить</button>
<button type="button" class="btn" onclick="closeDeleteModal()">Отмена</button>
</div> </div>
</div> </div>
</div> </div>
@@ -76,13 +75,8 @@
.log-time { font-size:10px; color:var(--text-muted,#6B8299); } .log-time { font-size:10px; color:var(--text-muted,#6B8299); }
.tab-btn { padding:6px 14px; border:1px solid var(--border,#253248); background:transparent; color:var(--text-primary,#E8EDF2); border-radius:4px; cursor:pointer; font-size:12px; } .tab-btn { padding:6px 14px; border:1px solid var(--border,#253248); background:transparent; color:var(--text-primary,#E8EDF2); border-radius:4px; cursor:pointer; font-size:12px; }
.tab-btn.active { background:var(--accent,#00ADEF); border-color:var(--accent,#00ADEF); color:#fff; } .tab-btn.active { background:var(--accent,#00ADEF); border-color:var(--accent,#00ADEF); color:#fff; }
.page-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
.header-controls { display:flex; gap:8px; align-items:center; }
.filter-input { padding:6px 10px; border:1px solid var(--border,#253248); border-radius:4px; background:var(--bg-body,#1B2838); color:var(--text-primary,#E8EDF2); font-size:12px; width:200px; }
.filter-select { padding:6px 10px; border:1px solid var(--border,#253248); border-radius:4px; background:var(--bg-body,#1B2838); color:var(--text-primary,#E8EDF2); font-size:12px; }
.conv-delete { background:none; border:none; cursor:pointer; font-size:14px; padding:2px 6px; border-radius:4px; opacity:0.5; transition:opacity 0.2s; margin-left:auto; } .conv-delete { background:none; border:none; cursor:pointer; font-size:14px; padding:2px 6px; border-radius:4px; opacity:0.5; transition:opacity 0.2s; margin-left:auto; }
.conv-delete:hover { opacity:1; background:rgba(232,107,107,0.15); } .conv-delete:hover { opacity:1; background:rgba(232,107,107,0.15); }
.conv-header { display:flex; align-items:center; gap:8px; }
#delete-modal .modal { max-width:420px; } #delete-modal .modal { max-width:420px; }
#delete-modal h2 { font-size:16px; margin-bottom:8px; } #delete-modal h2 { font-size:16px; margin-bottom:8px; }
#delete-modal p { color:var(--text-secondary); font-size:14px; } #delete-modal p { color:var(--text-secondary); font-size:14px; }
@@ -130,7 +124,7 @@ function renderConversations(data){
html += '<div class="conv-card" onclick="openDialog(' + c.id + ')">'; html += '<div class="conv-card" onclick="openDialog(' + c.id + ')">';
html += '<div class="conv-header"><span class="conv-user">' + escapeHtml(c.user_name) + '</span><span class="conv-id">ID: ' + c.id + '</span>'; html += '<div class="conv-header"><span class="conv-user">' + escapeHtml(c.user_name) + '</span><span class="conv-id">ID: ' + c.id + '</span>';
if(USER_ROLE === 'owner'){ if(USER_ROLE === 'owner'){
html += '<button class="conv-delete" onclick="event.stopPropagation();openDeleteModal(' + c.id + ', \'' + escapeHtml(c.user_name).replace(/'/g, "\\'") + '\')" title="Удалить диалог">🗑</button>'; html += '<button class="conv-delete" onclick="event.stopPropagation();openDeleteModal(' + c.id + ', \'' + escapeHtml(c.user_name).replace(/'/g, "\\'") + '\')" title="Удалить диалог"></button>';
} }
html += '</div>'; html += '</div>';
html += '<div class="conv-meta">'; html += '<div class="conv-meta">';
@@ -149,7 +143,7 @@ function filterConversations(){
function openDialog(convId){ function openDialog(convId){
currentConvId = convId; currentConvId = convId;
document.getElementById('dialog-modal').style.display = 'flex'; document.getElementById('dialog-modal').classList.add('open');
var conv = conversations.find(function(c){ return c.id === convId; }); var conv = conversations.find(function(c){ return c.id === convId; });
document.getElementById('dialog-title').textContent = 'Диалог с ' + (conv ? conv.user_name : '#' + convId); document.getElementById('dialog-title').textContent = 'Диалог с ' + (conv ? conv.user_name : '#' + convId);
showTab('messages'); showTab('messages');
@@ -157,7 +151,7 @@ function openDialog(convId){
} }
function closeDialog(){ function closeDialog(){
document.getElementById('dialog-modal').style.display = 'none'; document.getElementById('dialog-modal').classList.remove('open');
} }
function showTab(tab){ function showTab(tab){
+38 -23
View File
@@ -8,19 +8,6 @@
.kb-table .active-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; } .kb-table .active-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
.kb-table .active-badge.yes { background:var(--success-bg, #d4edda); color:var(--success, #155724); } .kb-table .active-badge.yes { background:var(--success-bg, #d4edda); color:var(--success, #155724); }
.kb-table .active-badge.no { background:var(--danger-bg, #f8d7da); color:var(--danger, #721c24); } .kb-table .active-badge.no { background:var(--danger-bg, #f8d7da); color:var(--danger, #721c24); }
.kb-modal { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.4);
z-index:1000; align-items:center; justify-content:center; }
.kb-modal.open { display:flex; }
.kb-modal-content { background:var(--bg-card); border-radius:12px; padding:30px; width:600px; max-width:90vw;
max-height:80vh; overflow-y:auto; }
.kb-modal-content h3 { margin-bottom:20px; }
.form-group { margin-bottom:14px; }
.form-group label { display:block; font-weight:600; margin-bottom:4px; font-size:13px; color:var(--text-muted); }
.form-group input, .form-group textarea, .form-group select {
width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:14px;
}
.form-group textarea { min-height:100px; resize:vertical; }
.modal-actions { display:flex; gap:12px; margin-top:16px; }
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; flex-wrap:wrap; } .filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; flex-wrap:wrap; }
.filter-bar input, .filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; } .filter-bar input, .filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
</style> </style>
@@ -60,9 +47,10 @@
</table> </table>
</div> </div>
<div class="kb-modal" id="kb-modal"> <div class="modal-overlay" id="kb-modal">
<div class="kb-modal-content"> <div class="modal" style="max-width:600px;">
<h3 id="modal-title">Новая карточка</h3> <button type="button" class="modal-close" onclick="closeModal()"></button>
<h2 id="modal-title">Новая карточка</h2>
<form id="kb-form"> <form id="kb-form">
<div class="form-group"> <div class="form-group">
<label>Категория</label> <label>Категория</label>
@@ -84,10 +72,8 @@
<label>URL источника</label> <label>URL источника</label>
<input type="url" name="source_url" id="kb-source-url" placeholder="https://aegisone.ru/..."> <input type="url" name="source_url" id="kb-source-url" placeholder="https://aegisone.ru/...">
</div> </div>
<div class="form-group"> <div class="form-check"><input type="checkbox" name="is_active" id="kb-is-active" checked><label for="kb-is-active">Активная карточка</label></div>
<label><input type="checkbox" name="is_active" id="kb-is-active" checked> Активная карточка</label> <div class="form-actions" style="justify-content:flex-end;">
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Сохранить</button> <button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn" onclick="closeModal()">Отмена</button> <button type="button" class="btn" onclick="closeModal()">Отмена</button>
</div> </div>
@@ -95,6 +81,19 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="confirm-delete-kb">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Удалить карточку?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
Карточка <strong id="delete-kb-name"></strong> будет удалена навсегда. Это действие нельзя отменить.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmDeleteKB()">Удалить</button>
<button type="button" class="btn" onclick="closeConfirmDeleteKB()">Отмена</button>
</div>
</div>
</div>
<script> <script>
var categories = {}; var categories = {};
var allCards = []; var allCards = [];
@@ -126,10 +125,11 @@ function renderKB(cards){
'<td>' + catName + '</td>' + '<td>' + catName + '</td>' +
'<td><span class="active-badge ' + activeClass + '">' + activeLabel + '</span></td>' + '<td><span class="active-badge ' + activeClass + '">' + activeLabel + '</span></td>' +
'<td style="font-size:12px;">' + (c.source_url ? '<a href="' + c.source_url + '" target="_blank">ссылка</a>' : '—') + '</td>' + '<td style="font-size:12px;">' + (c.source_url ? '<a href="' + c.source_url + '" target="_blank">ссылка</a>' : '—') + '</td>' +
'<td><button class="btn btn-sm" onclick="editCard(' + c.id + ')" title="Редактировать">✏️</button> <button class="btn btn-sm" onclick="deleteCard(' + c.id + ')" style="color:#dc3545;" title="Удалить">🗑</button></td>' + '<td><button class="btn btn-secondary btn-sm" onclick="editCard(' + c.id + ')" title="Редактировать"></button> <button class="btn btn-danger btn-sm" onclick="openConfirmDeleteKB(' + c.id + ', \'' + escapeHtml(c.question).replace(/'/g, "\\'") + '\')" title="Удалить"></button></td>' +
'</tr>'; '</tr>';
}); });
tbody.innerHTML = html; tbody.innerHTML = html;
if(typeof sortTables === 'function') sortTables();
} }
function filterKB(){ function filterKB(){
@@ -173,8 +173,23 @@ function editCard(id){
document.getElementById('kb-modal').classList.add('open'); document.getElementById('kb-modal').classList.add('open');
} }
function deleteCard(id){ var deleteKBId = null;
if(!confirm('Удалить карточку?')) return;
function openConfirmDeleteKB(id, name){
deleteKBId = id;
document.getElementById('delete-kb-name').textContent = name || '#' + id;
document.getElementById('confirm-delete-kb').classList.add('open');
}
function closeConfirmDeleteKB(){
document.getElementById('confirm-delete-kb').classList.remove('open');
deleteKBId = null;
}
function confirmDeleteKB(){
if(!deleteKBId) return;
var id = deleteKBId;
closeConfirmDeleteKB();
fetch('/api/bot/kb/' + id, { method: 'DELETE' }).then(function(r){ return r.json() }).then(function(d){ fetch('/api/bot/kb/' + id, { method: 'DELETE' }).then(function(r){ return r.json() }).then(function(d){
if(d.ok) { showNotification('Карточка удалена', 'success'); loadKB(); } if(d.ok) { showNotification('Карточка удалена', 'success'); loadKB(); }
else showNotification('Ошибка удаления', 'error'); else showNotification('Ошибка удаления', 'error');
+32 -3
View File
@@ -31,6 +31,14 @@ body { font-family:'Inter','Segoe UI',sans-serif; background:var(--bg-body, #1B2
.test-chat-messages::-webkit-scrollbar-track { background:var(--bg-body, #0D1B2A); border-radius:3px; } .test-chat-messages::-webkit-scrollbar-track { background:var(--bg-body, #0D1B2A); border-radius:3px; }
.test-chat-messages::-webkit-scrollbar-thumb { background:var(--border, #253248); border-radius:3px; } .test-chat-messages::-webkit-scrollbar-thumb { background:var(--border, #253248); border-radius:3px; }
.test-chat-messages::-webkit-scrollbar-thumb:hover { background:var(--text-muted, #6B8299); } .test-chat-messages::-webkit-scrollbar-thumb:hover { background:var(--text-muted, #6B8299); }
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:1000; align-items:center; justify-content:center; }
.modal-overlay.open { display:flex; }
.modal { background:var(--bg-card, #0A1628); border:1px solid var(--border, #253248); border-radius:12px; padding:24px; width:420px; max-width:90vw; }
.modal h2 { color:var(--text-primary, #E8EDF2); }
.modal p { color:var(--text-muted, #6B8299); }
.form-actions { display:flex; gap:10px; }
.btn-danger { background:var(--danger, #dc3545); color:#fff; border:none; padding:8px 16px; border-radius:6px; cursor:pointer; font-weight:600; }
.btn { background:var(--bg-input, #253248); color:var(--text-primary, #E8EDF2); border:1px solid var(--border, #3a4a60); padding:8px 16px; border-radius:6px; cursor:pointer; }
</style> </style>
<script> <script>
(function(){function g(){return window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'}var s=localStorage.getItem('aegisone_service_theme')||'system',r=s==='system'?g():s;document.documentElement.setAttribute('data-theme',r);})(); (function(){function g(){return window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'}var s=localStorage.getItem('aegisone_service_theme')||'system',r=s==='system'?g():s;document.documentElement.setAttribute('data-theme',r);})();
@@ -43,7 +51,7 @@ body { font-family:'Inter','Segoe UI',sans-serif; background:var(--bg-body, #1B2
<button onclick="runScenario('consent_no')">Отказ согласия</button> <button onclick="runScenario('consent_no')">Отказ согласия</button>
<button onclick="runScenario('question')">Общий вопрос</button> <button onclick="runScenario('question')">Общий вопрос</button>
<button onclick="clearTest()">Очистить</button> <button onclick="clearTest()">Очистить</button>
<button onclick="clearTestData()" style="background:var(--danger,#dc3545) !important;border-color:var(--danger,#dc3545) !important;">Сбросить все тестовые данные</button> <button onclick="openConfirmClearTest()" style="background:var(--danger,#dc3545) !important;border-color:var(--danger,#dc3545) !important;">Сбросить все тестовые данные</button>
</div> </div>
<div class="test-container"> <div class="test-container">
<div class="test-chat"> <div class="test-chat">
@@ -56,6 +64,21 @@ body { font-family:'Inter','Segoe UI',sans-serif; background:var(--bg-body, #1B2
</div> </div>
<div class="test-log" id="test-log"></div> <div class="test-log" id="test-log"></div>
</div> </div>
<div class="modal-overlay" id="confirm-clear-test">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Сбросить тестовые данные?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
Удалить все тестовые диалоги, сообщения и логи обработки?
Это действие нельзя отменить.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmClearTest()">Сбросить</button>
<button type="button" class="btn" onclick="closeConfirmClearTest()">Отмена</button>
</div>
</div>
</div>
<script> <script>
var testUserId = 999999001; var testUserId = 999999001;
function log(event, detail){ function log(event, detail){
@@ -156,8 +179,14 @@ function clearTest(){
document.getElementById('test-input').value = ''; document.getElementById('test-input').value = '';
log('info', 'Тест очищен'); log('info', 'Тест очищен');
} }
function clearTestData(){ function openConfirmClearTest(){
if(!confirm('Удалить все тестовые диалоги, сообщения и логи обработки? Это действие нельзя отменить.')) return; document.getElementById('confirm-clear-test').classList.add('open');
}
function closeConfirmClearTest(){
document.getElementById('confirm-clear-test').classList.remove('open');
}
function confirmClearTest(){
closeConfirmClearTest();
log('info', 'Очистка тестовых данных...'); log('info', 'Очистка тестовых данных...');
fetch('/api/bot/test/clear', {method: 'POST'}).then(function(r){ return r.json() }).then(function(d){ fetch('/api/bot/test/clear', {method: 'POST'}).then(function(r){ return r.json() }).then(function(d){
if(d && d.ok){ if(d && d.ok){
@@ -121,6 +121,7 @@ function renderTickets(tickets){
'</tr>'; '</tr>';
}); });
tbody.innerHTML = html; tbody.innerHTML = html;
if(typeof sortTables === 'function') sortTables();
} }
function filterTickets(){ function filterTickets(){
+37 -17
View File
@@ -10,16 +10,6 @@
.badge.auto_added { background:#cce5ff; color:#004085; } .badge.auto_added { background:#cce5ff; color:#004085; }
.badge.reviewed { background:#d4edda; color:#155724; } .badge.reviewed { background:#d4edda; color:#155724; }
.badge.ignored { background:#f8f9fa; color:#6c757d; } .badge.ignored { background:#f8f9fa; color:#6c757d; }
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.4); z-index:1000; align-items:center; justify-content:center; }
.modal-overlay.open { display:flex; }
.modal-box { background:var(--bg-card); border-radius:12px; padding:30px; width:650px; max-width:90vw; max-height:85vh; overflow-y:auto; }
.modal-box h3 { margin-bottom:20px; }
.form-group { margin-bottom:14px; }
.form-group label { display:block; font-weight:600; margin-bottom:4px; font-size:13px; color:var(--text-muted); }
.form-group input, .form-group textarea, .form-group select {
width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:14px; }
.form-group textarea { min-height:80px; resize:vertical; }
.modal-actions { display:flex; gap:12px; margin-top:16px; }
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; } .filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; }
.filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; } .filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
</style> </style>
@@ -55,8 +45,9 @@
</div> </div>
<div class="modal-overlay" id="uq-modal"> <div class="modal-overlay" id="uq-modal">
<div class="modal-box"> <div class="modal" style="max-width:650px;">
<h3 id="uq-modal-title">Проверить вопрос</h3> <button type="button" class="modal-close" onclick="closeModal()"></button>
<h2 id="uq-modal-title">Проверить вопрос</h2>
<form id="uq-form"> <form id="uq-form">
<input type="hidden" id="uq-edit-id"> <input type="hidden" id="uq-edit-id">
<div class="form-group"> <div class="form-group">
@@ -88,7 +79,7 @@
<option value="8">Отрасли и кейсы</option> <option value="8">Отрасли и кейсы</option>
</select> </select>
</div> </div>
<div class="modal-actions"> <div class="form-actions" style="justify-content:flex-end;">
<button type="submit" class="btn btn-primary">Опубликовать в базу знаний</button> <button type="submit" class="btn btn-primary">Опубликовать в базу знаний</button>
<button type="button" class="btn" onclick="closeModal()">Отмена</button> <button type="button" class="btn" onclick="closeModal()">Отмена</button>
</div> </div>
@@ -96,6 +87,19 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="confirm-ignore-uq">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Игнорировать вопрос?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
Вопрос <strong id="ignore-uq-text"></strong> будет отмечен как игнорированный.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmIgnoreUQ()">Игнорировать</button>
<button type="button" class="btn" onclick="closeConfirmIgnoreUQ()">Отмена</button>
</div>
</div>
</div>
<script> <script>
var allUQ = []; var allUQ = [];
@@ -132,12 +136,13 @@ function renderUQ(items){
'<td style="font-size:12px;">' + (q.created_at ? q.created_at.substring(0,10) : '—') + '</td>' + '<td style="font-size:12px;">' + (q.created_at ? q.created_at.substring(0,10) : '—') + '</td>' +
'<td>'; '<td>';
if(q.status === 'new' || q.status === 'auto_added'){ if(q.status === 'new' || q.status === 'auto_added'){
html += '<button class="btn btn-sm" onclick="openReview(' + q.id + ')" title="Проверить">✏️</button> '; html += '<button class="btn btn-secondary btn-sm" onclick="openReview(' + q.id + ')" title="Проверить"></button> ';
html += '<button class="btn btn-sm" onclick="ignoreUQ(' + q.id + ')" style="color:#dc3545;" title="Игнорировать">🗑</button>'; html += '<button class="btn btn-danger btn-sm" onclick="openConfirmIgnoreUQ(' + q.id + ', \'' + esc(q.question_text).replace(/'/g, "\\'") + '\')" title="Игнорировать"></button>';
} }
html += '</td></tr>'; html += '</td></tr>';
}); });
tbody.innerHTML = html; tbody.innerHTML = html;
if(typeof sortTables === 'function') sortTables();
} }
function openReview(id){ function openReview(id){
@@ -175,8 +180,23 @@ document.getElementById('uq-form').addEventListener('submit', function(e){
}); });
}); });
function ignoreUQ(id){ var ignoreUQId = null;
if(!confirm('Игнорировать вопрос?')) return;
function openConfirmIgnoreUQ(id, text){
ignoreUQId = id;
document.getElementById('ignore-uq-text').textContent = text || '#' + id;
document.getElementById('confirm-ignore-uq').classList.add('open');
}
function closeConfirmIgnoreUQ(){
document.getElementById('confirm-ignore-uq').classList.remove('open');
ignoreUQId = null;
}
function confirmIgnoreUQ(){
if(!ignoreUQId) return;
var id = ignoreUQId;
closeConfirmIgnoreUQ();
fetch('/api/bot/unknown-questions/' + id + '/ignore', { method: 'PUT' }) fetch('/api/bot/unknown-questions/' + id + '/ignore', { method: 'PUT' })
.then(function(r){ return r.json() }).then(function(d){ .then(function(r){ return r.json() }).then(function(d){
if(d.ok){ loadUQ(); showNotification('Игнорировано', 'success'); } if(d.ok){ loadUQ(); showNotification('Игнорировано', 'success'); }
+28 -1
View File
@@ -10,7 +10,7 @@
<td><span class="badge badge-{{ 'active' if c.is_active else 'inactive' }}">{{ 'Да' if c.is_active else 'Нет' }}</span></td> <td><span class="badge badge-{{ 'active' if c.is_active else 'inactive' }}">{{ 'Да' if c.is_active else 'Нет' }}</span></td>
<td class="actions"> <td class="actions">
<button class="btn btn-secondary btn-sm" onclick="editCase({{ c.id }},'{{ c.title|e }}','{{ c.text|e }}','{{ c.effect|e }}',{{ c.sort_order }},{{ 'true' if c.is_active else 'false' }})"></button> <button class="btn btn-secondary btn-sm" onclick="editCase({{ c.id }},'{{ c.title|e }}','{{ c.text|e }}','{{ c.effect|e }}',{{ c.sort_order }},{{ 'true' if c.is_active else 'false' }})"></button>
<form method="post" action="/service/cases/delete" style="display:inline" onsubmit="return confirm('Удалить?')"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-danger btn-sm"></button></form> <form method="post" action="/service/cases/delete" style="display:inline" id="delete-case-form-{{ c.id }}"><input type="hidden" name="id" value="{{ c.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteCase({{ c.id }},'{{ c.title|e }}')" title="Удалить"></button></form>
</td> </td>
</tr>{% endfor %}</tbody></table> </tr>{% endfor %}</tbody></table>
</div> </div>
@@ -31,7 +31,34 @@
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div> <button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form> </form>
</div></div> </div></div>
<div class="modal-overlay" id="confirm-delete-case">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Удалить?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
<strong id="delete-case-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmDeleteCase()">Удалить</button>
<button type="button" class="btn" onclick="closeConfirmDeleteCase()">Отмена</button>
</div>
</div>
</div>
<script> <script>
var deleteCaseId = null;
function openConfirmDeleteCase(id, name){
deleteCaseId = id;
document.getElementById('delete-case-name').textContent = name || '#' + id;
document.getElementById('confirm-delete-case').classList.add('open');
}
function closeConfirmDeleteCase(){
document.getElementById('confirm-delete-case').classList.remove('open');
deleteCaseId = null;
}
function confirmDeleteCase(){
if(!deleteCaseId) return;
document.getElementById('delete-case-form-' + deleteCaseId).submit();
closeConfirmDeleteCase();
}
function editCase(id,title,text,effect,order,active){ function editCase(id,title,text,effect,order,active){
document.getElementById('modal-case').classList.add('open'); document.getElementById('modal-case').classList.add('open');
document.getElementById('modal-case-title').textContent = 'Редактировать'; document.getElementById('modal-case-title').textContent = 'Редактировать';
+69 -5
View File
@@ -1,5 +1,17 @@
{% extends "page.html" %} {% extends "page.html" %}
{% block page_content %} {% block page_content %}
<style>
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:1000; align-items:center; justify-content:center; }
.modal-overlay.open { display:flex; }
.modal { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:24px; width:420px; max-width:90vw; }
.modal h2 { color:var(--text-primary); }
.modal p { color:var(--text-secondary); }
.form-actions { display:flex; gap:10px; }
.btn-danger { background:var(--danger, #dc3545); color:#fff; border:none; padding:8px 16px; border-radius:6px; cursor:pointer; font-weight:600; }
.btn-primary { background:var(--accent, #00ADEF); color:#fff; border:none; padding:8px 16px; border-radius:6px; cursor:pointer; font-weight:600; }
.btn { background:var(--bg-input); color:var(--text-primary); border:1px solid var(--border); padding:8px 16px; border-radius:6px; cursor:pointer; }
.form-group { margin-bottom:0; }
</style>
<div class="card"> <div class="card">
<div class="card-header"><h2>Графики и диаграммы</h2></div> <div class="card-header"><h2>Графики и диаграммы</h2></div>
<div class="filter-bar" style="flex-wrap:wrap;gap:8px;"> <div class="filter-bar" style="flex-wrap:wrap;gap:8px;">
@@ -30,21 +42,49 @@
</div> </div>
<div class="filter-bar" style="margin-top:8px;flex-wrap:wrap;gap:6px;"> <div class="filter-bar" style="margin-top:8px;flex-wrap:wrap;gap:6px;">
<span style="font-size:12px;color:var(--text-muted);">Пользовательские графики:</span> <span style="font-size:12px;color:var(--text-muted);">Пользовательские графики:</span>
<button class="btn btn-secondary btn-xs" onclick="saveCustomChart()">Сохранить текущий</button> <button class="btn btn-secondary btn-sm" onclick="saveCustomChart()">Сохранить текущий</button>
<select id="custom-chart-select" onchange="loadCustomChart(this.value)"> <select id="custom-chart-select" onchange="loadCustomChart(this.value)">
<option value="">— Загрузить —</option> <option value="">— Загрузить —</option>
</select> </select>
<button class="btn btn-danger btn-xs" onclick="deleteCustomChart()">Удалить</button> <button class="btn btn-danger btn-sm" onclick="deleteCustomChart()">Удалить</button>
</div> </div>
<div style="position:relative;height:400px;"> <div style="position:relative;height:400px;">
<canvas id="chart-canvas"></canvas> <canvas id="chart-canvas"></canvas>
</div> </div>
</div> </div>
<div class="modal-overlay" id="chart-name-modal">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Название графика</h2>
<div class="form-group">
<input type="text" id="chart-name-input" placeholder="Введите название..." style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:6px;font-size:14px;background:var(--bg-card);color:var(--text);box-sizing:border-box;">
</div>
<div class="form-actions" style="justify-content:flex-end;margin-top:16px;">
<button type="button" class="btn btn-primary" onclick="confirmSaveChart()">Сохранить</button>
<button type="button" class="btn" onclick="closeChartNameModal()">Отмена</button>
</div>
</div>
</div>
<div class="modal-overlay" id="confirm-delete-chart">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Удалить график?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
<strong id="delete-chart-name"></strong> будет удалён навсегда.
Это действие нельзя отменить.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmDeleteChart()">Удалить</button>
<button type="button" class="btn" onclick="closeConfirmDeleteChart()">Отмена</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script> <script>
var chartInstance = null; var chartInstance = null;
var currentChartConfig = null; var currentChartConfig = null;
var pendingDeleteChartName = null;
function loadChart(){ function loadChart(){
var metric = document.getElementById('metric-select').value; var metric = document.getElementById('metric-select').value;
@@ -107,8 +147,19 @@ function exportChart(){
function saveCustomChart(){ function saveCustomChart(){
if(!currentChartConfig) return; if(!currentChartConfig) return;
var name = prompt('Название графика:'); document.getElementById('chart-name-input').value = '';
if(!name) return; document.getElementById('chart-name-modal').classList.add('open');
document.getElementById('chart-name-input').focus();
}
function closeChartNameModal(){
document.getElementById('chart-name-modal').classList.remove('open');
}
function confirmSaveChart(){
var name = document.getElementById('chart-name-input').value.trim();
if(!name){ return; }
closeChartNameModal();
var saved = JSON.parse(localStorage.getItem('custom_charts') || '[]'); var saved = JSON.parse(localStorage.getItem('custom_charts') || '[]');
saved.push({ name: name, config: currentChartConfig }); saved.push({ name: name, config: currentChartConfig });
localStorage.setItem('custom_charts', JSON.stringify(saved)); localStorage.setItem('custom_charts', JSON.stringify(saved));
@@ -131,7 +182,20 @@ function deleteCustomChart(){
var sel = document.getElementById('custom-chart-select'); var sel = document.getElementById('custom-chart-select');
var name = sel.value; var name = sel.value;
if(!name) return; if(!name) return;
if(!confirm('Удалить "' + name + '"?')) return; pendingDeleteChartName = name;
document.getElementById('delete-chart-name').textContent = name;
document.getElementById('confirm-delete-chart').classList.add('open');
}
function closeConfirmDeleteChart(){
document.getElementById('confirm-delete-chart').classList.remove('open');
pendingDeleteChartName = null;
}
function confirmDeleteChart(){
var name = pendingDeleteChartName;
closeConfirmDeleteChart();
var sel = document.getElementById('custom-chart-select');
var saved = JSON.parse(localStorage.getItem('custom_charts') || '[]'); var saved = JSON.parse(localStorage.getItem('custom_charts') || '[]');
saved = saved.filter(function(c){ return c.name !== name; }); saved = saved.filter(function(c){ return c.name !== name; });
localStorage.setItem('custom_charts', JSON.stringify(saved)); localStorage.setItem('custom_charts', JSON.stringify(saved));
@@ -2,7 +2,7 @@
{% block page_content %} {% block page_content %}
{% for t in tasks %} {% for t in tasks %}
<div class="card"> <div class="card">
<h3>{{ t.title }}</h3> <div class="card-header"><h3>{{ t.title }}</h3></div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;"> <div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;">
{{ t.object.name if t.object else '' }} — {{ t.object.address if t.object else '' }} {{ t.object.name if t.object else '' }} — {{ t.object.address if t.object else '' }}
<span class="badge badge-{{ {'P1':'critical','P2':'high','P3':'medium','P4':'low'}[t.priority] }}" style="margin-left:8px;">{{ t.priority }}</span> <span class="badge badge-{{ {'P1':'critical','P2':'high','P3':'medium','P4':'low'}[t.priority] }}" style="margin-left:8px;">{{ t.priority }}</span>
@@ -33,7 +33,7 @@
<td><small>{{ c.formula_ref }}</small></td> <td><small>{{ c.formula_ref }}</small></td>
<td><form method="post" action="/service/coefficients/toggle"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-{{ 'success' if c.is_active else 'secondary' }} btn-sm">{{ '✓' if c.is_active else '✕' }}</button></form></td> <td><form method="post" action="/service/coefficients/toggle"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-{{ 'success' if c.is_active else 'secondary' }} btn-sm">{{ '✓' if c.is_active else '✕' }}</button></form></td>
<td class="actions"><button class="btn btn-secondary btn-sm" onclick="editCoeff({{ c.id }},'{{ c.key|e }}','{{ c.value }}','{{ c.description|e }}','{{ c.formula_ref|e }}')"></button> <td class="actions"><button class="btn btn-secondary btn-sm" onclick="editCoeff({{ c.id }},'{{ c.key|e }}','{{ c.value }}','{{ c.description|e }}','{{ c.formula_ref|e }}')"></button>
<form method="post" action="/service/coefficients/delete" style="display:inline" onsubmit="return confirm('Удалить коэффициент?')"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-danger btn-sm"></button></form> <form method="post" action="/service/coefficients/delete" style="display:inline" id="delete-coeff-form-{{ c.id }}"><input type="hidden" name="id" value="{{ c.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteCoeff({{ c.id }},'{{ c.key|e }}')" title="Удалить"></button></form>
</td> </td>
</tr>{% endfor %}</tbody></table> </tr>{% endfor %}</tbody></table>
</div> </div>
@@ -65,7 +65,35 @@
<div id="formula-info-content" style="margin-top:12px"></div> <div id="formula-info-content" style="margin-top:12px"></div>
</div></div> </div></div>
<div class="modal-overlay" id="confirm-delete-coeff">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Удалить коэффициент?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
<strong id="delete-coeff-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmDeleteCoeff()">Удалить</button>
<button type="button" class="btn" onclick="closeConfirmDeleteCoeff()">Отмена</button>
</div>
</div>
</div>
<script> <script>
var deleteCoeffId = null;
function openConfirmDeleteCoeff(id, name){
deleteCoeffId = id;
document.getElementById('delete-coeff-name').textContent = name || '#' + id;
document.getElementById('confirm-delete-coeff').classList.add('open');
}
function closeConfirmDeleteCoeff(){
document.getElementById('confirm-delete-coeff').classList.remove('open');
deleteCoeffId = null;
}
function confirmDeleteCoeff(){
if(!deleteCoeffId) return;
document.getElementById('delete-coeff-form-' + deleteCoeffId).submit();
closeConfirmDeleteCoeff();
}
function toggleAccordion(el) { function toggleAccordion(el) {
var arrow = el.querySelector('.accordion-arrow'); var arrow = el.querySelector('.accordion-arrow');
var body = el.nextElementSibling; var body = el.nextElementSibling;
+30 -4
View File
@@ -3,10 +3,9 @@
<div class="card"> <div class="card">
<div class="card-header"><h2>Клиенты</h2> <div class="card-header"><h2>Клиенты</h2>
{% if error %}<div class="alert alert-error" style="margin-bottom:12px;padding:8px 12px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:6px;color:var(--danger);font-size:13px;">{{ error }}</div>{% endif %} {% if error %}<div class="alert alert-error" style="margin-bottom:12px;padding:8px 12px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:6px;color:var(--danger);font-size:13px;">{{ error }}</div>{% endif %}
</div>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-customer').classList.add('open')">+ Добавить</button> <button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-customer').classList.add('open')">+ Добавить</button>
</div> </div>
<form method="get" class="search-box"><input type="text" name="search" placeholder="Поиск по названию или ИНН..." value="{{ search }}"><button class="btn btn-primary btn-sm">Найти</button></form> <form method="get" class="filter-bar"><input type="text" name="search" placeholder="Поиск по названию или ИНН..." value="{{ search }}"><button class="btn btn-primary btn-sm">Найти</button></form>
<div class="table-wrap"> <div class="table-wrap">
<table><thead><tr><th>Название</th><th>ИНН</th><th>Контакты</th><th>Статус</th><th>Объекты</th><th>Активные SLA</th><th class="actions"></th></tr></thead> <table><thead><tr><th>Название</th><th>ИНН</th><th>Контакты</th><th>Статус</th><th>Объекты</th><th>Активные SLA</th><th class="actions"></th></tr></thead>
<tbody>{% for d in customers %}<tr> <tbody>{% for d in customers %}<tr>
@@ -18,8 +17,8 @@
<td class="actions"> <td class="actions">
<button class="btn btn-secondary btn-sm" onclick="editCustomer({{ d.c.id }},'{{ d.c.name|e }}','{{ d.c.inn|e }}','{{ d.c.kpp|e }}','{{ d.c.legal_address|e }}','{{ d.c.contact_person|e }}','{{ d.c.contact_phone|e }}','{{ d.c.contact_email|e }}','{{ d.c.status }}','{{ d.c.notes|e }}')"></button> <button class="btn btn-secondary btn-sm" onclick="editCustomer({{ d.c.id }},'{{ d.c.name|e }}','{{ d.c.inn|e }}','{{ d.c.kpp|e }}','{{ d.c.legal_address|e }}','{{ d.c.contact_person|e }}','{{ d.c.contact_phone|e }}','{{ d.c.contact_email|e }}','{{ d.c.status }}','{{ d.c.notes|e }}')"></button>
{% if user.role == 'owner' %} {% if user.role == 'owner' %}
<form method="post" action="/service/customers/delete" style="display:inline" onsubmit="return confirm('Удалить клиента?')"> <form method="post" action="/service/customers/delete" style="display:inline" id="delete-customer-form-{{ d.c.id }}">
<input type="hidden" name="id" value="{{ d.c.id }}"><button class="btn btn-danger btn-sm"></button> <input type="hidden" name="id" value="{{ d.c.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteCustomer({{ d.c.id }},'{{ d.c.name|e }}')" title="Удалить"></button>
</form>{% endif %} </form>{% endif %}
</td> </td>
</tr>{% endfor %}</tbody></table> </tr>{% endfor %}</tbody></table>
@@ -53,7 +52,34 @@
</div> </div>
</form> </form>
</div></div> </div></div>
<div class="modal-overlay" id="confirm-delete-customer">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Удалить клиента?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
<strong id="delete-customer-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmDeleteCustomer()">Удалить</button>
<button type="button" class="btn" onclick="closeConfirmDeleteCustomer()">Отмена</button>
</div>
</div>
</div>
<script> <script>
var deleteCustomerId = null;
function openConfirmDeleteCustomer(id, name){
deleteCustomerId = id;
document.getElementById('delete-customer-name').textContent = name || '#' + id;
document.getElementById('confirm-delete-customer').classList.add('open');
}
function closeConfirmDeleteCustomer(){
document.getElementById('confirm-delete-customer').classList.remove('open');
deleteCustomerId = null;
}
function confirmDeleteCustomer(){
if(!deleteCustomerId) return;
document.getElementById('delete-customer-form-' + deleteCustomerId).submit();
closeConfirmDeleteCustomer();
}
function phoneMask(input){ function phoneMask(input){
var x = input.value.replace(/\D/g, '').match(/(\d{0,1})(\d{0,3})(\d{0,3})(\d{0,2})(\d{0,2})/); var x = input.value.replace(/\D/g, '').match(/(\d{0,1})(\d{0,3})(\d{0,3})(\d{0,2})(\d{0,2})/);
if (!x) return; if (!x) return;
+29 -1
View File
@@ -161,7 +161,7 @@
<td><small>{{ c.formula_ref }}</small></td> <td><small>{{ c.formula_ref }}</small></td>
<td><form method="post" action="/service/coefficients/toggle"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-{{ 'success' if c.is_active else 'secondary' }} btn-sm">{{ '✓' if c.is_active else '✕' }}</button></form></td> <td><form method="post" action="/service/coefficients/toggle"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-{{ 'success' if c.is_active else 'secondary' }} btn-sm">{{ '✓' if c.is_active else '✕' }}</button></form></td>
<td class="actions"><button class="btn btn-secondary btn-sm" onclick="editCoeff({{ c.id }},'{{ c.key|e }}','{{ c.value }}','{{ c.description|e }}','{{ c.formula_ref|e }}')"></button> <td class="actions"><button class="btn btn-secondary btn-sm" onclick="editCoeff({{ c.id }},'{{ c.key|e }}','{{ c.value }}','{{ c.description|e }}','{{ c.formula_ref|e }}')"></button>
<form method="post" action="/service/coefficients/delete" style="display:inline" onsubmit="return confirm('Удалить коэффициент?')"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-danger btn-sm"></button></form> <form method="post" action="/service/coefficients/delete" style="display:inline" id="delete-formula-coeff-form-{{ c.id }}"><input type="hidden" name="id" value="{{ c.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteFormulaCoeff({{ c.id }},'{{ c.key|e }}')" title="Удалить"></button></form>
</td> </td>
</tr>{% endfor %}</tbody></table> </tr>{% endfor %}</tbody></table>
</div> </div>
@@ -199,7 +199,35 @@
<div id="formula-info-content" style="margin-top:12px"></div> <div id="formula-info-content" style="margin-top:12px"></div>
</div></div> </div></div>
<div class="modal-overlay" id="confirm-delete-formula-coeff">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Удалить коэффициент?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
<strong id="delete-formula-coeff-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmDeleteFormulaCoeff()">Удалить</button>
<button type="button" class="btn" onclick="closeConfirmDeleteFormulaCoeff()">Отмена</button>
</div>
</div>
</div>
<script> <script>
var deleteFormulaCoeffId = null;
function openConfirmDeleteFormulaCoeff(id, name){
deleteFormulaCoeffId = id;
document.getElementById('delete-formula-coeff-name').textContent = name || '#' + id;
document.getElementById('confirm-delete-formula-coeff').classList.add('open');
}
function closeConfirmDeleteFormulaCoeff(){
document.getElementById('confirm-delete-formula-coeff').classList.remove('open');
deleteFormulaCoeffId = null;
}
function confirmDeleteFormulaCoeff(){
if(!deleteFormulaCoeffId) return;
document.getElementById('delete-formula-coeff-form-' + deleteFormulaCoeffId).submit();
closeConfirmDeleteFormulaCoeff();
}
function switchTab(name){ function switchTab(name){
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); }); document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); }); document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); });
+30 -2
View File
@@ -17,9 +17,9 @@
<button class="btn btn-secondary btn-sm" title="Переключить статус"></button> <button class="btn btn-secondary btn-sm" title="Переключить статус"></button>
</form> </form>
<button class="btn btn-secondary btn-sm" onclick="editIdea({{ idea.id }},{{ idea.title|tojson }},{{ idea.description|tojson }})"></button> <button class="btn btn-secondary btn-sm" onclick="editIdea({{ idea.id }},{{ idea.title|tojson }},{{ idea.description|tojson }})"></button>
<form method="post" action="/service/ideas/delete" style="display:inline" onsubmit="return confirm('Удалить идею?')"> <form method="post" action="/service/ideas/delete" style="display:inline" id="delete-idea-form-{{ idea.id }}">
<input type="hidden" name="id" value="{{ idea.id }}"> <input type="hidden" name="id" value="{{ idea.id }}">
<button class="btn btn-danger btn-sm"></button> <button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteIdea({{ idea.id }},{{ idea.title|tojson }})" title="Удалить"></button>
</form> </form>
</td> </td>
</tr>{% endfor %}</tbody></table> </tr>{% endfor %}</tbody></table>
@@ -38,7 +38,35 @@
</form> </form>
</div></div> </div></div>
<div class="modal-overlay" id="confirm-delete-idea">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Удалить идею?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
<strong id="delete-idea-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmDeleteIdea()">Удалить</button>
<button type="button" class="btn" onclick="closeConfirmDeleteIdea()">Отмена</button>
</div>
</div>
</div>
<script> <script>
var deleteIdeaId = null;
function openConfirmDeleteIdea(id, name){
deleteIdeaId = id;
document.getElementById('delete-idea-name').textContent = name || '#' + id;
document.getElementById('confirm-delete-idea').classList.add('open');
}
function closeConfirmDeleteIdea(){
document.getElementById('confirm-delete-idea').classList.remove('open');
deleteIdeaId = null;
}
function confirmDeleteIdea(){
if(!deleteIdeaId) return;
document.getElementById('delete-idea-form-' + deleteIdeaId).submit();
closeConfirmDeleteIdea();
}
function editIdea(id,title,description){ function editIdea(id,title,description){
document.getElementById('modal-idea').classList.add('open'); document.getElementById('modal-idea').classList.add('open');
document.getElementById('modal-idea-title').textContent = 'Редактировать идею'; document.getElementById('modal-idea-title').textContent = 'Редактировать идею';
+28 -1
View File
@@ -17,7 +17,7 @@
<td class="actions"> <td class="actions">
<button class="btn btn-secondary btn-sm" onclick="editObject({{ o.id }},'{{ o.name|e }}','{{ o.customer_id or '' }}','{{ o.object_type|e }}','{{ o.address|e }}','{{ o.contact_person|e }}','{{ o.contact_phone|e }}','{{ o.status }}','{{ o.notes|e }}')"></button> <button class="btn btn-secondary btn-sm" onclick="editObject({{ o.id }},'{{ o.name|e }}','{{ o.customer_id or '' }}','{{ o.object_type|e }}','{{ o.address|e }}','{{ o.contact_person|e }}','{{ o.contact_phone|e }}','{{ o.status }}','{{ o.notes|e }}')"></button>
{% if user.role == 'owner' %} {% if user.role == 'owner' %}
<form method="post" action="/service/objects/delete" style="display:inline" onsubmit="return confirm('Удалить объект?')"><input type="hidden" name="id" value="{{ o.id }}"><button class="btn btn-danger btn-sm"></button></form> <form method="post" action="/service/objects/delete" style="display:inline" id="delete-object-form-{{ o.id }}"><input type="hidden" name="id" value="{{ o.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteObject({{ o.id }},'{{ o.name|e }}')" title="Удалить"></button></form>
{% endif %} {% endif %}
</td> </td>
</tr>{% endfor %}</tbody></table> </tr>{% endfor %}</tbody></table>
@@ -48,7 +48,34 @@
</div> </div>
</form> </form>
</div></div> </div></div>
<div class="modal-overlay" id="confirm-delete-object">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Удалить объект?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
<strong id="delete-object-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmDeleteObject()">Удалить</button>
<button type="button" class="btn" onclick="closeConfirmDeleteObject()">Отмена</button>
</div>
</div>
</div>
<script> <script>
var deleteObjectId = null;
function openConfirmDeleteObject(id, name){
deleteObjectId = id;
document.getElementById('delete-object-name').textContent = name || '#' + id;
document.getElementById('confirm-delete-object').classList.add('open');
}
function closeConfirmDeleteObject(){
document.getElementById('confirm-delete-object').classList.remove('open');
deleteObjectId = null;
}
function confirmDeleteObject(){
if(!deleteObjectId) return;
document.getElementById('delete-object-form-' + deleteObjectId).submit();
closeConfirmDeleteObject();
}
function editObject(id,name,cid,type,addr,person,phone,status,notes){ function editObject(id,name,cid,type,addr,person,phone,status,notes){
document.getElementById('modal-object').classList.add('open'); document.getElementById('modal-object').classList.add('open');
document.getElementById('modal-object-title').textContent = 'Редактировать объект'; document.getElementById('modal-object-title').textContent = 'Редактировать объект';
@@ -12,7 +12,7 @@
<td><form method="post" action="/service/questionnaire-config/toggle"><input type="hidden" name="id" value="{{ qi.id }}"><button class="btn btn-{{ 'success' if qi.is_active else 'secondary' }} btn-sm">{{ '✓' if qi.is_active else '✕' }}</button></form></td> <td><form method="post" action="/service/questionnaire-config/toggle"><input type="hidden" name="id" value="{{ qi.id }}"><button class="btn btn-{{ 'success' if qi.is_active else 'secondary' }} btn-sm">{{ '✓' if qi.is_active else '✕' }}</button></form></td>
<td class="actions"> <td class="actions">
<button class="btn btn-secondary btn-sm" onclick="editQItem({{ qi.id }},{{ qi.step }},'{{ qi.section|e }}','{{ qi.question_key|e }}','{{ qi.label|e }}','{{ qi.type }}','{{ qi.required|lower }}','{{ qi.sort_order }}','{{ qi.help_text|e }}')"></button> <button class="btn btn-secondary btn-sm" onclick="editQItem({{ qi.id }},{{ qi.step }},'{{ qi.section|e }}','{{ qi.question_key|e }}','{{ qi.label|e }}','{{ qi.type }}','{{ qi.required|lower }}','{{ qi.sort_order }}','{{ qi.help_text|e }}')"></button>
<form method="post" action="/service/questionnaire-config/delete" style="display:inline" onsubmit="return confirm('Удалить вопрос?')"><input type="hidden" name="id" value="{{ qi.id }}"><button class="btn btn-danger btn-sm"></button></form> <form method="post" action="/service/questionnaire-config/delete" style="display:inline" id="delete-qitem-form-{{ qi.id }}"><input type="hidden" name="id" value="{{ qi.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteQitem({{ qi.id }},'{{ qi.label|e }}')" title="Удалить"></button></form>
</td> </td>
</tr>{% endfor %}</tbody></table> </tr>{% endfor %}</tbody></table>
</div> </div>
@@ -40,7 +40,34 @@
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div> <button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form> </form>
</div></div> </div></div>
<div class="modal-overlay" id="confirm-delete-qitem">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Удалить вопрос?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
<strong id="delete-qitem-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmDeleteQitem()">Удалить</button>
<button type="button" class="btn" onclick="closeConfirmDeleteQitem()">Отмена</button>
</div>
</div>
</div>
<script> <script>
var deleteQitemId = null;
function openConfirmDeleteQitem(id, name){
deleteQitemId = id;
document.getElementById('delete-qitem-name').textContent = name || '#' + id;
document.getElementById('confirm-delete-qitem').classList.add('open');
}
function closeConfirmDeleteQitem(){
document.getElementById('confirm-delete-qitem').classList.remove('open');
deleteQitemId = null;
}
function confirmDeleteQitem(){
if(!deleteQitemId) return;
document.getElementById('delete-qitem-form-' + deleteQitemId).submit();
closeConfirmDeleteQitem();
}
function editQItem(id,step,section,key,label,type,required,order,help){ function editQItem(id,step,section,key,label,type,required,order,help){
document.getElementById('modal-qitem').classList.add('open'); document.getElementById('modal-qitem').classList.add('open');
document.getElementById('modal-qitem-title').textContent = 'Редактировать вопрос'; document.getElementById('modal-qitem-title').textContent = 'Редактировать вопрос';
@@ -99,7 +99,7 @@ function loadPermissions(){
method: 'POST', headers: {'Content-Type': 'application/json'}, method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({role: role, permissions: perms}) body: JSON.stringify({role: role, permissions: perms})
}).then(function(r){ return r.json() }).then(function(d){ }).then(function(r){ return r.json() }).then(function(d){
if(d.ok) alert('Настройки роли "' + role + '" сохранены'); if(d.ok) showNotification('Настройки роли "' + role + '" сохранены', 'success');
}); });
}); });
}); });
+30 -1
View File
@@ -19,7 +19,7 @@
<td><span class="badge badge-{{ t.status }}">{{ {'open':'Открыта','in_progress':'В работе','completed':'Завершена','cancelled':'Отменена'}[t.status] }}</span></td> <td><span class="badge badge-{{ t.status }}">{{ {'open':'Открыта','in_progress':'В работе','completed':'Завершена','cancelled':'Отменена'}[t.status] }}</span></td>
<td><small>{{ t.deadline.strftime('%d.%m.%Y') if t.deadline else '' }}</small></td> <td><small>{{ t.deadline.strftime('%d.%m.%Y') if t.deadline else '' }}</small></td>
<td class="actions">{% if t.status in ['open','in_progress'] and (user.role in ['owner','engineer'] or t.assigned_to == user.id) %} <td class="actions">{% if t.status in ['open','in_progress'] and (user.role in ['owner','engineer'] or t.assigned_to == user.id) %}
<form method="post" action="/service/tasks/close" style="display:inline" onsubmit="return confirm('Завершить задачу?')"><input type="hidden" name="id" value="{{ t.id }}"><button class="btn btn-success btn-sm">✓ Закрыть</button></form> <form method="post" action="/service/tasks/close" style="display:inline" id="close-task-form-{{ t.id }}"><input type="hidden" name="id" value="{{ t.id }}"><button type="button" class="btn btn-success btn-sm" onclick="openConfirmCloseTask({{ t.id }},'{{ t.title|e }}')" title="Завершить">✓ Закрыть</button></form>
{% endif %} {% endif %}
</td> </td>
</tr>{% endfor %}</tbody></table> </tr>{% endfor %}</tbody></table>
@@ -43,4 +43,33 @@
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div> <button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form> </form>
</div></div> </div></div>
<div class="modal-overlay" id="confirm-close-task">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Завершить задачу?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
<strong id="delete-task-name"></strong> будет завершена. Это действие нельзя отменить.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-success" onclick="confirmCloseTask()">Завершить</button>
<button type="button" class="btn" onclick="closeConfirmCloseTask()">Отмена</button>
</div>
</div>
</div>
<script>
var closeTaskId = null;
function openConfirmCloseTask(id, name){
closeTaskId = id;
document.getElementById('delete-task-name').textContent = name || '#' + id;
document.getElementById('confirm-close-task').classList.add('open');
}
function closeConfirmCloseTask(){
document.getElementById('confirm-close-task').classList.remove('open');
closeTaskId = null;
}
function confirmCloseTask(){
if(!closeTaskId) return;
document.getElementById('close-task-form-' + closeTaskId).submit();
closeConfirmCloseTask();
}
</script>
{% endblock %} {% endblock %}
+29 -2
View File
@@ -16,9 +16,9 @@
<td class="actions"> <td class="actions">
<button class="btn btn-secondary btn-sm" onclick="editUser({{ u.id }},'{{ u.login }}','{{ u.full_name }}','{{ u.role }}','{{ u.phone }}','{{ u.email }}','{{ u.max_user_id or '' }}',{{ 'true' if u.is_active else 'false' }})"></button> <button class="btn btn-secondary btn-sm" onclick="editUser({{ u.id }},'{{ u.login }}','{{ u.full_name }}','{{ u.role }}','{{ u.phone }}','{{ u.email }}','{{ u.max_user_id or '' }}',{{ 'true' if u.is_active else 'false' }})"></button>
{% if u.role != 'owner' %} {% if u.role != 'owner' %}
<form method="post" action="/service/users/delete" style="display:inline" onsubmit="return confirm('Удалить пользователя?')"> <form method="post" action="/service/users/delete" style="display:inline" id="delete-user-form-{{ u.id }}">
<input type="hidden" name="id" value="{{ u.id }}"> <input type="hidden" name="id" value="{{ u.id }}">
<button type="submit" class="btn btn-danger btn-sm"></button> <button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteUser({{ u.id }},'{{ u.login|e }}')" title="Удалить"></button>
</form>{% endif %} </form>{% endif %}
</td> </td>
</tr>{% endfor %}</tbody></table> </tr>{% endfor %}</tbody></table>
@@ -53,7 +53,34 @@
</form> </form>
</div> </div>
</div> </div>
<div class="modal-overlay" id="confirm-delete-user">
<div class="modal" style="max-width:420px;">
<h2 style="font-size:16px;margin-bottom:8px;">Удалить пользователя?</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
<strong id="delete-user-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
</p>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmDeleteUser()">Удалить</button>
<button type="button" class="btn" onclick="closeConfirmDeleteUser()">Отмена</button>
</div>
</div>
</div>
<script> <script>
var deleteUserId = null;
function openConfirmDeleteUser(id, name){
deleteUserId = id;
document.getElementById('delete-user-name').textContent = name || '#' + id;
document.getElementById('confirm-delete-user').classList.add('open');
}
function closeConfirmDeleteUser(){
document.getElementById('confirm-delete-user').classList.remove('open');
deleteUserId = null;
}
function confirmDeleteUser(){
if(!deleteUserId) return;
document.getElementById('delete-user-form-' + deleteUserId).submit();
closeConfirmDeleteUser();
}
function editUser(id,login,name,role,phone,email,maxId,active){ function editUser(id,login,name,role,phone,email,maxId,active){
document.getElementById('modal-user').classList.add('open'); document.getElementById('modal-user').classList.add('open');
document.getElementById('modal-user-title').textContent = 'Редактировать сотрудника'; document.getElementById('modal-user-title').textContent = 'Редактировать сотрудника';
@@ -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);
View File
+1
View File
@@ -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
View File
@@ -1 +1 @@
1.8.2 1.9.0