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.3. **Если предлагаешь варианты — обязательно указывай рекомендацию и пояснение.**
### 0.4. Обязательные файлы для чтения
Перед началом работы агент ОБЯЗАН прочитать:
| Файл | Содержание | Когда читать |
|------|------------|--------------|
| `AGENTS.md` | Правила проекта (этот файл) | Всегда, перед началом работы |
| `other/AI-EXISTING-TWO-PROJECT-INSTRUCTION.md` | Архитектура сервера, порты, Docker-проекты, команды | Перед работой с сервером, деплоем, диагностикой |
| `py_service/SERVICE_STYLE_GUIDE.md` | Стили UI, CSS-классы, паттерны шаблонов | Перед работой с фронтендом портала |
---
## 1. Архитектура проекта
@@ -22,6 +32,8 @@
**ВАЖНО:** Публичная часть полностью готова и не подлежит изменениям!
**Подробная архитектура сервера:** `other/AI-EXISTING-TWO-PROJECT-INSTRUCTION.md` — порты, Docker-проекты, команды, troubleshooting.
---
## 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 pydantic_settings import BaseSettings
+6
View File
@@ -1,3 +1,9 @@
"""
Чтение конфигурации из config.php (AegisOne сайт).
Парсит PHP define() константы и возвращает их как dict.
Fallback на hardcoded значения если файл не найден.
"""
from __future__ import annotations
import re
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 app.config import settings
+5
View File
@@ -1,3 +1,8 @@
"""
Отправка email через sendmail (с fallback на логирование в файл).
Используется для уведомлений о заявках.
"""
import subprocess
import os
import logging
+20
View File
@@ -1,3 +1,11 @@
"""
Обработчик согласия на обработку персональных данных.
Управляет жизненным циклом согласия:
- запрос согласия → да/нет
- отзыв согласия (consent_revoked_date)
- повторное согласие после отказа
"""
import datetime
import re
import logging
@@ -12,6 +20,12 @@ logger = logging.getLogger(__name__)
def _matches_any(text: str, words: list) -> bool:
"""
Проверяет, содержит ли текст хотя бы одно из указанных слов.
Использует split для точного совпадения коротких слов (≤3 символов)
и подстроку для длинных (>3 символов).
"""
text_words = set(text.split())
for w in 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:
"""Маршрутизация callback-кнопок согласия (да/нет)."""
if callback_id == "consent_yes":
await handle_consent_yes(user_id, conv_id)
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:
"""Обработка текстового ответа на запрос согласия (да/нет/другое)."""
text_lower = text.strip().lower()
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие"]
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:
"""Обработка согласия: устанавливает consent_given=True, переводит в awaiting_contact."""
async with async_session() as db:
result = await db.execute(select(BotUser).where(BotUser.id == user_id))
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:
"""Обработка отказа: устанавливает consent_refused, показывает контакты."""
async with async_session() as db:
result = await db.execute(
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:
"""Повторная обработка после отказа: позволяет дать согласие или показывает контакты."""
text_lower = text.strip().lower()
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие", "передумал", "согласен дать"]
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:
"""Отзыв согласия: устанавливает consent_given=False, consent_revoked_date=now."""
async with async_session() as db:
user = await db.get(BotUser, user_id)
if user and user.consent_given:
+9
View File
@@ -1,3 +1,10 @@
"""
Обработчик контактов пользователя.
Принимает контакты (телефон, email) через:
- sharing контакта из мессенджера (VCF)
- ручной ввод текстом
"""
import datetime
import re
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:
"""Обработка контакта из мессенджера: парсинг телефона/email, переход к заявке."""
async with async_session() as db:
result = await db.execute(select(BotUser).where(BotUser.id == user_id))
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:
"""Обработка ручного ввода контакта: валидация телефона/email, отказ → consent_refused."""
text = text.strip()
text_lower = text.lower()
+35
View File
@@ -1,3 +1,13 @@
"""
Обработчик приветствий и основных сообщений бота.
Управляет жизненным циклом диалога:
- приветствие → создание пользователя и диалога
- маршрутизация по состояниям (state machine)
- анализ намерений через YandexGPT
- быстрые паттерны (заявка/приветствие) до NLP
- контекст из последних 10 минут
"""
import logging
import datetime
from typing import Optional
@@ -7,6 +17,7 @@ from app.database import async_session
MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3))
def moscow_now():
"""Возвращает текущее время в UTC+3 (Москва) без tzinfo."""
return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None)
from app.models import BotUser, BotConversation, BotMessage, BotKnowledgeBase, BotCategory, BotUnknownQuestion
from app.max_api import max_api
@@ -26,6 +37,7 @@ async def handle_greeting(
last_name: str = "",
username: str = "",
) -> None:
"""Обработка приветствия: создаёт/обновляет пользователя, создаёт диалог, отправляет приветствие."""
assistant_name = await settings_cache.get("assistant_name", "София")
greeting_text = (
@@ -94,6 +106,14 @@ async def handle_greeting(
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:
result = await db.execute(
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:
"""Формирует контекст из последних сообщений (10 мин) и открытых заявок пользователя."""
from app.models import BotTicket
async with async_session() as db:
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():
"""Выбор AI-провайдера (только YandexGPT)."""
if await yandex_gpt.is_available():
return yandex_gpt
return 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:
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:
"""Режим без AI: показывает контакты компании."""
async with async_session() as db:
result = await db.execute(
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:
"""Отправка контактных данных компании (телефон, email)."""
phones = config_reader.get_phones()
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:
"""Формирует контекст из базы знаний по ключевым словам (ранжирование по score)."""
async with async_session() as db:
result = await db.execute(
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:
"""Проверка схожести двух текстов по пересечению слов (≥50%)."""
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):
"""Автогенерация карточки KB через YandexGPT при 3+ обращениях."""
card_data = await yandex_gpt.generate_kb_card([question_record.question_text])
if not card_data:
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:
"""Сохранение неизвестного вопроса с дедупликацией и автогенерацией при 3+ обращениях."""
normalized_input = question_text.lower().strip()
async with async_session() as db:
+7
View File
@@ -1,3 +1,8 @@
"""
Обработчик callback-кнопок и шаринга контактов.
Маршрутизирует callback-и по типу (consent_* и др.).
"""
import datetime
import logging
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:
"""Маршрутизация callback-кнопок (consent_* → consent handler)."""
async with async_session() as db:
result = await db.execute(
select(BotConversation)
@@ -40,6 +46,7 @@ async def handle_contact_share(
vcf_data: dict = None,
contact_hash: str = "",
) -> None:
"""Обработка шаринга контакта из мессенджера: парсинг VCF, сохранение в BotUser."""
if vcf_data is None:
vcf_data = {}
async with async_session() as db:
+8
View File
@@ -1,3 +1,9 @@
"""
Обработчик заявок (inquiry).
Создаёт BotTicket с контактными данными пользователя,
отправляет уведомление на email, подтверждает пользователю.
"""
import datetime
import logging
from sqlalchemy import select
@@ -12,10 +18,12 @@ logger = logging.getLogger(__name__)
MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3))
def moscow_now():
"""Возвращает текущее время в UTC+3 (Москва) без tzinfo."""
return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None)
async def handle_inquiry(user_id: int, conv_id: int, text: str) -> None:
"""Обработка заявки: создаёт.ticket, отправляет email, подтверждает пользователю."""
async with async_session() as db:
result = await db.execute(
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
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
import logging
from contextlib import asynccontextmanager
@@ -33,6 +41,11 @@ async def lifespan(app: FastAPI):
logger.info("Database tables ensured.")
await _apply_migrations()
# Применение SQL-миграций из директории migrations/
from app.migrations_runner import run_migrations
await run_migrations()
await _seed_default_settings()
await _register_webhook()
@@ -54,6 +67,7 @@ app.add_middleware(
async def _apply_migrations():
"""Автоматические миграции: добавление колонок/таблиц при старте."""
async with engine.begin() as conn:
try:
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):
"""Безопасное добавление колонки (idempotent)."""
from sqlalchemy import text as sa_text
_validate_sql_name(table)
_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):
"""Безопасное создание таблицы (idempotent)."""
from sqlalchemy import text as sa_text
try:
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:
"""Валидация SQL-идентификатора (только буквы, цифры, подчёркивания)."""
import re
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_ ]*$', name):
raise ValueError(f"Invalid SQL identifier: {name}")
def _validate_sql_type(col_type: str) -> None:
"""Валидация SQL-типа колонки (разрешённые типы)."""
import re
allowed = r'^(VARCHAR\(\d+\)|TEXT|INTEGER( DEFAULT \d+)?|BOOLEAN( DEFAULT (TRUE|FALSE))?|TIMESTAMP)$'
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):
"""Запись лога обработки в bot_processing_logs."""
try:
async with async_session() as db:
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():
"""Заполнение настроек по умолчанию при первом запуске."""
defaults = {
"assistant_name": "София",
"assistant_role": "Представитель компании AegisOne Engineering, помогающий клиентам с запросами по безопасности",
@@ -185,6 +205,7 @@ async def _seed_default_settings():
async def _register_webhook():
"""Регистрация webhook-а в MAX Platform."""
try:
subs = await max_api.get_subscriptions()
logger.info(f"Current subscriptions: {subs}")
@@ -199,6 +220,7 @@ async def _register_webhook():
def _verify_secret(request: Request) -> bool:
"""Проверка webhook-секрета (пустой заголовок = OK для обратной совместимости)."""
secret = request.headers.get("X-Max-Bot-Api-Secret", "")
if not secret:
return True
@@ -206,6 +228,7 @@ def _verify_secret(request: Request) -> bool:
async def _get_active_conv_id(user_id: int) -> int | None:
"""Получение ID активного диалога пользователя."""
try:
async with async_session() as db:
result = await db.execute(
@@ -798,6 +821,7 @@ async def api_delete_conversation(conv_id: int):
@app.delete("/api/bot/users/{user_id}")
async def api_delete_bot_user(user_id: int):
"""Каскадное удаление пользователя: BotUser + BotConversation + BotMessage + BotProcessingLog. Заявки остаются."""
from app.models import BotMessage, BotProcessingLog
from sqlalchemy import delete as sa_delete
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 httpx
from typing import Optional
@@ -9,6 +17,7 @@ TEST_USER_THRESHOLD = 999999000
class MaxAPI:
"""Клиент MAX Platform API с поддержкой mock-реплик для тестов."""
def __init__(self):
self.base_url = settings.max_api_base
self.token = settings.max_token
@@ -65,6 +74,7 @@ class MaxAPI:
notify: bool = True,
conversation_id: int = None,
) -> dict:
"""Отправка сообщения. Если conversation_id — сохраняет outgoing BotMessage."""
if user_id >= TEST_USER_THRESHOLD:
logger.info(f"MOCK send_message to test user {user_id}: {text[:50]}")
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
import datetime
from sqlalchemy import Column, Integer, BigInteger, String, Text, Boolean, DateTime, ForeignKey, ARRAY
@@ -53,6 +70,7 @@ class BotKnowledgeBase(Base):
class BotUser(Base):
"""Пользователь бота: профиль, контакты, согласие на обработку ПД."""
__tablename__ = "bot_users"
id = Column(BigInteger, primary_key=True)
first_name = Column(String(255), nullable=True)
+6
View File
@@ -1,3 +1,9 @@
"""
In-memory кэш настроек бота с синхронизацией с БД.
При первом обращении загружает все настройки из bot_settings.
При set() обновляет и БД, и кэш.
"""
import asyncio
from typing import Optional, Dict
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 json
import logging
@@ -14,6 +20,7 @@ YANDEX_GPT_MODEL = "yandexgpt-lite"
class YandexGPT:
"""Клиент YandexGPT для анализа намерений и генерации ответов."""
def __init__(self):
self._api_key: Optional[str] = None
self._folder_id: Optional[str] = None
@@ -27,6 +34,7 @@ class YandexGPT:
return await self._load_credentials()
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():
return None
@@ -65,11 +73,13 @@ class YandexGPT:
return None
def _sanitize_input(self, text: str) -> str:
"""Очистка и обрезка входного текста (макс. 2000 символов)."""
text = text[:MAX_INPUT_LENGTH]
text = text.replace("\r\n", "\n").replace("\r", "\n")
return text
async def analyze_intent(self, text: str, history_context: str = "") -> str:
"""Анализ намерения: ticket/question/unknown. Aggressive ticket detection для услуг."""
safe_text = self._sanitize_input(text)
safe_history = self._sanitize_input(history_context) if history_context else ""
system_prompt = (
@@ -97,6 +107,7 @@ class YandexGPT:
return "unknown"
async def generate_answer(self, question: str, context: str, history_context: str = "") -> Optional[str]:
"""Генерация ответа на вопрос с контекстом из базы знаний."""
safe_question = self._sanitize_input(question)
safe_history = self._sanitize_input(history_context) if history_context else ""
system_prompt = (
@@ -116,6 +127,7 @@ class YandexGPT:
return await self._request(user_prompt, system_prompt)
async def clarify_intent(self, text: str, history_context: str = "") -> str:
"""Уточнение намерения: если есть слова-маркеры — отвечает без 'уточните'."""
safe_text = self._sanitize_input(text)
safe_history = self._sanitize_input(history_context) if history_context else ""
system_prompt = (
@@ -159,6 +171,7 @@ class YandexGPT:
return result or "Подскажите, чем я могу помочь? Вы хотите задать вопрос об услугах AegisOne или оставить заявку?"
async def generate_kb_card(self, user_questions: list) -> Optional[dict]:
"""Генерация карточки базы знаний из вопросов пользователей (JSON)."""
joined = "\n".join(f"- {q}" for q in user_questions)
system_prompt = (
"Ты — редактор базы знаний компании 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) |
| FastAPI | порт **8001** |
| FastAPI | порт **8002** |
| PostgreSQL | общая БД `aegisone` (порт 5432), таблицы с префиксом `bot_` |
| Домен | `max.aegisone.ru` |
| Директория | `/opt/projects/aegisone-py/max_bot/` |
| Контейнер | `aegisone-max-bot` |
| Webhook | `https://max.aegisone.ru/webhook` → localhost:8001 |
| Webhook | `https://max.aegisone.ru/webhook` → localhost:8002 |
| Токен бота | `id2311381465_bot` (хранится в bot_settings) |
**Структура:**
File diff suppressed because it is too large Load Diff
+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
## 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)
### Новые функции
- **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
from pathlib import Path
from pydantic_settings import BaseSettings
+4 -4
View File
@@ -153,8 +153,8 @@ async def tech_access_page(
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "engineer":
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
if user["role"] not in ("engineer", "owner"):
return HTMLResponse(content="Доступ только для инженеров и руководителей", status_code=403)
perms = discover_docs()
editable_docs = [
@@ -170,8 +170,8 @@ async def tech_access_update(
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "engineer":
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
if user["role"] not in ("engineer", "owner"):
return HTMLResponse(content="Доступ только для инженеров и руководителей", status_code=403)
form = await request.form()
perms = discover_docs()
+22
View File
@@ -1,3 +1,13 @@
"""
Главный модуль service portal FastAPI-приложения.
Портал для управления:
- Сотрудниками (owner/engineer/technician)
- Клиентами и объектами
- Задачами и инцидентами
- Настройками бота и базой знаний
- Документами и отчётами
"""
import os
import asyncio
from contextlib import asynccontextmanager
@@ -103,8 +113,20 @@ async def seed_initial_data():
async def lifespan(app: FastAPI):
from app.models.models import Base
from app.database import engine
# Применяем миграции (ALTER TABLE, CREATE TABLE IF NOT EXISTS)
from app.migrations_runner import run_migrations_on_startup
migration_results = await run_migrations_on_startup()
if migration_results:
import logging
logging.getLogger("migrations").info(
"Применены миграции: %s", ", ".join(migration_results)
)
# Создаём таблицы которые ещё не существуют (idempotent)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await seed_initial_data()
async with async_session() as db:
from app.calculations.engine import reload_coefficients
+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 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
@@ -18,9 +32,10 @@ class User(Base):
phone = Column(String(50), default="")
email = Column(String(255), default="")
max_user_id = Column(BigInteger, nullable=True)
extension = Column(String(10), default="") # Внутренний номер АТС (1001, 1002...)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = (
CheckConstraint("role IN ('owner','engineer','technician')", name="ck_users_role"),
@@ -41,17 +56,33 @@ class LoginAttempt(Base):
class AuditLog(Base):
"""Лог действий пользователей (аудит).
Записывает все изменения в системе: кто, что, когда изменил.
Используется для отслеживания дедупликации и ручных правок.
"""
__tablename__ = "audit_log"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
action = Column(String(255), nullable=False)
entity_type = Column(String(50), default="") # customer, lead, call_record, task
entity_id = Column(Integer, default=0)
field = Column(String(100), default="") # какое поле изменено
old_value = Column(Text, default="") # старое значение
new_value = Column(Text, default="") # новое значение
details = Column(Text)
ip_address = Column(String(45), default="")
created_at = Column(DateTime, nullable=False, server_default="NOW()")
class Customer(Base):
"""Клиент (контрагент).
Хранит контактные данные, ИНН/КПП, статус.
Статус 'draft' автосоздан системой из лида/звонка, ожидает заполнения.
Статус 'merged' объединён с другим клиентом.
"""
__tablename__ = "customers"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -64,12 +95,20 @@ class Customer(Base):
contact_email = Column(String(255), default="")
status = Column(String(20), nullable=False, default="active")
notes = Column(Text)
lead_source = Column(String(20), default="") # Откуда пришёл клиент
last_call_at = Column(DateTime) # Последний звонок
total_calls = Column(Integer, default=0) # Количество звонков
total_leads = Column(Integer, default=0) # Количество лидов
merged_into = Column(Integer, nullable=True) # ID клиента, с которым слили
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = (
CheckConstraint("status IN ('active','inactive','prospect')", name="ck_customers_status"),
CheckConstraint(
"status IN ('active','inactive','prospect','draft','merged')",
name="ck_customers_status"
),
)
objects = relationship("Object", back_populates="customer")
@@ -99,7 +138,7 @@ class Object(Base):
region_factor = Column(Numeric(3, 2), default=1.00)
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="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = (
CheckConstraint("status IN ('active','inactive','audit','prospective')", name="ck_objects_status"),
@@ -134,12 +173,19 @@ class ObjectAssignment(Base):
class Task(Base):
"""Задача (привязана к объекту).
Owner/engineer создают и назначают на любого.
Technician может создать только себе напоминание.
lead_id связь задачи с лидом (если задача создана из лида).
"""
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, autoincrement=True)
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
assigned_to = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
lead_id = Column(Integer, ForeignKey("leads.id", ondelete="SET NULL")) # Связь с лидом
title = Column(String(500), nullable=False)
description = Column(Text)
priority = Column(String(4), nullable=False, default="P3")
@@ -153,7 +199,7 @@ class Task(Base):
result_notes = Column(Text)
closed_at = Column(DateTime)
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__ = (
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")
client_confirmed = Column(Boolean, default=False)
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__ = (
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)
sla_price_monthly = Column(Numeric(12, 2), default=0)
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__ = (
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)
help_text = Column(String(500), default="")
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__ = (
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")
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="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = (
CheckConstraint("service_level IN ('start','business','enterprise')", name="ck_sla_level"),
@@ -359,7 +405,7 @@ class Incident(Base):
sla_breached = Column(Boolean, default=False)
resolved_at = Column(DateTime)
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__ = (
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")
published_at = Column(DateTime)
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__ = (
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="")
is_active = Column(Boolean, nullable=False, default=True)
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")
@@ -463,7 +509,7 @@ class Case(Base):
sort_order = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
class Idea(Base):
@@ -475,7 +521,7 @@ class Idea(Base):
status = Column(String(20), nullable=False, default="normal")
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="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
__table_args__ = (
CheckConstraint("status IN ('normal','in_progress','completed')", name="ck_ideas_status"),
@@ -493,7 +539,7 @@ class QuickMenuItem(Base):
url = Column(String(500), nullable=False)
sort_order = Column(Integer, nullable=False, default=0)
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")
@@ -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):
"""Прокси запросов от service portal к max_bot API."""
async with httpx.AsyncClient() as client:
if body is not None:
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}")
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}")
@router.delete("/api/bot/users/{user_id}")
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}")
+17
View File
@@ -164,6 +164,17 @@ img { max-width:100%; height:auto; }
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 {
display: inline-flex;
align-items: center;
@@ -576,6 +587,12 @@ table .actions button {
cursor: pointer;
}
.modal-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
/* ---------- TABS ---------- */
.tabs {
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>
<link rel="stylesheet" href="/service/static/css/service.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/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="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">
@@ -55,6 +55,7 @@
<div class="toast-container" id="toast-container"></div>
<script src="/service/static/js/theme.js"></script>
<script src="/service/static/js/role-switcher.js"></script>
<script src="/service/static/js/sort-table.js"></script>
<script>
function showNotification(msg, type){
type = type || 'success';
-10
View File
@@ -5,16 +5,6 @@
<div class="layout">
{% include "_sidebar.html" %}
<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 %}
<div class="shs-alert critical">⚠ {{ shs_alert }}</div>
{% endif %}
-12
View File
@@ -5,18 +5,6 @@
<div class="layout">
{% include "_sidebar.html" %}
<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 %}
</main>
</div>
@@ -10,10 +10,7 @@
<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 class="actions">
<form method="post" action="/service/assignments/unassign" style="display:inline">
<input type="hidden" name="id" value="{{ a.id }}">
<button class="btn btn-danger btn-sm">Отвязать</button>
</form>
<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>
</td>
</tr>{% endfor %}</tbody></table>
</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="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>
<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>
</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 %}
+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 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>
<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>
</tr>{% endfor %}</tbody></table>
</div>
@@ -33,7 +33,34 @@
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form>
</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>
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){
document.getElementById('modal-blog').classList.add('open');
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 .btn { min-width:140px; }
.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>
<div class="card">
@@ -93,10 +101,27 @@
</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>
var allConsents = [];
var confirmTimer = null;
var confirmSeconds = 10;
var pendingDeleteUserId = null;
var pendingDeleteUserName = null;
function loadConsents(){
var tbody = document.getElementById('consent-table-body');
@@ -135,11 +160,12 @@ function renderConsents(list){
'<td class="consent-yes">Да</td>' +
'<td style="font-size:12px;color:var(--text-muted);">' + dateStr + '</td>';
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>';
});
tbody.innerHTML = html;
if(typeof sortTables === 'function') sortTables();
}
function filterConsents(){
@@ -222,8 +248,24 @@ function escapeHtml(s){
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function confirmDeleteUser(userId, userName){
if(!confirm('Удалить пользователя ' + userName + ' (ID: ' + userId + ')?\n\nВсе его диалоги и сообщения будут удалены навсегда. Заявки останутся.')) return;
function openConfirmDeleteUser(userId, userName){
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' })
.then(function(r){ return r.json() })
.then(function(d){
@@ -1,30 +1,29 @@
{% extends "page.html" %}
{% block page_content %}
<div class="page-header">
<h1>Диалоги чат-бота</h1>
<div class="header-controls">
<input type="text" id="search-input" placeholder="Поиск по ID или имени..." oninput="filterConversations()" class="filter-input">
<button onclick="loadConversations()" class="btn btn-primary">Обновить</button>
<div class="card">
<div class="card-header"><h2>Диалоги чат-бота</h2>
<button onclick="loadConversations()" class="btn btn-primary btn-sm">Обновить</button>
</div>
<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>
<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%;">
<button type="button" class="modal-close" onclick="closeDialog()"></button>
<h2 id="dialog-title">Диалог</h2>
@@ -44,9 +43,9 @@
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
Диалог с <strong id="delete-conv-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
</p>
<div class="modal-actions" style="justify-content:flex-end;">
<button type="button" class="btn" onclick="closeDeleteModal()">Отмена</button>
<div class="form-actions" style="justify-content:flex-end;">
<button type="button" class="btn btn-danger" onclick="confirmDelete()">Удалить</button>
<button type="button" class="btn" onclick="closeDeleteModal()">Отмена</button>
</div>
</div>
</div>
@@ -76,13 +75,8 @@
.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.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: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 h2 { font-size:16px; margin-bottom:8px; }
#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-header"><span class="conv-user">' + escapeHtml(c.user_name) + '</span><span class="conv-id">ID: ' + c.id + '</span>';
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 class="conv-meta">';
@@ -149,7 +143,7 @@ function filterConversations(){
function openDialog(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; });
document.getElementById('dialog-title').textContent = 'Диалог с ' + (conv ? conv.user_name : '#' + convId);
showTab('messages');
@@ -157,7 +151,7 @@ function openDialog(convId){
}
function closeDialog(){
document.getElementById('dialog-modal').style.display = 'none';
document.getElementById('dialog-modal').classList.remove('open');
}
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.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-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 input, .filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
</style>
@@ -60,9 +47,10 @@
</table>
</div>
<div class="kb-modal" id="kb-modal">
<div class="kb-modal-content">
<h3 id="modal-title">Новая карточка</h3>
<div class="modal-overlay" id="kb-modal">
<div class="modal" style="max-width:600px;">
<button type="button" class="modal-close" onclick="closeModal()"></button>
<h2 id="modal-title">Новая карточка</h2>
<form id="kb-form">
<div class="form-group">
<label>Категория</label>
@@ -84,10 +72,8 @@
<label>URL источника</label>
<input type="url" name="source_url" id="kb-source-url" placeholder="https://aegisone.ru/...">
</div>
<div class="form-group">
<label><input type="checkbox" name="is_active" id="kb-is-active" checked> Активная карточка</label>
</div>
<div class="modal-actions">
<div class="form-check"><input type="checkbox" name="is_active" id="kb-is-active" checked><label for="kb-is-active">Активная карточка</label></div>
<div class="form-actions" style="justify-content:flex-end;">
<button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn" onclick="closeModal()">Отмена</button>
</div>
@@ -95,6 +81,19 @@
</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>
var categories = {};
var allCards = [];
@@ -126,10 +125,11 @@ function renderKB(cards){
'<td>' + catName + '</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><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>';
});
tbody.innerHTML = html;
if(typeof sortTables === 'function') sortTables();
}
function filterKB(){
@@ -173,8 +173,23 @@ function editCard(id){
document.getElementById('kb-modal').classList.add('open');
}
function deleteCard(id){
if(!confirm('Удалить карточку?')) return;
var deleteKBId = null;
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){
if(d.ok) { showNotification('Карточка удалена', 'success'); loadKB(); }
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-thumb { background:var(--border, #253248); border-radius:3px; }
.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>
<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);})();
@@ -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('question')">Общий вопрос</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 class="test-container">
<div class="test-chat">
@@ -56,6 +64,21 @@ body { font-family:'Inter','Segoe UI',sans-serif; background:var(--bg-body, #1B2
</div>
<div class="test-log" id="test-log"></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>
var testUserId = 999999001;
function log(event, detail){
@@ -156,8 +179,14 @@ function clearTest(){
document.getElementById('test-input').value = '';
log('info', 'Тест очищен');
}
function clearTestData(){
if(!confirm('Удалить все тестовые диалоги, сообщения и логи обработки? Это действие нельзя отменить.')) return;
function openConfirmClearTest(){
document.getElementById('confirm-clear-test').classList.add('open');
}
function closeConfirmClearTest(){
document.getElementById('confirm-clear-test').classList.remove('open');
}
function confirmClearTest(){
closeConfirmClearTest();
log('info', 'Очистка тестовых данных...');
fetch('/api/bot/test/clear', {method: 'POST'}).then(function(r){ return r.json() }).then(function(d){
if(d && d.ok){
@@ -121,6 +121,7 @@ function renderTickets(tickets){
'</tr>';
});
tbody.innerHTML = html;
if(typeof sortTables === 'function') sortTables();
}
function filterTickets(){
+37 -17
View File
@@ -10,16 +10,6 @@
.badge.auto_added { background:#cce5ff; color:#004085; }
.badge.reviewed { background:#d4edda; color:#155724; }
.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 select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
</style>
@@ -55,8 +45,9 @@
</div>
<div class="modal-overlay" id="uq-modal">
<div class="modal-box">
<h3 id="uq-modal-title">Проверить вопрос</h3>
<div class="modal" style="max-width:650px;">
<button type="button" class="modal-close" onclick="closeModal()"></button>
<h2 id="uq-modal-title">Проверить вопрос</h2>
<form id="uq-form">
<input type="hidden" id="uq-edit-id">
<div class="form-group">
@@ -88,7 +79,7 @@
<option value="8">Отрасли и кейсы</option>
</select>
</div>
<div class="modal-actions">
<div class="form-actions" style="justify-content:flex-end;">
<button type="submit" class="btn btn-primary">Опубликовать в базу знаний</button>
<button type="button" class="btn" onclick="closeModal()">Отмена</button>
</div>
@@ -96,6 +87,19 @@
</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>
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>';
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-sm" onclick="ignoreUQ(' + q.id + ')" style="color:#dc3545;" title="Игнорировать">🗑</button>';
html += '<button class="btn btn-secondary btn-sm" onclick="openReview(' + q.id + ')" 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>';
});
tbody.innerHTML = html;
if(typeof sortTables === 'function') sortTables();
}
function openReview(id){
@@ -175,8 +180,23 @@ document.getElementById('uq-form').addEventListener('submit', function(e){
});
});
function ignoreUQ(id){
if(!confirm('Игнорировать вопрос?')) return;
var ignoreUQId = null;
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' })
.then(function(r){ return r.json() }).then(function(d){
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 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>
<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>
</tr>{% endfor %}</tbody></table>
</div>
@@ -31,7 +31,34 @@
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form>
</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>
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){
document.getElementById('modal-case').classList.add('open');
document.getElementById('modal-case-title').textContent = 'Редактировать';
+69 -5
View File
@@ -1,5 +1,17 @@
{% extends "page.html" %}
{% 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-header"><h2>Графики и диаграммы</h2></div>
<div class="filter-bar" style="flex-wrap:wrap;gap:8px;">
@@ -30,21 +42,49 @@
</div>
<div class="filter-bar" style="margin-top:8px;flex-wrap:wrap;gap:6px;">
<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)">
<option value="">— Загрузить —</option>
</select>
<button class="btn btn-danger btn-xs" onclick="deleteCustomChart()">Удалить</button>
<button class="btn btn-danger btn-sm" onclick="deleteCustomChart()">Удалить</button>
</div>
<div style="position:relative;height:400px;">
<canvas id="chart-canvas"></canvas>
</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>
var chartInstance = null;
var currentChartConfig = null;
var pendingDeleteChartName = null;
function loadChart(){
var metric = document.getElementById('metric-select').value;
@@ -107,8 +147,19 @@ function exportChart(){
function saveCustomChart(){
if(!currentChartConfig) return;
var name = prompt('Название графика:');
if(!name) return;
document.getElementById('chart-name-input').value = '';
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') || '[]');
saved.push({ name: name, config: currentChartConfig });
localStorage.setItem('custom_charts', JSON.stringify(saved));
@@ -131,7 +182,20 @@ function deleteCustomChart(){
var sel = document.getElementById('custom-chart-select');
var name = sel.value;
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') || '[]');
saved = saved.filter(function(c){ return c.name !== name; });
localStorage.setItem('custom_charts', JSON.stringify(saved));
@@ -2,7 +2,7 @@
{% block page_content %}
{% for t in tasks %}
<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;">
{{ 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>
@@ -33,7 +33,7 @@
<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 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>
</tr>{% endfor %}</tbody></table>
</div>
@@ -65,7 +65,35 @@
<div id="formula-info-content" style="margin-top:12px"></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>
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) {
var arrow = el.querySelector('.accordion-arrow');
var body = el.nextElementSibling;
+30 -4
View File
@@ -3,10 +3,9 @@
<div class="card">
<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 %}
</div>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-customer').classList.add('open')">+ Добавить</button>
</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">
<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>
@@ -18,8 +17,8 @@
<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>
{% if user.role == 'owner' %}
<form method="post" action="/service/customers/delete" style="display:inline" onsubmit="return confirm('Удалить клиента?')">
<input type="hidden" name="id" value="{{ d.c.id }}"><button class="btn btn-danger btn-sm"></button>
<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 type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteCustomer({{ d.c.id }},'{{ d.c.name|e }}')" title="Удалить"></button>
</form>{% endif %}
</td>
</tr>{% endfor %}</tbody></table>
@@ -53,7 +52,34 @@
</div>
</form>
</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>
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){
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;
+29 -1
View File
@@ -161,7 +161,7 @@
<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 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>
</tr>{% endfor %}</tbody></table>
</div>
@@ -199,7 +199,35 @@
<div id="formula-info-content" style="margin-top:12px"></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>
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){
document.querySelectorAll('.tab-content').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>
</form>
<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 }}">
<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>
</td>
</tr>{% endfor %}</tbody></table>
@@ -38,7 +38,35 @@
</form>
</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>
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){
document.getElementById('modal-idea').classList.add('open');
document.getElementById('modal-idea-title').textContent = 'Редактировать идею';
+28 -1
View File
@@ -17,7 +17,7 @@
<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>
{% 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 %}
</td>
</tr>{% endfor %}</tbody></table>
@@ -48,7 +48,34 @@
</div>
</form>
</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>
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){
document.getElementById('modal-object').classList.add('open');
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 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>
<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>
</tr>{% endfor %}</tbody></table>
</div>
@@ -40,7 +40,34 @@
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form>
</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>
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){
document.getElementById('modal-qitem').classList.add('open');
document.getElementById('modal-qitem-title').textContent = 'Редактировать вопрос';
@@ -99,7 +99,7 @@ function loadPermissions(){
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({role: role, permissions: perms})
}).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><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) %}
<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 %}
</td>
</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>
</form>
</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 %}
+29 -2
View File
@@ -16,9 +16,9 @@
<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>
{% 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 }}">
<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 %}
</td>
</tr>{% endfor %}</tbody></table>
@@ -53,7 +53,34 @@
</form>
</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>
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){
document.getElementById('modal-user').classList.add('open');
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
pydantic==2.10.3
pydantic-settings==2.7.0
cryptography>=44.0.0
# Test
pytest>=9.0.0
+1 -1
View File
@@ -1 +1 @@
1.8.2
1.9.0