Compare commits
12 Commits
4c3026a80f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e2e8919ad | |||
| f06ecaabe3 | |||
| e29e2984da | |||
| 95ac20987f | |||
| 1199407a72 | |||
| b2a270cb2e | |||
| e184e5fe79 | |||
| df34048e2a | |||
| 5bb9ffd459 | |||
| 8c1486da8b | |||
| e7f670f21c | |||
| a7a39bdd5d |
@@ -10,6 +10,16 @@
|
|||||||
0.2. **Любое общение только на русском языке.** Код, комментарии, коммиты, CHANGELOG — всё по-русски.
|
0.2. **Любое общение только на русском языке.** Код, комментарии, коммиты, CHANGELOG — всё по-русски.
|
||||||
0.3. **Если предлагаешь варианты — обязательно указывай рекомендацию и пояснение.**
|
0.3. **Если предлагаешь варианты — обязательно указывай рекомендацию и пояснение.**
|
||||||
|
|
||||||
|
### 0.4. Обязательные файлы для чтения
|
||||||
|
|
||||||
|
Перед началом работы агент ОБЯЗАН прочитать:
|
||||||
|
|
||||||
|
| Файл | Содержание | Когда читать |
|
||||||
|
|------|------------|--------------|
|
||||||
|
| `AGENTS.md` | Правила проекта (этот файл) | Всегда, перед началом работы |
|
||||||
|
| `other/AI-EXISTING-TWO-PROJECT-INSTRUCTION.md` | Архитектура сервера, порты, Docker-проекты, команды | Перед работой с сервером, деплоем, диагностикой |
|
||||||
|
| `py_service/SERVICE_STYLE_GUIDE.md` | Стили UI, CSS-классы, паттерны шаблонов | Перед работой с фронтендом портала |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Архитектура проекта
|
## 1. Архитектура проекта
|
||||||
@@ -22,6 +32,8 @@
|
|||||||
|
|
||||||
**ВАЖНО:** Публичная часть полностью готова и не подлежит изменениям!
|
**ВАЖНО:** Публичная часть полностью готова и не подлежит изменениям!
|
||||||
|
|
||||||
|
**Подробная архитектура сервера:** `other/AI-EXISTING-TWO-PROJECT-INSTRUCTION.md` — порты, Docker-проекты, команды, troubleshooting.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Версионирование
|
## 2. Версионирование
|
||||||
@@ -223,3 +235,28 @@ docker logs aegisone-app --tail 10
|
|||||||
- Следуй существующему стилю кода
|
- Следуй существующему стилю кода
|
||||||
- Удаляй неиспользуемый код при изменениях
|
- Удаляй неиспользуемый код при изменениях
|
||||||
- Не добавляй лишних зависимостей без необходимости
|
- Не добавляй лишних зависимостей без необходимости
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Стили UI (сервисный портал)
|
||||||
|
|
||||||
|
- **Единый гайд:** `py_service/SERVICE_STYLE_GUIDE.md` — читать перед работой с фронтендом
|
||||||
|
- **Нельзя** использовать `confirm()`, `alert()`, `prompt()` — только `showNotification()` и кастомные модалки
|
||||||
|
- **Удаление:** простая модалка (Variant A), кнопки: **Действие** (слева) → **Отмена** (справа)
|
||||||
|
- **Иконки:** `✕` = удалить, `✎` = редактировать, `+` = создать
|
||||||
|
- **Модалки:** `.modal-overlay` + `.modal`, открытие через `classList.add('open')`
|
||||||
|
- **Структура страницы:** `card` > `card-header` > `filter-bar` > `table-wrap` > `table`
|
||||||
|
- **CSS классы кнопок:** `.btn-primary` (создать), `.btn-secondary` (редактировать), `.btn-danger` (удалить), `.btn-success` (выполнено)
|
||||||
|
- **Сортировка таблиц:** `sort-table.js` подключён глобально в `base.html`, автоматически сортирует все `<table>` по клику на `<th>` (текст/число/дата)
|
||||||
|
- **Number input:** спиннеры скрыты глобально через CSS (`service.css`), `type="number"` выглядит как обычный input
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Идеи
|
||||||
|
|
||||||
|
- **Страница:** `/service/ideas`
|
||||||
|
- **Агент может:** видеть список идей, добавлять новые, менять статус, предлагать к реализации, реализовывать
|
||||||
|
- **Статусы:** `normal` → `in_progress` → `completed` → `normal`
|
||||||
|
- **API:** POST `/service/ideas/create` (title, description), POST `/service/ideas/status` (id), POST `/service/ideas/delete` (id)
|
||||||
|
- Агент может добавить идею по просьбе пользователя через API
|
||||||
|
- Агент может реализовать идею, создав/изменив код, и предложить к реализации
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
"""
|
||||||
|
Конфигурация max_bot (Pydantic Settings).
|
||||||
|
|
||||||
|
Читает из .env с префиксом BOT_:
|
||||||
|
- database_url: PostgreSQL URL
|
||||||
|
- max_token: токен MAX Platform
|
||||||
|
- webhook_url/webhook_secret: настройки webhook
|
||||||
|
- config_php_path: путь к config.php сайта
|
||||||
|
"""
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
"""
|
||||||
|
Чтение конфигурации из config.php (AegisOne сайт).
|
||||||
|
|
||||||
|
Парсит PHP define() константы и возвращает их как dict.
|
||||||
|
Fallback на hardcoded значения если файл не найден.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
"""
|
||||||
|
Асинхронное подключение к PostgreSQL через SQLAlchemy 2.0.
|
||||||
|
|
||||||
|
engine: create_async_engine с pool_size=10
|
||||||
|
async_session: async_sessionmaker для создания сессий
|
||||||
|
"""
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Отправка email через sendmail (с fallback на логирование в файл).
|
||||||
|
|
||||||
|
Используется для уведомлений о заявках.
|
||||||
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
"""
|
||||||
|
Обработчик согласия на обработку персональных данных.
|
||||||
|
|
||||||
|
Управляет жизненным циклом согласия:
|
||||||
|
- запрос согласия → да/нет
|
||||||
|
- отзыв согласия (consent_revoked_date)
|
||||||
|
- повторное согласие после отказа
|
||||||
|
"""
|
||||||
import datetime
|
import datetime
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
@@ -12,6 +20,12 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def _matches_any(text: str, words: list) -> bool:
|
def _matches_any(text: str, words: list) -> bool:
|
||||||
|
"""
|
||||||
|
Проверяет, содержит ли текст хотя бы одно из указанных слов.
|
||||||
|
|
||||||
|
Использует split для точного совпадения коротких слов (≤3 символов)
|
||||||
|
и подстроку для длинных (>3 символов).
|
||||||
|
"""
|
||||||
text_words = set(text.split())
|
text_words = set(text.split())
|
||||||
for w in words:
|
for w in words:
|
||||||
if w in text_words:
|
if w in text_words:
|
||||||
@@ -22,6 +36,7 @@ def _matches_any(text: str, words: list) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def handle_consent_callback(user_id: int, conv_id: int, callback_id: str) -> None:
|
async def handle_consent_callback(user_id: int, conv_id: int, callback_id: str) -> None:
|
||||||
|
"""Маршрутизация callback-кнопок согласия (да/нет)."""
|
||||||
if callback_id == "consent_yes":
|
if callback_id == "consent_yes":
|
||||||
await handle_consent_yes(user_id, conv_id)
|
await handle_consent_yes(user_id, conv_id)
|
||||||
elif callback_id == "consent_no":
|
elif callback_id == "consent_no":
|
||||||
@@ -29,6 +44,7 @@ async def handle_consent_callback(user_id: int, conv_id: int, callback_id: str)
|
|||||||
|
|
||||||
|
|
||||||
async def handle_consent_response(user_id: int, conv_id: int, text: str) -> None:
|
async def handle_consent_response(user_id: int, conv_id: int, text: str) -> None:
|
||||||
|
"""Обработка текстового ответа на запрос согласия (да/нет/другое)."""
|
||||||
text_lower = text.strip().lower()
|
text_lower = text.strip().lower()
|
||||||
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие"]
|
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие"]
|
||||||
refuse_words = ["нет", "не даю", "отказ", "no", "не согласен", "не согласна"]
|
refuse_words = ["нет", "не даю", "отказ", "no", "не согласен", "не согласна"]
|
||||||
@@ -48,6 +64,7 @@ async def handle_consent_response(user_id: int, conv_id: int, text: str) -> None
|
|||||||
|
|
||||||
|
|
||||||
async def handle_consent_yes(user_id: int, conv_id: int) -> None:
|
async def handle_consent_yes(user_id: int, conv_id: int) -> None:
|
||||||
|
"""Обработка согласия: устанавливает consent_given=True, переводит в awaiting_contact."""
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
result = await db.execute(select(BotUser).where(BotUser.id == user_id))
|
result = await db.execute(select(BotUser).where(BotUser.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
@@ -78,6 +95,7 @@ async def handle_consent_yes(user_id: int, conv_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def handle_consent_no(user_id: int, conv_id: int) -> None:
|
async def handle_consent_no(user_id: int, conv_id: int) -> None:
|
||||||
|
"""Обработка отказа: устанавливает consent_refused, показывает контакты."""
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(BotConversation).where(BotConversation.id == conv_id)
|
select(BotConversation).where(BotConversation.id == conv_id)
|
||||||
@@ -106,6 +124,7 @@ async def handle_consent_no(user_id: int, conv_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def handle_refused_again(user_id: int, conv_id: int, text: str) -> None:
|
async def handle_refused_again(user_id: int, conv_id: int, text: str) -> None:
|
||||||
|
"""Повторная обработка после отказа: позволяет дать согласие или показывает контакты."""
|
||||||
text_lower = text.strip().lower()
|
text_lower = text.strip().lower()
|
||||||
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие", "передумал", "согласен дать"]
|
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие", "передумал", "согласен дать"]
|
||||||
refuse_words = ["нет", "не даю", "не хочу", "отказ", "отмена", "no", "cancel", "я отказался"]
|
refuse_words = ["нет", "не даю", "не хочу", "отказ", "отмена", "no", "cancel", "я отказался"]
|
||||||
@@ -145,6 +164,7 @@ async def handle_refused_again(user_id: int, conv_id: int, text: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def handle_revoke_consent(user_id: int, conv_id: int) -> None:
|
async def handle_revoke_consent(user_id: int, conv_id: int) -> None:
|
||||||
|
"""Отзыв согласия: устанавливает consent_given=False, consent_revoked_date=now."""
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
user = await db.get(BotUser, user_id)
|
user = await db.get(BotUser, user_id)
|
||||||
if user and user.consent_given:
|
if user and user.consent_given:
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
"""
|
||||||
|
Обработчик контактов пользователя.
|
||||||
|
|
||||||
|
Принимает контакты (телефон, email) через:
|
||||||
|
- sharing контакта из мессенджера (VCF)
|
||||||
|
- ручной ввод текстом
|
||||||
|
"""
|
||||||
import datetime
|
import datetime
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
@@ -13,6 +20,7 @@ EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
|
|||||||
|
|
||||||
|
|
||||||
async def handle_contact_received(user_id: int, conv_id: int, contact_text: str) -> None:
|
async def handle_contact_received(user_id: int, conv_id: int, contact_text: str) -> None:
|
||||||
|
"""Обработка контакта из мессенджера: парсинг телефона/email, переход к заявке."""
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
result = await db.execute(select(BotUser).where(BotUser.id == user_id))
|
result = await db.execute(select(BotUser).where(BotUser.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
@@ -37,6 +45,7 @@ async def handle_contact_received(user_id: int, conv_id: int, contact_text: str)
|
|||||||
|
|
||||||
|
|
||||||
async def handle_manual_contact(user_id: int, conv_id: int, text: str) -> None:
|
async def handle_manual_contact(user_id: int, conv_id: int, text: str) -> None:
|
||||||
|
"""Обработка ручного ввода контакта: валидация телефона/email, отказ → consent_refused."""
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
text_lower = text.lower()
|
text_lower = text.lower()
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
"""
|
||||||
|
Обработчик приветствий и основных сообщений бота.
|
||||||
|
|
||||||
|
Управляет жизненным циклом диалога:
|
||||||
|
- приветствие → создание пользователя и диалога
|
||||||
|
- маршрутизация по состояниям (state machine)
|
||||||
|
- анализ намерений через YandexGPT
|
||||||
|
- быстрые паттерны (заявка/приветствие) до NLP
|
||||||
|
- контекст из последних 10 минут
|
||||||
|
"""
|
||||||
import logging
|
import logging
|
||||||
import datetime
|
import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -7,6 +17,7 @@ from app.database import async_session
|
|||||||
MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3))
|
MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3))
|
||||||
|
|
||||||
def moscow_now():
|
def moscow_now():
|
||||||
|
"""Возвращает текущее время в UTC+3 (Москва) без tzinfo."""
|
||||||
return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None)
|
return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None)
|
||||||
from app.models import BotUser, BotConversation, BotMessage, BotKnowledgeBase, BotCategory, BotUnknownQuestion
|
from app.models import BotUser, BotConversation, BotMessage, BotKnowledgeBase, BotCategory, BotUnknownQuestion
|
||||||
from app.max_api import max_api
|
from app.max_api import max_api
|
||||||
@@ -26,6 +37,7 @@ async def handle_greeting(
|
|||||||
last_name: str = "",
|
last_name: str = "",
|
||||||
username: str = "",
|
username: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Обработка приветствия: создаёт/обновляет пользователя, создаёт диалог, отправляет приветствие."""
|
||||||
assistant_name = await settings_cache.get("assistant_name", "София")
|
assistant_name = await settings_cache.get("assistant_name", "София")
|
||||||
|
|
||||||
greeting_text = (
|
greeting_text = (
|
||||||
@@ -94,6 +106,14 @@ async def handle_greeting(
|
|||||||
|
|
||||||
|
|
||||||
async def handle_message(user_id: int, text: str) -> None:
|
async def handle_message(user_id: int, text: str) -> None:
|
||||||
|
"""
|
||||||
|
Основной обработчик сообщений. Маршрутизирует по состояниям:
|
||||||
|
- greeting/analyzing/awaiting_input → analyze_and_respond
|
||||||
|
- awaiting_consent/consent_refused → обработка согласия
|
||||||
|
- awaiting_contact → ручной ввод контакта
|
||||||
|
- awaiting_inquiry → обработка заявки
|
||||||
|
- completed/escalated → повторный анализ (escape hatch)
|
||||||
|
"""
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(BotConversation)
|
select(BotConversation)
|
||||||
@@ -199,6 +219,7 @@ async def handle_message(user_id: int, text: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _get_user_history_context(user_id: int) -> str:
|
async def _get_user_history_context(user_id: int) -> str:
|
||||||
|
"""Формирует контекст из последних сообщений (10 мин) и открытых заявок пользователя."""
|
||||||
from app.models import BotTicket
|
from app.models import BotTicket
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
ten_min_ago = moscow_now() - datetime.timedelta(minutes=10)
|
ten_min_ago = moscow_now() - datetime.timedelta(minutes=10)
|
||||||
@@ -237,12 +258,20 @@ async def _get_user_history_context(user_id: int) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def _select_ai():
|
async def _select_ai():
|
||||||
|
"""Выбор AI-провайдера (только YandexGPT)."""
|
||||||
if await yandex_gpt.is_available():
|
if await yandex_gpt.is_available():
|
||||||
return yandex_gpt
|
return yandex_gpt
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def analyze_and_respond(user_id: int, conv_id: int, text: str) -> None:
|
async def analyze_and_respond(user_id: int, conv_id: int, text: str) -> None:
|
||||||
|
"""
|
||||||
|
Анализ намерений и генерация ответа.
|
||||||
|
|
||||||
|
Быстрые паттерны (заявка/приветствие) проверяются ПЕРЕД NLP.
|
||||||
|
Затем: analyze_intent → ticket/question/unknown → обработка.
|
||||||
|
clarify_intent вызывается не более 1 раза за диалог.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info(f"analyze_and_respond: user={user_id} conv={conv_id} text={text[:80]}")
|
logger.info(f"analyze_and_respond: user={user_id} conv={conv_id} text={text[:80]}")
|
||||||
|
|
||||||
@@ -359,6 +388,7 @@ async def analyze_and_respond(user_id: int, conv_id: int, text: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _limited_mode(user_id: int, conv_id: int) -> None:
|
async def _limited_mode(user_id: int, conv_id: int) -> None:
|
||||||
|
"""Режим без AI: показывает контакты компании."""
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(BotConversation).where(BotConversation.id == conv_id)
|
select(BotConversation).where(BotConversation.id == conv_id)
|
||||||
@@ -372,6 +402,7 @@ async def _limited_mode(user_id: int, conv_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _send_contacts(user_id: int, conv_id: int = None) -> None:
|
async def _send_contacts(user_id: int, conv_id: int = None) -> None:
|
||||||
|
"""Отправка контактных данных компании (телефон, email)."""
|
||||||
phones = config_reader.get_phones()
|
phones = config_reader.get_phones()
|
||||||
email = config_reader.get_site_mail()
|
email = config_reader.get_site_mail()
|
||||||
|
|
||||||
@@ -387,6 +418,7 @@ async def _send_contacts(user_id: int, conv_id: int = None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _get_knowledge_base_context(query: str) -> str:
|
async def _get_knowledge_base_context(query: str) -> str:
|
||||||
|
"""Формирует контекст из базы знаний по ключевым словам (ранжирование по score)."""
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(BotKnowledgeBase)
|
select(BotKnowledgeBase)
|
||||||
@@ -433,6 +465,7 @@ async def _get_knowledge_base_context(query: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _texts_match(text_a: str, text_b: str) -> bool:
|
def _texts_match(text_a: str, text_b: str) -> bool:
|
||||||
|
"""Проверка схожести двух текстов по пересечению слов (≥50%)."""
|
||||||
stopwords = {"какой", "какая", "какие", "какое", "ваш", "ваша", "ваши",
|
stopwords = {"какой", "какая", "какие", "какое", "ваш", "ваша", "ваши",
|
||||||
"где", "когда", "сколько", "что", "это", "или", "для", "при",
|
"где", "когда", "сколько", "что", "это", "или", "для", "при",
|
||||||
"подскажите", "расскажите", "объясните", "пожалуйста"}
|
"подскажите", "расскажите", "объясните", "пожалуйста"}
|
||||||
@@ -445,6 +478,7 @@ def _texts_match(text_a: str, text_b: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def _auto_generate_and_add_kb(db, question_record):
|
async def _auto_generate_and_add_kb(db, question_record):
|
||||||
|
"""Автогенерация карточки KB через YandexGPT при 3+ обращениях."""
|
||||||
card_data = await yandex_gpt.generate_kb_card([question_record.question_text])
|
card_data = await yandex_gpt.generate_kb_card([question_record.question_text])
|
||||||
if not card_data:
|
if not card_data:
|
||||||
return
|
return
|
||||||
@@ -466,6 +500,7 @@ async def _auto_generate_and_add_kb(db, question_record):
|
|||||||
|
|
||||||
|
|
||||||
async def _save_unknown_question(user_id: int, question_text: str) -> None:
|
async def _save_unknown_question(user_id: int, question_text: str) -> None:
|
||||||
|
"""Сохранение неизвестного вопроса с дедупликацией и автогенерацией при 3+ обращениях."""
|
||||||
normalized_input = question_text.lower().strip()
|
normalized_input = question_text.lower().strip()
|
||||||
|
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Обработчик callback-кнопок и шаринга контактов.
|
||||||
|
|
||||||
|
Маршрутизирует callback-и по типу (consent_* и др.).
|
||||||
|
"""
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -10,6 +15,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def handle_callback(user_id: int, callback_id: str, callback_data: dict) -> None:
|
async def handle_callback(user_id: int, callback_id: str, callback_data: dict) -> None:
|
||||||
|
"""Маршрутизация callback-кнопок (consent_* → consent handler)."""
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(BotConversation)
|
select(BotConversation)
|
||||||
@@ -40,6 +46,7 @@ async def handle_contact_share(
|
|||||||
vcf_data: dict = None,
|
vcf_data: dict = None,
|
||||||
contact_hash: str = "",
|
contact_hash: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Обработка шаринга контакта из мессенджера: парсинг VCF, сохранение в BotUser."""
|
||||||
if vcf_data is None:
|
if vcf_data is None:
|
||||||
vcf_data = {}
|
vcf_data = {}
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
"""
|
||||||
|
Обработчик заявок (inquiry).
|
||||||
|
|
||||||
|
Создаёт BotTicket с контактными данными пользователя,
|
||||||
|
отправляет уведомление на email, подтверждает пользователю.
|
||||||
|
"""
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -12,10 +18,12 @@ logger = logging.getLogger(__name__)
|
|||||||
MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3))
|
MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3))
|
||||||
|
|
||||||
def moscow_now():
|
def moscow_now():
|
||||||
|
"""Возвращает текущее время в UTC+3 (Москва) без tzinfo."""
|
||||||
return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None)
|
return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
async def handle_inquiry(user_id: int, conv_id: int, text: str) -> None:
|
async def handle_inquiry(user_id: int, conv_id: int, text: str) -> None:
|
||||||
|
"""Обработка заявки: создаёт.ticket, отправляет email, подтверждает пользователю."""
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(BotConversation).where(BotConversation.id == conv_id)
|
select(BotConversation).where(BotConversation.id == conv_id)
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
"""
|
||||||
|
Клавиатуры для MAX Platform бота.
|
||||||
|
|
||||||
|
Типы кнопок:
|
||||||
|
- callback: callback-кнопка (consent_yes/consent_no)
|
||||||
|
- link: ссылка (политика конфиденциальности)
|
||||||
|
- request_contact: запрос контакта из мессенджера
|
||||||
|
- message: текстовая кнопка
|
||||||
|
"""
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
POLITICA_URL = "https://aegisone.ru/politica.php"
|
POLITICA_URL = "https://aegisone.ru/politica.php"
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
"""
|
||||||
|
Главный модуль max_bot FastAPI-приложения.
|
||||||
|
|
||||||
|
Эндпоинты:
|
||||||
|
- /webhook: входящие webhook-и от MAX Platform
|
||||||
|
- /health: healthcheck
|
||||||
|
- /api/bot/*: CRUD для настроек, KB, заявок, диалогов, пользователей
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@@ -33,6 +41,11 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("Database tables ensured.")
|
logger.info("Database tables ensured.")
|
||||||
|
|
||||||
await _apply_migrations()
|
await _apply_migrations()
|
||||||
|
|
||||||
|
# Применение SQL-миграций из директории migrations/
|
||||||
|
from app.migrations_runner import run_migrations
|
||||||
|
await run_migrations()
|
||||||
|
|
||||||
await _seed_default_settings()
|
await _seed_default_settings()
|
||||||
await _register_webhook()
|
await _register_webhook()
|
||||||
|
|
||||||
@@ -54,6 +67,7 @@ app.add_middleware(
|
|||||||
|
|
||||||
|
|
||||||
async def _apply_migrations():
|
async def _apply_migrations():
|
||||||
|
"""Автоматические миграции: добавление колонок/таблиц при старте."""
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
try:
|
try:
|
||||||
await conn.execute(text("""
|
await conn.execute(text("""
|
||||||
@@ -101,6 +115,7 @@ async def _apply_migrations():
|
|||||||
|
|
||||||
|
|
||||||
async def _add_column_if_not_exists(conn, table: str, column: str, col_type: str):
|
async def _add_column_if_not_exists(conn, table: str, column: str, col_type: str):
|
||||||
|
"""Безопасное добавление колонки (idempotent)."""
|
||||||
from sqlalchemy import text as sa_text
|
from sqlalchemy import text as sa_text
|
||||||
_validate_sql_name(table)
|
_validate_sql_name(table)
|
||||||
_validate_sql_name(column)
|
_validate_sql_name(column)
|
||||||
@@ -121,6 +136,7 @@ async def _add_column_if_not_exists(conn, table: str, column: str, col_type: str
|
|||||||
|
|
||||||
|
|
||||||
async def _create_table_if_not_exists(conn, table: str, create_sql: str):
|
async def _create_table_if_not_exists(conn, table: str, create_sql: str):
|
||||||
|
"""Безопасное создание таблицы (idempotent)."""
|
||||||
from sqlalchemy import text as sa_text
|
from sqlalchemy import text as sa_text
|
||||||
try:
|
try:
|
||||||
await conn.execute(sa_text(f"""
|
await conn.execute(sa_text(f"""
|
||||||
@@ -138,12 +154,14 @@ async def _create_table_if_not_exists(conn, table: str, create_sql: str):
|
|||||||
|
|
||||||
|
|
||||||
def _validate_sql_name(name: str) -> None:
|
def _validate_sql_name(name: str) -> None:
|
||||||
|
"""Валидация SQL-идентификатора (только буквы, цифры, подчёркивания)."""
|
||||||
import re
|
import re
|
||||||
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_ ]*$', name):
|
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_ ]*$', name):
|
||||||
raise ValueError(f"Invalid SQL identifier: {name}")
|
raise ValueError(f"Invalid SQL identifier: {name}")
|
||||||
|
|
||||||
|
|
||||||
def _validate_sql_type(col_type: str) -> None:
|
def _validate_sql_type(col_type: str) -> None:
|
||||||
|
"""Валидация SQL-типа колонки (разрешённые типы)."""
|
||||||
import re
|
import re
|
||||||
allowed = r'^(VARCHAR\(\d+\)|TEXT|INTEGER( DEFAULT \d+)?|BOOLEAN( DEFAULT (TRUE|FALSE))?|TIMESTAMP)$'
|
allowed = r'^(VARCHAR\(\d+\)|TEXT|INTEGER( DEFAULT \d+)?|BOOLEAN( DEFAULT (TRUE|FALSE))?|TIMESTAMP)$'
|
||||||
if not re.match(allowed, col_type.strip()):
|
if not re.match(allowed, col_type.strip()):
|
||||||
@@ -151,6 +169,7 @@ def _validate_sql_type(col_type: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _log_processing(user_id: int, step: str, status: str = "ok", message: str = None, detail: str = None, conversation_id: int = None):
|
async def _log_processing(user_id: int, step: str, status: str = "ok", message: str = None, detail: str = None, conversation_id: int = None):
|
||||||
|
"""Запись лога обработки в bot_processing_logs."""
|
||||||
try:
|
try:
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
db.add(BotProcessingLog(
|
db.add(BotProcessingLog(
|
||||||
@@ -167,6 +186,7 @@ async def _log_processing(user_id: int, step: str, status: str = "ok", message:
|
|||||||
|
|
||||||
|
|
||||||
async def _seed_default_settings():
|
async def _seed_default_settings():
|
||||||
|
"""Заполнение настроек по умолчанию при первом запуске."""
|
||||||
defaults = {
|
defaults = {
|
||||||
"assistant_name": "София",
|
"assistant_name": "София",
|
||||||
"assistant_role": "Представитель компании AegisOne Engineering, помогающий клиентам с запросами по безопасности",
|
"assistant_role": "Представитель компании AegisOne Engineering, помогающий клиентам с запросами по безопасности",
|
||||||
@@ -185,6 +205,7 @@ async def _seed_default_settings():
|
|||||||
|
|
||||||
|
|
||||||
async def _register_webhook():
|
async def _register_webhook():
|
||||||
|
"""Регистрация webhook-а в MAX Platform."""
|
||||||
try:
|
try:
|
||||||
subs = await max_api.get_subscriptions()
|
subs = await max_api.get_subscriptions()
|
||||||
logger.info(f"Current subscriptions: {subs}")
|
logger.info(f"Current subscriptions: {subs}")
|
||||||
@@ -199,6 +220,7 @@ async def _register_webhook():
|
|||||||
|
|
||||||
|
|
||||||
def _verify_secret(request: Request) -> bool:
|
def _verify_secret(request: Request) -> bool:
|
||||||
|
"""Проверка webhook-секрета (пустой заголовок = OK для обратной совместимости)."""
|
||||||
secret = request.headers.get("X-Max-Bot-Api-Secret", "")
|
secret = request.headers.get("X-Max-Bot-Api-Secret", "")
|
||||||
if not secret:
|
if not secret:
|
||||||
return True
|
return True
|
||||||
@@ -206,6 +228,7 @@ def _verify_secret(request: Request) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def _get_active_conv_id(user_id: int) -> int | None:
|
async def _get_active_conv_id(user_id: int) -> int | None:
|
||||||
|
"""Получение ID активного диалога пользователя."""
|
||||||
try:
|
try:
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -798,6 +821,7 @@ async def api_delete_conversation(conv_id: int):
|
|||||||
|
|
||||||
@app.delete("/api/bot/users/{user_id}")
|
@app.delete("/api/bot/users/{user_id}")
|
||||||
async def api_delete_bot_user(user_id: int):
|
async def api_delete_bot_user(user_id: int):
|
||||||
|
"""Каскадное удаление пользователя: BotUser + BotConversation + BotMessage + BotProcessingLog. Заявки остаются."""
|
||||||
from app.models import BotMessage, BotProcessingLog
|
from app.models import BotMessage, BotProcessingLog
|
||||||
from sqlalchemy import delete as sa_delete
|
from sqlalchemy import delete as sa_delete
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
"""
|
||||||
|
API-клиент для MAX Platform (Мой Мир / VK).
|
||||||
|
|
||||||
|
Основные методы:
|
||||||
|
- send_message: отправка сообщений (с логированием outgoing BotMessage)
|
||||||
|
- subscribe_webhook: регистрация webhook-а
|
||||||
|
- answer_callback: ответ на callback-кнопки
|
||||||
|
"""
|
||||||
import logging
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -9,6 +17,7 @@ TEST_USER_THRESHOLD = 999999000
|
|||||||
|
|
||||||
|
|
||||||
class MaxAPI:
|
class MaxAPI:
|
||||||
|
"""Клиент MAX Platform API с поддержкой mock-реплик для тестов."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.base_url = settings.max_api_base
|
self.base_url = settings.max_api_base
|
||||||
self.token = settings.max_token
|
self.token = settings.max_token
|
||||||
@@ -65,6 +74,7 @@ class MaxAPI:
|
|||||||
notify: bool = True,
|
notify: bool = True,
|
||||||
conversation_id: int = None,
|
conversation_id: int = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
"""Отправка сообщения. Если conversation_id — сохраняет outgoing BotMessage."""
|
||||||
if user_id >= TEST_USER_THRESHOLD:
|
if user_id >= TEST_USER_THRESHOLD:
|
||||||
logger.info(f"MOCK send_message to test user {user_id}: {text[:50]}")
|
logger.info(f"MOCK send_message to test user {user_id}: {text[:50]}")
|
||||||
self.mock_replies.append(text)
|
self.mock_replies.append(text)
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -1,3 +1,20 @@
|
|||||||
|
"""
|
||||||
|
ORM-модели для PostgreSQL (SQLAlchemy 2.0).
|
||||||
|
|
||||||
|
Таблицы:
|
||||||
|
- bot_settings: настройки бота (ключ-значение)
|
||||||
|
- bot_features: фичи бота (вкл/выкл)
|
||||||
|
- bot_categories: категории базы знаний
|
||||||
|
- bot_knowledge_base: карточки базы знаний
|
||||||
|
- bot_users: пользователи бота (контакты, согласие)
|
||||||
|
- bot_conversations: диалоги (state machine)
|
||||||
|
- bot_messages: сообщения (incoming/outgoing)
|
||||||
|
- bot_tickets: заявки
|
||||||
|
- bot_ticket_messages: сообщения заявок
|
||||||
|
- bot_processing_logs: логи обработки
|
||||||
|
- bot_ticket_statuses: история смены статусов заявок
|
||||||
|
- bot_unknown_questions: неизвестные вопросы (автогенерация KB)
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import datetime
|
import datetime
|
||||||
from sqlalchemy import Column, Integer, BigInteger, String, Text, Boolean, DateTime, ForeignKey, ARRAY
|
from sqlalchemy import Column, Integer, BigInteger, String, Text, Boolean, DateTime, ForeignKey, ARRAY
|
||||||
@@ -53,6 +70,7 @@ class BotKnowledgeBase(Base):
|
|||||||
|
|
||||||
|
|
||||||
class BotUser(Base):
|
class BotUser(Base):
|
||||||
|
"""Пользователь бота: профиль, контакты, согласие на обработку ПД."""
|
||||||
__tablename__ = "bot_users"
|
__tablename__ = "bot_users"
|
||||||
id = Column(BigInteger, primary_key=True)
|
id = Column(BigInteger, primary_key=True)
|
||||||
first_name = Column(String(255), nullable=True)
|
first_name = Column(String(255), nullable=True)
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
"""
|
||||||
|
In-memory кэш настроек бота с синхронизацией с БД.
|
||||||
|
|
||||||
|
При первом обращении загружает все настройки из bot_settings.
|
||||||
|
При set() обновляет и БД, и кэш.
|
||||||
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Optional, Dict
|
from typing import Optional, Dict
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
"""
|
||||||
|
YandexGPT интеграция для анализа намерений и генерации ответов.
|
||||||
|
|
||||||
|
Модель: yandexgpt-lite
|
||||||
|
API: https://llm.api.cloud.yandex.net/foundationModels/v1/completion
|
||||||
|
"""
|
||||||
import httpx
|
import httpx
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -14,6 +20,7 @@ YANDEX_GPT_MODEL = "yandexgpt-lite"
|
|||||||
|
|
||||||
|
|
||||||
class YandexGPT:
|
class YandexGPT:
|
||||||
|
"""Клиент YandexGPT для анализа намерений и генерации ответов."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._api_key: Optional[str] = None
|
self._api_key: Optional[str] = None
|
||||||
self._folder_id: Optional[str] = None
|
self._folder_id: Optional[str] = None
|
||||||
@@ -27,6 +34,7 @@ class YandexGPT:
|
|||||||
return await self._load_credentials()
|
return await self._load_credentials()
|
||||||
|
|
||||||
async def _request(self, prompt: str, system_prompt: str = "", temperature: float = 0.3, max_tokens: int = 1000) -> Optional[str]:
|
async def _request(self, prompt: str, system_prompt: str = "", temperature: float = 0.3, max_tokens: int = 1000) -> Optional[str]:
|
||||||
|
"""Базовый запрос к YandexGPT API."""
|
||||||
if not await self._load_credentials():
|
if not await self._load_credentials():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -65,11 +73,13 @@ class YandexGPT:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _sanitize_input(self, text: str) -> str:
|
def _sanitize_input(self, text: str) -> str:
|
||||||
|
"""Очистка и обрезка входного текста (макс. 2000 символов)."""
|
||||||
text = text[:MAX_INPUT_LENGTH]
|
text = text[:MAX_INPUT_LENGTH]
|
||||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
async def analyze_intent(self, text: str, history_context: str = "") -> str:
|
async def analyze_intent(self, text: str, history_context: str = "") -> str:
|
||||||
|
"""Анализ намерения: ticket/question/unknown. Aggressive ticket detection для услуг."""
|
||||||
safe_text = self._sanitize_input(text)
|
safe_text = self._sanitize_input(text)
|
||||||
safe_history = self._sanitize_input(history_context) if history_context else ""
|
safe_history = self._sanitize_input(history_context) if history_context else ""
|
||||||
system_prompt = (
|
system_prompt = (
|
||||||
@@ -97,6 +107,7 @@ class YandexGPT:
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
async def generate_answer(self, question: str, context: str, history_context: str = "") -> Optional[str]:
|
async def generate_answer(self, question: str, context: str, history_context: str = "") -> Optional[str]:
|
||||||
|
"""Генерация ответа на вопрос с контекстом из базы знаний."""
|
||||||
safe_question = self._sanitize_input(question)
|
safe_question = self._sanitize_input(question)
|
||||||
safe_history = self._sanitize_input(history_context) if history_context else ""
|
safe_history = self._sanitize_input(history_context) if history_context else ""
|
||||||
system_prompt = (
|
system_prompt = (
|
||||||
@@ -116,6 +127,7 @@ class YandexGPT:
|
|||||||
return await self._request(user_prompt, system_prompt)
|
return await self._request(user_prompt, system_prompt)
|
||||||
|
|
||||||
async def clarify_intent(self, text: str, history_context: str = "") -> str:
|
async def clarify_intent(self, text: str, history_context: str = "") -> str:
|
||||||
|
"""Уточнение намерения: если есть слова-маркеры — отвечает без 'уточните'."""
|
||||||
safe_text = self._sanitize_input(text)
|
safe_text = self._sanitize_input(text)
|
||||||
safe_history = self._sanitize_input(history_context) if history_context else ""
|
safe_history = self._sanitize_input(history_context) if history_context else ""
|
||||||
system_prompt = (
|
system_prompt = (
|
||||||
@@ -159,6 +171,7 @@ class YandexGPT:
|
|||||||
return result or "Подскажите, чем я могу помочь? Вы хотите задать вопрос об услугах AegisOne или оставить заявку?"
|
return result or "Подскажите, чем я могу помочь? Вы хотите задать вопрос об услугах AegisOne или оставить заявку?"
|
||||||
|
|
||||||
async def generate_kb_card(self, user_questions: list) -> Optional[dict]:
|
async def generate_kb_card(self, user_questions: list) -> Optional[dict]:
|
||||||
|
"""Генерация карточки базы знаний из вопросов пользователей (JSON)."""
|
||||||
joined = "\n".join(f"- {q}" for q in user_questions)
|
joined = "\n".join(f"- {q}" for q in user_questions)
|
||||||
system_prompt = (
|
system_prompt = (
|
||||||
"Ты — редактор базы знаний компании AegisOne Engineering. "
|
"Ты — редактор базы знаний компании AegisOne Engineering. "
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Миграция: consent_revoked_date + bot_processing_logs
|
||||||
|
-- Дата: 02.06.2026
|
||||||
|
-- Описание: Добавление поля consent_revoked_date в bot_users и создание таблицы bot_processing_logs
|
||||||
|
|
||||||
|
-- 1. Consent revoked date
|
||||||
|
ALTER TABLE bot_users ADD COLUMN IF NOT EXISTS consent_revoked_date TIMESTAMP NULL;
|
||||||
|
|
||||||
|
-- 2. BotProcessingLog table
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_processing_logs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES bot_users(id) ON DELETE CASCADE,
|
||||||
|
conversation_id BIGINT REFERENCES bot_conversations(id) ON DELETE SET NULL,
|
||||||
|
step VARCHAR(50) NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'ok',
|
||||||
|
message TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Индексы для производительности
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_processing_logs_user_id ON bot_processing_logs(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_processing_logs_conversation_id ON bot_processing_logs(conversation_id);
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.8.2
|
1.9.0
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
# Интеграция с 1С:УНФ 3.0 (1С-Фреш)
|
||||||
|
|
||||||
|
> Дата исследования: 04.06.2026
|
||||||
|
> Статус: ИССЛЕДОВАНИЕ (код не написан)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Краткое описание
|
||||||
|
|
||||||
|
Документ описывает доступные API для интеграции сервисного портала AegisOne с 1С:УНФ 3.0, работающей в облаке 1С-Фреш.
|
||||||
|
|
||||||
|
**Цель:** Синхронизация данных между 1С:УНФ и порталом — клиенты, заказы, контактные лица.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Доступные интерфейсы 1С-Фреш
|
||||||
|
|
||||||
|
| Интерфейс | Назначение | Чтение | Запись | Сложность |
|
||||||
|
|-----------|-----------|--------|--------|-----------|
|
||||||
|
| **OData** | Стандартный интерфейс платформы 1С | ✅ | ✅ | Средняя |
|
||||||
|
| **REST API** | Портал 1С:ITS Fresh-Integration | ✅ | ✅ | Средняя |
|
||||||
|
| **HTTP-сервис** | УниверсальнаяИнтеграция (UniversalIntegration) | ✅ | ✅ | Высокая |
|
||||||
|
| **Система взаимодействия** | Вебхуки (внешние → 1С) | ❌ | ✅ | Низкая |
|
||||||
|
|
||||||
|
### Главный вывод
|
||||||
|
|
||||||
|
**OData** — основной и самый доступный способ интеграции. Через OData доступны практически все объекты 1С:УНФ 3.0: справочники, документы, регистры.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. OData интерфейс
|
||||||
|
|
||||||
|
### 3.1 Формат URL
|
||||||
|
|
||||||
|
```
|
||||||
|
https://<server>/a/unf/<tenant>/odata/standard.odata/<Entity>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Параметр | Описание | Пример |
|
||||||
|
|----------|----------|--------|
|
||||||
|
| `server` | Адрес сервера 1С-Фреш | `https://xxx.1cfresh.com` |
|
||||||
|
| `app` | Код приложения | `unf` (для УНФ) |
|
||||||
|
| `tenant` | Номер области данных | `34` |
|
||||||
|
| `Entity` | Имя объекта метаданных | `Catalog_Контрагенты` |
|
||||||
|
|
||||||
|
### 3.2 Аутентификация
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Basic <base64(login:password)>
|
||||||
|
```
|
||||||
|
|
||||||
|
Используется сервисный пользователь 1С-Фреш с правами доступа к OData.
|
||||||
|
|
||||||
|
### 3.3 Формат ответа
|
||||||
|
|
||||||
|
```
|
||||||
|
?$format=json — JSON
|
||||||
|
?$format=atom — Atom/XML (по умолчанию)
|
||||||
|
?$format=json;odata=nometadata — JSON без метаданных
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Доступные сущности 1С:УНФ 3.0
|
||||||
|
|
||||||
|
### 4.1 Справочники (Catalogs)
|
||||||
|
|
||||||
|
| OData URL | Описание | Приоритет для нас |
|
||||||
|
|-----------|----------|-------------------|
|
||||||
|
| `Catalog_Контрагенты` | Покупатели, поставщики, прочие контрагенты | 🔴 Высокий → **Клиенты** |
|
||||||
|
| `Catalog_КонтактныеЛица` | Контактные лица контрагентов | 🔴 Высокий → **Контакты** |
|
||||||
|
| `Catalog_Номенклатура` | Услуги, товары, работы | 🟡 Средний → Каталог услуг |
|
||||||
|
| `Catalog_Договоры` | Договоры с контрагентами | 🟡 Средний → Привязка к клиенту |
|
||||||
|
| `Catalog_Проекты` | Проекты | 🟡 Средний → Связь с заказами |
|
||||||
|
| `Catalog_Организации` | Наши организации | 🟢 Низкий → Контекст |
|
||||||
|
| `Catalog_БанковскиеСчета` | Расчётные счета | 🟢 Низкий |
|
||||||
|
|
||||||
|
### 4.2 Документы (Documents)
|
||||||
|
|
||||||
|
| OData URL | Описание | Приоритет для нас |
|
||||||
|
|-----------|----------|-------------------|
|
||||||
|
| `Document_ЗаказКлиента` | Заказ покупателя | 🔴 Высокий → **Заказ/Лид** |
|
||||||
|
| `Document_РеализацияТоваровУслуг` | Реализация товаров и услуг | 🟡 Средний → История работ |
|
||||||
|
| `Document_АктВыполненныхРабот` | Акт выполненных работ | 🟡 Средний → Подтверждение |
|
||||||
|
| `Document_СчетНаОплату` | Счёт на оплату | 🟡 Средний → Финансы |
|
||||||
|
| `Document_ПоступлениеТоваров` | Поступление товаров | 🟢 Низкий → Закупки |
|
||||||
|
| `Document_СчетНаОплатуПоставщика` | Счёт от поставщика | 🟢 Низкий |
|
||||||
|
|
||||||
|
### 4.3 Регистры сведений
|
||||||
|
|
||||||
|
| OData URL | Описание |
|
||||||
|
|-----------|----------|
|
||||||
|
| `InformationRegister_ЦеныНоменклатурыДокументов` | Цены номенклатуры |
|
||||||
|
| `InformationRegister_КурсыВалют` | Курсы валют |
|
||||||
|
| `InformationRegister_НастройкиСистемыНалогообложения` | Учётная политика |
|
||||||
|
|
||||||
|
### 4.4 Табличные части
|
||||||
|
|
||||||
|
Доступны через суффикс имени:
|
||||||
|
|
||||||
|
```
|
||||||
|
Document_ЗаказКлиента_Товары — табличная часть «Товары»
|
||||||
|
Document_ЗаказКлиента_Услуги — табличная часть «Услуги»
|
||||||
|
Catalog_Контрагенты_КонтактнаяИнформация — контактная информация
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Примеры запросов
|
||||||
|
|
||||||
|
### 5.1 Все контрагенты (покупатели)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /odata/standard.odata/Catalog_Контрагенты?$format=json
|
||||||
|
&$select=Ref_Key,Description,ИНН,КПП,РегистрационныйНомер
|
||||||
|
&$filter=not (IsFolder)
|
||||||
|
&$orderby=Description
|
||||||
|
&$top=100
|
||||||
|
Authorization: Basic <auth>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Поиск контрагента по ИНН
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /odata/standard.odata/Catalog_Контрагенты?$format=json
|
||||||
|
&$select=Ref_Key,Description,ИНН,КПП
|
||||||
|
&$filter=(ИНН eq '2310031540')
|
||||||
|
Authorization: Basic <auth>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Поиск контрагента по наименованию
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /odata/standard.odata/Catalog_Контрагенты?$format=json
|
||||||
|
&$select=Ref_Key,Description,ИНН
|
||||||
|
&$filter=like(Description, 'Аегис%')
|
||||||
|
Authorization: Basic <auth>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Заказы конкретного клиента
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /odata/standard.odata/Document_ЗаказКлиента?$format=json
|
||||||
|
&$expand=Контрагент
|
||||||
|
&$select=Ref_Key,Number,Date,СуммаДокумента,Статус,Контрагент/Description
|
||||||
|
&$filter=Контрагент_Key eq guid'...'
|
||||||
|
&$orderby=Date desc
|
||||||
|
Authorization: Basic <auth>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 Контактные лица контрагента
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /odata/standard.odata/Catalog_КонтактныеЛица?$format=json
|
||||||
|
&$select=Ref_Key,Description,Должность,Владелец_Key
|
||||||
|
&$filter=Владелец_Key eq guid'...'
|
||||||
|
Authorization: Basic <auth>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.6 Номенклатура (услуги)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /odata/standard.odata/Catalog_Номенклатура?$format=json
|
||||||
|
&$expand=ЕдиницаИзмерения
|
||||||
|
&$select=Ref_Key,Description,Артикул,ВидНоменклатуры
|
||||||
|
&$filter=not (IsFolder)
|
||||||
|
&$orderby=Description
|
||||||
|
Authorization: Basic <auth>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.7 Создание нового контрагента (POST)
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /odata/standard.odata/Catalog_Контрагенты
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Basic <auth>
|
||||||
|
|
||||||
|
{
|
||||||
|
"Description": "ООО Ромашка",
|
||||||
|
"ИНН": "2310031540",
|
||||||
|
"КПП": "231001001",
|
||||||
|
"ЮридическоеФизическоеЛицо": "ЮридическоеЛицо"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.8 Обновление контрагента (PATCH)
|
||||||
|
|
||||||
|
```http
|
||||||
|
PATCH /odata/standard.odata/Catalog_Контрагенты(guid'...')
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Basic <auth>
|
||||||
|
|
||||||
|
{
|
||||||
|
"Description": "ООО Ромашка (обновлено)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Маппинг сущностей 1С:УНФ → Наш портал
|
||||||
|
|
||||||
|
### 6.1 Контрагенты → Клиенты
|
||||||
|
|
||||||
|
| Поле 1С:УНФ | Поле портала | Тип маппинга |
|
||||||
|
|-------------|-------------|--------------|
|
||||||
|
| `Ref_Key` | `onec_id` (UUID) | Прямой |
|
||||||
|
| `Description` | `company_name` | Прямой |
|
||||||
|
| `ИНН` | `inn` | Прямой (ключ дедупликации) |
|
||||||
|
| `КПП` | `kpp` | Прямой |
|
||||||
|
| `Телефон` | `phone` | Нормализация |
|
||||||
|
| `Email` | `email` | Прямой |
|
||||||
|
| `Адрес` | `address` | Прямой |
|
||||||
|
|
||||||
|
### 6.2 Заказ клиента → Лид/Заказ
|
||||||
|
|
||||||
|
| Поле 1С:УНФ | Поле портала | Тип маппинга |
|
||||||
|
|-------------|-------------|--------------|
|
||||||
|
| `Ref_Key` | `onec_order_id` (UUID) | Прямой |
|
||||||
|
| `Number` | `number` | Прямой |
|
||||||
|
| `Date` | `created_at` | Конвертация |
|
||||||
|
| `СуммаДокумента` | `amount` | Прямой |
|
||||||
|
| `Статус` | `status` | Маппинг статусов |
|
||||||
|
| `Контрагент_Key` | `customer_id` | FK (по onec_id) |
|
||||||
|
|
||||||
|
### 6.3 Статусы заказов
|
||||||
|
|
||||||
|
| Статус 1С:УНФ | Статус портала |
|
||||||
|
|---------------|---------------|
|
||||||
|
| В работе | `in_progress` |
|
||||||
|
| Закрыт | `completed` |
|
||||||
|
| Отменён | `cancelled` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Стратегия интеграции
|
||||||
|
|
||||||
|
### Фаза 0: Настройка доступа (отдельная задача)
|
||||||
|
|
||||||
|
1. **Получить от пользователя:**
|
||||||
|
- URL базы в 1С-Фреш (типа `https://xxx.1cfresh.com/a/unf/`)
|
||||||
|
- Логин/пароль сервисного пользователя
|
||||||
|
- Номер области данных (tenant)
|
||||||
|
|
||||||
|
2. **Проверить доступность:**
|
||||||
|
- Выполнить тестовый запрос к `$metadata`
|
||||||
|
- Убедиться в наличии нужных сущностей
|
||||||
|
- Проверить права доступа
|
||||||
|
|
||||||
|
3. **Создать таблицу настроек:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE onec_settings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
base_url VARCHAR(500) NOT NULL,
|
||||||
|
tenant VARCHAR(50) NOT NULL,
|
||||||
|
login VARCHAR(100) NOT NULL,
|
||||||
|
password_encrypted TEXT NOT NULL, -- fernet
|
||||||
|
last_sync_at TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Фаза 1: Синхронизация контрагентов → клиенты
|
||||||
|
|
||||||
|
1. **Миграция:** Таблица `onec_sync_log` для логирования
|
||||||
|
```sql
|
||||||
|
CREATE TABLE onec_sync_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
entity_type VARCHAR(50) NOT NULL, -- 'contragent', 'order', etc.
|
||||||
|
onec_id UUID NOT NULL,
|
||||||
|
portal_id INTEGER,
|
||||||
|
action VARCHAR(20) NOT NULL, -- 'create', 'update', 'skip'
|
||||||
|
details JSONB,
|
||||||
|
synced_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Задача синхронизации:**
|
||||||
|
- Читаем `Catalog_Контрагенты` из 1С
|
||||||
|
- Дедупликация по ИНН (точное совпадение)
|
||||||
|
- Если ИНН нет — по наименованию + ИНН КПП
|
||||||
|
- Создаём/обновляем в таблице `customers`
|
||||||
|
- Логируем каждое действие
|
||||||
|
|
||||||
|
3. **Периодичность:** Раз в день (ночью) или по требованию
|
||||||
|
|
||||||
|
### Фаза 2: Синхронизация заказов
|
||||||
|
|
||||||
|
1. **Миграция:** Поле `onec_order_id` в `leads` или новая таблица
|
||||||
|
2. **Задача синхронизации:**
|
||||||
|
- Читаем `Document_ЗаказКлиента` из 1С
|
||||||
|
- Привязка к клиенту по `Контрагент_Key`
|
||||||
|
- Маппинг статусов
|
||||||
|
- Логирование
|
||||||
|
|
||||||
|
### Фаза 3 (опционально): Обратная запись
|
||||||
|
|
||||||
|
- Создание заказа в портале → создание в 1С
|
||||||
|
- Требует аккуратной обработки ошибок
|
||||||
|
- **Рекомендация:** Только после стабильности фаз 1-2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Технические детали
|
||||||
|
|
||||||
|
### 8.1 Python-клиент для OData
|
||||||
|
|
||||||
|
Рекомендуемая библиотека: `pyodata` или `requests` (raw OData).
|
||||||
|
|
||||||
|
```python
|
||||||
|
import base64
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
class OneCFreshClient:
|
||||||
|
"""Клиент для работы с 1С-Фреш через OData."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, tenant: str, login: str, password: str):
|
||||||
|
self.base_url = base_url.rstrip('/')
|
||||||
|
self.tenant = tenant
|
||||||
|
self.auth = base64.b64encode(f"{login}:{password}".encode()).decode()
|
||||||
|
|
||||||
|
async def get_contragents(self, top: int = 100, skip: int = 0) -> list:
|
||||||
|
"""Получить список контрагентов."""
|
||||||
|
url = f"{self.base_url}/a/unf/{self.tenant}/odata/standard.odata/Catalog_Контрагенты"
|
||||||
|
params = {
|
||||||
|
"$format": "json;odata=nometadata",
|
||||||
|
"$select": "Ref_Key,Description,ИНН,КПП",
|
||||||
|
"$filter": "not (IsFolder)",
|
||||||
|
"$top": str(top),
|
||||||
|
"$skip": str(skip),
|
||||||
|
}
|
||||||
|
headers = {"Authorization": f"Basic {self.auth}"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(url, params=params, headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("value", [])
|
||||||
|
|
||||||
|
async def get_contragent_by_inn(self, inn: str) -> dict | None:
|
||||||
|
"""Найти контрагента по ИНН."""
|
||||||
|
url = f"{self.base_url}/a/unf/{self.tenant}/odata/standard.odata/Catalog_Контрагенты"
|
||||||
|
params = {
|
||||||
|
"$format": "json;odata=nometadata",
|
||||||
|
"$select": "Ref_Key,Description,ИНН,КПП",
|
||||||
|
"$filter": f"(ИНН eq '{inn}')",
|
||||||
|
}
|
||||||
|
headers = {"Authorization": f"Basic {self.auth}"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(url, params=params, headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
items = resp.json().get("value", [])
|
||||||
|
return items[0] if items else None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Ограничения
|
||||||
|
|
||||||
|
| Ограничение | Описание | Решение |
|
||||||
|
|-------------|----------|---------|
|
||||||
|
| **Multi-tenancy** | Каждая база в своей области данных | Указать tenant в URL |
|
||||||
|
| **Публикация OData** | Должна быть включена в конфигурации | Проверить через `$metadata` |
|
||||||
|
| **Права доступа** | Сервисный пользователь должен иметь права на чтение | Настроить роли в 1С |
|
||||||
|
| **Пагинация** | Большие выборки — через `$top`/`$skip` | Построчная загрузка |
|
||||||
|
| **Rate limits** | Возможны ограничения на частоту запросов | Очередь задач, retry |
|
||||||
|
|
||||||
|
### 8.3 Проверка доступности
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Тестовый запрос метаданных
|
||||||
|
curl -u "login:password" \
|
||||||
|
"https://xxx.1cfresh.com/a/unf/34/odata/standard.odata/$metadata"
|
||||||
|
|
||||||
|
# Тестовый запрос контрагентов
|
||||||
|
curl -u "login:password" \
|
||||||
|
"https://xxx.1cfresh.com/a/unf/34/odata/standard.odata/Catalog_Контрагенты?\$format=json&\$top=5"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Сравнение с webhook (Система взаимодействия)
|
||||||
|
|
||||||
|
| Критерий | OData | Webhook |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| **Чтение данных** | ✅ Да | ❌ Нет (только запись) |
|
||||||
|
| **Запись данных** | ✅ Да | ✅ Да (только в 1С) |
|
||||||
|
| **Направление** | Bidirectional | One-way (внешнее → 1С) |
|
||||||
|
| **Формат** | JSON (стандартный OData) | JSON (свой формат) |
|
||||||
|
| **Аутентификация** | Basic Auth | URL + логин/пароль |
|
||||||
|
| **Сложность** | Средняя | Низкая |
|
||||||
|
| **Использование** | Синхронизация данных | Уведомления, сообщения |
|
||||||
|
|
||||||
|
**Вывод:** OData — для синхронизации данных. Webhook — для отправки уведомлений в 1С.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Рекомендации
|
||||||
|
|
||||||
|
### Приоритет implementation
|
||||||
|
|
||||||
|
1. **OData синхронизация контрагентов** — самый важный функционал
|
||||||
|
2. **OData синхронизация заказов** — расширение функционала
|
||||||
|
3. **Webhook уведомления** — опционально, для двусторонней связи
|
||||||
|
|
||||||
|
### Вопросы к пользователю (для фазы 0)
|
||||||
|
|
||||||
|
1. URL базы в 1С-Фреш?
|
||||||
|
2. Логин/пароль сервисного пользователя?
|
||||||
|
3. Номер области данных (tenant)?
|
||||||
|
4. Какие данные в приоритете: контрагенты, заказы, или всё сразу?
|
||||||
|
5. Направление синхронизации: только из 1С → портал, или bidirectional?
|
||||||
|
|
||||||
|
### Рекомендация
|
||||||
|
|
||||||
|
**Начать с read-only синхронизации контрагентов** — самый безопасный и быстрый способ:
|
||||||
|
- Не требует записи обратно
|
||||||
|
- Минимальные риски
|
||||||
|
- Быстро даёт результат (клиенты из 1С появляются в портале)
|
||||||
|
- Потом можно расширять до заказов и bidirectional
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Ссылки
|
||||||
|
|
||||||
|
- [Документация 1С-Фреш: OData](https://its.1c.ru/db/fresh/content/19956692/hdoc)
|
||||||
|
- [Платформа 1С: REST интерфейс](https://v8.1c.ru/platforma/rest-interfeys/)
|
||||||
|
- [OData: правила формирования имени ресурса](https://42clouds.com/ru-ru/manuals/interfeys-odata-pravila-formirovaniya-imeni-resursa/)
|
||||||
|
- [Работа с 1С через OData (Infostart)](https://infostart.ru/1c/articles/1570140/)
|
||||||
|
- [odata1c-client (GitHub)](https://github.com/Dakword/odata1c-client) — PHP-клиент с примерами
|
||||||
|
- [Контрагенты и контактные лица в 1С:УНФ](https://unf4you.ru/publ/kontragenty_i_kontaktnye_lica/1-1-0-407)
|
||||||
|
- [Заказ покупателя в 1С:УНФ](https://estart1c.ru/unf-30/189-kak-v-1sroznice-i-1sunf-sozdat-i-ispolzovat-zakaz-pokupatelja.html)
|
||||||
@@ -85,12 +85,12 @@ py_service/
|
|||||||
| Параметр | Значение |
|
| Параметр | Значение |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
| Тип | Python FastAPI (Max Bot API) |
|
| Тип | Python FastAPI (Max Bot API) |
|
||||||
| FastAPI | порт **8001** |
|
| FastAPI | порт **8002** |
|
||||||
| PostgreSQL | общая БД `aegisone` (порт 5432), таблицы с префиксом `bot_` |
|
| PostgreSQL | общая БД `aegisone` (порт 5432), таблицы с префиксом `bot_` |
|
||||||
| Домен | `max.aegisone.ru` |
|
| Домен | `max.aegisone.ru` |
|
||||||
| Директория | `/opt/projects/aegisone-py/max_bot/` |
|
| Директория | `/opt/projects/aegisone-py/max_bot/` |
|
||||||
| Контейнер | `aegisone-max-bot` |
|
| Контейнер | `aegisone-max-bot` |
|
||||||
| Webhook | `https://max.aegisone.ru/webhook` → localhost:8001 |
|
| Webhook | `https://max.aegisone.ru/webhook` → localhost:8002 |
|
||||||
| Токен бота | `id2311381465_bot` (хранится в bot_settings) |
|
| Токен бота | `id2311381465_bot` (хранится в bot_settings) |
|
||||||
|
|
||||||
**Структура:**
|
**Структура:**
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,541 @@
|
|||||||
|
# Yeastar S20 — Интеграция с AegisOne
|
||||||
|
|
||||||
|
> Дата последнего обновления: 03.06.2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Подключение к АТС
|
||||||
|
|
||||||
|
### Сетевая схема
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐ ┌──────────────────────────┐
|
||||||
|
│ Сервер AegisOne │ │ Локальная сеть (офис) │
|
||||||
|
│ 81.177.141.34 │ │ 192.168.1.0/24 │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌───────────────┐ │ VPN/ │ ┌──────────────────┐ │
|
||||||
|
│ │ py_service │──┼──port───┼──│ Yeastar S20 │ │
|
||||||
|
│ │ max_bot │ │ forward │ │ 192.168.1.150 │ │
|
||||||
|
│ └───────────────┘ │ │ │ │ │
|
||||||
|
│ │ │ │ WAN: │ │
|
||||||
|
│ │ │ │ 185.105.171.187 │ │
|
||||||
|
└─────────────────────┘ │ └──────────────────┘ │
|
||||||
|
└──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### IP-адреса и порты
|
||||||
|
|
||||||
|
| Ресурс | Локальный IP | Белый IP | Порт (локал) | Порт (проброс) | Протокол |
|
||||||
|
|--------|-------------|----------|-------------|----------------|----------|
|
||||||
|
| **АТС (Web UI)** | 192.168.1.150 | 185.105.171.187 | 8088 | 45088 | HTTPS |
|
||||||
|
| **AMI** | 192.168.1.150 | 185.105.171.187 | 5038 | 45038 | TCP |
|
||||||
|
| **MySQL (CDR)** | 192.168.1.150 | 185.105.171.187 | 3306 | 43306 | TCP |
|
||||||
|
| **FTP** | 192.168.1.150 | 185.105.171.187 | 21 | 45021 | TCP |
|
||||||
|
| **SSH** | 192.168.1.150 | 185.105.171.187 | 8022 | — | TCP |
|
||||||
|
|
||||||
|
### Проброс портов (на роутере/ файрволе офиса)
|
||||||
|
|
||||||
|
| Направление | Протокол | Внешний порт → Внутренний IP:порт |
|
||||||
|
|-------------|----------|----------------------------------|
|
||||||
|
| Internet → АТС | TCP | 45038 → 192.168.1.150:5038 (AMI) |
|
||||||
|
| Internet → АТС | TCP | 45021 → 192.168.1.150:21 (FTP) |
|
||||||
|
| Internet → АТС | TCP | 43306 → 192.168.1.150:3306 (MySQL) |
|
||||||
|
| Internet → АТС | TCP | 45088 → 192.168.1.150:8088 (Web UI) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Учётные данные и параметры устройства
|
||||||
|
|
||||||
|
### Параметры устройства (актуально на 03.06.2026)
|
||||||
|
|
||||||
|
| Параметр | Значение | Источник |
|
||||||
|
|----------|----------|----------|
|
||||||
|
| Модель | Yeastar S20 | FTP `/support/tmp/deviceinfo.txt` |
|
||||||
|
| Серийный номер | `3691C1453100` | FTP `/support/tmp/deviceinfo.txt` |
|
||||||
|
| Прошивка (software) | `30.15.0.206` | FTP `/support/tmp/deviceinfo.txt` |
|
||||||
|
| Железо (hardware) | V1.30 0005-0000 | FTP `/support/tmp/deviceinfo.txt` |
|
||||||
|
| OEM | Yeastar S20 | FTP `/support/tmp/deviceinfo.txt` |
|
||||||
|
| Asterisk | 13.7.0 | AMI `CoreSettings` |
|
||||||
|
| AMI версия | 2.8.0 | AMI `CoreSettings` |
|
||||||
|
| MySQL | 5.1.61 | MySQL greeting |
|
||||||
|
| PBX Center | 1.24.2 | Web UI App Center |
|
||||||
|
| SSH-сервер | dropbear 2019.78 | SSH banner (порт 8022) |
|
||||||
|
| FTP-сервер | vsFTPd 3.0.2 | FTP banner (порт 21) |
|
||||||
|
| CDR | Включён | AMI `CoreSettings` |
|
||||||
|
| Max звонков | 10 | AMI `CoreSettings` |
|
||||||
|
| REST API | Не поддерживается на S20 | Документация Yeastar |
|
||||||
|
|
||||||
|
### Web UI (админ-панель АТС)
|
||||||
|
|
||||||
|
| Параметр | Значение |
|
||||||
|
|----------|----------|
|
||||||
|
| URL | `https://192.168.1.150:8088` (локально) или `https://185.105.171.187:45088` (интернет) |
|
||||||
|
| Логин | `admin` |
|
||||||
|
| Пароль | `.-SHGWa_12` |
|
||||||
|
|
||||||
|
### AMI (Asterisk Manager Interface)
|
||||||
|
|
||||||
|
| Параметр | Значение |
|
||||||
|
|----------|----------|
|
||||||
|
| Хост | `192.168.1.150` (локально) или `81.177.141.34` (из сервера, через проброс) |
|
||||||
|
| Порт | `5038` (проброс: `45038`) |
|
||||||
|
| Логин | `1cuser` |
|
||||||
|
| Пароль | `1csecret` |
|
||||||
|
| Привилегии | Ограниченные (нет `Command`, `Originate`) |
|
||||||
|
| Разрешённые IP | `81.177.141.34/255.255.255.255` + `192.168.1.0/255.255.255.0` |
|
||||||
|
|
||||||
|
### MySQL (доступ к CDR)
|
||||||
|
|
||||||
|
| Параметр | Значение |
|
||||||
|
|----------|----------|
|
||||||
|
| Хост | `192.168.1.150` (локально) или через проброс `43306` |
|
||||||
|
| Порт | `3306` (проброс: `43306`) |
|
||||||
|
| Логин | `1cuser` |
|
||||||
|
| Пароль | `1csecret` |
|
||||||
|
| **ВАЖНО** | `charset='utf8'` (MySQL 5.1.61 не поддерживает `utf8mb4`!) |
|
||||||
|
| БД | `cdr` |
|
||||||
|
|
||||||
|
### FTP (записи звонков)
|
||||||
|
|
||||||
|
| Параметр | Значение |
|
||||||
|
|----------|----------|
|
||||||
|
| Порт | `21` (проброс: `45021`) |
|
||||||
|
| Логин | `support` |
|
||||||
|
| Пароль | `T4oQSU_?` |
|
||||||
|
| Статус | ✅ **Работает!** |
|
||||||
|
| Доступ | Корневая ФС (чroot), `/tmp` недоступен |
|
||||||
|
|
||||||
|
Доступные каталоги через FTP:
|
||||||
|
- `/sounds/record/` — звуки (пусто)
|
||||||
|
- `/storage_share/mmc1` — симлинк → `/tmp/media/mmc1` (нерезолвящийся через FTP)
|
||||||
|
- `/support/tmp/deviceinfo.txt` — **информация об устройстве**
|
||||||
|
- `/support/` — системные файлы, bin
|
||||||
|
- `/gui_backups/` — бэкапы
|
||||||
|
- `/fax/`, `/www/`, `/var/`, `/etc/`, `/boot/`, `/cache/` — системные каталоги
|
||||||
|
|
||||||
|
**Ограничение**: записи звонков в `/tmp/media/mmc1/autorecords/` **недоступны** — `/tmp` за пределами FTP-чroot. Для доступа к записям нужен SSH.
|
||||||
|
|
||||||
|
### SSH (файловая система)
|
||||||
|
|
||||||
|
| Параметр | Значение |
|
||||||
|
|----------|----------|
|
||||||
|
| Порт | `8022` |
|
||||||
|
| SSH-сервер | dropbear 2019.78 |
|
||||||
|
| Пароль | `T4oQSU_?` |
|
||||||
|
| Логин | **НЕ ИЗВЕСТЕН** |
|
||||||
|
| Статус | ✅ Порт открыт, но логин не найден |
|
||||||
|
| **Рекомендация** | Не использовать SSH — только MySQL + AMI |
|
||||||
|
|
||||||
|
### Хранилище данных (в Web UI)
|
||||||
|
|
||||||
|
| Параметр | Значение |
|
||||||
|
|----------|----------|
|
||||||
|
| Имя файла | `share` |
|
||||||
|
| Логин | `share` |
|
||||||
|
| Пароль | `9tktYfM2` |
|
||||||
|
| Назначение | Сетевая папка (симлинк `mmc1 -> /tmp/media/mmc1` в `/storage_share/`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Где найти информацию об АТС
|
||||||
|
|
||||||
|
### Серийный номер, модель, прошивка
|
||||||
|
|
||||||
|
| Источник | Способ |
|
||||||
|
|----------|--------|
|
||||||
|
| **FTP** `/support/tmp/deviceinfo.txt` | Содержит: hardware, software, sn, product, oem |
|
||||||
|
| **Web UI** `Dashboard` или `Settings > System > General > Preferences` | Отображается на главной странице |
|
||||||
|
| **Web UI** `Settings > System > General > About` | Версия прошивки и модель |
|
||||||
|
|
||||||
|
Содержимое `deviceinfo.txt`:
|
||||||
|
```
|
||||||
|
hardware: V1.30 0005-0000
|
||||||
|
software: 30.15.0.206
|
||||||
|
sn: 3691C1453100
|
||||||
|
product: Yeastar S20
|
||||||
|
oem: Yeastar S20
|
||||||
|
```
|
||||||
|
|
||||||
|
### Версия Asterisk и компонентов
|
||||||
|
|
||||||
|
| Источник | Способ |
|
||||||
|
|----------|--------|
|
||||||
|
| **AMI** команда `CoreSettings` | AsteriskVersion, AMIversion, CoreCDRenabled |
|
||||||
|
| **AMI** команда `ListCommands` | Список доступных AMI-команд с привилегиями |
|
||||||
|
| **Web UI** `Settings > System > General > About` | Версия PBX Center, прошивки |
|
||||||
|
|
||||||
|
### Конфигурация (абоненты, очереди, транки)
|
||||||
|
|
||||||
|
| Источник | Что даёт |
|
||||||
|
|----------|----------|
|
||||||
|
| **AMI** `QueueStatus` | Очереди, участники, стратегии |
|
||||||
|
| **AMI** `VoicemailUsersList` | Абоненты, голосовая почта |
|
||||||
|
| **AMI** `PJSIPQualify` | Статус SIP-абонента |
|
||||||
|
| **MySQL** БД `cdr` | История звонков, абоненты (из CDR) |
|
||||||
|
| **Web UI** `Extensions` | Список расширений |
|
||||||
|
| **Web UI** `Trunks` | SIP-транки |
|
||||||
|
| **Web UI** `Call Queue` | Очереди |
|
||||||
|
|
||||||
|
### Записи звонков
|
||||||
|
|
||||||
|
| Источник | Способ |
|
||||||
|
|----------|--------|
|
||||||
|
| **MySQL** колонки `recordpath`, `monitorfile` | Путь и имя WAV-файла на PBX |
|
||||||
|
| **Web UI** `CDR and Recordings` | Ручное скачивание через браузер |
|
||||||
|
| **FTP** `/storage_share/mmc1` | Симлинк на `/tmp/media/mmc1` (нерезолвится через FTP) |
|
||||||
|
| **SSH** порт 8022 | Прямой доступ к файловой системе (логин неизвестен) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Абоненты и очереди
|
||||||
|
|
||||||
|
### Внутренние номера (расширения)
|
||||||
|
|
||||||
|
| Номер | Назначение |
|
||||||
|
|-------|-----------|
|
||||||
|
| 1001 | Основной (Инженер) |
|
||||||
|
| 1002 | Второй (Инженер) |
|
||||||
|
| 1003 | Дополнительный |
|
||||||
|
| 1000 | Тестовый |
|
||||||
|
|
||||||
|
### Очереди (Queue)
|
||||||
|
|
||||||
|
| Номер очереди | Стратегия | Расширения |
|
||||||
|
|---------------|-----------|------------|
|
||||||
|
| 6700 | ringall | 1001, 1002 |
|
||||||
|
| 6701 | rrmemory | 1001 |
|
||||||
|
| 6702 | ringall | 1002 |
|
||||||
|
|
||||||
|
### Транки (линии)
|
||||||
|
|
||||||
|
| Транк | Тип | Назначение |
|
||||||
|
|-------|-----|-----------|
|
||||||
|
| Mango | VoIP-провайдер | Исходящие/входящие через SIP |
|
||||||
|
| Tele2 | VoIP-провайдер | Исходящие/входящие через SIP |
|
||||||
|
| YSGSM | GSM-модуль | Исходящие через GSM-SIM |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. База данных CDR
|
||||||
|
|
||||||
|
### Структура
|
||||||
|
|
||||||
|
БД: `cdr`, таблицы: `cdr` (текущая) + `cdr_YYYYMM` (архивные).
|
||||||
|
|
||||||
|
### Колонки таблицы `cdr`
|
||||||
|
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|---------|-----|----------|
|
||||||
|
| `id` | int | ID записи |
|
||||||
|
| `datetime` | datetime | Дата и время звонка |
|
||||||
|
| `clid` | varchar | Caller ID (кто звонит) |
|
||||||
|
| `src` | varchar | Номер отправителя |
|
||||||
|
| `dst` | varchar | Номер получателя |
|
||||||
|
| `dcontext` | varchar | Контекст диалплана |
|
||||||
|
| `srctrunk` | varchar | Транк отправителя |
|
||||||
|
| `dstrunk` | varchar | Транк получателя |
|
||||||
|
| `lastapp` | varchar | Последнее приложение (Dial, Queue, BackGround...) |
|
||||||
|
| `lastdata` | varchar | Данные последнего приложения (номер, очередь) |
|
||||||
|
| `duration` | int | Общая длительность (секунды) |
|
||||||
|
| `billable` | int | Биллируемая длительность (секунды) |
|
||||||
|
| `disposition` | varchar | Статус: ANSWERED, NO ANSWER, BUSY, VOICEMAIL, FAILED |
|
||||||
|
| `calltype` | varchar | Тип: Inbound, Outbound, Internal |
|
||||||
|
| `accountcode` | varchar | Код аккаунта |
|
||||||
|
| `uniqueid` | varchar | Уникальный ID звонка |
|
||||||
|
| `recordfile` | varchar | Имя файла записи (WAV) |
|
||||||
|
| `recordpath` | varchar | Путь к записи на PBX |
|
||||||
|
| `monitorfile` | varchar | Имя файла мониторинга |
|
||||||
|
| `monitorpath` | varchar | Путь к мониторингу на PBX |
|
||||||
|
| `extfield1-5` | varchar | Дополнительные поля |
|
||||||
|
| `didnumber` | varchar | DID-номер (входящий номер) |
|
||||||
|
| `srcchanurl` | varchar | SIP URL канала отправителя |
|
||||||
|
| `dstchanurl` | varchar | SIP URL канала получателя |
|
||||||
|
| `companycontact` | varchar | Имя контакта компании |
|
||||||
|
| `personalcontact` | varchar | Имя личного контакта |
|
||||||
|
| `contactnumber` | varchar | Контактный номер |
|
||||||
|
|
||||||
|
### Примеры запросов
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Все звонки за сегодня
|
||||||
|
SELECT datetime, src, dst, duration, billable, disposition, calltype
|
||||||
|
FROM cdr.cdr
|
||||||
|
WHERE DATE(datetime) = CURDATE()
|
||||||
|
ORDER BY datetime DESC;
|
||||||
|
|
||||||
|
-- Звонки за конкретный месяц
|
||||||
|
SELECT * FROM cdr.cdr_202606 ORDER BY datetime DESC;
|
||||||
|
|
||||||
|
-- Входящие звонки на очередь 6700
|
||||||
|
SELECT datetime, src, dst, duration, disposition
|
||||||
|
FROM cdr.cdr
|
||||||
|
WHERE dst LIKE '6700%' AND calltype = 'Inbound'
|
||||||
|
ORDER BY datetime DESC;
|
||||||
|
|
||||||
|
-- Звонки с записями
|
||||||
|
SELECT datetime, src, dst, duration, recordfile, recordpath
|
||||||
|
FROM cdr.cdr
|
||||||
|
WHERE recordfile != ''
|
||||||
|
ORDER BY datetime DESC;
|
||||||
|
|
||||||
|
-- Статистика по дням
|
||||||
|
SELECT DATE(datetime) as day, COUNT(*) as calls,
|
||||||
|
SUM(CASE WHEN disposition='ANSWERED' THEN 1 ELSE 0 END) as answered
|
||||||
|
FROM cdr.cdr
|
||||||
|
WHERE datetime >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||||
|
GROUP BY DATE(datetime)
|
||||||
|
ORDER BY day DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Таблица `queue_log`
|
||||||
|
|
||||||
|
Содержит логи событий очередей (поступление, ответ, ожидание, сброс).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Записи звонков
|
||||||
|
|
||||||
|
### Где хранятся
|
||||||
|
|
||||||
|
На файловой системе PBX: `/tmp/media/mmc1/autorecords/YYYYMM/`
|
||||||
|
|
||||||
|
Формат имени файла:
|
||||||
|
```
|
||||||
|
YYYYMMDDHHMMSS-UNIQUEID-SRC-DST-Direction.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
Примеры из CDR:
|
||||||
|
```
|
||||||
|
20260110164821-1768052891.15-1001-+79531116788-Outbound.wav
|
||||||
|
20260402105623-1775116571.13-1001-89531116788-Outbound.wav
|
||||||
|
20260512162645-1778592395.0-+79898250183-1001-Inbound.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
### Как получить
|
||||||
|
|
||||||
|
1. **Путь в CDR**: колонки `recordpath`, `monitorpath` (путь на PBX)
|
||||||
|
2. **FTP** (`support`/`T4oQSU_?`): записи **недоступны** — `/tmp` за пределами FTP-чroot
|
||||||
|
3. **SSH** (порт 8022, пароль `T4oQSU_?`, логин неизвестен): теоретически доступен, но логин не найден
|
||||||
|
4. **Web UI**: `CDR and Recordings` — ручное скачивание через браузер
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. AMI — Доступные команды
|
||||||
|
|
||||||
|
### Работает с 1cuser/1csecret
|
||||||
|
|
||||||
|
| Команда | Описание | Статус |
|
||||||
|
|---------|----------|--------|
|
||||||
|
| `Ping` | Проверка соединения | ✅ |
|
||||||
|
| `CoreStatus` | Статус ядра (uptime, кол-во звонков) | ✅ |
|
||||||
|
| `CoreShowChannels` | Список активных каналов | ✅ |
|
||||||
|
| `CoreSettings` | Настройки ядра (Asterisk, CDR) | ✅ |
|
||||||
|
| `QueueStatus` | Статус очередей + участников | ✅ |
|
||||||
|
| `VoicemailUsersList` | Список абонентов голосовой почты | ✅ |
|
||||||
|
| `PJSIPQualify` | Пинг SIP-абонента | ✅ |
|
||||||
|
| `Status` | Статус всех каналов | ✅ |
|
||||||
|
| `ListCommands` | Список доступных команд | ✅ |
|
||||||
|
|
||||||
|
### Не работает (Permission denied)
|
||||||
|
|
||||||
|
| Команда | Описание |
|
||||||
|
|---------|----------|
|
||||||
|
| `Command` | Выполнение CLI-команд (`cdr show` и т.д.) |
|
||||||
|
| `Originate` | Исходящий звонок |
|
||||||
|
| `ShowDialPlan` | Просмотр диалплана |
|
||||||
|
| `Hangup` | Завершение звонка |
|
||||||
|
| `Redirect` | Переадресация звонка |
|
||||||
|
|
||||||
|
### Real-time события
|
||||||
|
|
||||||
|
При включённых `Events: On` AMI автоматически отправляет:
|
||||||
|
- `Event: Cdr` — при завершении звонка (содержит CDR-данные)
|
||||||
|
- `Event: Newchannel` — при начале звонка
|
||||||
|
- `Event: Dial` — при наборе номера
|
||||||
|
- `Event: Hangup` — при завершении
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Пример подключения (Python)
|
||||||
|
|
||||||
|
### MySQL — запрос CDR
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="192.168.1.150", # или внешний IP через проброс
|
||||||
|
port=3306, # или 43306 через проброс
|
||||||
|
user="1cuser",
|
||||||
|
password="1csecret",
|
||||||
|
database="cdr",
|
||||||
|
charset="utf8", # ВАЖНО: не utf8mb4!
|
||||||
|
connect_timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM cdr ORDER BY datetime DESC LIMIT 10")
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
print(row)
|
||||||
|
conn.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
### AMI — real-time события
|
||||||
|
|
||||||
|
```python
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
def ami_connect(host, port, username, password):
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.connect((host, port))
|
||||||
|
# Читаем приветствие
|
||||||
|
greeting = sock.recv(4096).decode()
|
||||||
|
# Логин
|
||||||
|
login_msg = (
|
||||||
|
f"Action: Login\r\n"
|
||||||
|
f"Username: {username}\r\n"
|
||||||
|
f"Secret: {password}\r\n"
|
||||||
|
f"Events: On\r\n"
|
||||||
|
f"\r\n"
|
||||||
|
)
|
||||||
|
sock.send(login_msg.encode())
|
||||||
|
time.sleep(1)
|
||||||
|
response = sock.recv(16384).decode()
|
||||||
|
return sock
|
||||||
|
|
||||||
|
# Использование
|
||||||
|
sock = ami_connect("192.168.1.150", 5038, "1cuser", "1csecret")
|
||||||
|
# Слушаем события
|
||||||
|
while True:
|
||||||
|
data = sock.recv(16384).decode()
|
||||||
|
if data:
|
||||||
|
print(data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### AMI — из сервера (через проброс)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Подключение через проброс портов с сервера
|
||||||
|
sock = ami_connect("81.177.141.34", 45038, "1cuser", "1csecret")
|
||||||
|
# Или напрямую (если сервер в локальной сети)
|
||||||
|
sock = ami_connect("192.168.1.150", 5038, "1cuser", "1csecret")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Схема интеграции с AegisOne
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐ MySQL:3306 ┌──────────────────┐
|
||||||
|
│ py_service │◄────────────────────│ Yeastar S20 │
|
||||||
|
│ (CDR polling) │ │ (cdr БД) │
|
||||||
|
│ │ AMI:5038 │ │
|
||||||
|
│ (real-time) │◄────────────────────│ (события) │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌──────────────┐ │ │ ┌──────────────┐ │
|
||||||
|
│ │ /service/ │ │ │ │ Записи WAV │ │
|
||||||
|
│ │ phone/ │ │ FTP:21 │ │ /tmp/media/ │ │
|
||||||
|
│ │ history │ │◄──(когда будет)────│ │ │ │
|
||||||
|
│ │ playback │ │ │ └──────────────┘ │
|
||||||
|
│ └──────────────┘ │ └──────────────────┘
|
||||||
|
└──────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ max_bot │
|
||||||
|
│ (уведомления о │
|
||||||
|
│ пропущенных) │
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. План интеграции (TODO)
|
||||||
|
|
||||||
|
### Фаза 1: CDR (готово к реализации)
|
||||||
|
- [ ] Модель БД для хранения звонков
|
||||||
|
- [ ] Периодический опрос MySQL (каждые 5 мин)
|
||||||
|
- [ ] Страница истории звонков в сервисном портале
|
||||||
|
- [ ] Фильтры: по дате, номеру, типу, статусу
|
||||||
|
|
||||||
|
### Фаза 2: Real-time
|
||||||
|
- [ ] AMI-клиент для получения событий
|
||||||
|
- [ ] Уведомления о пропущенных звонках (в max_bot)
|
||||||
|
- [ ] Статус абонентов (online/offline)
|
||||||
|
- [ ] Мониторинг очередей
|
||||||
|
|
||||||
|
### Фаза 3: Записи
|
||||||
|
- [x] FTP логин найден: `support` / `T4oQSU_?`
|
||||||
|
- [ ] FTP-чroot не даёт доступ к `/tmp/media/mmc1/autorecords/` — нужен SSH или другой способ
|
||||||
|
- [ ] Разобраться с SSH-логином (пароль `T4oQSU_?` известен)
|
||||||
|
- [ ] Скачивание записей
|
||||||
|
- [ ] Плеер для прослушивания в портале
|
||||||
|
|
||||||
|
### Фаза 4: Управление
|
||||||
|
- [ ] Исходящие звонки из портала (если AMI получит права)
|
||||||
|
- [ ] Переключение звонков
|
||||||
|
- [ ] Статистика и отчёты
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Известные проблемы
|
||||||
|
|
||||||
|
1. **Записи звонков недоступны через FTP** — логин `support` работает, но `/tmp/media/mmc1/autorecords/` за пределами FTP-чroot. Нужен SSH (порт 8022, пароль `T4oQSU_?`, логин неизвестен)
|
||||||
|
2. **AMI ограниченные права** — на S20 нельзя расширить привилегии AMI-пользователя через Web UI. Доступны только readonly-команды
|
||||||
|
3. **REST API не поддерживается** — только для S50/S100/S300
|
||||||
|
4. **MySQL 5.1.61** — старая версия, не поддерживает `utf8mb4`. Использовать `charset='utf8'`
|
||||||
|
5. **Кириллица в CDR** — имена контактов отображаются в кодировке CP1251/garbage (проблема кодировки MySQL-клиента)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Ссылки
|
||||||
|
|
||||||
|
- [Yeastar S-Series AMI Documentation](https://help.yeastar.com/en/s-series/topic/asterisk-manager-interface-ami.html)
|
||||||
|
- [Yeastar Developer Guide](https://support.yeastar.com/hc/en-us/articles/235972668)
|
||||||
|
- [Asterisk AMI Protocol](https://docs.asterisk.org/languages/en/Asterisk_18_Documentation/DAHDI_DAHDI_Hardware_Digium_Interface_Hardware/Administering/Asterisk_Manager_Interface_AMI/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. FTP — Структура каталогов
|
||||||
|
|
||||||
|
```
|
||||||
|
/ (корень FTP, доступен как support)
|
||||||
|
├── boot/
|
||||||
|
├── cache/
|
||||||
|
├── etc/
|
||||||
|
├── fax/
|
||||||
|
├── ftp_media/ (пусто)
|
||||||
|
├── gui_backups/
|
||||||
|
├── imageupdate/
|
||||||
|
├── lost+found/
|
||||||
|
├── rcstop/
|
||||||
|
├── sounds/
|
||||||
|
│ ├── moh/ (музыка на удержании)
|
||||||
|
│ └── record/ (пусто)
|
||||||
|
├── storage_share/
|
||||||
|
│ └── mmc1 → /tmp/media/mmc1 (симлинк, нерезолвящийся)
|
||||||
|
├── support/
|
||||||
|
│ ├── tmp/
|
||||||
|
│ │ ├── deviceinfo.txt ← ИНФОРМАЦИЯ ОБ УСТРОЙСТВЕ
|
||||||
|
│ │ └── testsysmailbox.eml
|
||||||
|
│ ├── autoptemplate/
|
||||||
|
│ ├── bin/
|
||||||
|
│ ├── customcfg/
|
||||||
|
│ └── fxotune/
|
||||||
|
├── syslog/
|
||||||
|
├── syslog_backup/
|
||||||
|
├── tftpboot/
|
||||||
|
├── var/
|
||||||
|
├── webupload/
|
||||||
|
├── www/
|
||||||
|
└── ysapps/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ключевые файлы
|
||||||
|
|
||||||
|
| Путь | Содержимое |
|
||||||
|
|------|-----------|
|
||||||
|
| `/support/tmp/deviceinfo.txt` | Серийный номер, модель, прошивка |
|
||||||
|
| `/support/tmp/testsysmailbox.eml` | Тестовый шаблон почты |
|
||||||
|
| `/storage_share/mmc1` | Симлинк на записанные файлы (недоступен через FTP) |
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
Если смотреть не со стороны маркетинга, а со стороны внутренней сервисной системы, то воронка продаж превращается в процесс обработки лида внутри портала.
|
||||||
|
|
||||||
|
В этом случае телефон, бот, сайт, почта — это просто каналы поступления обращений в единую систему.
|
||||||
|
**1. Общая архитектура**
|
||||||
|
```
|
||||||
|
Клиент
|
||||||
|
├── Телефон
|
||||||
|
├── Telegram-бот
|
||||||
|
├── Сайт
|
||||||
|
├── Email
|
||||||
|
└── WhatsApp
|
||||||
|
↓
|
||||||
|
Сервисный портал
|
||||||
|
↓
|
||||||
|
Лид (Lead)
|
||||||
|
↓
|
||||||
|
Квалификация
|
||||||
|
↓
|
||||||
|
Сделка
|
||||||
|
↓
|
||||||
|
Проект
|
||||||
|
↓
|
||||||
|
Выполнение
|
||||||
|
↓
|
||||||
|
Закрытие
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Роли в системе**
|
||||||
|
|
||||||
|
--- Руководитель
|
||||||
|
Видит:
|
||||||
|
новые лиды;
|
||||||
|
активные сделки;
|
||||||
|
загрузку инженеров;
|
||||||
|
прибыль;
|
||||||
|
SLA;
|
||||||
|
показатели конверсии.
|
||||||
|
|
||||||
|
Его задача:
|
||||||
|
назначать ответственных;
|
||||||
|
контролировать сроки;
|
||||||
|
отслеживать KPI.
|
||||||
|
|
||||||
|
--- Инженер
|
||||||
|
Работает с технической частью.
|
||||||
|
Видит:
|
||||||
|
назначенные заявки;
|
||||||
|
технические задания;
|
||||||
|
историю клиента;
|
||||||
|
комментарии.
|
||||||
|
|
||||||
|
Может:
|
||||||
|
создавать этапы работ;
|
||||||
|
запрашивать данные;
|
||||||
|
прикладывать документы;
|
||||||
|
переводить задачи между статусами.
|
||||||
|
|
||||||
|
--- Техник
|
||||||
|
Исполнитель.
|
||||||
|
Видит только:
|
||||||
|
свои задачи;
|
||||||
|
инструкции;
|
||||||
|
сроки;
|
||||||
|
вложения.
|
||||||
|
|
||||||
|
Может:
|
||||||
|
отмечать выполнение;
|
||||||
|
прикладывать фото;
|
||||||
|
добавлять комментарии.
|
||||||
|
|
||||||
|
**3. Как выглядит жизненный цикл обращения**
|
||||||
|
|
||||||
|
Шаг 1. Обращение через бота
|
||||||
|
|
||||||
|
Клиент пишет в бот
|
||||||
|
|
||||||
|
Бот собирает контактные данные и создается заявка (необходимо продумать как собрать название организации от клиента)
|
||||||
|
На основе этой заявки необходимо (тот кто ее обрабатывает) должен создать нового Клиента (Управление - Клиенты) или Добавить к существующему клиенту. Если есть точно совпадающие даннуе, то заявка должна автоматически привязываться к клиенту. Важно - Назавание организации может быть одинаковым у разных клиентов.
|
||||||
|
|
||||||
|
Шаг 2. Создание лида
|
||||||
|
|
||||||
|
В портале появляется:
|
||||||
|
```
|
||||||
|
Клиент №ххх
|
||||||
|
Источник: Max Bot
|
||||||
|
Компания: ООО Ромашка
|
||||||
|
Контакт: Иван Петров
|
||||||
|
Телефон: +79991234567
|
||||||
|
Услуга: Пример: Аудит безопасности
|
||||||
|
Статус: Новый
|
||||||
|
```
|
||||||
|
|
||||||
|
Шаг 3. Как подвязать телефонные звонки
|
||||||
|
Это одна из самых полезных функций.
|
||||||
|
Вариант 1. IP-телефония (лучший вариант)
|
||||||
|
Подключаются:
|
||||||
|
Asterisk (Локальная АТС)
|
||||||
|
FreePBX
|
||||||
|
MikoPBX
|
||||||
|
Zadarma
|
||||||
|
Mango Office (Доступ к webui)
|
||||||
|
Novofon
|
||||||
|
|
||||||
|
Каждый звонок автоматически попадает в портал.
|
||||||
|
--- Входящий звонок
|
||||||
|
Телефон звонит.
|
||||||
|
Портал получает событие:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"phone":"+79991234567",
|
||||||
|
"type":"incoming"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Портал ищет клиента.
|
||||||
|
Если клиент найден:
|
||||||
|
```
|
||||||
|
Входящий звонок
|
||||||
|
ООО Ромашка
|
||||||
|
Последняя заявка: Аудит безопасности
|
||||||
|
Ответственный: Иванов
|
||||||
|
```
|
||||||
|
Карточка открывается автоматически.
|
||||||
|
|
||||||
|
--- Если номер неизвестен
|
||||||
|
Создается временный клиент:
|
||||||
|
```
|
||||||
|
Клиент №ххх
|
||||||
|
Источник: Телефон
|
||||||
|
Телефон: +79991234567
|
||||||
|
Статус: Новый
|
||||||
|
```
|
||||||
|
После разговора менеджер заполняет остальные поля и привязывает его к существующему или создает нового клиента.
|
||||||
|
|
||||||
|
--- Автоматическая запись звонков
|
||||||
|
В карточке хранится:
|
||||||
|
```
|
||||||
|
Звонок #1
|
||||||
|
Дата: 03.06.2026
|
||||||
|
Длительность: 12:34
|
||||||
|
Запись: play.wav
|
||||||
|
```
|
||||||
|
Руководитель может прослушать разговор.
|
||||||
|
|
||||||
|
--- Автоматическая расшифровка
|
||||||
|
После разговора:
|
||||||
|
```
|
||||||
|
Whisper
|
||||||
|
Yandex SpeechKit
|
||||||
|
Google Speech-to-Text
|
||||||
|
YandexGPT
|
||||||
|
```
|
||||||
|
создают текст.
|
||||||
|
|
||||||
|
Карточка получает:
|
||||||
|
``` Пример:
|
||||||
|
Краткое содержание:
|
||||||
|
Клиент интересуется аудитом.
|
||||||
|
Планирует внедрение в июле.
|
||||||
|
Бюджет около 500 тыс.
|
||||||
|
```
|
||||||
|
|
||||||
|
Шаг 4. Автоматическая постановка задач
|
||||||
|
После завершения звонка система может создавать задачи основываясь на расшифровке разговара и запрашивает подтверждение у сотрудника.
|
||||||
|
Пример:
|
||||||
|
Менеджер выбрал:
|
||||||
|
```
|
||||||
|
Подготовить КП
|
||||||
|
```
|
||||||
|
Портал автоматически создает задачу инженеру.
|
||||||
|
```
|
||||||
|
Задача #ууу
|
||||||
|
Подготовить коммерческое предложение
|
||||||
|
Исполнитель: Инженер Петров
|
||||||
|
Срок: Завтра 12:00
|
||||||
|
```
|
||||||
|
|
||||||
|
--- Рекомендуемая структура статусов
|
||||||
|
Для лидов:
|
||||||
|
```
|
||||||
|
Новый
|
||||||
|
Связаться
|
||||||
|
Квалификация
|
||||||
|
Коммерческое предложение
|
||||||
|
Переговоры
|
||||||
|
Согласование
|
||||||
|
Выигран
|
||||||
|
Проигран
|
||||||
|
```
|
||||||
|
(Добавить возможность создавать и удалять дополнительно свои)
|
||||||
|
|
||||||
|
Для проектов:
|
||||||
|
```
|
||||||
|
Подготовка
|
||||||
|
В работе
|
||||||
|
Ожидание клиента
|
||||||
|
Тестирование
|
||||||
|
Завершено
|
||||||
|
Закрыто
|
||||||
|
```
|
||||||
|
(Добавить возможность создавать и удалять дополнительно свои)
|
||||||
|
|
||||||
|
**4. Что особенно полезно реализовать **
|
||||||
|
|
||||||
|
Единая карточка клиента
|
||||||
|
Внутри:
|
||||||
|
```
|
||||||
|
Контакты
|
||||||
|
Компания
|
||||||
|
История звонков
|
||||||
|
История чатов
|
||||||
|
Файлы
|
||||||
|
Проекты
|
||||||
|
Акты
|
||||||
|
Счета
|
||||||
|
Договоры
|
||||||
|
Задачи
|
||||||
|
Заявки / Тикеты
|
||||||
|
Комментарии
|
||||||
|
```
|
||||||
|
Заявкам добавить Критичность (сейчас приоритет)
|
||||||
|
```
|
||||||
|
Критичная
|
||||||
|
Высокая
|
||||||
|
Средняя
|
||||||
|
Низкая
|
||||||
|
```
|
||||||
|
Никто не ищет информацию по разным системам!
|
||||||
|
|
||||||
|
**5. Оптимальная схема для собственной разработки **
|
||||||
|
```
|
||||||
|
Telegram Bot
|
||||||
|
│
|
||||||
|
├─────────┐
|
||||||
|
│ │
|
||||||
|
Телефония Сайт
|
||||||
|
│ │
|
||||||
|
└────┬────┘
|
||||||
|
│
|
||||||
|
Lead Service
|
||||||
|
│
|
||||||
|
CRM Module
|
||||||
|
│
|
||||||
|
Workflow Engine
|
||||||
|
│
|
||||||
|
Engineer Portal
|
||||||
|
│
|
||||||
|
Client Projects
|
||||||
|
```
|
||||||
|
Если ваша компания оказывает сервисные услуги (ИБ, IT-аутсорсинг, инженерные работы, обслуживание оборудования и т.д.), то я бы рекомендовал строить систему не как классическую CRM, а как Service Desk + CRM + Телефония в одном портале. Тогда звонок, сообщение из бота, заявка с сайта и дальнейшие технические работы будут проходить через одну карточку клиента и один жизненный цикл обращения. Это существенно упрощает контроль для руководителя и ускоряет работу инженеров и техников.
|
||||||
|
|
||||||
|
**6. Что должен видеть руководитель на главной странице**
|
||||||
|
Блок KPI
|
||||||
|
```
|
||||||
|
Новые лиды: 12
|
||||||
|
Сегодня обработано: 9
|
||||||
|
Конверсия: 34%
|
||||||
|
Средний чек: 280 000 ₽
|
||||||
|
Активные проекты: 18
|
||||||
|
```
|
||||||
|
Воронка
|
||||||
|
```
|
||||||
|
Новые лиды 100
|
||||||
|
Квалификация 65
|
||||||
|
КП отправлено 40
|
||||||
|
Переговоры 22
|
||||||
|
Сделка 11
|
||||||
|
```
|
||||||
@@ -1,5 +1,73 @@
|
|||||||
# Changelog — AegisOne Service Portal
|
# Changelog — AegisOne Service Portal
|
||||||
|
|
||||||
|
## 1.9.0 (04.06.2026)
|
||||||
|
### Новые функции
|
||||||
|
- **Phase 0 — Инфраструктура CRM/лидов:**
|
||||||
|
- Новые таблицы: `leads` (20 полей), `call_records` (20 полей CDR), `client_notifications` (10 полей), `pbx_settings` (27 полей)
|
||||||
|
- Расширены модели: `User` (extension — внутренний номер PBX), `Customer` (lead_source, last_call_at, total_calls, total_leads, merged_into, статусы draft/merged), `Task` (lead_id — FK → leads), `AuditLog` (entity_type, entity_id, field, old_value, new_value)
|
||||||
|
- Миграции БД: система `migrations_runner.py` с авто-применением при старте, отслеживание в `_applied_migrations`
|
||||||
|
- SQL-миграция `001_phase0_infrastructure.sql`: 31 операция (CREATE TABLE → ALTER TABLE в правильном порядке)
|
||||||
|
- Шифрование паролей PBX через `cryptography.fernet` (PBXSettings)
|
||||||
|
|
||||||
|
### Исправления
|
||||||
|
- **documents_router.py:** доступ «Владелец/Инженер» для tech-access — `!= "engineer"` → `not in ("engineer", "owner")`
|
||||||
|
|
||||||
|
### Документация
|
||||||
|
- **AGENTS.md:** раздел 0.4 — обязательные файлы для чтения; раздел 1 — ссылка на архитектуру сервера
|
||||||
|
- **SERVICE_STYLE_GUIDE.md:** единый гайд UI
|
||||||
|
- **other/Yeastar_S20.md:** полная документация по PBX (AMI, MySQL CDR, FTP, SSH)
|
||||||
|
- **other/LEAD_IMPLEMENTATION_PLAN.md:** 8-фазный план (0-7) реализации CRM/лидов
|
||||||
|
- **other/1C_UNF_INTEGRATION.md:** документация по интеграции с 1С:УНФ 3.0 через OData API
|
||||||
|
- **other/voronka.md:** документ жизненного цикла лидов
|
||||||
|
|
||||||
|
## 1.8.6 (02.06.2026)
|
||||||
|
### Новые функции
|
||||||
|
- **sort-table.js:** универсальный модуль сортировки таблиц по столбцам (текст/число/дата), подключён глобально
|
||||||
|
- **Number input:** спиннеры скрыты через CSS (`appearance: textfield`) на всех страницах
|
||||||
|
|
||||||
|
### Исправления
|
||||||
|
- **customers.html:** восстановлена сломанная HTML-структура — `.card` > `.card-header`, поиск в `.filter-bar`
|
||||||
|
- **bot_dialogues.html:** обёрнут в `.card` > `.card-header`, фильтры в `.filter-bar`, удалены кастомные `.page-header`/`.header-controls`
|
||||||
|
- **checklist.html:** `<h3>` обёрнут в `.card-header`
|
||||||
|
- **18 страниц:** `modal-actions` → `form-actions` (единый класс для кнопок в модалках)
|
||||||
|
- **charts.html:** `btn-xs` → `btn-sm`
|
||||||
|
- **assignments.html:** добавлена модалка подтверждения отвязки сотрудника
|
||||||
|
- **4 AJAX-страницы:** `sortTables()` вызывается после загрузки данных (bot_kb, bot_unknown, bot_consent, bot_tickets)
|
||||||
|
|
||||||
|
## 1.8.5 (02.06.2026)
|
||||||
|
### Новые функции
|
||||||
|
- **AGENTS.md (раздел 12):** правила по Идеям — агент может видеть, добавлять, менять статус, предлагать к реализации, реализовывать
|
||||||
|
|
||||||
|
### Исправления
|
||||||
|
- **models.py:** `onupdate="NOW()"` → `onupdate=func.now()` — fix Internal Server Error при смене статуса Идеи (14 моделей)
|
||||||
|
- **page.html:** удалён `main-header` (заголовок + дата) — больше не дублируется на всех страницах
|
||||||
|
- **dashboard.html:** удалён `main-header`
|
||||||
|
- **bot_dialogues.html:** удалён CSS `.main-header { display: none !important; }` (больше не нужен)
|
||||||
|
- **version.txt / CHANGELOG.md:** синхронизация версии в контейнере aegisone-app
|
||||||
|
|
||||||
|
## 1.8.4 (02.06.2026)
|
||||||
|
### Новые функции
|
||||||
|
- **SERVICE_STYLE_GUIDE.md:** единый гайд стилей сервисного портала
|
||||||
|
- **AGENTS.md (раздел 11):** правила стилей UI — см. SERVICE_STYLE_GUIDE.md
|
||||||
|
- **CSS `.modal-actions`:** общий класс для кнопок в модалках
|
||||||
|
|
||||||
|
### Исправления
|
||||||
|
- **bot_dialogues.html:** скрыт `main-header` (дублирующий заголовок с датой)
|
||||||
|
- **bot_kb.html:** замена кастомных классов на `.modal-overlay`/`.modal`, `confirm()` → кастомная модалка, иконки ✎/✕
|
||||||
|
- **bot_unknown.html:** `.modal-box` → `.modal`, `confirm()` → кастомная модалка, иконки ✎/✕
|
||||||
|
- **bot_consent.html:** `confirm()` → кастомная модалка удаления пользователя
|
||||||
|
- **bot_test.html:** `confirm()` → кастомная модалка очистки тестовых данных
|
||||||
|
- **charts.html:** `prompt()` → кастомная модалка ввода имени графика, `confirm()` → кастомная модалка удаления
|
||||||
|
- **role_settings.html:** `alert()` → `showNotification()`
|
||||||
|
- **10 простых страниц:** blog, cases, coefficients, customers, formulas, ideas, objects, questionnaire_config, tasks, users — `confirm()` → кастомные модалки с уникальными ID
|
||||||
|
- **migrations_runner.py:** split SQL на `;` для совместимости с asyncpg
|
||||||
|
|
||||||
|
## 1.8.3 (02.06.2026)
|
||||||
|
### Новые функции
|
||||||
|
- **Docstrings:** добавлены docstrings и комментарии по AGENTS.md ко всем ключевым Python-файлам
|
||||||
|
- **File headers:** каждый файл имеет шапку с описанием назначения
|
||||||
|
- **Function docstrings:** каждая функция имеет docstring на русском языке
|
||||||
|
|
||||||
## 1.8.2 (02.06.2026)
|
## 1.8.2 (02.06.2026)
|
||||||
### Новые функции
|
### Новые функции
|
||||||
- **AGENTS.md:** правила проекта для AI-агентов (корень репозитория)
|
- **AGENTS.md:** правила проекта для AI-агентов (корень репозитория)
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
|
"""
|
||||||
|
Конфигурация service portal (Pydantic Settings).
|
||||||
|
|
||||||
|
Читает из .env:
|
||||||
|
- DATABASE_URL: PostgreSQL URL
|
||||||
|
- SECRET_KEY: ключ сессий
|
||||||
|
- SESSION_TTL: время жизни сессии (сек)
|
||||||
|
- DOCS_DIR/UPLOADS_DIR: пути к документам
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|||||||
@@ -153,8 +153,8 @@ async def tech_access_page(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: dict = Depends(get_current_user),
|
user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
if user["role"] != "engineer":
|
if user["role"] not in ("engineer", "owner"):
|
||||||
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
|
return HTMLResponse(content="Доступ только для инженеров и руководителей", status_code=403)
|
||||||
|
|
||||||
perms = discover_docs()
|
perms = discover_docs()
|
||||||
editable_docs = [
|
editable_docs = [
|
||||||
@@ -170,8 +170,8 @@ async def tech_access_update(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: dict = Depends(get_current_user),
|
user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
if user["role"] != "engineer":
|
if user["role"] not in ("engineer", "owner"):
|
||||||
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
|
return HTMLResponse(content="Доступ только для инженеров и руководителей", status_code=403)
|
||||||
|
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
perms = discover_docs()
|
perms = discover_docs()
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
"""
|
||||||
|
Главный модуль service portal FastAPI-приложения.
|
||||||
|
|
||||||
|
Портал для управления:
|
||||||
|
- Сотрудниками (owner/engineer/technician)
|
||||||
|
- Клиентами и объектами
|
||||||
|
- Задачами и инцидентами
|
||||||
|
- Настройками бота и базой знаний
|
||||||
|
- Документами и отчётами
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@@ -103,8 +113,20 @@ async def seed_initial_data():
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
from app.models.models import Base
|
from app.models.models import Base
|
||||||
from app.database import engine
|
from app.database import engine
|
||||||
|
|
||||||
|
# Применяем миграции (ALTER TABLE, CREATE TABLE IF NOT EXISTS)
|
||||||
|
from app.migrations_runner import run_migrations_on_startup
|
||||||
|
migration_results = await run_migrations_on_startup()
|
||||||
|
if migration_results:
|
||||||
|
import logging
|
||||||
|
logging.getLogger("migrations").info(
|
||||||
|
"Применены миграции: %s", ", ".join(migration_results)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Создаём таблицы которые ещё не существуют (idempotent)
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
await seed_initial_data()
|
await seed_initial_data()
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
from app.calculations.engine import reload_coefficients
|
from app.calculations.engine import reload_coefficients
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,20 @@
|
|||||||
|
"""
|
||||||
|
ORM-модели для service portal (SQLAlchemy 2.0).
|
||||||
|
|
||||||
|
Основные таблицы:
|
||||||
|
- users: сотрудники (owner/engineer/technician)
|
||||||
|
- customers: клиенты
|
||||||
|
- objects: объекты обслуживания
|
||||||
|
- tasks / incidents: задачи и инциденты
|
||||||
|
- leads: лиды (обращения из бота, звонков, сайта, email)
|
||||||
|
- call_records: звонки (CDR из Yeastar S20)
|
||||||
|
- client_notifications: уведомления
|
||||||
|
- pbx_settings: настройки АТС
|
||||||
|
- blog/cases/ideas: контент
|
||||||
|
"""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column, Integer, String, Boolean, Text, DateTime, Numeric, Date, BigInteger, SmallInteger, JSON, ForeignKey, CheckConstraint
|
Column, Integer, String, Boolean, Text, DateTime, Numeric, Date, BigInteger, SmallInteger, JSON, ForeignKey, CheckConstraint, func
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship, declarative_base
|
from sqlalchemy.orm import relationship, declarative_base
|
||||||
|
|
||||||
@@ -18,9 +32,10 @@ class User(Base):
|
|||||||
phone = Column(String(50), default="")
|
phone = Column(String(50), default="")
|
||||||
email = Column(String(255), default="")
|
email = Column(String(255), default="")
|
||||||
max_user_id = Column(BigInteger, nullable=True)
|
max_user_id = Column(BigInteger, nullable=True)
|
||||||
|
extension = Column(String(10), default="") # Внутренний номер АТС (1001, 1002...)
|
||||||
is_active = Column(Boolean, nullable=False, default=True)
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint("role IN ('owner','engineer','technician')", name="ck_users_role"),
|
CheckConstraint("role IN ('owner','engineer','technician')", name="ck_users_role"),
|
||||||
@@ -41,17 +56,33 @@ class LoginAttempt(Base):
|
|||||||
|
|
||||||
|
|
||||||
class AuditLog(Base):
|
class AuditLog(Base):
|
||||||
|
"""Лог действий пользователей (аудит).
|
||||||
|
|
||||||
|
Записывает все изменения в системе: кто, что, когда изменил.
|
||||||
|
Используется для отслеживания дедупликации и ручных правок.
|
||||||
|
"""
|
||||||
__tablename__ = "audit_log"
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
action = Column(String(255), nullable=False)
|
action = Column(String(255), nullable=False)
|
||||||
|
entity_type = Column(String(50), default="") # customer, lead, call_record, task
|
||||||
|
entity_id = Column(Integer, default=0)
|
||||||
|
field = Column(String(100), default="") # какое поле изменено
|
||||||
|
old_value = Column(Text, default="") # старое значение
|
||||||
|
new_value = Column(Text, default="") # новое значение
|
||||||
details = Column(Text)
|
details = Column(Text)
|
||||||
ip_address = Column(String(45), default="")
|
ip_address = Column(String(45), default="")
|
||||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
|
|
||||||
|
|
||||||
class Customer(Base):
|
class Customer(Base):
|
||||||
|
"""Клиент (контрагент).
|
||||||
|
|
||||||
|
Хранит контактные данные, ИНН/КПП, статус.
|
||||||
|
Статус 'draft' — автосоздан системой из лида/звонка, ожидает заполнения.
|
||||||
|
Статус 'merged' — объединён с другим клиентом.
|
||||||
|
"""
|
||||||
__tablename__ = "customers"
|
__tablename__ = "customers"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -64,12 +95,20 @@ class Customer(Base):
|
|||||||
contact_email = Column(String(255), default="")
|
contact_email = Column(String(255), default="")
|
||||||
status = Column(String(20), nullable=False, default="active")
|
status = Column(String(20), nullable=False, default="active")
|
||||||
notes = Column(Text)
|
notes = Column(Text)
|
||||||
|
lead_source = Column(String(20), default="") # Откуда пришёл клиент
|
||||||
|
last_call_at = Column(DateTime) # Последний звонок
|
||||||
|
total_calls = Column(Integer, default=0) # Количество звонков
|
||||||
|
total_leads = Column(Integer, default=0) # Количество лидов
|
||||||
|
merged_into = Column(Integer, nullable=True) # ID клиента, с которым слили
|
||||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint("status IN ('active','inactive','prospect')", name="ck_customers_status"),
|
CheckConstraint(
|
||||||
|
"status IN ('active','inactive','prospect','draft','merged')",
|
||||||
|
name="ck_customers_status"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = relationship("Object", back_populates="customer")
|
objects = relationship("Object", back_populates="customer")
|
||||||
@@ -99,7 +138,7 @@ class Object(Base):
|
|||||||
region_factor = Column(Numeric(3, 2), default=1.00)
|
region_factor = Column(Numeric(3, 2), default=1.00)
|
||||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint("status IN ('active','inactive','audit','prospective')", name="ck_objects_status"),
|
CheckConstraint("status IN ('active','inactive','audit','prospective')", name="ck_objects_status"),
|
||||||
@@ -134,12 +173,19 @@ class ObjectAssignment(Base):
|
|||||||
|
|
||||||
|
|
||||||
class Task(Base):
|
class Task(Base):
|
||||||
|
"""Задача (привязана к объекту).
|
||||||
|
|
||||||
|
Owner/engineer создают и назначают на любого.
|
||||||
|
Technician может создать только себе напоминание.
|
||||||
|
lead_id — связь задачи с лидом (если задача создана из лида).
|
||||||
|
"""
|
||||||
__tablename__ = "tasks"
|
__tablename__ = "tasks"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
|
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
|
||||||
assigned_to = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
assigned_to = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
|
lead_id = Column(Integer, ForeignKey("leads.id", ondelete="SET NULL")) # Связь с лидом
|
||||||
title = Column(String(500), nullable=False)
|
title = Column(String(500), nullable=False)
|
||||||
description = Column(Text)
|
description = Column(Text)
|
||||||
priority = Column(String(4), nullable=False, default="P3")
|
priority = Column(String(4), nullable=False, default="P3")
|
||||||
@@ -153,7 +199,7 @@ class Task(Base):
|
|||||||
result_notes = Column(Text)
|
result_notes = Column(Text)
|
||||||
closed_at = Column(DateTime)
|
closed_at = Column(DateTime)
|
||||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint("priority IN ('P1','P2','P3','P4')", name="ck_tasks_priority"),
|
CheckConstraint("priority IN ('P1','P2','P3','P4')", name="ck_tasks_priority"),
|
||||||
@@ -217,7 +263,7 @@ class Report(Base):
|
|||||||
status = Column(String(20), nullable=False, default="draft")
|
status = Column(String(20), nullable=False, default="draft")
|
||||||
client_confirmed = Column(Boolean, default=False)
|
client_confirmed = Column(Boolean, default=False)
|
||||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint("report_type IN ('inspection','service','emergency','audit','monthly')", name="ck_reports_type"),
|
CheckConstraint("report_type IN ('inspection','service','emergency','audit','monthly')", name="ck_reports_type"),
|
||||||
@@ -245,7 +291,7 @@ class QuestionnaireSession(Base):
|
|||||||
object_index = Column(Numeric(5, 2), default=0)
|
object_index = Column(Numeric(5, 2), default=0)
|
||||||
sla_price_monthly = Column(Numeric(12, 2), default=0)
|
sla_price_monthly = Column(Numeric(12, 2), default=0)
|
||||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint("status IN ('draft','completed','cancelled')", name="ck_qsessions_status"),
|
CheckConstraint("status IN ('draft','completed','cancelled')", name="ck_qsessions_status"),
|
||||||
@@ -283,7 +329,7 @@ class QuestionnaireItem(Base):
|
|||||||
is_active = Column(Boolean, nullable=False, default=True)
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
help_text = Column(String(500), default="")
|
help_text = Column(String(500), default="")
|
||||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint("type IN ('text','number','select','radio','checkbox','textarea')", name="ck_qitems_type"),
|
CheckConstraint("type IN ('text','number','select','radio','checkbox','textarea')", name="ck_qitems_type"),
|
||||||
@@ -330,7 +376,7 @@ class SLAContract(Base):
|
|||||||
status = Column(String(20), nullable=False, default="negotiation")
|
status = Column(String(20), nullable=False, default="negotiation")
|
||||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint("service_level IN ('start','business','enterprise')", name="ck_sla_level"),
|
CheckConstraint("service_level IN ('start','business','enterprise')", name="ck_sla_level"),
|
||||||
@@ -359,7 +405,7 @@ class Incident(Base):
|
|||||||
sla_breached = Column(Boolean, default=False)
|
sla_breached = Column(Boolean, default=False)
|
||||||
resolved_at = Column(DateTime)
|
resolved_at = Column(DateTime)
|
||||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint("severity IN ('P1','P2','P3')", name="ck_incidents_severity"),
|
CheckConstraint("severity IN ('P1','P2','P3')", name="ck_incidents_severity"),
|
||||||
@@ -428,7 +474,7 @@ class BlogPost(Base):
|
|||||||
status = Column(String(20), nullable=False, default="draft")
|
status = Column(String(20), nullable=False, default="draft")
|
||||||
published_at = Column(DateTime)
|
published_at = Column(DateTime)
|
||||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint("category IN ('audit','sla','incident','supervision','documentation','risk','cases')", name="ck_blog_category"),
|
CheckConstraint("category IN ('audit','sla','incident','supervision','documentation','risk','cases')", name="ck_blog_category"),
|
||||||
@@ -448,7 +494,7 @@ class FormulaCoefficient(Base):
|
|||||||
formula_ref = Column(String(255), default="")
|
formula_ref = Column(String(255), default="")
|
||||||
is_active = Column(Boolean, nullable=False, default=True)
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
updated_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
updated_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
updater = relationship("User")
|
updater = relationship("User")
|
||||||
|
|
||||||
@@ -463,7 +509,7 @@ class Case(Base):
|
|||||||
sort_order = Column(Integer, nullable=False, default=0)
|
sort_order = Column(Integer, nullable=False, default=0)
|
||||||
is_active = Column(Boolean, nullable=False, default=True)
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
class Idea(Base):
|
class Idea(Base):
|
||||||
@@ -475,7 +521,7 @@ class Idea(Base):
|
|||||||
status = Column(String(20), nullable=False, default="normal")
|
status = Column(String(20), nullable=False, default="normal")
|
||||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint("status IN ('normal','in_progress','completed')", name="ck_ideas_status"),
|
CheckConstraint("status IN ('normal','in_progress','completed')", name="ck_ideas_status"),
|
||||||
@@ -493,7 +539,7 @@ class QuickMenuItem(Base):
|
|||||||
url = Column(String(500), nullable=False)
|
url = Column(String(500), nullable=False)
|
||||||
sort_order = Column(Integer, nullable=False, default=0)
|
sort_order = Column(Integer, nullable=False, default=0)
|
||||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
creator = relationship("User")
|
creator = relationship("User")
|
||||||
|
|
||||||
@@ -511,4 +557,199 @@ class RoleMenuPermission(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Модели для лидов, звонков, уведомлений, настроек АТС ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class Lead(Base):
|
||||||
|
"""Лид — обращение из бота, звонка, сайта или email.
|
||||||
|
|
||||||
|
Источники: bot, phone_in, phone_out, site, email, manual.
|
||||||
|
Автопривязка к клиенту: ИНН → телефон → email → создание draft.
|
||||||
|
"""
|
||||||
|
__tablename__ = "leads"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"))
|
||||||
|
|
||||||
|
# Источник обращения
|
||||||
|
source = Column(String(20), nullable=False, default="manual")
|
||||||
|
# bot | phone_in | phone_out | site | email | manual
|
||||||
|
|
||||||
|
# Данные клиента
|
||||||
|
company_name = Column(String(255), default="")
|
||||||
|
contact_name = Column(String(255), default="")
|
||||||
|
contact_phone = Column(String(50), default="")
|
||||||
|
contact_email = Column(String(255), default="")
|
||||||
|
inn = Column(String(12), default="")
|
||||||
|
|
||||||
|
# Содержание обращения
|
||||||
|
title = Column(String(500), default="")
|
||||||
|
service_type = Column(String(255), default="")
|
||||||
|
description = Column(Text, default="")
|
||||||
|
urgency = Column(String(10), default="medium")
|
||||||
|
# critical | high | medium | low
|
||||||
|
|
||||||
|
# Статус
|
||||||
|
status = Column(String(30), default="new")
|
||||||
|
# new | contact | qualification | proposal | negotiation | approved | won | lost | merged
|
||||||
|
|
||||||
|
# Привязка
|
||||||
|
assigned_to = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
|
bot_ticket_id = Column(BigInteger) # ID заявки из бота
|
||||||
|
|
||||||
|
# Воронка
|
||||||
|
deal_amount = Column(Numeric(12, 2), default=0)
|
||||||
|
expected_close_date = Column(Date)
|
||||||
|
|
||||||
|
# Мета
|
||||||
|
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('new','contact','qualification','proposal','negotiation','approved','won','lost','merged')",
|
||||||
|
name="ck_leads_status"
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"urgency IN ('critical','high','medium','low')",
|
||||||
|
name="ck_leads_urgency"
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"source IN ('bot','phone_in','phone_out','site','email','manual')",
|
||||||
|
name="ck_leads_source"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
customer = relationship("Customer", foreign_keys=[customer_id])
|
||||||
|
assignee = relationship("User", foreign_keys=[assigned_to])
|
||||||
|
creator = relationship("User", foreign_keys=[created_by])
|
||||||
|
|
||||||
|
|
||||||
|
class CallRecord(Base):
|
||||||
|
"""Звонок (CDR из Yeastar S20 через MySQL).
|
||||||
|
|
||||||
|
Синхронизируется каждые 5 минут из таблицы cdr.
|
||||||
|
Автопривязка к клиенту по номеру телефона.
|
||||||
|
Автопривязка к сотруднику по внутреннему номеру (extension).
|
||||||
|
"""
|
||||||
|
__tablename__ = "call_records"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# Данные из CDR
|
||||||
|
cdr_uniqueid = Column(String(32), unique=True) # Уникальный ID из CDR
|
||||||
|
call_datetime = Column(DateTime, nullable=False)
|
||||||
|
caller_number = Column(String(80), default="") # Номер звонящего (src)
|
||||||
|
callee_number = Column(String(80), default="") # Номер получателя (dst)
|
||||||
|
trunk = Column(String(80), default="") # Транк (Mango, Tele2)
|
||||||
|
duration = Column(Integer, default=0) # Общая длительность (сек)
|
||||||
|
billable_seconds = Column(Integer, default=0) # Биллируемая (сек)
|
||||||
|
disposition = Column(String(30), default="") # ANSWERED, NO ANSWER, BUSY, VOICEMAIL
|
||||||
|
call_type = Column(String(20), default="") # Inbound, Outbound, Internal
|
||||||
|
queue_name = Column(String(30), default="") # Очередь (6700, 6701, 6702)
|
||||||
|
|
||||||
|
# Привязка к клиенту
|
||||||
|
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"))
|
||||||
|
lead_id = Column(Integer, ForeignKey("leads.id", ondelete="SET NULL"))
|
||||||
|
|
||||||
|
# Инженер/техник кто принял звонок
|
||||||
|
answered_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
|
answered_by_name = Column(String(100), default="")
|
||||||
|
|
||||||
|
# Запись
|
||||||
|
has_recording = Column(Boolean, default=False)
|
||||||
|
recording_path = Column(String(500), default="") # Путь на PBX
|
||||||
|
recording_filename = Column(String(200), default="") # Имя WAV-файла
|
||||||
|
|
||||||
|
# Уведомления
|
||||||
|
notification_sent = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
|
|
||||||
|
customer = relationship("Customer", foreign_keys=[customer_id])
|
||||||
|
lead = relationship("Lead", foreign_keys=[lead_id])
|
||||||
|
employee = relationship("User", foreign_keys=[answered_by])
|
||||||
|
|
||||||
|
|
||||||
|
class ClientNotification(Base):
|
||||||
|
"""Уведомление для сотрудника.
|
||||||
|
|
||||||
|
Типы: пропущенный звонок, новый лид, изменение статуса лида,
|
||||||
|
нарушение SLA, просроченная задача.
|
||||||
|
"""
|
||||||
|
__tablename__ = "client_notifications"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
|
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"))
|
||||||
|
lead_id = Column(Integer, ForeignKey("leads.id", ondelete="SET NULL"))
|
||||||
|
call_record_id = Column(Integer, ForeignKey("call_records.id", ondelete="SET NULL"))
|
||||||
|
|
||||||
|
type = Column(String(30), nullable=False)
|
||||||
|
# missed_call | new_lead | lead_status_change | sla_breach | task_overdue
|
||||||
|
|
||||||
|
title = Column(String(500), nullable=False)
|
||||||
|
message = Column(Text, default="")
|
||||||
|
is_read = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||||
|
|
||||||
|
user = relationship("User", foreign_keys=[user_id])
|
||||||
|
customer = relationship("Customer", foreign_keys=[customer_id])
|
||||||
|
lead = relationship("Lead", foreign_keys=[lead_id])
|
||||||
|
call_record = relationship("CallRecord", foreign_keys=[call_record_id])
|
||||||
|
|
||||||
|
|
||||||
|
class PbxSettings(Base):
|
||||||
|
"""Настройки подключения к АТС (Yeastar S20).
|
||||||
|
|
||||||
|
Всегда одна запись (id=1). Пароли шифруются через cryptography.fernet.
|
||||||
|
"""
|
||||||
|
__tablename__ = "pbx_settings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, default=1)
|
||||||
|
|
||||||
|
# Подключение MySQL (CDR)
|
||||||
|
mysql_host = Column(String(255), default="192.168.1.150")
|
||||||
|
mysql_port = Column(Integer, default=3306)
|
||||||
|
mysql_user = Column(String(100), default="")
|
||||||
|
mysql_password = Column(String(255), default="") # зашифровано
|
||||||
|
mysql_database = Column(String(100), default="cdr")
|
||||||
|
|
||||||
|
# AMI
|
||||||
|
ami_host = Column(String(255), default="192.168.1.150")
|
||||||
|
ami_port = Column(Integer, default=5038)
|
||||||
|
ami_user = Column(String(100), default="")
|
||||||
|
ami_password = Column(String(255), default="") # зашифровано
|
||||||
|
|
||||||
|
# FTP (записи WAV)
|
||||||
|
ftp_host = Column(String(255), default="192.168.1.150")
|
||||||
|
ftp_port = Column(Integer, default=21)
|
||||||
|
ftp_user = Column(String(100), default="")
|
||||||
|
ftp_password = Column(String(255), default="") # зашифровано
|
||||||
|
|
||||||
|
# Веб-интерфейс
|
||||||
|
web_host = Column(String(255), default="192.168.1.150")
|
||||||
|
web_port = Column(Integer, default=8088)
|
||||||
|
web_user = Column(String(100), default="")
|
||||||
|
web_password = Column(String(255), default="") # зашифровано
|
||||||
|
|
||||||
|
# Информация
|
||||||
|
serial_number = Column(String(50), default="") # Ручной ввод
|
||||||
|
actual_serial = Column(String(50), default="") # Из FTP
|
||||||
|
|
||||||
|
# Статус подключения
|
||||||
|
mysql_status = Column(String(20), default="unknown") # ok | error | unknown
|
||||||
|
ami_status = Column(String(20), default="unknown")
|
||||||
|
ftp_status = Column(String(20), default="unknown")
|
||||||
|
serial_match = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Синхронизация
|
||||||
|
last_sync_at = Column(DateTime)
|
||||||
|
sync_interval = Column(Integer, default=5) # минут
|
||||||
|
|
||||||
|
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1293,6 +1293,7 @@ BOT_SERVICE_URL = "http://127.0.0.1:8002"
|
|||||||
|
|
||||||
|
|
||||||
async def _proxy(method: str, path: str, body=None):
|
async def _proxy(method: str, path: str, body=None):
|
||||||
|
"""Прокси запросов от service portal к max_bot API."""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
if body is not None:
|
if body is not None:
|
||||||
resp = await client.request(method, f"{BOT_SERVICE_URL}{path}", json=body)
|
resp = await client.request(method, f"{BOT_SERVICE_URL}{path}", json=body)
|
||||||
@@ -1431,11 +1432,13 @@ async def proxy_bot_conversation_logs(conv_id: int, user: dict = Depends(get_cur
|
|||||||
|
|
||||||
@router.delete("/api/bot/conversations/{conv_id}")
|
@router.delete("/api/bot/conversations/{conv_id}")
|
||||||
async def proxy_delete_conversation(conv_id: int, request: Request, user: dict = Depends(require_role("owner"))):
|
async def proxy_delete_conversation(conv_id: int, request: Request, user: dict = Depends(require_role("owner"))):
|
||||||
|
"""Удаление диалога (каскадно: сообщения + логи). Доступно только owner."""
|
||||||
return await _proxy("DELETE", f"/api/bot/conversations/{conv_id}")
|
return await _proxy("DELETE", f"/api/bot/conversations/{conv_id}")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/bot/users/{user_id}")
|
@router.delete("/api/bot/users/{user_id}")
|
||||||
async def proxy_delete_bot_user(user_id: int, request: Request, user: dict = Depends(require_role("owner"))):
|
async def proxy_delete_bot_user(user_id: int, request: Request, user: dict = Depends(require_role("owner"))):
|
||||||
|
"""Удаление пользователя бота (каскадно: диалоги + сообщения + логи). Заявки остаются. Доступно только owner."""
|
||||||
return await _proxy("DELETE", f"/api/bot/users/{user_id}")
|
return await _proxy("DELETE", f"/api/bot/users/{user_id}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,17 @@ img { max-width:100%; height:auto; }
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Скрытие стандартных спиннеров на number input */
|
||||||
|
input[type="number"]::-webkit-inner-spin-button,
|
||||||
|
input[type="number"]::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
input[type="number"] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -576,6 +587,12 @@ table .actions button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- TABS ---------- */
|
/* ---------- TABS ---------- */
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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 |
@@ -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;
|
||||||
|
})();
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
<title>{% block title %}Service Portal — AegisOne Engineering{% endblock %}</title>
|
<title>{% block title %}Service Portal — AegisOne Engineering{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="/service/static/css/service.css">
|
<link rel="stylesheet" href="/service/static/css/service.css">
|
||||||
<link rel="stylesheet" href="/service/static/css/service-theme.css">
|
<link rel="stylesheet" href="/service/static/css/service-theme.css">
|
||||||
<link rel="icon" type="image/x-icon" href="/service/static/img/favicons/favicon.ico">
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/service/static/img/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/service/static/img/favicon.svg">
|
||||||
|
<link rel="icon" type="image/x-icon" href="/service/static/img/favicons/favicon.ico">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/service/static/img/favicons/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="/service/static/img/favicons/favicon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/service/static/img/favicons/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="/service/static/img/favicons/favicon-16x16.png">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/service/static/img/favicons/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="/service/static/img/favicons/apple-touch-icon.png">
|
||||||
@@ -55,6 +55,7 @@
|
|||||||
<div class="toast-container" id="toast-container"></div>
|
<div class="toast-container" id="toast-container"></div>
|
||||||
<script src="/service/static/js/theme.js"></script>
|
<script src="/service/static/js/theme.js"></script>
|
||||||
<script src="/service/static/js/role-switcher.js"></script>
|
<script src="/service/static/js/role-switcher.js"></script>
|
||||||
|
<script src="/service/static/js/sort-table.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function showNotification(msg, type){
|
function showNotification(msg, type){
|
||||||
type = type || 'success';
|
type = type || 'success';
|
||||||
|
|||||||
@@ -5,16 +5,6 @@
|
|||||||
<div class="layout">
|
<div class="layout">
|
||||||
{% include "_sidebar.html" %}
|
{% include "_sidebar.html" %}
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<div class="main-header">
|
|
||||||
<div>
|
|
||||||
<h1>Дашборд</h1>
|
|
||||||
<div class="breadcrumb"></div>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<span style="font-size:12px;color:var(--text-muted);">{{ now }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if shs_alert %}
|
{% if shs_alert %}
|
||||||
<div class="shs-alert critical">⚠ {{ shs_alert }}</div>
|
<div class="shs-alert critical">⚠ {{ shs_alert }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -5,18 +5,6 @@
|
|||||||
<div class="layout">
|
<div class="layout">
|
||||||
{% include "_sidebar.html" %}
|
{% include "_sidebar.html" %}
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<div class="main-header">
|
|
||||||
<div>
|
|
||||||
<h1>{% block page_title %}{{ title|default(page_name|capitalize) }}{% endblock %}</h1>
|
|
||||||
<div class="breadcrumb">
|
|
||||||
{% block breadcrumb %}{% endblock %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
{% block page_actions %}{% endblock %}
|
|
||||||
<span style="font-size:12px;color:var(--text-muted);">{{ now }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% block page_content %}{% endblock %}
|
{% block page_content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,10 +10,7 @@
|
|||||||
<td><span class="badge badge-{{ a.role }}">{{ {'engineer':'Инженер','technician':'Техник'}[a.role] }}</span></td>
|
<td><span class="badge badge-{{ a.role }}">{{ {'engineer':'Инженер','technician':'Техник'}[a.role] }}</span></td>
|
||||||
<td>{{ a.assigned_at.strftime('%d.%m.%Y %H:%M') if a.assigned_at else '' }}</td>
|
<td>{{ a.assigned_at.strftime('%d.%m.%Y %H:%M') if a.assigned_at else '' }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<form method="post" action="/service/assignments/unassign" style="display:inline">
|
<button class="btn btn-danger btn-sm" onclick="openConfirmUnassign({{ a.id }},'{{ (a.user.full_name if a.user else '')|e }}','{{ (a.object.name if a.object else '')|e }}')" title="Отвязать">✕</button>
|
||||||
<input type="hidden" name="id" value="{{ a.id }}">
|
|
||||||
<button class="btn btn-danger btn-sm">Отвязать</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>{% endfor %}</tbody></table>
|
</tr>{% endfor %}</tbody></table>
|
||||||
</div>
|
</div>
|
||||||
@@ -25,7 +22,38 @@
|
|||||||
<div class="form-group"><label>Объект</label><select name="object_id" required>{% for o in objects %}<option value="{{ o.id }}">{{ o.name }}</option>{% endfor %}</select></div>
|
<div class="form-group"><label>Объект</label><select name="object_id" required>{% for o in objects %}<option value="{{ o.id }}">{{ o.name }}</option>{% endfor %}</select></div>
|
||||||
<div class="form-group"><label>Сотрудник</label><select name="user_id" required>{% for u in users %}<option value="{{ u.id }}">{{ u.full_name }} ({{ u.role }})</option>{% endfor %}</select></div>
|
<div class="form-group"><label>Сотрудник</label><select name="user_id" required>{% for u in users %}<option value="{{ u.id }}">{{ u.full_name }} ({{ u.role }})</option>{% endfor %}</select></div>
|
||||||
<div class="form-actions"><button type="submit" class="btn btn-primary">Назначить</button>
|
<div class="form-actions"><button type="submit" class="btn btn-primary">Назначить</button>
|
||||||
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
|
<button type="button" class="btn" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
<div class="modal-overlay" id="confirm-unassign">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Отвязать сотрудника?</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
|
Назначение <strong id="unassign-info"></strong> будет удалено. Это действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmUnassign()">Отвязать</button>
|
||||||
|
<button type="button" class="btn" onclick="document.getElementById('confirm-unassign').classList.remove('open')">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var unassignId = null;
|
||||||
|
function openConfirmUnassign(id, userName, objectName){
|
||||||
|
unassignId = id;
|
||||||
|
document.getElementById('unassign-info').textContent = userName + ' → ' + objectName;
|
||||||
|
document.getElementById('confirm-unassign').classList.add('open');
|
||||||
|
}
|
||||||
|
function confirmUnassign(){
|
||||||
|
if(!unassignId) return;
|
||||||
|
var form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '/service/assignments/unassign';
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'hidden'; input.name = 'id'; input.value = unassignId;
|
||||||
|
form.appendChild(input);
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<td><small>{{ p.created_at.strftime('%d.%m.%Y') if p.created_at else '' }}</small></td>
|
<td><small>{{ p.created_at.strftime('%d.%m.%Y') if p.created_at else '' }}</small></td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="editBlog({{ p.id }},'{{ p.title|e }}','{{ p.slug|e }}','{{ p.category|e }}','{{ p.status }}','{{ p.excerpt|e }}','{{ p.content|e }}')">✎</button>
|
<button class="btn btn-secondary btn-sm" onclick="editBlog({{ p.id }},'{{ p.title|e }}','{{ p.slug|e }}','{{ p.category|e }}','{{ p.status }}','{{ p.excerpt|e }}','{{ p.content|e }}')">✎</button>
|
||||||
<form method="post" action="/service/blog/delete" style="display:inline" onsubmit="return confirm('Удалить запись?')"><input type="hidden" name="id" value="{{ p.id }}"><button class="btn btn-danger btn-sm">✕</button></form>
|
<form method="post" action="/service/blog/delete" style="display:inline" id="delete-blog-form-{{ p.id }}"><input type="hidden" name="id" value="{{ p.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteBlog({{ p.id }},'{{ p.title|e }}')" title="Удалить">✕</button></form>
|
||||||
</td>
|
</td>
|
||||||
</tr>{% endfor %}</tbody></table>
|
</tr>{% endfor %}</tbody></table>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,7 +33,34 @@
|
|||||||
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
|
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
<div class="modal-overlay" id="confirm-delete-blog">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Удалить запись?</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
|
<strong id="delete-blog-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmDeleteBlog()">Удалить</button>
|
||||||
|
<button type="button" class="btn" onclick="closeConfirmDeleteBlog()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
var deleteBlogId = null;
|
||||||
|
function openConfirmDeleteBlog(id, name){
|
||||||
|
deleteBlogId = id;
|
||||||
|
document.getElementById('delete-blog-name').textContent = name || '#' + id;
|
||||||
|
document.getElementById('confirm-delete-blog').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeConfirmDeleteBlog(){
|
||||||
|
document.getElementById('confirm-delete-blog').classList.remove('open');
|
||||||
|
deleteBlogId = null;
|
||||||
|
}
|
||||||
|
function confirmDeleteBlog(){
|
||||||
|
if(!deleteBlogId) return;
|
||||||
|
document.getElementById('delete-blog-form-' + deleteBlogId).submit();
|
||||||
|
closeConfirmDeleteBlog();
|
||||||
|
}
|
||||||
function editBlog(id,title,slug,category,status,excerpt,content){
|
function editBlog(id,title,slug,category,status,excerpt,content){
|
||||||
document.getElementById('modal-blog').classList.add('open');
|
document.getElementById('modal-blog').classList.add('open');
|
||||||
document.getElementById('modal-blog-title').textContent = 'Редактировать запись';
|
document.getElementById('modal-blog-title').textContent = 'Редактировать запись';
|
||||||
|
|||||||
@@ -31,6 +31,14 @@
|
|||||||
.confirm-modal .confirm-actions { display:flex; gap:10px; justify-content:center; }
|
.confirm-modal .confirm-actions { display:flex; gap:10px; justify-content:center; }
|
||||||
.confirm-modal .confirm-actions .btn { min-width:140px; }
|
.confirm-modal .confirm-actions .btn { min-width:140px; }
|
||||||
.timer-danger { color:var(--danger) !important; }
|
.timer-danger { color:var(--danger) !important; }
|
||||||
|
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:1000; align-items:center; justify-content:center; }
|
||||||
|
.modal-overlay.open { display:flex; }
|
||||||
|
.modal { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:24px; width:420px; max-width:90vw; }
|
||||||
|
.modal h2 { color:var(--text-primary); }
|
||||||
|
.modal p { color:var(--text-secondary); }
|
||||||
|
.form-actions { display:flex; gap:10px; }
|
||||||
|
.btn-danger { background:var(--danger, #dc3545); color:#fff; border:none; padding:8px 16px; border-radius:6px; cursor:pointer; font-weight:600; }
|
||||||
|
.btn { background:var(--bg-input); color:var(--text-primary); border:1px solid var(--border); padding:8px 16px; border-radius:6px; cursor:pointer; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -93,10 +101,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="confirm-delete-user">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Удалить пользователя?</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
|
<strong id="delete-user-name"></strong> (ID: <span id="delete-user-id"></span>) будет удалён навсегда.
|
||||||
|
Все его диалоги и сообщения будут удалены. Заявки останутся.
|
||||||
|
Это действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmDeleteUser()">Удалить</button>
|
||||||
|
<button type="button" class="btn" onclick="closeConfirmDeleteUser()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var allConsents = [];
|
var allConsents = [];
|
||||||
var confirmTimer = null;
|
var confirmTimer = null;
|
||||||
var confirmSeconds = 10;
|
var confirmSeconds = 10;
|
||||||
|
var pendingDeleteUserId = null;
|
||||||
|
var pendingDeleteUserName = null;
|
||||||
|
|
||||||
function loadConsents(){
|
function loadConsents(){
|
||||||
var tbody = document.getElementById('consent-table-body');
|
var tbody = document.getElementById('consent-table-body');
|
||||||
@@ -135,11 +160,12 @@ function renderConsents(list){
|
|||||||
'<td class="consent-yes">Да</td>' +
|
'<td class="consent-yes">Да</td>' +
|
||||||
'<td style="font-size:12px;color:var(--text-muted);">' + dateStr + '</td>';
|
'<td style="font-size:12px;color:var(--text-muted);">' + dateStr + '</td>';
|
||||||
if(USER_ROLE === 'owner'){
|
if(USER_ROLE === 'owner'){
|
||||||
html += '<td><button class="btn btn-danger btn-sm" onclick="confirmDeleteUser(' + u.id + ', \'' + escapeHtml(name).replace(/'/g, "\\'") + '\')" title="Удалить пользователя">🗑</button></td>';
|
html += '<td><button class="btn btn-danger btn-sm" onclick="openConfirmDeleteUser(' + u.id + ', \'' + escapeHtml(name).replace(/'/g, "\\'") + '\')" title="Удалить пользователя">🗑</button></td>';
|
||||||
}
|
}
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
});
|
});
|
||||||
tbody.innerHTML = html;
|
tbody.innerHTML = html;
|
||||||
|
if(typeof sortTables === 'function') sortTables();
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterConsents(){
|
function filterConsents(){
|
||||||
@@ -222,8 +248,24 @@ function escapeHtml(s){
|
|||||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDeleteUser(userId, userName){
|
function openConfirmDeleteUser(userId, userName){
|
||||||
if(!confirm('Удалить пользователя ' + userName + ' (ID: ' + userId + ')?\n\nВсе его диалоги и сообщения будут удалены навсегда. Заявки останутся.')) return;
|
pendingDeleteUserId = userId;
|
||||||
|
pendingDeleteUserName = userName;
|
||||||
|
document.getElementById('delete-user-name').textContent = userName;
|
||||||
|
document.getElementById('delete-user-id').textContent = userId;
|
||||||
|
document.getElementById('confirm-delete-user').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirmDeleteUser(){
|
||||||
|
document.getElementById('confirm-delete-user').classList.remove('open');
|
||||||
|
pendingDeleteUserId = null;
|
||||||
|
pendingDeleteUserName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteUser(){
|
||||||
|
var userId = pendingDeleteUserId;
|
||||||
|
var userName = pendingDeleteUserName;
|
||||||
|
closeConfirmDeleteUser();
|
||||||
fetch('/api/bot/users/' + userId, { method: 'DELETE' })
|
fetch('/api/bot/users/' + userId, { method: 'DELETE' })
|
||||||
.then(function(r){ return r.json() })
|
.then(function(r){ return r.json() })
|
||||||
.then(function(d){
|
.then(function(d){
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
{% extends "page.html" %}
|
{% extends "page.html" %}
|
||||||
{% block page_content %}
|
{% block page_content %}
|
||||||
<div class="page-header">
|
<div class="card">
|
||||||
<h1>Диалоги чат-бота</h1>
|
<div class="card-header"><h2>Диалоги чат-бота</h2>
|
||||||
<div class="header-controls">
|
<button onclick="loadConversations()" class="btn btn-primary btn-sm">Обновить</button>
|
||||||
<input type="text" id="search-input" placeholder="Поиск по ID или имени..." oninput="filterConversations()" class="filter-input">
|
</div>
|
||||||
<button onclick="loadConversations()" class="btn btn-primary">Обновить</button>
|
<div class="filter-bar">
|
||||||
|
<input type="text" id="search-input" placeholder="Поиск по ID или имени..." oninput="filterConversations()">
|
||||||
|
<select id="status-filter" onchange="filterConversations()" class="filter-select">
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
<option value="greeting">Приветствие</option>
|
||||||
|
<option value="collecting_name">Сбор имени</option>
|
||||||
|
<option value="collecting_phone">Сбор телефона</option>
|
||||||
|
<option value="collecting_email">Сбор email</option>
|
||||||
|
<option value="ticket_creation">Создание заявки</option>
|
||||||
|
<option value="question">Вопрос</option>
|
||||||
|
<option value="done">Завершён</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<script>var USER_ROLE = '{{ user.role }}';</script>
|
||||||
|
<div id="conversations-container">
|
||||||
|
<div class="loading"><div class="spinner"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>var USER_ROLE = '{{ user.role }}';</script>
|
|
||||||
<div style="margin-bottom:12px;display:flex;gap:8px;flex-wrap:wrap;">
|
|
||||||
<select id="status-filter" onchange="filterConversations()" class="filter-select">
|
|
||||||
<option value="">Все статусы</option>
|
|
||||||
<option value="greeting">Приветствие</option>
|
|
||||||
<option value="collecting_name">Сбор имени</option>
|
|
||||||
<option value="collecting_phone">Сбор телефона</option>
|
|
||||||
<option value="collecting_email">Сбор email</option>
|
|
||||||
<option value="ticket_creation">Создание заявки</option>
|
|
||||||
<option value="question">Вопрос</option>
|
|
||||||
<option value="done">Завершён</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="conversations-container">
|
|
||||||
<div class="loading"><div class="spinner"></div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-overlay" id="dialog-modal" style="display:none;">
|
<div class="modal-overlay" id="dialog-modal">
|
||||||
<div class="modal" style="max-width:800px;width:90%;">
|
<div class="modal" style="max-width:800px;width:90%;">
|
||||||
<button type="button" class="modal-close" onclick="closeDialog()">✕</button>
|
<button type="button" class="modal-close" onclick="closeDialog()">✕</button>
|
||||||
<h2 id="dialog-title">Диалог</h2>
|
<h2 id="dialog-title">Диалог</h2>
|
||||||
@@ -44,9 +43,9 @@
|
|||||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
Диалог с <strong id="delete-conv-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
Диалог с <strong id="delete-conv-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||||
</p>
|
</p>
|
||||||
<div class="modal-actions" style="justify-content:flex-end;">
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
<button type="button" class="btn" onclick="closeDeleteModal()">Отмена</button>
|
|
||||||
<button type="button" class="btn btn-danger" onclick="confirmDelete()">Удалить</button>
|
<button type="button" class="btn btn-danger" onclick="confirmDelete()">Удалить</button>
|
||||||
|
<button type="button" class="btn" onclick="closeDeleteModal()">Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,13 +75,8 @@
|
|||||||
.log-time { font-size:10px; color:var(--text-muted,#6B8299); }
|
.log-time { font-size:10px; color:var(--text-muted,#6B8299); }
|
||||||
.tab-btn { padding:6px 14px; border:1px solid var(--border,#253248); background:transparent; color:var(--text-primary,#E8EDF2); border-radius:4px; cursor:pointer; font-size:12px; }
|
.tab-btn { padding:6px 14px; border:1px solid var(--border,#253248); background:transparent; color:var(--text-primary,#E8EDF2); border-radius:4px; cursor:pointer; font-size:12px; }
|
||||||
.tab-btn.active { background:var(--accent,#00ADEF); border-color:var(--accent,#00ADEF); color:#fff; }
|
.tab-btn.active { background:var(--accent,#00ADEF); border-color:var(--accent,#00ADEF); color:#fff; }
|
||||||
.page-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
|
|
||||||
.header-controls { display:flex; gap:8px; align-items:center; }
|
|
||||||
.filter-input { padding:6px 10px; border:1px solid var(--border,#253248); border-radius:4px; background:var(--bg-body,#1B2838); color:var(--text-primary,#E8EDF2); font-size:12px; width:200px; }
|
|
||||||
.filter-select { padding:6px 10px; border:1px solid var(--border,#253248); border-radius:4px; background:var(--bg-body,#1B2838); color:var(--text-primary,#E8EDF2); font-size:12px; }
|
|
||||||
.conv-delete { background:none; border:none; cursor:pointer; font-size:14px; padding:2px 6px; border-radius:4px; opacity:0.5; transition:opacity 0.2s; margin-left:auto; }
|
.conv-delete { background:none; border:none; cursor:pointer; font-size:14px; padding:2px 6px; border-radius:4px; opacity:0.5; transition:opacity 0.2s; margin-left:auto; }
|
||||||
.conv-delete:hover { opacity:1; background:rgba(232,107,107,0.15); }
|
.conv-delete:hover { opacity:1; background:rgba(232,107,107,0.15); }
|
||||||
.conv-header { display:flex; align-items:center; gap:8px; }
|
|
||||||
#delete-modal .modal { max-width:420px; }
|
#delete-modal .modal { max-width:420px; }
|
||||||
#delete-modal h2 { font-size:16px; margin-bottom:8px; }
|
#delete-modal h2 { font-size:16px; margin-bottom:8px; }
|
||||||
#delete-modal p { color:var(--text-secondary); font-size:14px; }
|
#delete-modal p { color:var(--text-secondary); font-size:14px; }
|
||||||
@@ -130,7 +124,7 @@ function renderConversations(data){
|
|||||||
html += '<div class="conv-card" onclick="openDialog(' + c.id + ')">';
|
html += '<div class="conv-card" onclick="openDialog(' + c.id + ')">';
|
||||||
html += '<div class="conv-header"><span class="conv-user">' + escapeHtml(c.user_name) + '</span><span class="conv-id">ID: ' + c.id + '</span>';
|
html += '<div class="conv-header"><span class="conv-user">' + escapeHtml(c.user_name) + '</span><span class="conv-id">ID: ' + c.id + '</span>';
|
||||||
if(USER_ROLE === 'owner'){
|
if(USER_ROLE === 'owner'){
|
||||||
html += '<button class="conv-delete" onclick="event.stopPropagation();openDeleteModal(' + c.id + ', \'' + escapeHtml(c.user_name).replace(/'/g, "\\'") + '\')" title="Удалить диалог">🗑</button>';
|
html += '<button class="conv-delete" onclick="event.stopPropagation();openDeleteModal(' + c.id + ', \'' + escapeHtml(c.user_name).replace(/'/g, "\\'") + '\')" title="Удалить диалог">✕</button>';
|
||||||
}
|
}
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
html += '<div class="conv-meta">';
|
html += '<div class="conv-meta">';
|
||||||
@@ -149,7 +143,7 @@ function filterConversations(){
|
|||||||
|
|
||||||
function openDialog(convId){
|
function openDialog(convId){
|
||||||
currentConvId = convId;
|
currentConvId = convId;
|
||||||
document.getElementById('dialog-modal').style.display = 'flex';
|
document.getElementById('dialog-modal').classList.add('open');
|
||||||
var conv = conversations.find(function(c){ return c.id === convId; });
|
var conv = conversations.find(function(c){ return c.id === convId; });
|
||||||
document.getElementById('dialog-title').textContent = 'Диалог с ' + (conv ? conv.user_name : '#' + convId);
|
document.getElementById('dialog-title').textContent = 'Диалог с ' + (conv ? conv.user_name : '#' + convId);
|
||||||
showTab('messages');
|
showTab('messages');
|
||||||
@@ -157,7 +151,7 @@ function openDialog(convId){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeDialog(){
|
function closeDialog(){
|
||||||
document.getElementById('dialog-modal').style.display = 'none';
|
document.getElementById('dialog-modal').classList.remove('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTab(tab){
|
function showTab(tab){
|
||||||
|
|||||||
@@ -8,19 +8,6 @@
|
|||||||
.kb-table .active-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
|
.kb-table .active-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
|
||||||
.kb-table .active-badge.yes { background:var(--success-bg, #d4edda); color:var(--success, #155724); }
|
.kb-table .active-badge.yes { background:var(--success-bg, #d4edda); color:var(--success, #155724); }
|
||||||
.kb-table .active-badge.no { background:var(--danger-bg, #f8d7da); color:var(--danger, #721c24); }
|
.kb-table .active-badge.no { background:var(--danger-bg, #f8d7da); color:var(--danger, #721c24); }
|
||||||
.kb-modal { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.4);
|
|
||||||
z-index:1000; align-items:center; justify-content:center; }
|
|
||||||
.kb-modal.open { display:flex; }
|
|
||||||
.kb-modal-content { background:var(--bg-card); border-radius:12px; padding:30px; width:600px; max-width:90vw;
|
|
||||||
max-height:80vh; overflow-y:auto; }
|
|
||||||
.kb-modal-content h3 { margin-bottom:20px; }
|
|
||||||
.form-group { margin-bottom:14px; }
|
|
||||||
.form-group label { display:block; font-weight:600; margin-bottom:4px; font-size:13px; color:var(--text-muted); }
|
|
||||||
.form-group input, .form-group textarea, .form-group select {
|
|
||||||
width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:14px;
|
|
||||||
}
|
|
||||||
.form-group textarea { min-height:100px; resize:vertical; }
|
|
||||||
.modal-actions { display:flex; gap:12px; margin-top:16px; }
|
|
||||||
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; flex-wrap:wrap; }
|
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; flex-wrap:wrap; }
|
||||||
.filter-bar input, .filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
|
.filter-bar input, .filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
|
||||||
</style>
|
</style>
|
||||||
@@ -60,9 +47,10 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kb-modal" id="kb-modal">
|
<div class="modal-overlay" id="kb-modal">
|
||||||
<div class="kb-modal-content">
|
<div class="modal" style="max-width:600px;">
|
||||||
<h3 id="modal-title">Новая карточка</h3>
|
<button type="button" class="modal-close" onclick="closeModal()">✕</button>
|
||||||
|
<h2 id="modal-title">Новая карточка</h2>
|
||||||
<form id="kb-form">
|
<form id="kb-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Категория</label>
|
<label>Категория</label>
|
||||||
@@ -84,10 +72,8 @@
|
|||||||
<label>URL источника</label>
|
<label>URL источника</label>
|
||||||
<input type="url" name="source_url" id="kb-source-url" placeholder="https://aegisone.ru/...">
|
<input type="url" name="source_url" id="kb-source-url" placeholder="https://aegisone.ru/...">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-check"><input type="checkbox" name="is_active" id="kb-is-active" checked><label for="kb-is-active">Активная карточка</label></div>
|
||||||
<label><input type="checkbox" name="is_active" id="kb-is-active" checked> Активная карточка</label>
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
</div>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||||
<button type="button" class="btn" onclick="closeModal()">Отмена</button>
|
<button type="button" class="btn" onclick="closeModal()">Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,6 +81,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="confirm-delete-kb">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Удалить карточку?</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
|
Карточка <strong id="delete-kb-name"></strong> будет удалена навсегда. Это действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmDeleteKB()">Удалить</button>
|
||||||
|
<button type="button" class="btn" onclick="closeConfirmDeleteKB()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var categories = {};
|
var categories = {};
|
||||||
var allCards = [];
|
var allCards = [];
|
||||||
@@ -126,10 +125,11 @@ function renderKB(cards){
|
|||||||
'<td>' + catName + '</td>' +
|
'<td>' + catName + '</td>' +
|
||||||
'<td><span class="active-badge ' + activeClass + '">' + activeLabel + '</span></td>' +
|
'<td><span class="active-badge ' + activeClass + '">' + activeLabel + '</span></td>' +
|
||||||
'<td style="font-size:12px;">' + (c.source_url ? '<a href="' + c.source_url + '" target="_blank">ссылка</a>' : '—') + '</td>' +
|
'<td style="font-size:12px;">' + (c.source_url ? '<a href="' + c.source_url + '" target="_blank">ссылка</a>' : '—') + '</td>' +
|
||||||
'<td><button class="btn btn-sm" onclick="editCard(' + c.id + ')" title="Редактировать">✏️</button> <button class="btn btn-sm" onclick="deleteCard(' + c.id + ')" style="color:#dc3545;" title="Удалить">🗑</button></td>' +
|
'<td><button class="btn btn-secondary btn-sm" onclick="editCard(' + c.id + ')" title="Редактировать">✎</button> <button class="btn btn-danger btn-sm" onclick="openConfirmDeleteKB(' + c.id + ', \'' + escapeHtml(c.question).replace(/'/g, "\\'") + '\')" title="Удалить">✕</button></td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
});
|
});
|
||||||
tbody.innerHTML = html;
|
tbody.innerHTML = html;
|
||||||
|
if(typeof sortTables === 'function') sortTables();
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterKB(){
|
function filterKB(){
|
||||||
@@ -173,8 +173,23 @@ function editCard(id){
|
|||||||
document.getElementById('kb-modal').classList.add('open');
|
document.getElementById('kb-modal').classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteCard(id){
|
var deleteKBId = null;
|
||||||
if(!confirm('Удалить карточку?')) return;
|
|
||||||
|
function openConfirmDeleteKB(id, name){
|
||||||
|
deleteKBId = id;
|
||||||
|
document.getElementById('delete-kb-name').textContent = name || '#' + id;
|
||||||
|
document.getElementById('confirm-delete-kb').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirmDeleteKB(){
|
||||||
|
document.getElementById('confirm-delete-kb').classList.remove('open');
|
||||||
|
deleteKBId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteKB(){
|
||||||
|
if(!deleteKBId) return;
|
||||||
|
var id = deleteKBId;
|
||||||
|
closeConfirmDeleteKB();
|
||||||
fetch('/api/bot/kb/' + id, { method: 'DELETE' }).then(function(r){ return r.json() }).then(function(d){
|
fetch('/api/bot/kb/' + id, { method: 'DELETE' }).then(function(r){ return r.json() }).then(function(d){
|
||||||
if(d.ok) { showNotification('Карточка удалена', 'success'); loadKB(); }
|
if(d.ok) { showNotification('Карточка удалена', 'success'); loadKB(); }
|
||||||
else showNotification('Ошибка удаления', 'error');
|
else showNotification('Ошибка удаления', 'error');
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ body { font-family:'Inter','Segoe UI',sans-serif; background:var(--bg-body, #1B2
|
|||||||
.test-chat-messages::-webkit-scrollbar-track { background:var(--bg-body, #0D1B2A); border-radius:3px; }
|
.test-chat-messages::-webkit-scrollbar-track { background:var(--bg-body, #0D1B2A); border-radius:3px; }
|
||||||
.test-chat-messages::-webkit-scrollbar-thumb { background:var(--border, #253248); border-radius:3px; }
|
.test-chat-messages::-webkit-scrollbar-thumb { background:var(--border, #253248); border-radius:3px; }
|
||||||
.test-chat-messages::-webkit-scrollbar-thumb:hover { background:var(--text-muted, #6B8299); }
|
.test-chat-messages::-webkit-scrollbar-thumb:hover { background:var(--text-muted, #6B8299); }
|
||||||
|
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:1000; align-items:center; justify-content:center; }
|
||||||
|
.modal-overlay.open { display:flex; }
|
||||||
|
.modal { background:var(--bg-card, #0A1628); border:1px solid var(--border, #253248); border-radius:12px; padding:24px; width:420px; max-width:90vw; }
|
||||||
|
.modal h2 { color:var(--text-primary, #E8EDF2); }
|
||||||
|
.modal p { color:var(--text-muted, #6B8299); }
|
||||||
|
.form-actions { display:flex; gap:10px; }
|
||||||
|
.btn-danger { background:var(--danger, #dc3545); color:#fff; border:none; padding:8px 16px; border-radius:6px; cursor:pointer; font-weight:600; }
|
||||||
|
.btn { background:var(--bg-input, #253248); color:var(--text-primary, #E8EDF2); border:1px solid var(--border, #3a4a60); padding:8px 16px; border-radius:6px; cursor:pointer; }
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
(function(){function g(){return window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'}var s=localStorage.getItem('aegisone_service_theme')||'system',r=s==='system'?g():s;document.documentElement.setAttribute('data-theme',r);})();
|
(function(){function g(){return window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'}var s=localStorage.getItem('aegisone_service_theme')||'system',r=s==='system'?g():s;document.documentElement.setAttribute('data-theme',r);})();
|
||||||
@@ -43,7 +51,7 @@ body { font-family:'Inter','Segoe UI',sans-serif; background:var(--bg-body, #1B2
|
|||||||
<button onclick="runScenario('consent_no')">Отказ согласия</button>
|
<button onclick="runScenario('consent_no')">Отказ согласия</button>
|
||||||
<button onclick="runScenario('question')">Общий вопрос</button>
|
<button onclick="runScenario('question')">Общий вопрос</button>
|
||||||
<button onclick="clearTest()">Очистить</button>
|
<button onclick="clearTest()">Очистить</button>
|
||||||
<button onclick="clearTestData()" style="background:var(--danger,#dc3545) !important;border-color:var(--danger,#dc3545) !important;">Сбросить все тестовые данные</button>
|
<button onclick="openConfirmClearTest()" style="background:var(--danger,#dc3545) !important;border-color:var(--danger,#dc3545) !important;">Сбросить все тестовые данные</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="test-container">
|
<div class="test-container">
|
||||||
<div class="test-chat">
|
<div class="test-chat">
|
||||||
@@ -56,6 +64,21 @@ body { font-family:'Inter','Segoe UI',sans-serif; background:var(--bg-body, #1B2
|
|||||||
</div>
|
</div>
|
||||||
<div class="test-log" id="test-log"></div>
|
<div class="test-log" id="test-log"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="confirm-clear-test">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Сбросить тестовые данные?</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
|
Удалить все тестовые диалоги, сообщения и логи обработки?
|
||||||
|
Это действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmClearTest()">Сбросить</button>
|
||||||
|
<button type="button" class="btn" onclick="closeConfirmClearTest()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var testUserId = 999999001;
|
var testUserId = 999999001;
|
||||||
function log(event, detail){
|
function log(event, detail){
|
||||||
@@ -156,8 +179,14 @@ function clearTest(){
|
|||||||
document.getElementById('test-input').value = '';
|
document.getElementById('test-input').value = '';
|
||||||
log('info', 'Тест очищен');
|
log('info', 'Тест очищен');
|
||||||
}
|
}
|
||||||
function clearTestData(){
|
function openConfirmClearTest(){
|
||||||
if(!confirm('Удалить все тестовые диалоги, сообщения и логи обработки? Это действие нельзя отменить.')) return;
|
document.getElementById('confirm-clear-test').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeConfirmClearTest(){
|
||||||
|
document.getElementById('confirm-clear-test').classList.remove('open');
|
||||||
|
}
|
||||||
|
function confirmClearTest(){
|
||||||
|
closeConfirmClearTest();
|
||||||
log('info', 'Очистка тестовых данных...');
|
log('info', 'Очистка тестовых данных...');
|
||||||
fetch('/api/bot/test/clear', {method: 'POST'}).then(function(r){ return r.json() }).then(function(d){
|
fetch('/api/bot/test/clear', {method: 'POST'}).then(function(r){ return r.json() }).then(function(d){
|
||||||
if(d && d.ok){
|
if(d && d.ok){
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ function renderTickets(tickets){
|
|||||||
'</tr>';
|
'</tr>';
|
||||||
});
|
});
|
||||||
tbody.innerHTML = html;
|
tbody.innerHTML = html;
|
||||||
|
if(typeof sortTables === 'function') sortTables();
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterTickets(){
|
function filterTickets(){
|
||||||
|
|||||||
@@ -10,16 +10,6 @@
|
|||||||
.badge.auto_added { background:#cce5ff; color:#004085; }
|
.badge.auto_added { background:#cce5ff; color:#004085; }
|
||||||
.badge.reviewed { background:#d4edda; color:#155724; }
|
.badge.reviewed { background:#d4edda; color:#155724; }
|
||||||
.badge.ignored { background:#f8f9fa; color:#6c757d; }
|
.badge.ignored { background:#f8f9fa; color:#6c757d; }
|
||||||
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.4); z-index:1000; align-items:center; justify-content:center; }
|
|
||||||
.modal-overlay.open { display:flex; }
|
|
||||||
.modal-box { background:var(--bg-card); border-radius:12px; padding:30px; width:650px; max-width:90vw; max-height:85vh; overflow-y:auto; }
|
|
||||||
.modal-box h3 { margin-bottom:20px; }
|
|
||||||
.form-group { margin-bottom:14px; }
|
|
||||||
.form-group label { display:block; font-weight:600; margin-bottom:4px; font-size:13px; color:var(--text-muted); }
|
|
||||||
.form-group input, .form-group textarea, .form-group select {
|
|
||||||
width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:14px; }
|
|
||||||
.form-group textarea { min-height:80px; resize:vertical; }
|
|
||||||
.modal-actions { display:flex; gap:12px; margin-top:16px; }
|
|
||||||
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; }
|
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; }
|
||||||
.filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
|
.filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
|
||||||
</style>
|
</style>
|
||||||
@@ -55,8 +45,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="uq-modal">
|
<div class="modal-overlay" id="uq-modal">
|
||||||
<div class="modal-box">
|
<div class="modal" style="max-width:650px;">
|
||||||
<h3 id="uq-modal-title">Проверить вопрос</h3>
|
<button type="button" class="modal-close" onclick="closeModal()">✕</button>
|
||||||
|
<h2 id="uq-modal-title">Проверить вопрос</h2>
|
||||||
<form id="uq-form">
|
<form id="uq-form">
|
||||||
<input type="hidden" id="uq-edit-id">
|
<input type="hidden" id="uq-edit-id">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -88,7 +79,7 @@
|
|||||||
<option value="8">Отрасли и кейсы</option>
|
<option value="8">Отрасли и кейсы</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
<button type="submit" class="btn btn-primary">Опубликовать в базу знаний</button>
|
<button type="submit" class="btn btn-primary">Опубликовать в базу знаний</button>
|
||||||
<button type="button" class="btn" onclick="closeModal()">Отмена</button>
|
<button type="button" class="btn" onclick="closeModal()">Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +87,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="confirm-ignore-uq">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Игнорировать вопрос?</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
|
Вопрос <strong id="ignore-uq-text"></strong> будет отмечен как игнорированный.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmIgnoreUQ()">Игнорировать</button>
|
||||||
|
<button type="button" class="btn" onclick="closeConfirmIgnoreUQ()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var allUQ = [];
|
var allUQ = [];
|
||||||
|
|
||||||
@@ -132,12 +136,13 @@ function renderUQ(items){
|
|||||||
'<td style="font-size:12px;">' + (q.created_at ? q.created_at.substring(0,10) : '—') + '</td>' +
|
'<td style="font-size:12px;">' + (q.created_at ? q.created_at.substring(0,10) : '—') + '</td>' +
|
||||||
'<td>';
|
'<td>';
|
||||||
if(q.status === 'new' || q.status === 'auto_added'){
|
if(q.status === 'new' || q.status === 'auto_added'){
|
||||||
html += '<button class="btn btn-sm" onclick="openReview(' + q.id + ')" title="Проверить">✏️</button> ';
|
html += '<button class="btn btn-secondary btn-sm" onclick="openReview(' + q.id + ')" title="Проверить">✎</button> ';
|
||||||
html += '<button class="btn btn-sm" onclick="ignoreUQ(' + q.id + ')" style="color:#dc3545;" title="Игнорировать">🗑</button>';
|
html += '<button class="btn btn-danger btn-sm" onclick="openConfirmIgnoreUQ(' + q.id + ', \'' + esc(q.question_text).replace(/'/g, "\\'") + '\')" title="Игнорировать">✕</button>';
|
||||||
}
|
}
|
||||||
html += '</td></tr>';
|
html += '</td></tr>';
|
||||||
});
|
});
|
||||||
tbody.innerHTML = html;
|
tbody.innerHTML = html;
|
||||||
|
if(typeof sortTables === 'function') sortTables();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openReview(id){
|
function openReview(id){
|
||||||
@@ -175,8 +180,23 @@ document.getElementById('uq-form').addEventListener('submit', function(e){
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function ignoreUQ(id){
|
var ignoreUQId = null;
|
||||||
if(!confirm('Игнорировать вопрос?')) return;
|
|
||||||
|
function openConfirmIgnoreUQ(id, text){
|
||||||
|
ignoreUQId = id;
|
||||||
|
document.getElementById('ignore-uq-text').textContent = text || '#' + id;
|
||||||
|
document.getElementById('confirm-ignore-uq').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirmIgnoreUQ(){
|
||||||
|
document.getElementById('confirm-ignore-uq').classList.remove('open');
|
||||||
|
ignoreUQId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmIgnoreUQ(){
|
||||||
|
if(!ignoreUQId) return;
|
||||||
|
var id = ignoreUQId;
|
||||||
|
closeConfirmIgnoreUQ();
|
||||||
fetch('/api/bot/unknown-questions/' + id + '/ignore', { method: 'PUT' })
|
fetch('/api/bot/unknown-questions/' + id + '/ignore', { method: 'PUT' })
|
||||||
.then(function(r){ return r.json() }).then(function(d){
|
.then(function(r){ return r.json() }).then(function(d){
|
||||||
if(d.ok){ loadUQ(); showNotification('Игнорировано', 'success'); }
|
if(d.ok){ loadUQ(); showNotification('Игнорировано', 'success'); }
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<td><span class="badge badge-{{ 'active' if c.is_active else 'inactive' }}">{{ 'Да' if c.is_active else 'Нет' }}</span></td>
|
<td><span class="badge badge-{{ 'active' if c.is_active else 'inactive' }}">{{ 'Да' if c.is_active else 'Нет' }}</span></td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="editCase({{ c.id }},'{{ c.title|e }}','{{ c.text|e }}','{{ c.effect|e }}',{{ c.sort_order }},{{ 'true' if c.is_active else 'false' }})">✎</button>
|
<button class="btn btn-secondary btn-sm" onclick="editCase({{ c.id }},'{{ c.title|e }}','{{ c.text|e }}','{{ c.effect|e }}',{{ c.sort_order }},{{ 'true' if c.is_active else 'false' }})">✎</button>
|
||||||
<form method="post" action="/service/cases/delete" style="display:inline" onsubmit="return confirm('Удалить?')"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-danger btn-sm">✕</button></form>
|
<form method="post" action="/service/cases/delete" style="display:inline" id="delete-case-form-{{ c.id }}"><input type="hidden" name="id" value="{{ c.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteCase({{ c.id }},'{{ c.title|e }}')" title="Удалить">✕</button></form>
|
||||||
</td>
|
</td>
|
||||||
</tr>{% endfor %}</tbody></table>
|
</tr>{% endfor %}</tbody></table>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,7 +31,34 @@
|
|||||||
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
|
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
<div class="modal-overlay" id="confirm-delete-case">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Удалить?</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
|
<strong id="delete-case-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmDeleteCase()">Удалить</button>
|
||||||
|
<button type="button" class="btn" onclick="closeConfirmDeleteCase()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
var deleteCaseId = null;
|
||||||
|
function openConfirmDeleteCase(id, name){
|
||||||
|
deleteCaseId = id;
|
||||||
|
document.getElementById('delete-case-name').textContent = name || '#' + id;
|
||||||
|
document.getElementById('confirm-delete-case').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeConfirmDeleteCase(){
|
||||||
|
document.getElementById('confirm-delete-case').classList.remove('open');
|
||||||
|
deleteCaseId = null;
|
||||||
|
}
|
||||||
|
function confirmDeleteCase(){
|
||||||
|
if(!deleteCaseId) return;
|
||||||
|
document.getElementById('delete-case-form-' + deleteCaseId).submit();
|
||||||
|
closeConfirmDeleteCase();
|
||||||
|
}
|
||||||
function editCase(id,title,text,effect,order,active){
|
function editCase(id,title,text,effect,order,active){
|
||||||
document.getElementById('modal-case').classList.add('open');
|
document.getElementById('modal-case').classList.add('open');
|
||||||
document.getElementById('modal-case-title').textContent = 'Редактировать';
|
document.getElementById('modal-case-title').textContent = 'Редактировать';
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
{% extends "page.html" %}
|
{% extends "page.html" %}
|
||||||
{% block page_content %}
|
{% block page_content %}
|
||||||
|
<style>
|
||||||
|
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:1000; align-items:center; justify-content:center; }
|
||||||
|
.modal-overlay.open { display:flex; }
|
||||||
|
.modal { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:24px; width:420px; max-width:90vw; }
|
||||||
|
.modal h2 { color:var(--text-primary); }
|
||||||
|
.modal p { color:var(--text-secondary); }
|
||||||
|
.form-actions { display:flex; gap:10px; }
|
||||||
|
.btn-danger { background:var(--danger, #dc3545); color:#fff; border:none; padding:8px 16px; border-radius:6px; cursor:pointer; font-weight:600; }
|
||||||
|
.btn-primary { background:var(--accent, #00ADEF); color:#fff; border:none; padding:8px 16px; border-radius:6px; cursor:pointer; font-weight:600; }
|
||||||
|
.btn { background:var(--bg-input); color:var(--text-primary); border:1px solid var(--border); padding:8px 16px; border-radius:6px; cursor:pointer; }
|
||||||
|
.form-group { margin-bottom:0; }
|
||||||
|
</style>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><h2>Графики и диаграммы</h2></div>
|
<div class="card-header"><h2>Графики и диаграммы</h2></div>
|
||||||
<div class="filter-bar" style="flex-wrap:wrap;gap:8px;">
|
<div class="filter-bar" style="flex-wrap:wrap;gap:8px;">
|
||||||
@@ -30,21 +42,49 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="filter-bar" style="margin-top:8px;flex-wrap:wrap;gap:6px;">
|
<div class="filter-bar" style="margin-top:8px;flex-wrap:wrap;gap:6px;">
|
||||||
<span style="font-size:12px;color:var(--text-muted);">Пользовательские графики:</span>
|
<span style="font-size:12px;color:var(--text-muted);">Пользовательские графики:</span>
|
||||||
<button class="btn btn-secondary btn-xs" onclick="saveCustomChart()">Сохранить текущий</button>
|
<button class="btn btn-secondary btn-sm" onclick="saveCustomChart()">Сохранить текущий</button>
|
||||||
<select id="custom-chart-select" onchange="loadCustomChart(this.value)">
|
<select id="custom-chart-select" onchange="loadCustomChart(this.value)">
|
||||||
<option value="">— Загрузить —</option>
|
<option value="">— Загрузить —</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-danger btn-xs" onclick="deleteCustomChart()">Удалить</button>
|
<button class="btn btn-danger btn-sm" onclick="deleteCustomChart()">Удалить</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="position:relative;height:400px;">
|
<div style="position:relative;height:400px;">
|
||||||
<canvas id="chart-canvas"></canvas>
|
<canvas id="chart-canvas"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="chart-name-modal">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Название графика</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" id="chart-name-input" placeholder="Введите название..." style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:6px;font-size:14px;background:var(--bg-card);color:var(--text);box-sizing:border-box;">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;margin-top:16px;">
|
||||||
|
<button type="button" class="btn btn-primary" onclick="confirmSaveChart()">Сохранить</button>
|
||||||
|
<button type="button" class="btn" onclick="closeChartNameModal()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="confirm-delete-chart">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Удалить график?</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
|
<strong id="delete-chart-name"></strong> будет удалён навсегда.
|
||||||
|
Это действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmDeleteChart()">Удалить</button>
|
||||||
|
<button type="button" class="btn" onclick="closeConfirmDeleteChart()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
var chartInstance = null;
|
var chartInstance = null;
|
||||||
var currentChartConfig = null;
|
var currentChartConfig = null;
|
||||||
|
var pendingDeleteChartName = null;
|
||||||
|
|
||||||
function loadChart(){
|
function loadChart(){
|
||||||
var metric = document.getElementById('metric-select').value;
|
var metric = document.getElementById('metric-select').value;
|
||||||
@@ -107,8 +147,19 @@ function exportChart(){
|
|||||||
|
|
||||||
function saveCustomChart(){
|
function saveCustomChart(){
|
||||||
if(!currentChartConfig) return;
|
if(!currentChartConfig) return;
|
||||||
var name = prompt('Название графика:');
|
document.getElementById('chart-name-input').value = '';
|
||||||
if(!name) return;
|
document.getElementById('chart-name-modal').classList.add('open');
|
||||||
|
document.getElementById('chart-name-input').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeChartNameModal(){
|
||||||
|
document.getElementById('chart-name-modal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmSaveChart(){
|
||||||
|
var name = document.getElementById('chart-name-input').value.trim();
|
||||||
|
if(!name){ return; }
|
||||||
|
closeChartNameModal();
|
||||||
var saved = JSON.parse(localStorage.getItem('custom_charts') || '[]');
|
var saved = JSON.parse(localStorage.getItem('custom_charts') || '[]');
|
||||||
saved.push({ name: name, config: currentChartConfig });
|
saved.push({ name: name, config: currentChartConfig });
|
||||||
localStorage.setItem('custom_charts', JSON.stringify(saved));
|
localStorage.setItem('custom_charts', JSON.stringify(saved));
|
||||||
@@ -131,7 +182,20 @@ function deleteCustomChart(){
|
|||||||
var sel = document.getElementById('custom-chart-select');
|
var sel = document.getElementById('custom-chart-select');
|
||||||
var name = sel.value;
|
var name = sel.value;
|
||||||
if(!name) return;
|
if(!name) return;
|
||||||
if(!confirm('Удалить "' + name + '"?')) return;
|
pendingDeleteChartName = name;
|
||||||
|
document.getElementById('delete-chart-name').textContent = name;
|
||||||
|
document.getElementById('confirm-delete-chart').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirmDeleteChart(){
|
||||||
|
document.getElementById('confirm-delete-chart').classList.remove('open');
|
||||||
|
pendingDeleteChartName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteChart(){
|
||||||
|
var name = pendingDeleteChartName;
|
||||||
|
closeConfirmDeleteChart();
|
||||||
|
var sel = document.getElementById('custom-chart-select');
|
||||||
var saved = JSON.parse(localStorage.getItem('custom_charts') || '[]');
|
var saved = JSON.parse(localStorage.getItem('custom_charts') || '[]');
|
||||||
saved = saved.filter(function(c){ return c.name !== name; });
|
saved = saved.filter(function(c){ return c.name !== name; });
|
||||||
localStorage.setItem('custom_charts', JSON.stringify(saved));
|
localStorage.setItem('custom_charts', JSON.stringify(saved));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block page_content %}
|
{% block page_content %}
|
||||||
{% for t in tasks %}
|
{% for t in tasks %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>{{ t.title }}</h3>
|
<div class="card-header"><h3>{{ t.title }}</h3></div>
|
||||||
<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;">
|
<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;">
|
||||||
{{ t.object.name if t.object else '' }} — {{ t.object.address if t.object else '' }}
|
{{ t.object.name if t.object else '' }} — {{ t.object.address if t.object else '' }}
|
||||||
<span class="badge badge-{{ {'P1':'critical','P2':'high','P3':'medium','P4':'low'}[t.priority] }}" style="margin-left:8px;">{{ t.priority }}</span>
|
<span class="badge badge-{{ {'P1':'critical','P2':'high','P3':'medium','P4':'low'}[t.priority] }}" style="margin-left:8px;">{{ t.priority }}</span>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<td><small>{{ c.formula_ref }}</small></td>
|
<td><small>{{ c.formula_ref }}</small></td>
|
||||||
<td><form method="post" action="/service/coefficients/toggle"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-{{ 'success' if c.is_active else 'secondary' }} btn-sm">{{ '✓' if c.is_active else '✕' }}</button></form></td>
|
<td><form method="post" action="/service/coefficients/toggle"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-{{ 'success' if c.is_active else 'secondary' }} btn-sm">{{ '✓' if c.is_active else '✕' }}</button></form></td>
|
||||||
<td class="actions"><button class="btn btn-secondary btn-sm" onclick="editCoeff({{ c.id }},'{{ c.key|e }}','{{ c.value }}','{{ c.description|e }}','{{ c.formula_ref|e }}')">✎</button>
|
<td class="actions"><button class="btn btn-secondary btn-sm" onclick="editCoeff({{ c.id }},'{{ c.key|e }}','{{ c.value }}','{{ c.description|e }}','{{ c.formula_ref|e }}')">✎</button>
|
||||||
<form method="post" action="/service/coefficients/delete" style="display:inline" onsubmit="return confirm('Удалить коэффициент?')"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-danger btn-sm">✕</button></form>
|
<form method="post" action="/service/coefficients/delete" style="display:inline" id="delete-coeff-form-{{ c.id }}"><input type="hidden" name="id" value="{{ c.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteCoeff({{ c.id }},'{{ c.key|e }}')" title="Удалить">✕</button></form>
|
||||||
</td>
|
</td>
|
||||||
</tr>{% endfor %}</tbody></table>
|
</tr>{% endfor %}</tbody></table>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +65,35 @@
|
|||||||
<div id="formula-info-content" style="margin-top:12px"></div>
|
<div id="formula-info-content" style="margin-top:12px"></div>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="confirm-delete-coeff">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Удалить коэффициент?</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
|
<strong id="delete-coeff-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmDeleteCoeff()">Удалить</button>
|
||||||
|
<button type="button" class="btn" onclick="closeConfirmDeleteCoeff()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
var deleteCoeffId = null;
|
||||||
|
function openConfirmDeleteCoeff(id, name){
|
||||||
|
deleteCoeffId = id;
|
||||||
|
document.getElementById('delete-coeff-name').textContent = name || '#' + id;
|
||||||
|
document.getElementById('confirm-delete-coeff').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeConfirmDeleteCoeff(){
|
||||||
|
document.getElementById('confirm-delete-coeff').classList.remove('open');
|
||||||
|
deleteCoeffId = null;
|
||||||
|
}
|
||||||
|
function confirmDeleteCoeff(){
|
||||||
|
if(!deleteCoeffId) return;
|
||||||
|
document.getElementById('delete-coeff-form-' + deleteCoeffId).submit();
|
||||||
|
closeConfirmDeleteCoeff();
|
||||||
|
}
|
||||||
function toggleAccordion(el) {
|
function toggleAccordion(el) {
|
||||||
var arrow = el.querySelector('.accordion-arrow');
|
var arrow = el.querySelector('.accordion-arrow');
|
||||||
var body = el.nextElementSibling;
|
var body = el.nextElementSibling;
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><h2>Клиенты</h2>
|
<div class="card-header"><h2>Клиенты</h2>
|
||||||
{% if error %}<div class="alert alert-error" style="margin-bottom:12px;padding:8px 12px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:6px;color:var(--danger);font-size:13px;">{{ error }}</div>{% endif %}
|
{% if error %}<div class="alert alert-error" style="margin-bottom:12px;padding:8px 12px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:6px;color:var(--danger);font-size:13px;">{{ error }}</div>{% endif %}
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-customer').classList.add('open')">+ Добавить</button>
|
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-customer').classList.add('open')">+ Добавить</button>
|
||||||
</div>
|
</div>
|
||||||
<form method="get" class="search-box"><input type="text" name="search" placeholder="Поиск по названию или ИНН..." value="{{ search }}"><button class="btn btn-primary btn-sm">Найти</button></form>
|
<form method="get" class="filter-bar"><input type="text" name="search" placeholder="Поиск по названию или ИНН..." value="{{ search }}"><button class="btn btn-primary btn-sm">Найти</button></form>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table><thead><tr><th>Название</th><th>ИНН</th><th>Контакты</th><th>Статус</th><th>Объекты</th><th>Активные SLA</th><th class="actions"></th></tr></thead>
|
<table><thead><tr><th>Название</th><th>ИНН</th><th>Контакты</th><th>Статус</th><th>Объекты</th><th>Активные SLA</th><th class="actions"></th></tr></thead>
|
||||||
<tbody>{% for d in customers %}<tr>
|
<tbody>{% for d in customers %}<tr>
|
||||||
@@ -18,8 +17,8 @@
|
|||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="editCustomer({{ d.c.id }},'{{ d.c.name|e }}','{{ d.c.inn|e }}','{{ d.c.kpp|e }}','{{ d.c.legal_address|e }}','{{ d.c.contact_person|e }}','{{ d.c.contact_phone|e }}','{{ d.c.contact_email|e }}','{{ d.c.status }}','{{ d.c.notes|e }}')">✎</button>
|
<button class="btn btn-secondary btn-sm" onclick="editCustomer({{ d.c.id }},'{{ d.c.name|e }}','{{ d.c.inn|e }}','{{ d.c.kpp|e }}','{{ d.c.legal_address|e }}','{{ d.c.contact_person|e }}','{{ d.c.contact_phone|e }}','{{ d.c.contact_email|e }}','{{ d.c.status }}','{{ d.c.notes|e }}')">✎</button>
|
||||||
{% if user.role == 'owner' %}
|
{% if user.role == 'owner' %}
|
||||||
<form method="post" action="/service/customers/delete" style="display:inline" onsubmit="return confirm('Удалить клиента?')">
|
<form method="post" action="/service/customers/delete" style="display:inline" id="delete-customer-form-{{ d.c.id }}">
|
||||||
<input type="hidden" name="id" value="{{ d.c.id }}"><button class="btn btn-danger btn-sm">✕</button>
|
<input type="hidden" name="id" value="{{ d.c.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteCustomer({{ d.c.id }},'{{ d.c.name|e }}')" title="Удалить">✕</button>
|
||||||
</form>{% endif %}
|
</form>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>{% endfor %}</tbody></table>
|
</tr>{% endfor %}</tbody></table>
|
||||||
@@ -53,7 +52,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
<div class="modal-overlay" id="confirm-delete-customer">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Удалить клиента?</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
|
<strong id="delete-customer-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmDeleteCustomer()">Удалить</button>
|
||||||
|
<button type="button" class="btn" onclick="closeConfirmDeleteCustomer()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
var deleteCustomerId = null;
|
||||||
|
function openConfirmDeleteCustomer(id, name){
|
||||||
|
deleteCustomerId = id;
|
||||||
|
document.getElementById('delete-customer-name').textContent = name || '#' + id;
|
||||||
|
document.getElementById('confirm-delete-customer').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeConfirmDeleteCustomer(){
|
||||||
|
document.getElementById('confirm-delete-customer').classList.remove('open');
|
||||||
|
deleteCustomerId = null;
|
||||||
|
}
|
||||||
|
function confirmDeleteCustomer(){
|
||||||
|
if(!deleteCustomerId) return;
|
||||||
|
document.getElementById('delete-customer-form-' + deleteCustomerId).submit();
|
||||||
|
closeConfirmDeleteCustomer();
|
||||||
|
}
|
||||||
function phoneMask(input){
|
function phoneMask(input){
|
||||||
var x = input.value.replace(/\D/g, '').match(/(\d{0,1})(\d{0,3})(\d{0,3})(\d{0,2})(\d{0,2})/);
|
var x = input.value.replace(/\D/g, '').match(/(\d{0,1})(\d{0,3})(\d{0,3})(\d{0,2})(\d{0,2})/);
|
||||||
if (!x) return;
|
if (!x) return;
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
<td><small>{{ c.formula_ref }}</small></td>
|
<td><small>{{ c.formula_ref }}</small></td>
|
||||||
<td><form method="post" action="/service/coefficients/toggle"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-{{ 'success' if c.is_active else 'secondary' }} btn-sm">{{ '✓' if c.is_active else '✕' }}</button></form></td>
|
<td><form method="post" action="/service/coefficients/toggle"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-{{ 'success' if c.is_active else 'secondary' }} btn-sm">{{ '✓' if c.is_active else '✕' }}</button></form></td>
|
||||||
<td class="actions"><button class="btn btn-secondary btn-sm" onclick="editCoeff({{ c.id }},'{{ c.key|e }}','{{ c.value }}','{{ c.description|e }}','{{ c.formula_ref|e }}')">✎</button>
|
<td class="actions"><button class="btn btn-secondary btn-sm" onclick="editCoeff({{ c.id }},'{{ c.key|e }}','{{ c.value }}','{{ c.description|e }}','{{ c.formula_ref|e }}')">✎</button>
|
||||||
<form method="post" action="/service/coefficients/delete" style="display:inline" onsubmit="return confirm('Удалить коэффициент?')"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-danger btn-sm">✕</button></form>
|
<form method="post" action="/service/coefficients/delete" style="display:inline" id="delete-formula-coeff-form-{{ c.id }}"><input type="hidden" name="id" value="{{ c.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteFormulaCoeff({{ c.id }},'{{ c.key|e }}')" title="Удалить">✕</button></form>
|
||||||
</td>
|
</td>
|
||||||
</tr>{% endfor %}</tbody></table>
|
</tr>{% endfor %}</tbody></table>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +199,35 @@
|
|||||||
<div id="formula-info-content" style="margin-top:12px"></div>
|
<div id="formula-info-content" style="margin-top:12px"></div>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="confirm-delete-formula-coeff">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Удалить коэффициент?</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
|
<strong id="delete-formula-coeff-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmDeleteFormulaCoeff()">Удалить</button>
|
||||||
|
<button type="button" class="btn" onclick="closeConfirmDeleteFormulaCoeff()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
var deleteFormulaCoeffId = null;
|
||||||
|
function openConfirmDeleteFormulaCoeff(id, name){
|
||||||
|
deleteFormulaCoeffId = id;
|
||||||
|
document.getElementById('delete-formula-coeff-name').textContent = name || '#' + id;
|
||||||
|
document.getElementById('confirm-delete-formula-coeff').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeConfirmDeleteFormulaCoeff(){
|
||||||
|
document.getElementById('confirm-delete-formula-coeff').classList.remove('open');
|
||||||
|
deleteFormulaCoeffId = null;
|
||||||
|
}
|
||||||
|
function confirmDeleteFormulaCoeff(){
|
||||||
|
if(!deleteFormulaCoeffId) return;
|
||||||
|
document.getElementById('delete-formula-coeff-form-' + deleteFormulaCoeffId).submit();
|
||||||
|
closeConfirmDeleteFormulaCoeff();
|
||||||
|
}
|
||||||
function switchTab(name){
|
function switchTab(name){
|
||||||
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
|
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
|
||||||
document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); });
|
document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); });
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
<button class="btn btn-secondary btn-sm" title="Переключить статус">↻</button>
|
<button class="btn btn-secondary btn-sm" title="Переключить статус">↻</button>
|
||||||
</form>
|
</form>
|
||||||
<button class="btn btn-secondary btn-sm" onclick="editIdea({{ idea.id }},{{ idea.title|tojson }},{{ idea.description|tojson }})">✎</button>
|
<button class="btn btn-secondary btn-sm" onclick="editIdea({{ idea.id }},{{ idea.title|tojson }},{{ idea.description|tojson }})">✎</button>
|
||||||
<form method="post" action="/service/ideas/delete" style="display:inline" onsubmit="return confirm('Удалить идею?')">
|
<form method="post" action="/service/ideas/delete" style="display:inline" id="delete-idea-form-{{ idea.id }}">
|
||||||
<input type="hidden" name="id" value="{{ idea.id }}">
|
<input type="hidden" name="id" value="{{ idea.id }}">
|
||||||
<button class="btn btn-danger btn-sm">✕</button>
|
<button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteIdea({{ idea.id }},{{ idea.title|tojson }})" title="Удалить">✕</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>{% endfor %}</tbody></table>
|
</tr>{% endfor %}</tbody></table>
|
||||||
@@ -38,7 +38,35 @@
|
|||||||
</form>
|
</form>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="confirm-delete-idea">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Удалить идею?</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
|
<strong id="delete-idea-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmDeleteIdea()">Удалить</button>
|
||||||
|
<button type="button" class="btn" onclick="closeConfirmDeleteIdea()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
var deleteIdeaId = null;
|
||||||
|
function openConfirmDeleteIdea(id, name){
|
||||||
|
deleteIdeaId = id;
|
||||||
|
document.getElementById('delete-idea-name').textContent = name || '#' + id;
|
||||||
|
document.getElementById('confirm-delete-idea').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeConfirmDeleteIdea(){
|
||||||
|
document.getElementById('confirm-delete-idea').classList.remove('open');
|
||||||
|
deleteIdeaId = null;
|
||||||
|
}
|
||||||
|
function confirmDeleteIdea(){
|
||||||
|
if(!deleteIdeaId) return;
|
||||||
|
document.getElementById('delete-idea-form-' + deleteIdeaId).submit();
|
||||||
|
closeConfirmDeleteIdea();
|
||||||
|
}
|
||||||
function editIdea(id,title,description){
|
function editIdea(id,title,description){
|
||||||
document.getElementById('modal-idea').classList.add('open');
|
document.getElementById('modal-idea').classList.add('open');
|
||||||
document.getElementById('modal-idea-title').textContent = 'Редактировать идею';
|
document.getElementById('modal-idea-title').textContent = 'Редактировать идею';
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="editObject({{ o.id }},'{{ o.name|e }}','{{ o.customer_id or '' }}','{{ o.object_type|e }}','{{ o.address|e }}','{{ o.contact_person|e }}','{{ o.contact_phone|e }}','{{ o.status }}','{{ o.notes|e }}')">✎</button>
|
<button class="btn btn-secondary btn-sm" onclick="editObject({{ o.id }},'{{ o.name|e }}','{{ o.customer_id or '' }}','{{ o.object_type|e }}','{{ o.address|e }}','{{ o.contact_person|e }}','{{ o.contact_phone|e }}','{{ o.status }}','{{ o.notes|e }}')">✎</button>
|
||||||
{% if user.role == 'owner' %}
|
{% if user.role == 'owner' %}
|
||||||
<form method="post" action="/service/objects/delete" style="display:inline" onsubmit="return confirm('Удалить объект?')"><input type="hidden" name="id" value="{{ o.id }}"><button class="btn btn-danger btn-sm">✕</button></form>
|
<form method="post" action="/service/objects/delete" style="display:inline" id="delete-object-form-{{ o.id }}"><input type="hidden" name="id" value="{{ o.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteObject({{ o.id }},'{{ o.name|e }}')" title="Удалить">✕</button></form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>{% endfor %}</tbody></table>
|
</tr>{% endfor %}</tbody></table>
|
||||||
@@ -48,7 +48,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
<div class="modal-overlay" id="confirm-delete-object">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Удалить объект?</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
|
<strong id="delete-object-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmDeleteObject()">Удалить</button>
|
||||||
|
<button type="button" class="btn" onclick="closeConfirmDeleteObject()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
var deleteObjectId = null;
|
||||||
|
function openConfirmDeleteObject(id, name){
|
||||||
|
deleteObjectId = id;
|
||||||
|
document.getElementById('delete-object-name').textContent = name || '#' + id;
|
||||||
|
document.getElementById('confirm-delete-object').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeConfirmDeleteObject(){
|
||||||
|
document.getElementById('confirm-delete-object').classList.remove('open');
|
||||||
|
deleteObjectId = null;
|
||||||
|
}
|
||||||
|
function confirmDeleteObject(){
|
||||||
|
if(!deleteObjectId) return;
|
||||||
|
document.getElementById('delete-object-form-' + deleteObjectId).submit();
|
||||||
|
closeConfirmDeleteObject();
|
||||||
|
}
|
||||||
function editObject(id,name,cid,type,addr,person,phone,status,notes){
|
function editObject(id,name,cid,type,addr,person,phone,status,notes){
|
||||||
document.getElementById('modal-object').classList.add('open');
|
document.getElementById('modal-object').classList.add('open');
|
||||||
document.getElementById('modal-object-title').textContent = 'Редактировать объект';
|
document.getElementById('modal-object-title').textContent = 'Редактировать объект';
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<td><form method="post" action="/service/questionnaire-config/toggle"><input type="hidden" name="id" value="{{ qi.id }}"><button class="btn btn-{{ 'success' if qi.is_active else 'secondary' }} btn-sm">{{ '✓' if qi.is_active else '✕' }}</button></form></td>
|
<td><form method="post" action="/service/questionnaire-config/toggle"><input type="hidden" name="id" value="{{ qi.id }}"><button class="btn btn-{{ 'success' if qi.is_active else 'secondary' }} btn-sm">{{ '✓' if qi.is_active else '✕' }}</button></form></td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="editQItem({{ qi.id }},{{ qi.step }},'{{ qi.section|e }}','{{ qi.question_key|e }}','{{ qi.label|e }}','{{ qi.type }}','{{ qi.required|lower }}','{{ qi.sort_order }}','{{ qi.help_text|e }}')">✎</button>
|
<button class="btn btn-secondary btn-sm" onclick="editQItem({{ qi.id }},{{ qi.step }},'{{ qi.section|e }}','{{ qi.question_key|e }}','{{ qi.label|e }}','{{ qi.type }}','{{ qi.required|lower }}','{{ qi.sort_order }}','{{ qi.help_text|e }}')">✎</button>
|
||||||
<form method="post" action="/service/questionnaire-config/delete" style="display:inline" onsubmit="return confirm('Удалить вопрос?')"><input type="hidden" name="id" value="{{ qi.id }}"><button class="btn btn-danger btn-sm">✕</button></form>
|
<form method="post" action="/service/questionnaire-config/delete" style="display:inline" id="delete-qitem-form-{{ qi.id }}"><input type="hidden" name="id" value="{{ qi.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteQitem({{ qi.id }},'{{ qi.label|e }}')" title="Удалить">✕</button></form>
|
||||||
</td>
|
</td>
|
||||||
</tr>{% endfor %}</tbody></table>
|
</tr>{% endfor %}</tbody></table>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,7 +40,34 @@
|
|||||||
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
|
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
<div class="modal-overlay" id="confirm-delete-qitem">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Удалить вопрос?</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
|
<strong id="delete-qitem-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmDeleteQitem()">Удалить</button>
|
||||||
|
<button type="button" class="btn" onclick="closeConfirmDeleteQitem()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
var deleteQitemId = null;
|
||||||
|
function openConfirmDeleteQitem(id, name){
|
||||||
|
deleteQitemId = id;
|
||||||
|
document.getElementById('delete-qitem-name').textContent = name || '#' + id;
|
||||||
|
document.getElementById('confirm-delete-qitem').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeConfirmDeleteQitem(){
|
||||||
|
document.getElementById('confirm-delete-qitem').classList.remove('open');
|
||||||
|
deleteQitemId = null;
|
||||||
|
}
|
||||||
|
function confirmDeleteQitem(){
|
||||||
|
if(!deleteQitemId) return;
|
||||||
|
document.getElementById('delete-qitem-form-' + deleteQitemId).submit();
|
||||||
|
closeConfirmDeleteQitem();
|
||||||
|
}
|
||||||
function editQItem(id,step,section,key,label,type,required,order,help){
|
function editQItem(id,step,section,key,label,type,required,order,help){
|
||||||
document.getElementById('modal-qitem').classList.add('open');
|
document.getElementById('modal-qitem').classList.add('open');
|
||||||
document.getElementById('modal-qitem-title').textContent = 'Редактировать вопрос';
|
document.getElementById('modal-qitem-title').textContent = 'Редактировать вопрос';
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ function loadPermissions(){
|
|||||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({role: role, permissions: perms})
|
body: JSON.stringify({role: role, permissions: perms})
|
||||||
}).then(function(r){ return r.json() }).then(function(d){
|
}).then(function(r){ return r.json() }).then(function(d){
|
||||||
if(d.ok) alert('Настройки роли "' + role + '" сохранены');
|
if(d.ok) showNotification('Настройки роли "' + role + '" сохранены', 'success');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<td><span class="badge badge-{{ t.status }}">{{ {'open':'Открыта','in_progress':'В работе','completed':'Завершена','cancelled':'Отменена'}[t.status] }}</span></td>
|
<td><span class="badge badge-{{ t.status }}">{{ {'open':'Открыта','in_progress':'В работе','completed':'Завершена','cancelled':'Отменена'}[t.status] }}</span></td>
|
||||||
<td><small>{{ t.deadline.strftime('%d.%m.%Y') if t.deadline else '' }}</small></td>
|
<td><small>{{ t.deadline.strftime('%d.%m.%Y') if t.deadline else '' }}</small></td>
|
||||||
<td class="actions">{% if t.status in ['open','in_progress'] and (user.role in ['owner','engineer'] or t.assigned_to == user.id) %}
|
<td class="actions">{% if t.status in ['open','in_progress'] and (user.role in ['owner','engineer'] or t.assigned_to == user.id) %}
|
||||||
<form method="post" action="/service/tasks/close" style="display:inline" onsubmit="return confirm('Завершить задачу?')"><input type="hidden" name="id" value="{{ t.id }}"><button class="btn btn-success btn-sm">✓ Закрыть</button></form>
|
<form method="post" action="/service/tasks/close" style="display:inline" id="close-task-form-{{ t.id }}"><input type="hidden" name="id" value="{{ t.id }}"><button type="button" class="btn btn-success btn-sm" onclick="openConfirmCloseTask({{ t.id }},'{{ t.title|e }}')" title="Завершить">✓ Закрыть</button></form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>{% endfor %}</tbody></table>
|
</tr>{% endfor %}</tbody></table>
|
||||||
@@ -43,4 +43,33 @@
|
|||||||
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
|
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
<div class="modal-overlay" id="confirm-close-task">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Завершить задачу?</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
|
<strong id="delete-task-name"></strong> будет завершена. Это действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-success" onclick="confirmCloseTask()">Завершить</button>
|
||||||
|
<button type="button" class="btn" onclick="closeConfirmCloseTask()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var closeTaskId = null;
|
||||||
|
function openConfirmCloseTask(id, name){
|
||||||
|
closeTaskId = id;
|
||||||
|
document.getElementById('delete-task-name').textContent = name || '#' + id;
|
||||||
|
document.getElementById('confirm-close-task').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeConfirmCloseTask(){
|
||||||
|
document.getElementById('confirm-close-task').classList.remove('open');
|
||||||
|
closeTaskId = null;
|
||||||
|
}
|
||||||
|
function confirmCloseTask(){
|
||||||
|
if(!closeTaskId) return;
|
||||||
|
document.getElementById('close-task-form-' + closeTaskId).submit();
|
||||||
|
closeConfirmCloseTask();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="editUser({{ u.id }},'{{ u.login }}','{{ u.full_name }}','{{ u.role }}','{{ u.phone }}','{{ u.email }}','{{ u.max_user_id or '' }}',{{ 'true' if u.is_active else 'false' }})">✎</button>
|
<button class="btn btn-secondary btn-sm" onclick="editUser({{ u.id }},'{{ u.login }}','{{ u.full_name }}','{{ u.role }}','{{ u.phone }}','{{ u.email }}','{{ u.max_user_id or '' }}',{{ 'true' if u.is_active else 'false' }})">✎</button>
|
||||||
{% if u.role != 'owner' %}
|
{% if u.role != 'owner' %}
|
||||||
<form method="post" action="/service/users/delete" style="display:inline" onsubmit="return confirm('Удалить пользователя?')">
|
<form method="post" action="/service/users/delete" style="display:inline" id="delete-user-form-{{ u.id }}">
|
||||||
<input type="hidden" name="id" value="{{ u.id }}">
|
<input type="hidden" name="id" value="{{ u.id }}">
|
||||||
<button type="submit" class="btn btn-danger btn-sm">✕</button>
|
<button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteUser({{ u.id }},'{{ u.login|e }}')" title="Удалить">✕</button>
|
||||||
</form>{% endif %}
|
</form>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>{% endfor %}</tbody></table>
|
</tr>{% endfor %}</tbody></table>
|
||||||
@@ -53,7 +53,34 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal-overlay" id="confirm-delete-user">
|
||||||
|
<div class="modal" style="max-width:420px;">
|
||||||
|
<h2 style="font-size:16px;margin-bottom:8px;">Удалить пользователя?</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||||
|
<strong id="delete-user-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmDeleteUser()">Удалить</button>
|
||||||
|
<button type="button" class="btn" onclick="closeConfirmDeleteUser()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
var deleteUserId = null;
|
||||||
|
function openConfirmDeleteUser(id, name){
|
||||||
|
deleteUserId = id;
|
||||||
|
document.getElementById('delete-user-name').textContent = name || '#' + id;
|
||||||
|
document.getElementById('confirm-delete-user').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeConfirmDeleteUser(){
|
||||||
|
document.getElementById('confirm-delete-user').classList.remove('open');
|
||||||
|
deleteUserId = null;
|
||||||
|
}
|
||||||
|
function confirmDeleteUser(){
|
||||||
|
if(!deleteUserId) return;
|
||||||
|
document.getElementById('delete-user-form-' + deleteUserId).submit();
|
||||||
|
closeConfirmDeleteUser();
|
||||||
|
}
|
||||||
function editUser(id,login,name,role,phone,email,maxId,active){
|
function editUser(id,login,name,role,phone,email,maxId,active){
|
||||||
document.getElementById('modal-user').classList.add('open');
|
document.getElementById('modal-user').classList.add('open');
|
||||||
document.getElementById('modal-user-title').textContent = 'Редактировать сотрудника';
|
document.getElementById('modal-user-title').textContent = 'Редактировать сотрудника';
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
-- Миграция 001: Инфраструктура для фазы лидов и звонков.
|
||||||
|
-- Дата: 04.06.2026
|
||||||
|
-- Описание: Расширение существующих таблиц + создание новых.
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. Новые таблицы (создаём ПЕРВЫМИ, чтобы FK ссылались)
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- 1.1 Таблица лидов
|
||||||
|
CREATE TABLE IF NOT EXISTS leads (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||||
|
source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||||
|
company_name VARCHAR(255) DEFAULT '',
|
||||||
|
contact_name VARCHAR(255) DEFAULT '',
|
||||||
|
contact_phone VARCHAR(50) DEFAULT '',
|
||||||
|
contact_email VARCHAR(255) DEFAULT '',
|
||||||
|
inn VARCHAR(12) DEFAULT '',
|
||||||
|
title VARCHAR(500) DEFAULT '',
|
||||||
|
service_type VARCHAR(255) DEFAULT '',
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
urgency VARCHAR(10) DEFAULT 'medium',
|
||||||
|
status VARCHAR(30) DEFAULT 'new',
|
||||||
|
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
bot_ticket_id BIGINT,
|
||||||
|
deal_amount NUMERIC(12, 2) DEFAULT 0,
|
||||||
|
expected_close_date DATE,
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_leads_customer ON leads(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_leads_status ON leads(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_leads_source ON leads(source);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_leads_assigned ON leads(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_leads_created ON leads(created_at);
|
||||||
|
|
||||||
|
-- 1.2 Таблица звонков (CDR из Yeastar S20)
|
||||||
|
CREATE TABLE IF NOT EXISTS call_records (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
cdr_uniqueid VARCHAR(32) UNIQUE,
|
||||||
|
call_datetime TIMESTAMP NOT NULL,
|
||||||
|
caller_number VARCHAR(80) DEFAULT '',
|
||||||
|
callee_number VARCHAR(80) DEFAULT '',
|
||||||
|
trunk VARCHAR(80) DEFAULT '',
|
||||||
|
duration INTEGER DEFAULT 0,
|
||||||
|
billable_seconds INTEGER DEFAULT 0,
|
||||||
|
disposition VARCHAR(30) DEFAULT '',
|
||||||
|
call_type VARCHAR(20) DEFAULT '',
|
||||||
|
queue_name VARCHAR(30) DEFAULT '',
|
||||||
|
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||||
|
lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL,
|
||||||
|
answered_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
answered_by_name VARCHAR(100) DEFAULT '',
|
||||||
|
has_recording BOOLEAN DEFAULT FALSE,
|
||||||
|
recording_path VARCHAR(500) DEFAULT '',
|
||||||
|
recording_filename VARCHAR(200) DEFAULT '',
|
||||||
|
notification_sent BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_call_records_customer ON call_records(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_call_records_datetime ON call_records(call_datetime);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_call_records_caller ON call_records(caller_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_call_records_disposition ON call_records(disposition);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_call_records_uniqueid ON call_records(cdr_uniqueid);
|
||||||
|
|
||||||
|
-- 1.3 Таблица уведомлений
|
||||||
|
CREATE TABLE IF NOT EXISTS client_notifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||||
|
lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL,
|
||||||
|
call_record_id INTEGER REFERENCES call_records(id) ON DELETE SET NULL,
|
||||||
|
type VARCHAR(30) NOT NULL,
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
message TEXT DEFAULT '',
|
||||||
|
is_read BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user ON client_notifications(user_id, is_read);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON client_notifications(is_read, created_at);
|
||||||
|
|
||||||
|
-- 1.4 Таблица настроек АТС (всегда одна запись)
|
||||||
|
CREATE TABLE IF NOT EXISTS pbx_settings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
mysql_host VARCHAR(255) DEFAULT '192.168.1.150',
|
||||||
|
mysql_port INTEGER DEFAULT 3306,
|
||||||
|
mysql_user VARCHAR(100) DEFAULT '',
|
||||||
|
mysql_password VARCHAR(255) DEFAULT '',
|
||||||
|
mysql_database VARCHAR(100) DEFAULT 'cdr',
|
||||||
|
ami_host VARCHAR(255) DEFAULT '192.168.1.150',
|
||||||
|
ami_port INTEGER DEFAULT 5038,
|
||||||
|
ami_user VARCHAR(100) DEFAULT '',
|
||||||
|
ami_password VARCHAR(255) DEFAULT '',
|
||||||
|
ftp_host VARCHAR(255) DEFAULT '192.168.1.150',
|
||||||
|
ftp_port INTEGER DEFAULT 21,
|
||||||
|
ftp_user VARCHAR(100) DEFAULT '',
|
||||||
|
ftp_password VARCHAR(255) DEFAULT '',
|
||||||
|
web_host VARCHAR(255) DEFAULT '192.168.1.150',
|
||||||
|
web_port INTEGER DEFAULT 8088,
|
||||||
|
web_user VARCHAR(100) DEFAULT '',
|
||||||
|
web_password VARCHAR(255) DEFAULT '',
|
||||||
|
serial_number VARCHAR(50) DEFAULT '',
|
||||||
|
actual_serial VARCHAR(50) DEFAULT '',
|
||||||
|
mysql_status VARCHAR(20) DEFAULT 'unknown',
|
||||||
|
ami_status VARCHAR(20) DEFAULT 'unknown',
|
||||||
|
ftp_status VARCHAR(20) DEFAULT 'unknown',
|
||||||
|
serial_match BOOLEAN DEFAULT FALSE,
|
||||||
|
last_sync_at TIMESTAMP,
|
||||||
|
sync_interval INTEGER DEFAULT 5,
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO pbx_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. Изменения существующих таблиц
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- 2.1 Users: внутренний номер АТС
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS extension VARCHAR(10) DEFAULT '';
|
||||||
|
|
||||||
|
-- 2.2 Customers: поля CRM + расширенный статус
|
||||||
|
ALTER TABLE customers ADD COLUMN IF NOT EXISTS lead_source VARCHAR(20) DEFAULT '';
|
||||||
|
ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_call_at TIMESTAMP;
|
||||||
|
ALTER TABLE customers ADD COLUMN IF NOT EXISTS total_calls INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE customers ADD COLUMN IF NOT EXISTS total_leads INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE customers DROP CONSTRAINT IF EXISTS ck_customers_status;
|
||||||
|
ALTER TABLE customers ADD CONSTRAINT ck_customers_status
|
||||||
|
CHECK (status IN ('active','inactive','prospect','draft','merged'));
|
||||||
|
|
||||||
|
-- 2.3 Tasks: связь с лидом
|
||||||
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- 2.4 AuditLog: расширенное аудирование
|
||||||
|
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS entity_type VARCHAR(50) DEFAULT '';
|
||||||
|
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS entity_id INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS field VARCHAR(100) DEFAULT '';
|
||||||
|
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS old_value TEXT DEFAULT '';
|
||||||
|
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS new_value TEXT DEFAULT '';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
|
||||||
@@ -11,6 +11,7 @@ itsdangerous==2.2.0
|
|||||||
markdown==3.7
|
markdown==3.7
|
||||||
pydantic==2.10.3
|
pydantic==2.10.3
|
||||||
pydantic-settings==2.7.0
|
pydantic-settings==2.7.0
|
||||||
|
cryptography>=44.0.0
|
||||||
|
|
||||||
# Test
|
# Test
|
||||||
pytest>=9.0.0
|
pytest>=9.0.0
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.8.2
|
1.9.0
|
||||||
|
|||||||
Reference in New Issue
Block a user