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.3. **Если предлагаешь варианты — обязательно указывай рекомендацию и пояснение.**
|
||||
|
||||
### 0.4. Обязательные файлы для чтения
|
||||
|
||||
Перед началом работы агент ОБЯЗАН прочитать:
|
||||
|
||||
| Файл | Содержание | Когда читать |
|
||||
|------|------------|--------------|
|
||||
| `AGENTS.md` | Правила проекта (этот файл) | Всегда, перед началом работы |
|
||||
| `other/AI-EXISTING-TWO-PROJECT-INSTRUCTION.md` | Архитектура сервера, порты, Docker-проекты, команды | Перед работой с сервером, деплоем, диагностикой |
|
||||
| `py_service/SERVICE_STYLE_GUIDE.md` | Стили UI, CSS-классы, паттерны шаблонов | Перед работой с фронтендом портала |
|
||||
|
||||
---
|
||||
|
||||
## 1. Архитектура проекта
|
||||
@@ -22,6 +32,8 @@
|
||||
|
||||
**ВАЖНО:** Публичная часть полностью готова и не подлежит изменениям!
|
||||
|
||||
**Подробная архитектура сервера:** `other/AI-EXISTING-TWO-PROJECT-INSTRUCTION.md` — порты, Docker-проекты, команды, troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
## 2. Версионирование
|
||||
@@ -223,3 +235,28 @@ docker logs aegisone-app --tail 10
|
||||
- Следуй существующему стилю кода
|
||||
- Удаляй неиспользуемый код при изменениях
|
||||
- Не добавляй лишних зависимостей без необходимости
|
||||
|
||||
---
|
||||
|
||||
## 11. Стили UI (сервисный портал)
|
||||
|
||||
- **Единый гайд:** `py_service/SERVICE_STYLE_GUIDE.md` — читать перед работой с фронтендом
|
||||
- **Нельзя** использовать `confirm()`, `alert()`, `prompt()` — только `showNotification()` и кастомные модалки
|
||||
- **Удаление:** простая модалка (Variant A), кнопки: **Действие** (слева) → **Отмена** (справа)
|
||||
- **Иконки:** `✕` = удалить, `✎` = редактировать, `+` = создать
|
||||
- **Модалки:** `.modal-overlay` + `.modal`, открытие через `classList.add('open')`
|
||||
- **Структура страницы:** `card` > `card-header` > `filter-bar` > `table-wrap` > `table`
|
||||
- **CSS классы кнопок:** `.btn-primary` (создать), `.btn-secondary` (редактировать), `.btn-danger` (удалить), `.btn-success` (выполнено)
|
||||
- **Сортировка таблиц:** `sort-table.js` подключён глобально в `base.html`, автоматически сортирует все `<table>` по клику на `<th>` (текст/число/дата)
|
||||
- **Number input:** спиннеры скрыты глобально через CSS (`service.css`), `type="number"` выглядит как обычный input
|
||||
|
||||
---
|
||||
|
||||
## 12. Идеи
|
||||
|
||||
- **Страница:** `/service/ideas`
|
||||
- **Агент может:** видеть список идей, добавлять новые, менять статус, предлагать к реализации, реализовывать
|
||||
- **Статусы:** `normal` → `in_progress` → `completed` → `normal`
|
||||
- **API:** POST `/service/ideas/create` (title, description), POST `/service/ideas/status` (id), POST `/service/ideas/delete` (id)
|
||||
- Агент может добавить идею по просьбе пользователя через API
|
||||
- Агент может реализовать идею, создав/изменив код, и предложить к реализации
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
"""
|
||||
Конфигурация max_bot (Pydantic Settings).
|
||||
|
||||
Читает из .env с префиксом BOT_:
|
||||
- database_url: PostgreSQL URL
|
||||
- max_token: токен MAX Platform
|
||||
- webhook_url/webhook_secret: настройки webhook
|
||||
- config_php_path: путь к config.php сайта
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""
|
||||
Чтение конфигурации из config.php (AegisOne сайт).
|
||||
|
||||
Парсит PHP define() константы и возвращает их как dict.
|
||||
Fallback на hardcoded значения если файл не найден.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
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 app.config import settings
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
"""
|
||||
Отправка email через sendmail (с fallback на логирование в файл).
|
||||
|
||||
Используется для уведомлений о заявках.
|
||||
"""
|
||||
import subprocess
|
||||
import os
|
||||
import logging
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
"""
|
||||
Обработчик согласия на обработку персональных данных.
|
||||
|
||||
Управляет жизненным циклом согласия:
|
||||
- запрос согласия → да/нет
|
||||
- отзыв согласия (consent_revoked_date)
|
||||
- повторное согласие после отказа
|
||||
"""
|
||||
import datetime
|
||||
import re
|
||||
import logging
|
||||
@@ -12,6 +20,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _matches_any(text: str, words: list) -> bool:
|
||||
"""
|
||||
Проверяет, содержит ли текст хотя бы одно из указанных слов.
|
||||
|
||||
Использует split для точного совпадения коротких слов (≤3 символов)
|
||||
и подстроку для длинных (>3 символов).
|
||||
"""
|
||||
text_words = set(text.split())
|
||||
for w in words:
|
||||
if w in text_words:
|
||||
@@ -22,6 +36,7 @@ def _matches_any(text: str, words: list) -> bool:
|
||||
|
||||
|
||||
async def handle_consent_callback(user_id: int, conv_id: int, callback_id: str) -> None:
|
||||
"""Маршрутизация callback-кнопок согласия (да/нет)."""
|
||||
if callback_id == "consent_yes":
|
||||
await handle_consent_yes(user_id, conv_id)
|
||||
elif callback_id == "consent_no":
|
||||
@@ -29,6 +44,7 @@ async def handle_consent_callback(user_id: int, conv_id: int, callback_id: str)
|
||||
|
||||
|
||||
async def handle_consent_response(user_id: int, conv_id: int, text: str) -> None:
|
||||
"""Обработка текстового ответа на запрос согласия (да/нет/другое)."""
|
||||
text_lower = text.strip().lower()
|
||||
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие"]
|
||||
refuse_words = ["нет", "не даю", "отказ", "no", "не согласен", "не согласна"]
|
||||
@@ -48,6 +64,7 @@ async def handle_consent_response(user_id: int, conv_id: int, text: str) -> None
|
||||
|
||||
|
||||
async def handle_consent_yes(user_id: int, conv_id: int) -> None:
|
||||
"""Обработка согласия: устанавливает consent_given=True, переводит в awaiting_contact."""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(BotUser).where(BotUser.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -78,6 +95,7 @@ async def handle_consent_yes(user_id: int, conv_id: int) -> None:
|
||||
|
||||
|
||||
async def handle_consent_no(user_id: int, conv_id: int) -> None:
|
||||
"""Обработка отказа: устанавливает consent_refused, показывает контакты."""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(BotConversation).where(BotConversation.id == conv_id)
|
||||
@@ -106,6 +124,7 @@ async def handle_consent_no(user_id: int, conv_id: int) -> None:
|
||||
|
||||
|
||||
async def handle_refused_again(user_id: int, conv_id: int, text: str) -> None:
|
||||
"""Повторная обработка после отказа: позволяет дать согласие или показывает контакты."""
|
||||
text_lower = text.strip().lower()
|
||||
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие", "передумал", "согласен дать"]
|
||||
refuse_words = ["нет", "не даю", "не хочу", "отказ", "отмена", "no", "cancel", "я отказался"]
|
||||
@@ -145,6 +164,7 @@ async def handle_refused_again(user_id: int, conv_id: int, text: str) -> None:
|
||||
|
||||
|
||||
async def handle_revoke_consent(user_id: int, conv_id: int) -> None:
|
||||
"""Отзыв согласия: устанавливает consent_given=False, consent_revoked_date=now."""
|
||||
async with async_session() as db:
|
||||
user = await db.get(BotUser, user_id)
|
||||
if user and user.consent_given:
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
"""
|
||||
Обработчик контактов пользователя.
|
||||
|
||||
Принимает контакты (телефон, email) через:
|
||||
- sharing контакта из мессенджера (VCF)
|
||||
- ручной ввод текстом
|
||||
"""
|
||||
import datetime
|
||||
import re
|
||||
import logging
|
||||
@@ -13,6 +20,7 @@ EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
|
||||
|
||||
|
||||
async def handle_contact_received(user_id: int, conv_id: int, contact_text: str) -> None:
|
||||
"""Обработка контакта из мессенджера: парсинг телефона/email, переход к заявке."""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(BotUser).where(BotUser.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -37,6 +45,7 @@ async def handle_contact_received(user_id: int, conv_id: int, contact_text: str)
|
||||
|
||||
|
||||
async def handle_manual_contact(user_id: int, conv_id: int, text: str) -> None:
|
||||
"""Обработка ручного ввода контакта: валидация телефона/email, отказ → consent_refused."""
|
||||
text = text.strip()
|
||||
text_lower = text.lower()
|
||||
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
"""
|
||||
Обработчик приветствий и основных сообщений бота.
|
||||
|
||||
Управляет жизненным циклом диалога:
|
||||
- приветствие → создание пользователя и диалога
|
||||
- маршрутизация по состояниям (state machine)
|
||||
- анализ намерений через YandexGPT
|
||||
- быстрые паттерны (заявка/приветствие) до NLP
|
||||
- контекст из последних 10 минут
|
||||
"""
|
||||
import logging
|
||||
import datetime
|
||||
from typing import Optional
|
||||
@@ -7,6 +17,7 @@ from app.database import async_session
|
||||
MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3))
|
||||
|
||||
def moscow_now():
|
||||
"""Возвращает текущее время в UTC+3 (Москва) без tzinfo."""
|
||||
return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None)
|
||||
from app.models import BotUser, BotConversation, BotMessage, BotKnowledgeBase, BotCategory, BotUnknownQuestion
|
||||
from app.max_api import max_api
|
||||
@@ -26,6 +37,7 @@ async def handle_greeting(
|
||||
last_name: str = "",
|
||||
username: str = "",
|
||||
) -> None:
|
||||
"""Обработка приветствия: создаёт/обновляет пользователя, создаёт диалог, отправляет приветствие."""
|
||||
assistant_name = await settings_cache.get("assistant_name", "София")
|
||||
|
||||
greeting_text = (
|
||||
@@ -94,6 +106,14 @@ async def handle_greeting(
|
||||
|
||||
|
||||
async def handle_message(user_id: int, text: str) -> None:
|
||||
"""
|
||||
Основной обработчик сообщений. Маршрутизирует по состояниям:
|
||||
- greeting/analyzing/awaiting_input → analyze_and_respond
|
||||
- awaiting_consent/consent_refused → обработка согласия
|
||||
- awaiting_contact → ручной ввод контакта
|
||||
- awaiting_inquiry → обработка заявки
|
||||
- completed/escalated → повторный анализ (escape hatch)
|
||||
"""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(BotConversation)
|
||||
@@ -199,6 +219,7 @@ async def handle_message(user_id: int, text: str) -> None:
|
||||
|
||||
|
||||
async def _get_user_history_context(user_id: int) -> str:
|
||||
"""Формирует контекст из последних сообщений (10 мин) и открытых заявок пользователя."""
|
||||
from app.models import BotTicket
|
||||
async with async_session() as db:
|
||||
ten_min_ago = moscow_now() - datetime.timedelta(minutes=10)
|
||||
@@ -237,12 +258,20 @@ async def _get_user_history_context(user_id: int) -> str:
|
||||
|
||||
|
||||
async def _select_ai():
|
||||
"""Выбор AI-провайдера (только YandexGPT)."""
|
||||
if await yandex_gpt.is_available():
|
||||
return yandex_gpt
|
||||
return None
|
||||
|
||||
|
||||
async def analyze_and_respond(user_id: int, conv_id: int, text: str) -> None:
|
||||
"""
|
||||
Анализ намерений и генерация ответа.
|
||||
|
||||
Быстрые паттерны (заявка/приветствие) проверяются ПЕРЕД NLP.
|
||||
Затем: analyze_intent → ticket/question/unknown → обработка.
|
||||
clarify_intent вызывается не более 1 раза за диалог.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"analyze_and_respond: user={user_id} conv={conv_id} text={text[:80]}")
|
||||
|
||||
@@ -359,6 +388,7 @@ async def analyze_and_respond(user_id: int, conv_id: int, text: str) -> None:
|
||||
|
||||
|
||||
async def _limited_mode(user_id: int, conv_id: int) -> None:
|
||||
"""Режим без AI: показывает контакты компании."""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(BotConversation).where(BotConversation.id == conv_id)
|
||||
@@ -372,6 +402,7 @@ async def _limited_mode(user_id: int, conv_id: int) -> None:
|
||||
|
||||
|
||||
async def _send_contacts(user_id: int, conv_id: int = None) -> None:
|
||||
"""Отправка контактных данных компании (телефон, email)."""
|
||||
phones = config_reader.get_phones()
|
||||
email = config_reader.get_site_mail()
|
||||
|
||||
@@ -387,6 +418,7 @@ async def _send_contacts(user_id: int, conv_id: int = None) -> None:
|
||||
|
||||
|
||||
async def _get_knowledge_base_context(query: str) -> str:
|
||||
"""Формирует контекст из базы знаний по ключевым словам (ранжирование по score)."""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(BotKnowledgeBase)
|
||||
@@ -433,6 +465,7 @@ async def _get_knowledge_base_context(query: str) -> str:
|
||||
|
||||
|
||||
def _texts_match(text_a: str, text_b: str) -> bool:
|
||||
"""Проверка схожести двух текстов по пересечению слов (≥50%)."""
|
||||
stopwords = {"какой", "какая", "какие", "какое", "ваш", "ваша", "ваши",
|
||||
"где", "когда", "сколько", "что", "это", "или", "для", "при",
|
||||
"подскажите", "расскажите", "объясните", "пожалуйста"}
|
||||
@@ -445,6 +478,7 @@ def _texts_match(text_a: str, text_b: str) -> bool:
|
||||
|
||||
|
||||
async def _auto_generate_and_add_kb(db, question_record):
|
||||
"""Автогенерация карточки KB через YandexGPT при 3+ обращениях."""
|
||||
card_data = await yandex_gpt.generate_kb_card([question_record.question_text])
|
||||
if not card_data:
|
||||
return
|
||||
@@ -466,6 +500,7 @@ async def _auto_generate_and_add_kb(db, question_record):
|
||||
|
||||
|
||||
async def _save_unknown_question(user_id: int, question_text: str) -> None:
|
||||
"""Сохранение неизвестного вопроса с дедупликацией и автогенерацией при 3+ обращениях."""
|
||||
normalized_input = question_text.lower().strip()
|
||||
|
||||
async with async_session() as db:
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
"""
|
||||
Обработчик callback-кнопок и шаринга контактов.
|
||||
|
||||
Маршрутизирует callback-и по типу (consent_* и др.).
|
||||
"""
|
||||
import datetime
|
||||
import logging
|
||||
from sqlalchemy import select
|
||||
@@ -10,6 +15,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_callback(user_id: int, callback_id: str, callback_data: dict) -> None:
|
||||
"""Маршрутизация callback-кнопок (consent_* → consent handler)."""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(BotConversation)
|
||||
@@ -40,6 +46,7 @@ async def handle_contact_share(
|
||||
vcf_data: dict = None,
|
||||
contact_hash: str = "",
|
||||
) -> None:
|
||||
"""Обработка шаринга контакта из мессенджера: парсинг VCF, сохранение в BotUser."""
|
||||
if vcf_data is None:
|
||||
vcf_data = {}
|
||||
async with async_session() as db:
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""
|
||||
Обработчик заявок (inquiry).
|
||||
|
||||
Создаёт BotTicket с контактными данными пользователя,
|
||||
отправляет уведомление на email, подтверждает пользователю.
|
||||
"""
|
||||
import datetime
|
||||
import logging
|
||||
from sqlalchemy import select
|
||||
@@ -12,10 +18,12 @@ logger = logging.getLogger(__name__)
|
||||
MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3))
|
||||
|
||||
def moscow_now():
|
||||
"""Возвращает текущее время в UTC+3 (Москва) без tzinfo."""
|
||||
return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None)
|
||||
|
||||
|
||||
async def handle_inquiry(user_id: int, conv_id: int, text: str) -> None:
|
||||
"""Обработка заявки: создаёт.ticket, отправляет email, подтверждает пользователю."""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(BotConversation).where(BotConversation.id == conv_id)
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
"""
|
||||
Клавиатуры для MAX Platform бота.
|
||||
|
||||
Типы кнопок:
|
||||
- callback: callback-кнопка (consent_yes/consent_no)
|
||||
- link: ссылка (политика конфиденциальности)
|
||||
- request_contact: запрос контакта из мессенджера
|
||||
- message: текстовая кнопка
|
||||
"""
|
||||
from typing import List, Dict, Any
|
||||
|
||||
POLITICA_URL = "https://aegisone.ru/politica.php"
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
"""
|
||||
Главный модуль max_bot FastAPI-приложения.
|
||||
|
||||
Эндпоинты:
|
||||
- /webhook: входящие webhook-и от MAX Platform
|
||||
- /health: healthcheck
|
||||
- /api/bot/*: CRUD для настроек, KB, заявок, диалогов, пользователей
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -33,6 +41,11 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("Database tables ensured.")
|
||||
|
||||
await _apply_migrations()
|
||||
|
||||
# Применение SQL-миграций из директории migrations/
|
||||
from app.migrations_runner import run_migrations
|
||||
await run_migrations()
|
||||
|
||||
await _seed_default_settings()
|
||||
await _register_webhook()
|
||||
|
||||
@@ -54,6 +67,7 @@ app.add_middleware(
|
||||
|
||||
|
||||
async def _apply_migrations():
|
||||
"""Автоматические миграции: добавление колонок/таблиц при старте."""
|
||||
async with engine.begin() as conn:
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
@@ -101,6 +115,7 @@ async def _apply_migrations():
|
||||
|
||||
|
||||
async def _add_column_if_not_exists(conn, table: str, column: str, col_type: str):
|
||||
"""Безопасное добавление колонки (idempotent)."""
|
||||
from sqlalchemy import text as sa_text
|
||||
_validate_sql_name(table)
|
||||
_validate_sql_name(column)
|
||||
@@ -121,6 +136,7 @@ async def _add_column_if_not_exists(conn, table: str, column: str, col_type: str
|
||||
|
||||
|
||||
async def _create_table_if_not_exists(conn, table: str, create_sql: str):
|
||||
"""Безопасное создание таблицы (idempotent)."""
|
||||
from sqlalchemy import text as sa_text
|
||||
try:
|
||||
await conn.execute(sa_text(f"""
|
||||
@@ -138,12 +154,14 @@ async def _create_table_if_not_exists(conn, table: str, create_sql: str):
|
||||
|
||||
|
||||
def _validate_sql_name(name: str) -> None:
|
||||
"""Валидация SQL-идентификатора (только буквы, цифры, подчёркивания)."""
|
||||
import re
|
||||
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_ ]*$', name):
|
||||
raise ValueError(f"Invalid SQL identifier: {name}")
|
||||
|
||||
|
||||
def _validate_sql_type(col_type: str) -> None:
|
||||
"""Валидация SQL-типа колонки (разрешённые типы)."""
|
||||
import re
|
||||
allowed = r'^(VARCHAR\(\d+\)|TEXT|INTEGER( DEFAULT \d+)?|BOOLEAN( DEFAULT (TRUE|FALSE))?|TIMESTAMP)$'
|
||||
if not re.match(allowed, col_type.strip()):
|
||||
@@ -151,6 +169,7 @@ def _validate_sql_type(col_type: str) -> None:
|
||||
|
||||
|
||||
async def _log_processing(user_id: int, step: str, status: str = "ok", message: str = None, detail: str = None, conversation_id: int = None):
|
||||
"""Запись лога обработки в bot_processing_logs."""
|
||||
try:
|
||||
async with async_session() as db:
|
||||
db.add(BotProcessingLog(
|
||||
@@ -167,6 +186,7 @@ async def _log_processing(user_id: int, step: str, status: str = "ok", message:
|
||||
|
||||
|
||||
async def _seed_default_settings():
|
||||
"""Заполнение настроек по умолчанию при первом запуске."""
|
||||
defaults = {
|
||||
"assistant_name": "София",
|
||||
"assistant_role": "Представитель компании AegisOne Engineering, помогающий клиентам с запросами по безопасности",
|
||||
@@ -185,6 +205,7 @@ async def _seed_default_settings():
|
||||
|
||||
|
||||
async def _register_webhook():
|
||||
"""Регистрация webhook-а в MAX Platform."""
|
||||
try:
|
||||
subs = await max_api.get_subscriptions()
|
||||
logger.info(f"Current subscriptions: {subs}")
|
||||
@@ -199,6 +220,7 @@ async def _register_webhook():
|
||||
|
||||
|
||||
def _verify_secret(request: Request) -> bool:
|
||||
"""Проверка webhook-секрета (пустой заголовок = OK для обратной совместимости)."""
|
||||
secret = request.headers.get("X-Max-Bot-Api-Secret", "")
|
||||
if not secret:
|
||||
return True
|
||||
@@ -206,6 +228,7 @@ def _verify_secret(request: Request) -> bool:
|
||||
|
||||
|
||||
async def _get_active_conv_id(user_id: int) -> int | None:
|
||||
"""Получение ID активного диалога пользователя."""
|
||||
try:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
@@ -798,6 +821,7 @@ async def api_delete_conversation(conv_id: int):
|
||||
|
||||
@app.delete("/api/bot/users/{user_id}")
|
||||
async def api_delete_bot_user(user_id: int):
|
||||
"""Каскадное удаление пользователя: BotUser + BotConversation + BotMessage + BotProcessingLog. Заявки остаются."""
|
||||
from app.models import BotMessage, BotProcessingLog
|
||||
from sqlalchemy import delete as sa_delete
|
||||
async with async_session() as db:
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
"""
|
||||
API-клиент для MAX Platform (Мой Мир / VK).
|
||||
|
||||
Основные методы:
|
||||
- send_message: отправка сообщений (с логированием outgoing BotMessage)
|
||||
- subscribe_webhook: регистрация webhook-а
|
||||
- answer_callback: ответ на callback-кнопки
|
||||
"""
|
||||
import logging
|
||||
import httpx
|
||||
from typing import Optional
|
||||
@@ -9,6 +17,7 @@ TEST_USER_THRESHOLD = 999999000
|
||||
|
||||
|
||||
class MaxAPI:
|
||||
"""Клиент MAX Platform API с поддержкой mock-реплик для тестов."""
|
||||
def __init__(self):
|
||||
self.base_url = settings.max_api_base
|
||||
self.token = settings.max_token
|
||||
@@ -65,6 +74,7 @@ class MaxAPI:
|
||||
notify: bool = True,
|
||||
conversation_id: int = None,
|
||||
) -> dict:
|
||||
"""Отправка сообщения. Если conversation_id — сохраняет outgoing BotMessage."""
|
||||
if user_id >= TEST_USER_THRESHOLD:
|
||||
logger.info(f"MOCK send_message to test user {user_id}: {text[:50]}")
|
||||
self.mock_replies.append(text)
|
||||
|
||||
@@ -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
|
||||
import datetime
|
||||
from sqlalchemy import Column, Integer, BigInteger, String, Text, Boolean, DateTime, ForeignKey, ARRAY
|
||||
@@ -53,6 +70,7 @@ class BotKnowledgeBase(Base):
|
||||
|
||||
|
||||
class BotUser(Base):
|
||||
"""Пользователь бота: профиль, контакты, согласие на обработку ПД."""
|
||||
__tablename__ = "bot_users"
|
||||
id = Column(BigInteger, primary_key=True)
|
||||
first_name = Column(String(255), nullable=True)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""
|
||||
In-memory кэш настроек бота с синхронизацией с БД.
|
||||
|
||||
При первом обращении загружает все настройки из bot_settings.
|
||||
При set() обновляет и БД, и кэш.
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Optional, Dict
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""
|
||||
YandexGPT интеграция для анализа намерений и генерации ответов.
|
||||
|
||||
Модель: yandexgpt-lite
|
||||
API: https://llm.api.cloud.yandex.net/foundationModels/v1/completion
|
||||
"""
|
||||
import httpx
|
||||
import json
|
||||
import logging
|
||||
@@ -14,6 +20,7 @@ YANDEX_GPT_MODEL = "yandexgpt-lite"
|
||||
|
||||
|
||||
class YandexGPT:
|
||||
"""Клиент YandexGPT для анализа намерений и генерации ответов."""
|
||||
def __init__(self):
|
||||
self._api_key: Optional[str] = None
|
||||
self._folder_id: Optional[str] = None
|
||||
@@ -27,6 +34,7 @@ class YandexGPT:
|
||||
return await self._load_credentials()
|
||||
|
||||
async def _request(self, prompt: str, system_prompt: str = "", temperature: float = 0.3, max_tokens: int = 1000) -> Optional[str]:
|
||||
"""Базовый запрос к YandexGPT API."""
|
||||
if not await self._load_credentials():
|
||||
return None
|
||||
|
||||
@@ -65,11 +73,13 @@ class YandexGPT:
|
||||
return None
|
||||
|
||||
def _sanitize_input(self, text: str) -> str:
|
||||
"""Очистка и обрезка входного текста (макс. 2000 символов)."""
|
||||
text = text[:MAX_INPUT_LENGTH]
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
return text
|
||||
|
||||
async def analyze_intent(self, text: str, history_context: str = "") -> str:
|
||||
"""Анализ намерения: ticket/question/unknown. Aggressive ticket detection для услуг."""
|
||||
safe_text = self._sanitize_input(text)
|
||||
safe_history = self._sanitize_input(history_context) if history_context else ""
|
||||
system_prompt = (
|
||||
@@ -97,6 +107,7 @@ class YandexGPT:
|
||||
return "unknown"
|
||||
|
||||
async def generate_answer(self, question: str, context: str, history_context: str = "") -> Optional[str]:
|
||||
"""Генерация ответа на вопрос с контекстом из базы знаний."""
|
||||
safe_question = self._sanitize_input(question)
|
||||
safe_history = self._sanitize_input(history_context) if history_context else ""
|
||||
system_prompt = (
|
||||
@@ -116,6 +127,7 @@ class YandexGPT:
|
||||
return await self._request(user_prompt, system_prompt)
|
||||
|
||||
async def clarify_intent(self, text: str, history_context: str = "") -> str:
|
||||
"""Уточнение намерения: если есть слова-маркеры — отвечает без 'уточните'."""
|
||||
safe_text = self._sanitize_input(text)
|
||||
safe_history = self._sanitize_input(history_context) if history_context else ""
|
||||
system_prompt = (
|
||||
@@ -159,6 +171,7 @@ class YandexGPT:
|
||||
return result or "Подскажите, чем я могу помочь? Вы хотите задать вопрос об услугах AegisOne или оставить заявку?"
|
||||
|
||||
async def generate_kb_card(self, user_questions: list) -> Optional[dict]:
|
||||
"""Генерация карточки базы знаний из вопросов пользователей (JSON)."""
|
||||
joined = "\n".join(f"- {q}" for q in user_questions)
|
||||
system_prompt = (
|
||||
"Ты — редактор базы знаний компании AegisOne Engineering. "
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Миграция: consent_revoked_date + bot_processing_logs
|
||||
-- Дата: 02.06.2026
|
||||
-- Описание: Добавление поля consent_revoked_date в bot_users и создание таблицы bot_processing_logs
|
||||
|
||||
-- 1. Consent revoked date
|
||||
ALTER TABLE bot_users ADD COLUMN IF NOT EXISTS consent_revoked_date TIMESTAMP NULL;
|
||||
|
||||
-- 2. BotProcessingLog table
|
||||
CREATE TABLE IF NOT EXISTS bot_processing_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES bot_users(id) ON DELETE CASCADE,
|
||||
conversation_id BIGINT REFERENCES bot_conversations(id) ON DELETE SET NULL,
|
||||
step VARCHAR(50) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'ok',
|
||||
message TEXT,
|
||||
detail TEXT,
|
||||
created_at TIMESTAMP DEFAULT now()
|
||||
);
|
||||
|
||||
-- Индексы для производительности
|
||||
CREATE INDEX IF NOT EXISTS idx_bot_processing_logs_user_id ON bot_processing_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bot_processing_logs_conversation_id ON bot_processing_logs(conversation_id);
|
||||
+1
-1
@@ -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) |
|
||||
| FastAPI | порт **8001** |
|
||||
| FastAPI | порт **8002** |
|
||||
| PostgreSQL | общая БД `aegisone` (порт 5432), таблицы с префиксом `bot_` |
|
||||
| Домен | `max.aegisone.ru` |
|
||||
| Директория | `/opt/projects/aegisone-py/max_bot/` |
|
||||
| Контейнер | `aegisone-max-bot` |
|
||||
| Webhook | `https://max.aegisone.ru/webhook` → localhost:8001 |
|
||||
| Webhook | `https://max.aegisone.ru/webhook` → localhost:8002 |
|
||||
| Токен бота | `id2311381465_bot` (хранится в bot_settings) |
|
||||
|
||||
**Структура:**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,541 @@
|
||||
# Yeastar S20 — Интеграция с AegisOne
|
||||
|
||||
> Дата последнего обновления: 03.06.2026
|
||||
|
||||
---
|
||||
|
||||
## 1. Подключение к АТС
|
||||
|
||||
### Сетевая схема
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌──────────────────────────┐
|
||||
│ Сервер AegisOne │ │ Локальная сеть (офис) │
|
||||
│ 81.177.141.34 │ │ 192.168.1.0/24 │
|
||||
│ │ │ │
|
||||
│ ┌───────────────┐ │ VPN/ │ ┌──────────────────┐ │
|
||||
│ │ py_service │──┼──port───┼──│ Yeastar S20 │ │
|
||||
│ │ max_bot │ │ forward │ │ 192.168.1.150 │ │
|
||||
│ └───────────────┘ │ │ │ │ │
|
||||
│ │ │ │ WAN: │ │
|
||||
│ │ │ │ 185.105.171.187 │ │
|
||||
└─────────────────────┘ │ └──────────────────┘ │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
### IP-адреса и порты
|
||||
|
||||
| Ресурс | Локальный IP | Белый IP | Порт (локал) | Порт (проброс) | Протокол |
|
||||
|--------|-------------|----------|-------------|----------------|----------|
|
||||
| **АТС (Web UI)** | 192.168.1.150 | 185.105.171.187 | 8088 | 45088 | HTTPS |
|
||||
| **AMI** | 192.168.1.150 | 185.105.171.187 | 5038 | 45038 | TCP |
|
||||
| **MySQL (CDR)** | 192.168.1.150 | 185.105.171.187 | 3306 | 43306 | TCP |
|
||||
| **FTP** | 192.168.1.150 | 185.105.171.187 | 21 | 45021 | TCP |
|
||||
| **SSH** | 192.168.1.150 | 185.105.171.187 | 8022 | — | TCP |
|
||||
|
||||
### Проброс портов (на роутере/ файрволе офиса)
|
||||
|
||||
| Направление | Протокол | Внешний порт → Внутренний IP:порт |
|
||||
|-------------|----------|----------------------------------|
|
||||
| Internet → АТС | TCP | 45038 → 192.168.1.150:5038 (AMI) |
|
||||
| Internet → АТС | TCP | 45021 → 192.168.1.150:21 (FTP) |
|
||||
| Internet → АТС | TCP | 43306 → 192.168.1.150:3306 (MySQL) |
|
||||
| Internet → АТС | TCP | 45088 → 192.168.1.150:8088 (Web UI) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Учётные данные и параметры устройства
|
||||
|
||||
### Параметры устройства (актуально на 03.06.2026)
|
||||
|
||||
| Параметр | Значение | Источник |
|
||||
|----------|----------|----------|
|
||||
| Модель | Yeastar S20 | FTP `/support/tmp/deviceinfo.txt` |
|
||||
| Серийный номер | `3691C1453100` | FTP `/support/tmp/deviceinfo.txt` |
|
||||
| Прошивка (software) | `30.15.0.206` | FTP `/support/tmp/deviceinfo.txt` |
|
||||
| Железо (hardware) | V1.30 0005-0000 | FTP `/support/tmp/deviceinfo.txt` |
|
||||
| OEM | Yeastar S20 | FTP `/support/tmp/deviceinfo.txt` |
|
||||
| Asterisk | 13.7.0 | AMI `CoreSettings` |
|
||||
| AMI версия | 2.8.0 | AMI `CoreSettings` |
|
||||
| MySQL | 5.1.61 | MySQL greeting |
|
||||
| PBX Center | 1.24.2 | Web UI App Center |
|
||||
| SSH-сервер | dropbear 2019.78 | SSH banner (порт 8022) |
|
||||
| FTP-сервер | vsFTPd 3.0.2 | FTP banner (порт 21) |
|
||||
| CDR | Включён | AMI `CoreSettings` |
|
||||
| Max звонков | 10 | AMI `CoreSettings` |
|
||||
| REST API | Не поддерживается на S20 | Документация Yeastar |
|
||||
|
||||
### Web UI (админ-панель АТС)
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| URL | `https://192.168.1.150:8088` (локально) или `https://185.105.171.187:45088` (интернет) |
|
||||
| Логин | `admin` |
|
||||
| Пароль | `.-SHGWa_12` |
|
||||
|
||||
### AMI (Asterisk Manager Interface)
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Хост | `192.168.1.150` (локально) или `81.177.141.34` (из сервера, через проброс) |
|
||||
| Порт | `5038` (проброс: `45038`) |
|
||||
| Логин | `1cuser` |
|
||||
| Пароль | `1csecret` |
|
||||
| Привилегии | Ограниченные (нет `Command`, `Originate`) |
|
||||
| Разрешённые IP | `81.177.141.34/255.255.255.255` + `192.168.1.0/255.255.255.0` |
|
||||
|
||||
### MySQL (доступ к CDR)
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Хост | `192.168.1.150` (локально) или через проброс `43306` |
|
||||
| Порт | `3306` (проброс: `43306`) |
|
||||
| Логин | `1cuser` |
|
||||
| Пароль | `1csecret` |
|
||||
| **ВАЖНО** | `charset='utf8'` (MySQL 5.1.61 не поддерживает `utf8mb4`!) |
|
||||
| БД | `cdr` |
|
||||
|
||||
### FTP (записи звонков)
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Порт | `21` (проброс: `45021`) |
|
||||
| Логин | `support` |
|
||||
| Пароль | `T4oQSU_?` |
|
||||
| Статус | ✅ **Работает!** |
|
||||
| Доступ | Корневая ФС (чroot), `/tmp` недоступен |
|
||||
|
||||
Доступные каталоги через FTP:
|
||||
- `/sounds/record/` — звуки (пусто)
|
||||
- `/storage_share/mmc1` — симлинк → `/tmp/media/mmc1` (нерезолвящийся через FTP)
|
||||
- `/support/tmp/deviceinfo.txt` — **информация об устройстве**
|
||||
- `/support/` — системные файлы, bin
|
||||
- `/gui_backups/` — бэкапы
|
||||
- `/fax/`, `/www/`, `/var/`, `/etc/`, `/boot/`, `/cache/` — системные каталоги
|
||||
|
||||
**Ограничение**: записи звонков в `/tmp/media/mmc1/autorecords/` **недоступны** — `/tmp` за пределами FTP-чroot. Для доступа к записям нужен SSH.
|
||||
|
||||
### SSH (файловая система)
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Порт | `8022` |
|
||||
| SSH-сервер | dropbear 2019.78 |
|
||||
| Пароль | `T4oQSU_?` |
|
||||
| Логин | **НЕ ИЗВЕСТЕН** |
|
||||
| Статус | ✅ Порт открыт, но логин не найден |
|
||||
| **Рекомендация** | Не использовать SSH — только MySQL + AMI |
|
||||
|
||||
### Хранилище данных (в Web UI)
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Имя файла | `share` |
|
||||
| Логин | `share` |
|
||||
| Пароль | `9tktYfM2` |
|
||||
| Назначение | Сетевая папка (симлинк `mmc1 -> /tmp/media/mmc1` в `/storage_share/`) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Где найти информацию об АТС
|
||||
|
||||
### Серийный номер, модель, прошивка
|
||||
|
||||
| Источник | Способ |
|
||||
|----------|--------|
|
||||
| **FTP** `/support/tmp/deviceinfo.txt` | Содержит: hardware, software, sn, product, oem |
|
||||
| **Web UI** `Dashboard` или `Settings > System > General > Preferences` | Отображается на главной странице |
|
||||
| **Web UI** `Settings > System > General > About` | Версия прошивки и модель |
|
||||
|
||||
Содержимое `deviceinfo.txt`:
|
||||
```
|
||||
hardware: V1.30 0005-0000
|
||||
software: 30.15.0.206
|
||||
sn: 3691C1453100
|
||||
product: Yeastar S20
|
||||
oem: Yeastar S20
|
||||
```
|
||||
|
||||
### Версия Asterisk и компонентов
|
||||
|
||||
| Источник | Способ |
|
||||
|----------|--------|
|
||||
| **AMI** команда `CoreSettings` | AsteriskVersion, AMIversion, CoreCDRenabled |
|
||||
| **AMI** команда `ListCommands` | Список доступных AMI-команд с привилегиями |
|
||||
| **Web UI** `Settings > System > General > About` | Версия PBX Center, прошивки |
|
||||
|
||||
### Конфигурация (абоненты, очереди, транки)
|
||||
|
||||
| Источник | Что даёт |
|
||||
|----------|----------|
|
||||
| **AMI** `QueueStatus` | Очереди, участники, стратегии |
|
||||
| **AMI** `VoicemailUsersList` | Абоненты, голосовая почта |
|
||||
| **AMI** `PJSIPQualify` | Статус SIP-абонента |
|
||||
| **MySQL** БД `cdr` | История звонков, абоненты (из CDR) |
|
||||
| **Web UI** `Extensions` | Список расширений |
|
||||
| **Web UI** `Trunks` | SIP-транки |
|
||||
| **Web UI** `Call Queue` | Очереди |
|
||||
|
||||
### Записи звонков
|
||||
|
||||
| Источник | Способ |
|
||||
|----------|--------|
|
||||
| **MySQL** колонки `recordpath`, `monitorfile` | Путь и имя WAV-файла на PBX |
|
||||
| **Web UI** `CDR and Recordings` | Ручное скачивание через браузер |
|
||||
| **FTP** `/storage_share/mmc1` | Симлинк на `/tmp/media/mmc1` (нерезолвится через FTP) |
|
||||
| **SSH** порт 8022 | Прямой доступ к файловой системе (логин неизвестен) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Абоненты и очереди
|
||||
|
||||
### Внутренние номера (расширения)
|
||||
|
||||
| Номер | Назначение |
|
||||
|-------|-----------|
|
||||
| 1001 | Основной (Инженер) |
|
||||
| 1002 | Второй (Инженер) |
|
||||
| 1003 | Дополнительный |
|
||||
| 1000 | Тестовый |
|
||||
|
||||
### Очереди (Queue)
|
||||
|
||||
| Номер очереди | Стратегия | Расширения |
|
||||
|---------------|-----------|------------|
|
||||
| 6700 | ringall | 1001, 1002 |
|
||||
| 6701 | rrmemory | 1001 |
|
||||
| 6702 | ringall | 1002 |
|
||||
|
||||
### Транки (линии)
|
||||
|
||||
| Транк | Тип | Назначение |
|
||||
|-------|-----|-----------|
|
||||
| Mango | VoIP-провайдер | Исходящие/входящие через SIP |
|
||||
| Tele2 | VoIP-провайдер | Исходящие/входящие через SIP |
|
||||
| YSGSM | GSM-модуль | Исходящие через GSM-SIM |
|
||||
|
||||
---
|
||||
|
||||
## 5. База данных CDR
|
||||
|
||||
### Структура
|
||||
|
||||
БД: `cdr`, таблицы: `cdr` (текущая) + `cdr_YYYYMM` (архивные).
|
||||
|
||||
### Колонки таблицы `cdr`
|
||||
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | int | ID записи |
|
||||
| `datetime` | datetime | Дата и время звонка |
|
||||
| `clid` | varchar | Caller ID (кто звонит) |
|
||||
| `src` | varchar | Номер отправителя |
|
||||
| `dst` | varchar | Номер получателя |
|
||||
| `dcontext` | varchar | Контекст диалплана |
|
||||
| `srctrunk` | varchar | Транк отправителя |
|
||||
| `dstrunk` | varchar | Транк получателя |
|
||||
| `lastapp` | varchar | Последнее приложение (Dial, Queue, BackGround...) |
|
||||
| `lastdata` | varchar | Данные последнего приложения (номер, очередь) |
|
||||
| `duration` | int | Общая длительность (секунды) |
|
||||
| `billable` | int | Биллируемая длительность (секунды) |
|
||||
| `disposition` | varchar | Статус: ANSWERED, NO ANSWER, BUSY, VOICEMAIL, FAILED |
|
||||
| `calltype` | varchar | Тип: Inbound, Outbound, Internal |
|
||||
| `accountcode` | varchar | Код аккаунта |
|
||||
| `uniqueid` | varchar | Уникальный ID звонка |
|
||||
| `recordfile` | varchar | Имя файла записи (WAV) |
|
||||
| `recordpath` | varchar | Путь к записи на PBX |
|
||||
| `monitorfile` | varchar | Имя файла мониторинга |
|
||||
| `monitorpath` | varchar | Путь к мониторингу на PBX |
|
||||
| `extfield1-5` | varchar | Дополнительные поля |
|
||||
| `didnumber` | varchar | DID-номер (входящий номер) |
|
||||
| `srcchanurl` | varchar | SIP URL канала отправителя |
|
||||
| `dstchanurl` | varchar | SIP URL канала получателя |
|
||||
| `companycontact` | varchar | Имя контакта компании |
|
||||
| `personalcontact` | varchar | Имя личного контакта |
|
||||
| `contactnumber` | varchar | Контактный номер |
|
||||
|
||||
### Примеры запросов
|
||||
|
||||
```sql
|
||||
-- Все звонки за сегодня
|
||||
SELECT datetime, src, dst, duration, billable, disposition, calltype
|
||||
FROM cdr.cdr
|
||||
WHERE DATE(datetime) = CURDATE()
|
||||
ORDER BY datetime DESC;
|
||||
|
||||
-- Звонки за конкретный месяц
|
||||
SELECT * FROM cdr.cdr_202606 ORDER BY datetime DESC;
|
||||
|
||||
-- Входящие звонки на очередь 6700
|
||||
SELECT datetime, src, dst, duration, disposition
|
||||
FROM cdr.cdr
|
||||
WHERE dst LIKE '6700%' AND calltype = 'Inbound'
|
||||
ORDER BY datetime DESC;
|
||||
|
||||
-- Звонки с записями
|
||||
SELECT datetime, src, dst, duration, recordfile, recordpath
|
||||
FROM cdr.cdr
|
||||
WHERE recordfile != ''
|
||||
ORDER BY datetime DESC;
|
||||
|
||||
-- Статистика по дням
|
||||
SELECT DATE(datetime) as day, COUNT(*) as calls,
|
||||
SUM(CASE WHEN disposition='ANSWERED' THEN 1 ELSE 0 END) as answered
|
||||
FROM cdr.cdr
|
||||
WHERE datetime >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(datetime)
|
||||
ORDER BY day DESC;
|
||||
```
|
||||
|
||||
### Таблица `queue_log`
|
||||
|
||||
Содержит логи событий очередей (поступление, ответ, ожидание, сброс).
|
||||
|
||||
---
|
||||
|
||||
## 6. Записи звонков
|
||||
|
||||
### Где хранятся
|
||||
|
||||
На файловой системе PBX: `/tmp/media/mmc1/autorecords/YYYYMM/`
|
||||
|
||||
Формат имени файла:
|
||||
```
|
||||
YYYYMMDDHHMMSS-UNIQUEID-SRC-DST-Direction.wav
|
||||
```
|
||||
|
||||
Примеры из CDR:
|
||||
```
|
||||
20260110164821-1768052891.15-1001-+79531116788-Outbound.wav
|
||||
20260402105623-1775116571.13-1001-89531116788-Outbound.wav
|
||||
20260512162645-1778592395.0-+79898250183-1001-Inbound.wav
|
||||
```
|
||||
|
||||
### Как получить
|
||||
|
||||
1. **Путь в CDR**: колонки `recordpath`, `monitorpath` (путь на PBX)
|
||||
2. **FTP** (`support`/`T4oQSU_?`): записи **недоступны** — `/tmp` за пределами FTP-чroot
|
||||
3. **SSH** (порт 8022, пароль `T4oQSU_?`, логин неизвестен): теоретически доступен, но логин не найден
|
||||
4. **Web UI**: `CDR and Recordings` — ручное скачивание через браузер
|
||||
|
||||
---
|
||||
|
||||
## 7. AMI — Доступные команды
|
||||
|
||||
### Работает с 1cuser/1csecret
|
||||
|
||||
| Команда | Описание | Статус |
|
||||
|---------|----------|--------|
|
||||
| `Ping` | Проверка соединения | ✅ |
|
||||
| `CoreStatus` | Статус ядра (uptime, кол-во звонков) | ✅ |
|
||||
| `CoreShowChannels` | Список активных каналов | ✅ |
|
||||
| `CoreSettings` | Настройки ядра (Asterisk, CDR) | ✅ |
|
||||
| `QueueStatus` | Статус очередей + участников | ✅ |
|
||||
| `VoicemailUsersList` | Список абонентов голосовой почты | ✅ |
|
||||
| `PJSIPQualify` | Пинг SIP-абонента | ✅ |
|
||||
| `Status` | Статус всех каналов | ✅ |
|
||||
| `ListCommands` | Список доступных команд | ✅ |
|
||||
|
||||
### Не работает (Permission denied)
|
||||
|
||||
| Команда | Описание |
|
||||
|---------|----------|
|
||||
| `Command` | Выполнение CLI-команд (`cdr show` и т.д.) |
|
||||
| `Originate` | Исходящий звонок |
|
||||
| `ShowDialPlan` | Просмотр диалплана |
|
||||
| `Hangup` | Завершение звонка |
|
||||
| `Redirect` | Переадресация звонка |
|
||||
|
||||
### Real-time события
|
||||
|
||||
При включённых `Events: On` AMI автоматически отправляет:
|
||||
- `Event: Cdr` — при завершении звонка (содержит CDR-данные)
|
||||
- `Event: Newchannel` — при начале звонка
|
||||
- `Event: Dial` — при наборе номера
|
||||
- `Event: Hangup` — при завершении
|
||||
|
||||
---
|
||||
|
||||
## 8. Пример подключения (Python)
|
||||
|
||||
### MySQL — запрос CDR
|
||||
|
||||
```python
|
||||
import pymysql
|
||||
|
||||
conn = pymysql.connect(
|
||||
host="192.168.1.150", # или внешний IP через проброс
|
||||
port=3306, # или 43306 через проброс
|
||||
user="1cuser",
|
||||
password="1csecret",
|
||||
database="cdr",
|
||||
charset="utf8", # ВАЖНО: не utf8mb4!
|
||||
connect_timeout=5
|
||||
)
|
||||
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM cdr ORDER BY datetime DESC LIMIT 10")
|
||||
for row in cursor.fetchall():
|
||||
print(row)
|
||||
conn.close()
|
||||
```
|
||||
|
||||
### AMI — real-time события
|
||||
|
||||
```python
|
||||
import socket
|
||||
import time
|
||||
|
||||
def ami_connect(host, port, username, password):
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.connect((host, port))
|
||||
# Читаем приветствие
|
||||
greeting = sock.recv(4096).decode()
|
||||
# Логин
|
||||
login_msg = (
|
||||
f"Action: Login\r\n"
|
||||
f"Username: {username}\r\n"
|
||||
f"Secret: {password}\r\n"
|
||||
f"Events: On\r\n"
|
||||
f"\r\n"
|
||||
)
|
||||
sock.send(login_msg.encode())
|
||||
time.sleep(1)
|
||||
response = sock.recv(16384).decode()
|
||||
return sock
|
||||
|
||||
# Использование
|
||||
sock = ami_connect("192.168.1.150", 5038, "1cuser", "1csecret")
|
||||
# Слушаем события
|
||||
while True:
|
||||
data = sock.recv(16384).decode()
|
||||
if data:
|
||||
print(data)
|
||||
```
|
||||
|
||||
### AMI — из сервера (через проброс)
|
||||
|
||||
```python
|
||||
# Подключение через проброс портов с сервера
|
||||
sock = ami_connect("81.177.141.34", 45038, "1cuser", "1csecret")
|
||||
# Или напрямую (если сервер в локальной сети)
|
||||
sock = ami_connect("192.168.1.150", 5038, "1cuser", "1csecret")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Схема интеграции с AegisOne
|
||||
|
||||
```
|
||||
┌──────────────────┐ MySQL:3306 ┌──────────────────┐
|
||||
│ py_service │◄────────────────────│ Yeastar S20 │
|
||||
│ (CDR polling) │ │ (cdr БД) │
|
||||
│ │ AMI:5038 │ │
|
||||
│ (real-time) │◄────────────────────│ (события) │
|
||||
│ │ │ │
|
||||
│ ┌──────────────┐ │ │ ┌──────────────┐ │
|
||||
│ │ /service/ │ │ │ │ Записи WAV │ │
|
||||
│ │ phone/ │ │ FTP:21 │ │ /tmp/media/ │ │
|
||||
│ │ history │ │◄──(когда будет)────│ │ │ │
|
||||
│ │ playback │ │ │ └──────────────┘ │
|
||||
│ └──────────────┘ │ └──────────────────┘
|
||||
└──────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ max_bot │
|
||||
│ (уведомления о │
|
||||
│ пропущенных) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. План интеграции (TODO)
|
||||
|
||||
### Фаза 1: CDR (готово к реализации)
|
||||
- [ ] Модель БД для хранения звонков
|
||||
- [ ] Периодический опрос MySQL (каждые 5 мин)
|
||||
- [ ] Страница истории звонков в сервисном портале
|
||||
- [ ] Фильтры: по дате, номеру, типу, статусу
|
||||
|
||||
### Фаза 2: Real-time
|
||||
- [ ] AMI-клиент для получения событий
|
||||
- [ ] Уведомления о пропущенных звонках (в max_bot)
|
||||
- [ ] Статус абонентов (online/offline)
|
||||
- [ ] Мониторинг очередей
|
||||
|
||||
### Фаза 3: Записи
|
||||
- [x] FTP логин найден: `support` / `T4oQSU_?`
|
||||
- [ ] FTP-чroot не даёт доступ к `/tmp/media/mmc1/autorecords/` — нужен SSH или другой способ
|
||||
- [ ] Разобраться с SSH-логином (пароль `T4oQSU_?` известен)
|
||||
- [ ] Скачивание записей
|
||||
- [ ] Плеер для прослушивания в портале
|
||||
|
||||
### Фаза 4: Управление
|
||||
- [ ] Исходящие звонки из портала (если AMI получит права)
|
||||
- [ ] Переключение звонков
|
||||
- [ ] Статистика и отчёты
|
||||
|
||||
---
|
||||
|
||||
## 11. Известные проблемы
|
||||
|
||||
1. **Записи звонков недоступны через FTP** — логин `support` работает, но `/tmp/media/mmc1/autorecords/` за пределами FTP-чroot. Нужен SSH (порт 8022, пароль `T4oQSU_?`, логин неизвестен)
|
||||
2. **AMI ограниченные права** — на S20 нельзя расширить привилегии AMI-пользователя через Web UI. Доступны только readonly-команды
|
||||
3. **REST API не поддерживается** — только для S50/S100/S300
|
||||
4. **MySQL 5.1.61** — старая версия, не поддерживает `utf8mb4`. Использовать `charset='utf8'`
|
||||
5. **Кириллица в CDR** — имена контактов отображаются в кодировке CP1251/garbage (проблема кодировки MySQL-клиента)
|
||||
|
||||
---
|
||||
|
||||
## 12. Ссылки
|
||||
|
||||
- [Yeastar S-Series AMI Documentation](https://help.yeastar.com/en/s-series/topic/asterisk-manager-interface-ami.html)
|
||||
- [Yeastar Developer Guide](https://support.yeastar.com/hc/en-us/articles/235972668)
|
||||
- [Asterisk AMI Protocol](https://docs.asterisk.org/languages/en/Asterisk_18_Documentation/DAHDI_DAHDI_Hardware_Digium_Interface_Hardware/Administering/Asterisk_Manager_Interface_AMI/)
|
||||
|
||||
---
|
||||
|
||||
## 13. FTP — Структура каталогов
|
||||
|
||||
```
|
||||
/ (корень FTP, доступен как support)
|
||||
├── boot/
|
||||
├── cache/
|
||||
├── etc/
|
||||
├── fax/
|
||||
├── ftp_media/ (пусто)
|
||||
├── gui_backups/
|
||||
├── imageupdate/
|
||||
├── lost+found/
|
||||
├── rcstop/
|
||||
├── sounds/
|
||||
│ ├── moh/ (музыка на удержании)
|
||||
│ └── record/ (пусто)
|
||||
├── storage_share/
|
||||
│ └── mmc1 → /tmp/media/mmc1 (симлинк, нерезолвящийся)
|
||||
├── support/
|
||||
│ ├── tmp/
|
||||
│ │ ├── deviceinfo.txt ← ИНФОРМАЦИЯ ОБ УСТРОЙСТВЕ
|
||||
│ │ └── testsysmailbox.eml
|
||||
│ ├── autoptemplate/
|
||||
│ ├── bin/
|
||||
│ ├── customcfg/
|
||||
│ └── fxotune/
|
||||
├── syslog/
|
||||
├── syslog_backup/
|
||||
├── tftpboot/
|
||||
├── var/
|
||||
├── webupload/
|
||||
├── www/
|
||||
└── ysapps/
|
||||
```
|
||||
|
||||
### Ключевые файлы
|
||||
|
||||
| Путь | Содержимое |
|
||||
|------|-----------|
|
||||
| `/support/tmp/deviceinfo.txt` | Серийный номер, модель, прошивка |
|
||||
| `/support/tmp/testsysmailbox.eml` | Тестовый шаблон почты |
|
||||
| `/storage_share/mmc1` | Симлинк на записанные файлы (недоступен через FTP) |
|
||||
@@ -0,0 +1,267 @@
|
||||
Если смотреть не со стороны маркетинга, а со стороны внутренней сервисной системы, то воронка продаж превращается в процесс обработки лида внутри портала.
|
||||
|
||||
В этом случае телефон, бот, сайт, почта — это просто каналы поступления обращений в единую систему.
|
||||
**1. Общая архитектура**
|
||||
```
|
||||
Клиент
|
||||
├── Телефон
|
||||
├── Telegram-бот
|
||||
├── Сайт
|
||||
├── Email
|
||||
└── WhatsApp
|
||||
↓
|
||||
Сервисный портал
|
||||
↓
|
||||
Лид (Lead)
|
||||
↓
|
||||
Квалификация
|
||||
↓
|
||||
Сделка
|
||||
↓
|
||||
Проект
|
||||
↓
|
||||
Выполнение
|
||||
↓
|
||||
Закрытие
|
||||
```
|
||||
|
||||
**2. Роли в системе**
|
||||
|
||||
--- Руководитель
|
||||
Видит:
|
||||
новые лиды;
|
||||
активные сделки;
|
||||
загрузку инженеров;
|
||||
прибыль;
|
||||
SLA;
|
||||
показатели конверсии.
|
||||
|
||||
Его задача:
|
||||
назначать ответственных;
|
||||
контролировать сроки;
|
||||
отслеживать KPI.
|
||||
|
||||
--- Инженер
|
||||
Работает с технической частью.
|
||||
Видит:
|
||||
назначенные заявки;
|
||||
технические задания;
|
||||
историю клиента;
|
||||
комментарии.
|
||||
|
||||
Может:
|
||||
создавать этапы работ;
|
||||
запрашивать данные;
|
||||
прикладывать документы;
|
||||
переводить задачи между статусами.
|
||||
|
||||
--- Техник
|
||||
Исполнитель.
|
||||
Видит только:
|
||||
свои задачи;
|
||||
инструкции;
|
||||
сроки;
|
||||
вложения.
|
||||
|
||||
Может:
|
||||
отмечать выполнение;
|
||||
прикладывать фото;
|
||||
добавлять комментарии.
|
||||
|
||||
**3. Как выглядит жизненный цикл обращения**
|
||||
|
||||
Шаг 1. Обращение через бота
|
||||
|
||||
Клиент пишет в бот
|
||||
|
||||
Бот собирает контактные данные и создается заявка (необходимо продумать как собрать название организации от клиента)
|
||||
На основе этой заявки необходимо (тот кто ее обрабатывает) должен создать нового Клиента (Управление - Клиенты) или Добавить к существующему клиенту. Если есть точно совпадающие даннуе, то заявка должна автоматически привязываться к клиенту. Важно - Назавание организации может быть одинаковым у разных клиентов.
|
||||
|
||||
Шаг 2. Создание лида
|
||||
|
||||
В портале появляется:
|
||||
```
|
||||
Клиент №ххх
|
||||
Источник: Max Bot
|
||||
Компания: ООО Ромашка
|
||||
Контакт: Иван Петров
|
||||
Телефон: +79991234567
|
||||
Услуга: Пример: Аудит безопасности
|
||||
Статус: Новый
|
||||
```
|
||||
|
||||
Шаг 3. Как подвязать телефонные звонки
|
||||
Это одна из самых полезных функций.
|
||||
Вариант 1. IP-телефония (лучший вариант)
|
||||
Подключаются:
|
||||
Asterisk (Локальная АТС)
|
||||
FreePBX
|
||||
MikoPBX
|
||||
Zadarma
|
||||
Mango Office (Доступ к webui)
|
||||
Novofon
|
||||
|
||||
Каждый звонок автоматически попадает в портал.
|
||||
--- Входящий звонок
|
||||
Телефон звонит.
|
||||
Портал получает событие:
|
||||
```
|
||||
{
|
||||
"phone":"+79991234567",
|
||||
"type":"incoming"
|
||||
}
|
||||
```
|
||||
Портал ищет клиента.
|
||||
Если клиент найден:
|
||||
```
|
||||
Входящий звонок
|
||||
ООО Ромашка
|
||||
Последняя заявка: Аудит безопасности
|
||||
Ответственный: Иванов
|
||||
```
|
||||
Карточка открывается автоматически.
|
||||
|
||||
--- Если номер неизвестен
|
||||
Создается временный клиент:
|
||||
```
|
||||
Клиент №ххх
|
||||
Источник: Телефон
|
||||
Телефон: +79991234567
|
||||
Статус: Новый
|
||||
```
|
||||
После разговора менеджер заполняет остальные поля и привязывает его к существующему или создает нового клиента.
|
||||
|
||||
--- Автоматическая запись звонков
|
||||
В карточке хранится:
|
||||
```
|
||||
Звонок #1
|
||||
Дата: 03.06.2026
|
||||
Длительность: 12:34
|
||||
Запись: play.wav
|
||||
```
|
||||
Руководитель может прослушать разговор.
|
||||
|
||||
--- Автоматическая расшифровка
|
||||
После разговора:
|
||||
```
|
||||
Whisper
|
||||
Yandex SpeechKit
|
||||
Google Speech-to-Text
|
||||
YandexGPT
|
||||
```
|
||||
создают текст.
|
||||
|
||||
Карточка получает:
|
||||
``` Пример:
|
||||
Краткое содержание:
|
||||
Клиент интересуется аудитом.
|
||||
Планирует внедрение в июле.
|
||||
Бюджет около 500 тыс.
|
||||
```
|
||||
|
||||
Шаг 4. Автоматическая постановка задач
|
||||
После завершения звонка система может создавать задачи основываясь на расшифровке разговара и запрашивает подтверждение у сотрудника.
|
||||
Пример:
|
||||
Менеджер выбрал:
|
||||
```
|
||||
Подготовить КП
|
||||
```
|
||||
Портал автоматически создает задачу инженеру.
|
||||
```
|
||||
Задача #ууу
|
||||
Подготовить коммерческое предложение
|
||||
Исполнитель: Инженер Петров
|
||||
Срок: Завтра 12:00
|
||||
```
|
||||
|
||||
--- Рекомендуемая структура статусов
|
||||
Для лидов:
|
||||
```
|
||||
Новый
|
||||
Связаться
|
||||
Квалификация
|
||||
Коммерческое предложение
|
||||
Переговоры
|
||||
Согласование
|
||||
Выигран
|
||||
Проигран
|
||||
```
|
||||
(Добавить возможность создавать и удалять дополнительно свои)
|
||||
|
||||
Для проектов:
|
||||
```
|
||||
Подготовка
|
||||
В работе
|
||||
Ожидание клиента
|
||||
Тестирование
|
||||
Завершено
|
||||
Закрыто
|
||||
```
|
||||
(Добавить возможность создавать и удалять дополнительно свои)
|
||||
|
||||
**4. Что особенно полезно реализовать **
|
||||
|
||||
Единая карточка клиента
|
||||
Внутри:
|
||||
```
|
||||
Контакты
|
||||
Компания
|
||||
История звонков
|
||||
История чатов
|
||||
Файлы
|
||||
Проекты
|
||||
Акты
|
||||
Счета
|
||||
Договоры
|
||||
Задачи
|
||||
Заявки / Тикеты
|
||||
Комментарии
|
||||
```
|
||||
Заявкам добавить Критичность (сейчас приоритет)
|
||||
```
|
||||
Критичная
|
||||
Высокая
|
||||
Средняя
|
||||
Низкая
|
||||
```
|
||||
Никто не ищет информацию по разным системам!
|
||||
|
||||
**5. Оптимальная схема для собственной разработки **
|
||||
```
|
||||
Telegram Bot
|
||||
│
|
||||
├─────────┐
|
||||
│ │
|
||||
Телефония Сайт
|
||||
│ │
|
||||
└────┬────┘
|
||||
│
|
||||
Lead Service
|
||||
│
|
||||
CRM Module
|
||||
│
|
||||
Workflow Engine
|
||||
│
|
||||
Engineer Portal
|
||||
│
|
||||
Client Projects
|
||||
```
|
||||
Если ваша компания оказывает сервисные услуги (ИБ, IT-аутсорсинг, инженерные работы, обслуживание оборудования и т.д.), то я бы рекомендовал строить систему не как классическую CRM, а как Service Desk + CRM + Телефония в одном портале. Тогда звонок, сообщение из бота, заявка с сайта и дальнейшие технические работы будут проходить через одну карточку клиента и один жизненный цикл обращения. Это существенно упрощает контроль для руководителя и ускоряет работу инженеров и техников.
|
||||
|
||||
**6. Что должен видеть руководитель на главной странице**
|
||||
Блок KPI
|
||||
```
|
||||
Новые лиды: 12
|
||||
Сегодня обработано: 9
|
||||
Конверсия: 34%
|
||||
Средний чек: 280 000 ₽
|
||||
Активные проекты: 18
|
||||
```
|
||||
Воронка
|
||||
```
|
||||
Новые лиды 100
|
||||
Квалификация 65
|
||||
КП отправлено 40
|
||||
Переговоры 22
|
||||
Сделка 11
|
||||
```
|
||||
@@ -1,5 +1,73 @@
|
||||
# Changelog — AegisOne Service Portal
|
||||
|
||||
## 1.9.0 (04.06.2026)
|
||||
### Новые функции
|
||||
- **Phase 0 — Инфраструктура CRM/лидов:**
|
||||
- Новые таблицы: `leads` (20 полей), `call_records` (20 полей CDR), `client_notifications` (10 полей), `pbx_settings` (27 полей)
|
||||
- Расширены модели: `User` (extension — внутренний номер PBX), `Customer` (lead_source, last_call_at, total_calls, total_leads, merged_into, статусы draft/merged), `Task` (lead_id — FK → leads), `AuditLog` (entity_type, entity_id, field, old_value, new_value)
|
||||
- Миграции БД: система `migrations_runner.py` с авто-применением при старте, отслеживание в `_applied_migrations`
|
||||
- SQL-миграция `001_phase0_infrastructure.sql`: 31 операция (CREATE TABLE → ALTER TABLE в правильном порядке)
|
||||
- Шифрование паролей PBX через `cryptography.fernet` (PBXSettings)
|
||||
|
||||
### Исправления
|
||||
- **documents_router.py:** доступ «Владелец/Инженер» для tech-access — `!= "engineer"` → `not in ("engineer", "owner")`
|
||||
|
||||
### Документация
|
||||
- **AGENTS.md:** раздел 0.4 — обязательные файлы для чтения; раздел 1 — ссылка на архитектуру сервера
|
||||
- **SERVICE_STYLE_GUIDE.md:** единый гайд UI
|
||||
- **other/Yeastar_S20.md:** полная документация по PBX (AMI, MySQL CDR, FTP, SSH)
|
||||
- **other/LEAD_IMPLEMENTATION_PLAN.md:** 8-фазный план (0-7) реализации CRM/лидов
|
||||
- **other/1C_UNF_INTEGRATION.md:** документация по интеграции с 1С:УНФ 3.0 через OData API
|
||||
- **other/voronka.md:** документ жизненного цикла лидов
|
||||
|
||||
## 1.8.6 (02.06.2026)
|
||||
### Новые функции
|
||||
- **sort-table.js:** универсальный модуль сортировки таблиц по столбцам (текст/число/дата), подключён глобально
|
||||
- **Number input:** спиннеры скрыты через CSS (`appearance: textfield`) на всех страницах
|
||||
|
||||
### Исправления
|
||||
- **customers.html:** восстановлена сломанная HTML-структура — `.card` > `.card-header`, поиск в `.filter-bar`
|
||||
- **bot_dialogues.html:** обёрнут в `.card` > `.card-header`, фильтры в `.filter-bar`, удалены кастомные `.page-header`/`.header-controls`
|
||||
- **checklist.html:** `<h3>` обёрнут в `.card-header`
|
||||
- **18 страниц:** `modal-actions` → `form-actions` (единый класс для кнопок в модалках)
|
||||
- **charts.html:** `btn-xs` → `btn-sm`
|
||||
- **assignments.html:** добавлена модалка подтверждения отвязки сотрудника
|
||||
- **4 AJAX-страницы:** `sortTables()` вызывается после загрузки данных (bot_kb, bot_unknown, bot_consent, bot_tickets)
|
||||
|
||||
## 1.8.5 (02.06.2026)
|
||||
### Новые функции
|
||||
- **AGENTS.md (раздел 12):** правила по Идеям — агент может видеть, добавлять, менять статус, предлагать к реализации, реализовывать
|
||||
|
||||
### Исправления
|
||||
- **models.py:** `onupdate="NOW()"` → `onupdate=func.now()` — fix Internal Server Error при смене статуса Идеи (14 моделей)
|
||||
- **page.html:** удалён `main-header` (заголовок + дата) — больше не дублируется на всех страницах
|
||||
- **dashboard.html:** удалён `main-header`
|
||||
- **bot_dialogues.html:** удалён CSS `.main-header { display: none !important; }` (больше не нужен)
|
||||
- **version.txt / CHANGELOG.md:** синхронизация версии в контейнере aegisone-app
|
||||
|
||||
## 1.8.4 (02.06.2026)
|
||||
### Новые функции
|
||||
- **SERVICE_STYLE_GUIDE.md:** единый гайд стилей сервисного портала
|
||||
- **AGENTS.md (раздел 11):** правила стилей UI — см. SERVICE_STYLE_GUIDE.md
|
||||
- **CSS `.modal-actions`:** общий класс для кнопок в модалках
|
||||
|
||||
### Исправления
|
||||
- **bot_dialogues.html:** скрыт `main-header` (дублирующий заголовок с датой)
|
||||
- **bot_kb.html:** замена кастомных классов на `.modal-overlay`/`.modal`, `confirm()` → кастомная модалка, иконки ✎/✕
|
||||
- **bot_unknown.html:** `.modal-box` → `.modal`, `confirm()` → кастомная модалка, иконки ✎/✕
|
||||
- **bot_consent.html:** `confirm()` → кастомная модалка удаления пользователя
|
||||
- **bot_test.html:** `confirm()` → кастомная модалка очистки тестовых данных
|
||||
- **charts.html:** `prompt()` → кастомная модалка ввода имени графика, `confirm()` → кастомная модалка удаления
|
||||
- **role_settings.html:** `alert()` → `showNotification()`
|
||||
- **10 простых страниц:** blog, cases, coefficients, customers, formulas, ideas, objects, questionnaire_config, tasks, users — `confirm()` → кастомные модалки с уникальными ID
|
||||
- **migrations_runner.py:** split SQL на `;` для совместимости с asyncpg
|
||||
|
||||
## 1.8.3 (02.06.2026)
|
||||
### Новые функции
|
||||
- **Docstrings:** добавлены docstrings и комментарии по AGENTS.md ко всем ключевым Python-файлам
|
||||
- **File headers:** каждый файл имеет шапку с описанием назначения
|
||||
- **Function docstrings:** каждая функция имеет docstring на русском языке
|
||||
|
||||
## 1.8.2 (02.06.2026)
|
||||
### Новые функции
|
||||
- **AGENTS.md:** правила проекта для AI-агентов (корень репозитория)
|
||||
|
||||
@@ -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
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
@@ -153,8 +153,8 @@ async def tech_access_page(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
if user["role"] != "engineer":
|
||||
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
|
||||
if user["role"] not in ("engineer", "owner"):
|
||||
return HTMLResponse(content="Доступ только для инженеров и руководителей", status_code=403)
|
||||
|
||||
perms = discover_docs()
|
||||
editable_docs = [
|
||||
@@ -170,8 +170,8 @@ async def tech_access_update(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
if user["role"] != "engineer":
|
||||
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
|
||||
if user["role"] not in ("engineer", "owner"):
|
||||
return HTMLResponse(content="Доступ только для инженеров и руководителей", status_code=403)
|
||||
|
||||
form = await request.form()
|
||||
perms = discover_docs()
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
"""
|
||||
Главный модуль service portal FastAPI-приложения.
|
||||
|
||||
Портал для управления:
|
||||
- Сотрудниками (owner/engineer/technician)
|
||||
- Клиентами и объектами
|
||||
- Задачами и инцидентами
|
||||
- Настройками бота и базой знаний
|
||||
- Документами и отчётами
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -103,8 +113,20 @@ async def seed_initial_data():
|
||||
async def lifespan(app: FastAPI):
|
||||
from app.models.models import Base
|
||||
from app.database import engine
|
||||
|
||||
# Применяем миграции (ALTER TABLE, CREATE TABLE IF NOT EXISTS)
|
||||
from app.migrations_runner import run_migrations_on_startup
|
||||
migration_results = await run_migrations_on_startup()
|
||||
if migration_results:
|
||||
import logging
|
||||
logging.getLogger("migrations").info(
|
||||
"Применены миграции: %s", ", ".join(migration_results)
|
||||
)
|
||||
|
||||
# Создаём таблицы которые ещё не существуют (idempotent)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
await seed_initial_data()
|
||||
async with async_session() as db:
|
||||
from app.calculations.engine import reload_coefficients
|
||||
|
||||
@@ -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 sqlalchemy import (
|
||||
Column, Integer, String, Boolean, Text, DateTime, Numeric, Date, BigInteger, SmallInteger, JSON, ForeignKey, CheckConstraint
|
||||
Column, Integer, String, Boolean, Text, DateTime, Numeric, Date, BigInteger, SmallInteger, JSON, ForeignKey, CheckConstraint, func
|
||||
)
|
||||
from sqlalchemy.orm import relationship, declarative_base
|
||||
|
||||
@@ -18,9 +32,10 @@ class User(Base):
|
||||
phone = Column(String(50), default="")
|
||||
email = Column(String(255), default="")
|
||||
max_user_id = Column(BigInteger, nullable=True)
|
||||
extension = Column(String(10), default="") # Внутренний номер АТС (1001, 1002...)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("role IN ('owner','engineer','technician')", name="ck_users_role"),
|
||||
@@ -41,17 +56,33 @@ class LoginAttempt(Base):
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Лог действий пользователей (аудит).
|
||||
|
||||
Записывает все изменения в системе: кто, что, когда изменил.
|
||||
Используется для отслеживания дедупликации и ручных правок.
|
||||
"""
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||
action = Column(String(255), nullable=False)
|
||||
entity_type = Column(String(50), default="") # customer, lead, call_record, task
|
||||
entity_id = Column(Integer, default=0)
|
||||
field = Column(String(100), default="") # какое поле изменено
|
||||
old_value = Column(Text, default="") # старое значение
|
||||
new_value = Column(Text, default="") # новое значение
|
||||
details = Column(Text)
|
||||
ip_address = Column(String(45), default="")
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
"""Клиент (контрагент).
|
||||
|
||||
Хранит контактные данные, ИНН/КПП, статус.
|
||||
Статус 'draft' — автосоздан системой из лида/звонка, ожидает заполнения.
|
||||
Статус 'merged' — объединён с другим клиентом.
|
||||
"""
|
||||
__tablename__ = "customers"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
@@ -64,12 +95,20 @@ class Customer(Base):
|
||||
contact_email = Column(String(255), default="")
|
||||
status = Column(String(20), nullable=False, default="active")
|
||||
notes = Column(Text)
|
||||
lead_source = Column(String(20), default="") # Откуда пришёл клиент
|
||||
last_call_at = Column(DateTime) # Последний звонок
|
||||
total_calls = Column(Integer, default=0) # Количество звонков
|
||||
total_leads = Column(Integer, default=0) # Количество лидов
|
||||
merged_into = Column(Integer, nullable=True) # ID клиента, с которым слили
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("status IN ('active','inactive','prospect')", name="ck_customers_status"),
|
||||
CheckConstraint(
|
||||
"status IN ('active','inactive','prospect','draft','merged')",
|
||||
name="ck_customers_status"
|
||||
),
|
||||
)
|
||||
|
||||
objects = relationship("Object", back_populates="customer")
|
||||
@@ -99,7 +138,7 @@ class Object(Base):
|
||||
region_factor = Column(Numeric(3, 2), default=1.00)
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("status IN ('active','inactive','audit','prospective')", name="ck_objects_status"),
|
||||
@@ -134,12 +173,19 @@ class ObjectAssignment(Base):
|
||||
|
||||
|
||||
class Task(Base):
|
||||
"""Задача (привязана к объекту).
|
||||
|
||||
Owner/engineer создают и назначают на любого.
|
||||
Technician может создать только себе напоминание.
|
||||
lead_id — связь задачи с лидом (если задача создана из лида).
|
||||
"""
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
|
||||
assigned_to = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||
lead_id = Column(Integer, ForeignKey("leads.id", ondelete="SET NULL")) # Связь с лидом
|
||||
title = Column(String(500), nullable=False)
|
||||
description = Column(Text)
|
||||
priority = Column(String(4), nullable=False, default="P3")
|
||||
@@ -153,7 +199,7 @@ class Task(Base):
|
||||
result_notes = Column(Text)
|
||||
closed_at = Column(DateTime)
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("priority IN ('P1','P2','P3','P4')", name="ck_tasks_priority"),
|
||||
@@ -217,7 +263,7 @@ class Report(Base):
|
||||
status = Column(String(20), nullable=False, default="draft")
|
||||
client_confirmed = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("report_type IN ('inspection','service','emergency','audit','monthly')", name="ck_reports_type"),
|
||||
@@ -245,7 +291,7 @@ class QuestionnaireSession(Base):
|
||||
object_index = Column(Numeric(5, 2), default=0)
|
||||
sla_price_monthly = Column(Numeric(12, 2), default=0)
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("status IN ('draft','completed','cancelled')", name="ck_qsessions_status"),
|
||||
@@ -283,7 +329,7 @@ class QuestionnaireItem(Base):
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
help_text = Column(String(500), default="")
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("type IN ('text','number','select','radio','checkbox','textarea')", name="ck_qitems_type"),
|
||||
@@ -330,7 +376,7 @@ class SLAContract(Base):
|
||||
status = Column(String(20), nullable=False, default="negotiation")
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("service_level IN ('start','business','enterprise')", name="ck_sla_level"),
|
||||
@@ -359,7 +405,7 @@ class Incident(Base):
|
||||
sla_breached = Column(Boolean, default=False)
|
||||
resolved_at = Column(DateTime)
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("severity IN ('P1','P2','P3')", name="ck_incidents_severity"),
|
||||
@@ -428,7 +474,7 @@ class BlogPost(Base):
|
||||
status = Column(String(20), nullable=False, default="draft")
|
||||
published_at = Column(DateTime)
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("category IN ('audit','sla','incident','supervision','documentation','risk','cases')", name="ck_blog_category"),
|
||||
@@ -448,7 +494,7 @@ class FormulaCoefficient(Base):
|
||||
formula_ref = Column(String(255), default="")
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
updated_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||
|
||||
updater = relationship("User")
|
||||
|
||||
@@ -463,7 +509,7 @@ class Case(Base):
|
||||
sort_order = Column(Integer, nullable=False, default=0)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||
|
||||
|
||||
class Idea(Base):
|
||||
@@ -475,7 +521,7 @@ class Idea(Base):
|
||||
status = Column(String(20), nullable=False, default="normal")
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("status IN ('normal','in_progress','completed')", name="ck_ideas_status"),
|
||||
@@ -493,7 +539,7 @@ class QuickMenuItem(Base):
|
||||
url = Column(String(500), nullable=False)
|
||||
sort_order = Column(Integer, nullable=False, default=0)
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||
|
||||
creator = relationship("User")
|
||||
|
||||
@@ -511,4 +557,199 @@ class RoleMenuPermission(Base):
|
||||
)
|
||||
|
||||
|
||||
# ─── Модели для лидов, звонков, уведомлений, настроек АТС ──────────────────────
|
||||
|
||||
|
||||
class Lead(Base):
|
||||
"""Лид — обращение из бота, звонка, сайта или email.
|
||||
|
||||
Источники: bot, phone_in, phone_out, site, email, manual.
|
||||
Автопривязка к клиенту: ИНН → телефон → email → создание draft.
|
||||
"""
|
||||
__tablename__ = "leads"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"))
|
||||
|
||||
# Источник обращения
|
||||
source = Column(String(20), nullable=False, default="manual")
|
||||
# bot | phone_in | phone_out | site | email | manual
|
||||
|
||||
# Данные клиента
|
||||
company_name = Column(String(255), default="")
|
||||
contact_name = Column(String(255), default="")
|
||||
contact_phone = Column(String(50), default="")
|
||||
contact_email = Column(String(255), default="")
|
||||
inn = Column(String(12), default="")
|
||||
|
||||
# Содержание обращения
|
||||
title = Column(String(500), default="")
|
||||
service_type = Column(String(255), default="")
|
||||
description = Column(Text, default="")
|
||||
urgency = Column(String(10), default="medium")
|
||||
# critical | high | medium | low
|
||||
|
||||
# Статус
|
||||
status = Column(String(30), default="new")
|
||||
# new | contact | qualification | proposal | negotiation | approved | won | lost | merged
|
||||
|
||||
# Привязка
|
||||
assigned_to = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||
bot_ticket_id = Column(BigInteger) # ID заявки из бота
|
||||
|
||||
# Воронка
|
||||
deal_amount = Column(Numeric(12, 2), default=0)
|
||||
expected_close_date = Column(Date)
|
||||
|
||||
# Мета
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('new','contact','qualification','proposal','negotiation','approved','won','lost','merged')",
|
||||
name="ck_leads_status"
|
||||
),
|
||||
CheckConstraint(
|
||||
"urgency IN ('critical','high','medium','low')",
|
||||
name="ck_leads_urgency"
|
||||
),
|
||||
CheckConstraint(
|
||||
"source IN ('bot','phone_in','phone_out','site','email','manual')",
|
||||
name="ck_leads_source"
|
||||
),
|
||||
)
|
||||
|
||||
customer = relationship("Customer", foreign_keys=[customer_id])
|
||||
assignee = relationship("User", foreign_keys=[assigned_to])
|
||||
creator = relationship("User", foreign_keys=[created_by])
|
||||
|
||||
|
||||
class CallRecord(Base):
|
||||
"""Звонок (CDR из Yeastar S20 через MySQL).
|
||||
|
||||
Синхронизируется каждые 5 минут из таблицы cdr.
|
||||
Автопривязка к клиенту по номеру телефона.
|
||||
Автопривязка к сотруднику по внутреннему номеру (extension).
|
||||
"""
|
||||
__tablename__ = "call_records"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Данные из CDR
|
||||
cdr_uniqueid = Column(String(32), unique=True) # Уникальный ID из CDR
|
||||
call_datetime = Column(DateTime, nullable=False)
|
||||
caller_number = Column(String(80), default="") # Номер звонящего (src)
|
||||
callee_number = Column(String(80), default="") # Номер получателя (dst)
|
||||
trunk = Column(String(80), default="") # Транк (Mango, Tele2)
|
||||
duration = Column(Integer, default=0) # Общая длительность (сек)
|
||||
billable_seconds = Column(Integer, default=0) # Биллируемая (сек)
|
||||
disposition = Column(String(30), default="") # ANSWERED, NO ANSWER, BUSY, VOICEMAIL
|
||||
call_type = Column(String(20), default="") # Inbound, Outbound, Internal
|
||||
queue_name = Column(String(30), default="") # Очередь (6700, 6701, 6702)
|
||||
|
||||
# Привязка к клиенту
|
||||
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"))
|
||||
lead_id = Column(Integer, ForeignKey("leads.id", ondelete="SET NULL"))
|
||||
|
||||
# Инженер/техник кто принял звонок
|
||||
answered_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||
answered_by_name = Column(String(100), default="")
|
||||
|
||||
# Запись
|
||||
has_recording = Column(Boolean, default=False)
|
||||
recording_path = Column(String(500), default="") # Путь на PBX
|
||||
recording_filename = Column(String(200), default="") # Имя WAV-файла
|
||||
|
||||
# Уведомления
|
||||
notification_sent = Column(Boolean, default=False)
|
||||
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
|
||||
customer = relationship("Customer", foreign_keys=[customer_id])
|
||||
lead = relationship("Lead", foreign_keys=[lead_id])
|
||||
employee = relationship("User", foreign_keys=[answered_by])
|
||||
|
||||
|
||||
class ClientNotification(Base):
|
||||
"""Уведомление для сотрудника.
|
||||
|
||||
Типы: пропущенный звонок, новый лид, изменение статуса лида,
|
||||
нарушение SLA, просроченная задача.
|
||||
"""
|
||||
__tablename__ = "client_notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"))
|
||||
lead_id = Column(Integer, ForeignKey("leads.id", ondelete="SET NULL"))
|
||||
call_record_id = Column(Integer, ForeignKey("call_records.id", ondelete="SET NULL"))
|
||||
|
||||
type = Column(String(30), nullable=False)
|
||||
# missed_call | new_lead | lead_status_change | sla_breach | task_overdue
|
||||
|
||||
title = Column(String(500), nullable=False)
|
||||
message = Column(Text, default="")
|
||||
is_read = Column(Boolean, default=False)
|
||||
|
||||
created_at = Column(DateTime, nullable=False, server_default="NOW()")
|
||||
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
customer = relationship("Customer", foreign_keys=[customer_id])
|
||||
lead = relationship("Lead", foreign_keys=[lead_id])
|
||||
call_record = relationship("CallRecord", foreign_keys=[call_record_id])
|
||||
|
||||
|
||||
class PbxSettings(Base):
|
||||
"""Настройки подключения к АТС (Yeastar S20).
|
||||
|
||||
Всегда одна запись (id=1). Пароли шифруются через cryptography.fernet.
|
||||
"""
|
||||
__tablename__ = "pbx_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, default=1)
|
||||
|
||||
# Подключение MySQL (CDR)
|
||||
mysql_host = Column(String(255), default="192.168.1.150")
|
||||
mysql_port = Column(Integer, default=3306)
|
||||
mysql_user = Column(String(100), default="")
|
||||
mysql_password = Column(String(255), default="") # зашифровано
|
||||
mysql_database = Column(String(100), default="cdr")
|
||||
|
||||
# AMI
|
||||
ami_host = Column(String(255), default="192.168.1.150")
|
||||
ami_port = Column(Integer, default=5038)
|
||||
ami_user = Column(String(100), default="")
|
||||
ami_password = Column(String(255), default="") # зашифровано
|
||||
|
||||
# FTP (записи WAV)
|
||||
ftp_host = Column(String(255), default="192.168.1.150")
|
||||
ftp_port = Column(Integer, default=21)
|
||||
ftp_user = Column(String(100), default="")
|
||||
ftp_password = Column(String(255), default="") # зашифровано
|
||||
|
||||
# Веб-интерфейс
|
||||
web_host = Column(String(255), default="192.168.1.150")
|
||||
web_port = Column(Integer, default=8088)
|
||||
web_user = Column(String(100), default="")
|
||||
web_password = Column(String(255), default="") # зашифровано
|
||||
|
||||
# Информация
|
||||
serial_number = Column(String(50), default="") # Ручной ввод
|
||||
actual_serial = Column(String(50), default="") # Из FTP
|
||||
|
||||
# Статус подключения
|
||||
mysql_status = Column(String(20), default="unknown") # ok | error | unknown
|
||||
ami_status = Column(String(20), default="unknown")
|
||||
ftp_status = Column(String(20), default="unknown")
|
||||
serial_match = Column(Boolean, default=False)
|
||||
|
||||
# Синхронизация
|
||||
last_sync_at = Column(DateTime)
|
||||
sync_interval = Column(Integer, default=5) # минут
|
||||
|
||||
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate=func.now())
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1293,6 +1293,7 @@ BOT_SERVICE_URL = "http://127.0.0.1:8002"
|
||||
|
||||
|
||||
async def _proxy(method: str, path: str, body=None):
|
||||
"""Прокси запросов от service portal к max_bot API."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
if body is not None:
|
||||
resp = await client.request(method, f"{BOT_SERVICE_URL}{path}", json=body)
|
||||
@@ -1431,11 +1432,13 @@ async def proxy_bot_conversation_logs(conv_id: int, user: dict = Depends(get_cur
|
||||
|
||||
@router.delete("/api/bot/conversations/{conv_id}")
|
||||
async def proxy_delete_conversation(conv_id: int, request: Request, user: dict = Depends(require_role("owner"))):
|
||||
"""Удаление диалога (каскадно: сообщения + логи). Доступно только owner."""
|
||||
return await _proxy("DELETE", f"/api/bot/conversations/{conv_id}")
|
||||
|
||||
|
||||
@router.delete("/api/bot/users/{user_id}")
|
||||
async def proxy_delete_bot_user(user_id: int, request: Request, user: dict = Depends(require_role("owner"))):
|
||||
"""Удаление пользователя бота (каскадно: диалоги + сообщения + логи). Заявки остаются. Доступно только owner."""
|
||||
return await _proxy("DELETE", f"/api/bot/users/{user_id}")
|
||||
|
||||
|
||||
|
||||
@@ -164,6 +164,17 @@ img { max-width:100%; height:auto; }
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Скрытие стандартных спиннеров на number input */
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -576,6 +587,12 @@ table .actions button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* ---------- TABS ---------- */
|
||||
.tabs {
|
||||
display: flex;
|
||||
|
||||
@@ -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>
|
||||
<link rel="stylesheet" href="/service/static/css/service.css">
|
||||
<link rel="stylesheet" href="/service/static/css/service-theme.css">
|
||||
<link rel="icon" type="image/x-icon" href="/service/static/img/favicons/favicon.ico">
|
||||
<link rel="icon" type="image/svg+xml" href="/service/static/img/favicon.svg">
|
||||
<link rel="icon" type="image/x-icon" href="/service/static/img/favicons/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/service/static/img/favicons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/service/static/img/favicons/favicon-16x16.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/service/static/img/favicons/apple-touch-icon.png">
|
||||
@@ -55,6 +55,7 @@
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
<script src="/service/static/js/theme.js"></script>
|
||||
<script src="/service/static/js/role-switcher.js"></script>
|
||||
<script src="/service/static/js/sort-table.js"></script>
|
||||
<script>
|
||||
function showNotification(msg, type){
|
||||
type = type || 'success';
|
||||
|
||||
@@ -5,16 +5,6 @@
|
||||
<div class="layout">
|
||||
{% include "_sidebar.html" %}
|
||||
<main class="main">
|
||||
<div class="main-header">
|
||||
<div>
|
||||
<h1>Дашборд</h1>
|
||||
<div class="breadcrumb"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span style="font-size:12px;color:var(--text-muted);">{{ now }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if shs_alert %}
|
||||
<div class="shs-alert critical">⚠ {{ shs_alert }}</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -5,18 +5,6 @@
|
||||
<div class="layout">
|
||||
{% include "_sidebar.html" %}
|
||||
<main class="main">
|
||||
<div class="main-header">
|
||||
<div>
|
||||
<h1>{% block page_title %}{{ title|default(page_name|capitalize) }}{% endblock %}</h1>
|
||||
<div class="breadcrumb">
|
||||
{% block breadcrumb %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{% block page_actions %}{% endblock %}
|
||||
<span style="font-size:12px;color:var(--text-muted);">{{ now }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% block page_content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -10,10 +10,7 @@
|
||||
<td><span class="badge badge-{{ a.role }}">{{ {'engineer':'Инженер','technician':'Техник'}[a.role] }}</span></td>
|
||||
<td>{{ a.assigned_at.strftime('%d.%m.%Y %H:%M') if a.assigned_at else '' }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/service/assignments/unassign" style="display:inline">
|
||||
<input type="hidden" name="id" value="{{ a.id }}">
|
||||
<button class="btn btn-danger btn-sm">Отвязать</button>
|
||||
</form>
|
||||
<button class="btn btn-danger btn-sm" onclick="openConfirmUnassign({{ a.id }},'{{ (a.user.full_name if a.user else '')|e }}','{{ (a.object.name if a.object else '')|e }}')" title="Отвязать">✕</button>
|
||||
</td>
|
||||
</tr>{% endfor %}</tbody></table>
|
||||
</div>
|
||||
@@ -25,7 +22,38 @@
|
||||
<div class="form-group"><label>Объект</label><select name="object_id" required>{% for o in objects %}<option value="{{ o.id }}">{{ o.name }}</option>{% endfor %}</select></div>
|
||||
<div class="form-group"><label>Сотрудник</label><select name="user_id" required>{% for u in users %}<option value="{{ u.id }}">{{ u.full_name }} ({{ u.role }})</option>{% endfor %}</select></div>
|
||||
<div class="form-actions"><button type="submit" class="btn btn-primary">Назначить</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
|
||||
<button type="button" class="btn" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
|
||||
</form>
|
||||
</div></div>
|
||||
<div class="modal-overlay" id="confirm-unassign">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Отвязать сотрудника?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
Назначение <strong id="unassign-info"></strong> будет удалено. Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirmUnassign()">Отвязать</button>
|
||||
<button type="button" class="btn" onclick="document.getElementById('confirm-unassign').classList.remove('open')">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var unassignId = null;
|
||||
function openConfirmUnassign(id, userName, objectName){
|
||||
unassignId = id;
|
||||
document.getElementById('unassign-info').textContent = userName + ' → ' + objectName;
|
||||
document.getElementById('confirm-unassign').classList.add('open');
|
||||
}
|
||||
function confirmUnassign(){
|
||||
if(!unassignId) return;
|
||||
var form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/service/assignments/unassign';
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden'; input.name = 'id'; input.value = unassignId;
|
||||
form.appendChild(input);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<td><small>{{ p.created_at.strftime('%d.%m.%Y') if p.created_at else '' }}</small></td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="editBlog({{ p.id }},'{{ p.title|e }}','{{ p.slug|e }}','{{ p.category|e }}','{{ p.status }}','{{ p.excerpt|e }}','{{ p.content|e }}')">✎</button>
|
||||
<form method="post" action="/service/blog/delete" style="display:inline" onsubmit="return confirm('Удалить запись?')"><input type="hidden" name="id" value="{{ p.id }}"><button class="btn btn-danger btn-sm">✕</button></form>
|
||||
<form method="post" action="/service/blog/delete" style="display:inline" id="delete-blog-form-{{ p.id }}"><input type="hidden" name="id" value="{{ p.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteBlog({{ p.id }},'{{ p.title|e }}')" title="Удалить">✕</button></form>
|
||||
</td>
|
||||
</tr>{% endfor %}</tbody></table>
|
||||
</div>
|
||||
@@ -33,7 +33,34 @@
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
|
||||
</form>
|
||||
</div></div>
|
||||
<div class="modal-overlay" id="confirm-delete-blog">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Удалить запись?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
<strong id="delete-blog-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirmDeleteBlog()">Удалить</button>
|
||||
<button type="button" class="btn" onclick="closeConfirmDeleteBlog()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var deleteBlogId = null;
|
||||
function openConfirmDeleteBlog(id, name){
|
||||
deleteBlogId = id;
|
||||
document.getElementById('delete-blog-name').textContent = name || '#' + id;
|
||||
document.getElementById('confirm-delete-blog').classList.add('open');
|
||||
}
|
||||
function closeConfirmDeleteBlog(){
|
||||
document.getElementById('confirm-delete-blog').classList.remove('open');
|
||||
deleteBlogId = null;
|
||||
}
|
||||
function confirmDeleteBlog(){
|
||||
if(!deleteBlogId) return;
|
||||
document.getElementById('delete-blog-form-' + deleteBlogId).submit();
|
||||
closeConfirmDeleteBlog();
|
||||
}
|
||||
function editBlog(id,title,slug,category,status,excerpt,content){
|
||||
document.getElementById('modal-blog').classList.add('open');
|
||||
document.getElementById('modal-blog-title').textContent = 'Редактировать запись';
|
||||
|
||||
@@ -31,6 +31,14 @@
|
||||
.confirm-modal .confirm-actions { display:flex; gap:10px; justify-content:center; }
|
||||
.confirm-modal .confirm-actions .btn { min-width:140px; }
|
||||
.timer-danger { color:var(--danger) !important; }
|
||||
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:1000; align-items:center; justify-content:center; }
|
||||
.modal-overlay.open { display:flex; }
|
||||
.modal { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:24px; width:420px; max-width:90vw; }
|
||||
.modal h2 { color:var(--text-primary); }
|
||||
.modal p { color:var(--text-secondary); }
|
||||
.form-actions { display:flex; gap:10px; }
|
||||
.btn-danger { background:var(--danger, #dc3545); color:#fff; border:none; padding:8px 16px; border-radius:6px; cursor:pointer; font-weight:600; }
|
||||
.btn { background:var(--bg-input); color:var(--text-primary); border:1px solid var(--border); padding:8px 16px; border-radius:6px; cursor:pointer; }
|
||||
</style>
|
||||
|
||||
<div class="card">
|
||||
@@ -93,10 +101,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="confirm-delete-user">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Удалить пользователя?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
<strong id="delete-user-name"></strong> (ID: <span id="delete-user-id"></span>) будет удалён навсегда.
|
||||
Все его диалоги и сообщения будут удалены. Заявки останутся.
|
||||
Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirmDeleteUser()">Удалить</button>
|
||||
<button type="button" class="btn" onclick="closeConfirmDeleteUser()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var allConsents = [];
|
||||
var confirmTimer = null;
|
||||
var confirmSeconds = 10;
|
||||
var pendingDeleteUserId = null;
|
||||
var pendingDeleteUserName = null;
|
||||
|
||||
function loadConsents(){
|
||||
var tbody = document.getElementById('consent-table-body');
|
||||
@@ -135,11 +160,12 @@ function renderConsents(list){
|
||||
'<td class="consent-yes">Да</td>' +
|
||||
'<td style="font-size:12px;color:var(--text-muted);">' + dateStr + '</td>';
|
||||
if(USER_ROLE === 'owner'){
|
||||
html += '<td><button class="btn btn-danger btn-sm" onclick="confirmDeleteUser(' + u.id + ', \'' + escapeHtml(name).replace(/'/g, "\\'") + '\')" title="Удалить пользователя">🗑</button></td>';
|
||||
html += '<td><button class="btn btn-danger btn-sm" onclick="openConfirmDeleteUser(' + u.id + ', \'' + escapeHtml(name).replace(/'/g, "\\'") + '\')" title="Удалить пользователя">🗑</button></td>';
|
||||
}
|
||||
html += '</tr>';
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
if(typeof sortTables === 'function') sortTables();
|
||||
}
|
||||
|
||||
function filterConsents(){
|
||||
@@ -222,8 +248,24 @@ function escapeHtml(s){
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function confirmDeleteUser(userId, userName){
|
||||
if(!confirm('Удалить пользователя ' + userName + ' (ID: ' + userId + ')?\n\nВсе его диалоги и сообщения будут удалены навсегда. Заявки останутся.')) return;
|
||||
function openConfirmDeleteUser(userId, userName){
|
||||
pendingDeleteUserId = userId;
|
||||
pendingDeleteUserName = userName;
|
||||
document.getElementById('delete-user-name').textContent = userName;
|
||||
document.getElementById('delete-user-id').textContent = userId;
|
||||
document.getElementById('confirm-delete-user').classList.add('open');
|
||||
}
|
||||
|
||||
function closeConfirmDeleteUser(){
|
||||
document.getElementById('confirm-delete-user').classList.remove('open');
|
||||
pendingDeleteUserId = null;
|
||||
pendingDeleteUserName = null;
|
||||
}
|
||||
|
||||
function confirmDeleteUser(){
|
||||
var userId = pendingDeleteUserId;
|
||||
var userName = pendingDeleteUserName;
|
||||
closeConfirmDeleteUser();
|
||||
fetch('/api/bot/users/' + userId, { method: 'DELETE' })
|
||||
.then(function(r){ return r.json() })
|
||||
.then(function(d){
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
{% extends "page.html" %}
|
||||
{% block page_content %}
|
||||
<div class="page-header">
|
||||
<h1>Диалоги чат-бота</h1>
|
||||
<div class="header-controls">
|
||||
<input type="text" id="search-input" placeholder="Поиск по ID или имени..." oninput="filterConversations()" class="filter-input">
|
||||
<button onclick="loadConversations()" class="btn btn-primary">Обновить</button>
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Диалоги чат-бота</h2>
|
||||
<button onclick="loadConversations()" class="btn btn-primary btn-sm">Обновить</button>
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<input type="text" id="search-input" placeholder="Поиск по ID или имени..." oninput="filterConversations()">
|
||||
<select id="status-filter" onchange="filterConversations()" class="filter-select">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="greeting">Приветствие</option>
|
||||
<option value="collecting_name">Сбор имени</option>
|
||||
<option value="collecting_phone">Сбор телефона</option>
|
||||
<option value="collecting_email">Сбор email</option>
|
||||
<option value="ticket_creation">Создание заявки</option>
|
||||
<option value="question">Вопрос</option>
|
||||
<option value="done">Завершён</option>
|
||||
</select>
|
||||
</div>
|
||||
<script>var USER_ROLE = '{{ user.role }}';</script>
|
||||
<div id="conversations-container">
|
||||
<div class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>var USER_ROLE = '{{ user.role }}';</script>
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<select id="status-filter" onchange="filterConversations()" class="filter-select">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="greeting">Приветствие</option>
|
||||
<option value="collecting_name">Сбор имени</option>
|
||||
<option value="collecting_phone">Сбор телефона</option>
|
||||
<option value="collecting_email">Сбор email</option>
|
||||
<option value="ticket_creation">Создание заявки</option>
|
||||
<option value="question">Вопрос</option>
|
||||
<option value="done">Завершён</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="conversations-container">
|
||||
<div class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="dialog-modal" style="display:none;">
|
||||
<div class="modal-overlay" id="dialog-modal">
|
||||
<div class="modal" style="max-width:800px;width:90%;">
|
||||
<button type="button" class="modal-close" onclick="closeDialog()">✕</button>
|
||||
<h2 id="dialog-title">Диалог</h2>
|
||||
@@ -44,9 +43,9 @@
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
Диалог с <strong id="delete-conv-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="modal-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn" onclick="closeDeleteModal()">Отмена</button>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirmDelete()">Удалить</button>
|
||||
<button type="button" class="btn" onclick="closeDeleteModal()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,13 +75,8 @@
|
||||
.log-time { font-size:10px; color:var(--text-muted,#6B8299); }
|
||||
.tab-btn { padding:6px 14px; border:1px solid var(--border,#253248); background:transparent; color:var(--text-primary,#E8EDF2); border-radius:4px; cursor:pointer; font-size:12px; }
|
||||
.tab-btn.active { background:var(--accent,#00ADEF); border-color:var(--accent,#00ADEF); color:#fff; }
|
||||
.page-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
|
||||
.header-controls { display:flex; gap:8px; align-items:center; }
|
||||
.filter-input { padding:6px 10px; border:1px solid var(--border,#253248); border-radius:4px; background:var(--bg-body,#1B2838); color:var(--text-primary,#E8EDF2); font-size:12px; width:200px; }
|
||||
.filter-select { padding:6px 10px; border:1px solid var(--border,#253248); border-radius:4px; background:var(--bg-body,#1B2838); color:var(--text-primary,#E8EDF2); font-size:12px; }
|
||||
.conv-delete { background:none; border:none; cursor:pointer; font-size:14px; padding:2px 6px; border-radius:4px; opacity:0.5; transition:opacity 0.2s; margin-left:auto; }
|
||||
.conv-delete:hover { opacity:1; background:rgba(232,107,107,0.15); }
|
||||
.conv-header { display:flex; align-items:center; gap:8px; }
|
||||
#delete-modal .modal { max-width:420px; }
|
||||
#delete-modal h2 { font-size:16px; margin-bottom:8px; }
|
||||
#delete-modal p { color:var(--text-secondary); font-size:14px; }
|
||||
@@ -130,7 +124,7 @@ function renderConversations(data){
|
||||
html += '<div class="conv-card" onclick="openDialog(' + c.id + ')">';
|
||||
html += '<div class="conv-header"><span class="conv-user">' + escapeHtml(c.user_name) + '</span><span class="conv-id">ID: ' + c.id + '</span>';
|
||||
if(USER_ROLE === 'owner'){
|
||||
html += '<button class="conv-delete" onclick="event.stopPropagation();openDeleteModal(' + c.id + ', \'' + escapeHtml(c.user_name).replace(/'/g, "\\'") + '\')" title="Удалить диалог">🗑</button>';
|
||||
html += '<button class="conv-delete" onclick="event.stopPropagation();openDeleteModal(' + c.id + ', \'' + escapeHtml(c.user_name).replace(/'/g, "\\'") + '\')" title="Удалить диалог">✕</button>';
|
||||
}
|
||||
html += '</div>';
|
||||
html += '<div class="conv-meta">';
|
||||
@@ -149,7 +143,7 @@ function filterConversations(){
|
||||
|
||||
function openDialog(convId){
|
||||
currentConvId = convId;
|
||||
document.getElementById('dialog-modal').style.display = 'flex';
|
||||
document.getElementById('dialog-modal').classList.add('open');
|
||||
var conv = conversations.find(function(c){ return c.id === convId; });
|
||||
document.getElementById('dialog-title').textContent = 'Диалог с ' + (conv ? conv.user_name : '#' + convId);
|
||||
showTab('messages');
|
||||
@@ -157,7 +151,7 @@ function openDialog(convId){
|
||||
}
|
||||
|
||||
function closeDialog(){
|
||||
document.getElementById('dialog-modal').style.display = 'none';
|
||||
document.getElementById('dialog-modal').classList.remove('open');
|
||||
}
|
||||
|
||||
function showTab(tab){
|
||||
|
||||
@@ -8,19 +8,6 @@
|
||||
.kb-table .active-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
|
||||
.kb-table .active-badge.yes { background:var(--success-bg, #d4edda); color:var(--success, #155724); }
|
||||
.kb-table .active-badge.no { background:var(--danger-bg, #f8d7da); color:var(--danger, #721c24); }
|
||||
.kb-modal { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.4);
|
||||
z-index:1000; align-items:center; justify-content:center; }
|
||||
.kb-modal.open { display:flex; }
|
||||
.kb-modal-content { background:var(--bg-card); border-radius:12px; padding:30px; width:600px; max-width:90vw;
|
||||
max-height:80vh; overflow-y:auto; }
|
||||
.kb-modal-content h3 { margin-bottom:20px; }
|
||||
.form-group { margin-bottom:14px; }
|
||||
.form-group label { display:block; font-weight:600; margin-bottom:4px; font-size:13px; color:var(--text-muted); }
|
||||
.form-group input, .form-group textarea, .form-group select {
|
||||
width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:14px;
|
||||
}
|
||||
.form-group textarea { min-height:100px; resize:vertical; }
|
||||
.modal-actions { display:flex; gap:12px; margin-top:16px; }
|
||||
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; flex-wrap:wrap; }
|
||||
.filter-bar input, .filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
|
||||
</style>
|
||||
@@ -60,9 +47,10 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="kb-modal" id="kb-modal">
|
||||
<div class="kb-modal-content">
|
||||
<h3 id="modal-title">Новая карточка</h3>
|
||||
<div class="modal-overlay" id="kb-modal">
|
||||
<div class="modal" style="max-width:600px;">
|
||||
<button type="button" class="modal-close" onclick="closeModal()">✕</button>
|
||||
<h2 id="modal-title">Новая карточка</h2>
|
||||
<form id="kb-form">
|
||||
<div class="form-group">
|
||||
<label>Категория</label>
|
||||
@@ -84,10 +72,8 @@
|
||||
<label>URL источника</label>
|
||||
<input type="url" name="source_url" id="kb-source-url" placeholder="https://aegisone.ru/...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" name="is_active" id="kb-is-active" checked> Активная карточка</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<div class="form-check"><input type="checkbox" name="is_active" id="kb-is-active" checked><label for="kb-is-active">Активная карточка</label></div>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
<button type="button" class="btn" onclick="closeModal()">Отмена</button>
|
||||
</div>
|
||||
@@ -95,6 +81,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="confirm-delete-kb">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Удалить карточку?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
Карточка <strong id="delete-kb-name"></strong> будет удалена навсегда. Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirmDeleteKB()">Удалить</button>
|
||||
<button type="button" class="btn" onclick="closeConfirmDeleteKB()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var categories = {};
|
||||
var allCards = [];
|
||||
@@ -126,10 +125,11 @@ function renderKB(cards){
|
||||
'<td>' + catName + '</td>' +
|
||||
'<td><span class="active-badge ' + activeClass + '">' + activeLabel + '</span></td>' +
|
||||
'<td style="font-size:12px;">' + (c.source_url ? '<a href="' + c.source_url + '" target="_blank">ссылка</a>' : '—') + '</td>' +
|
||||
'<td><button class="btn btn-sm" onclick="editCard(' + c.id + ')" title="Редактировать">✏️</button> <button class="btn btn-sm" onclick="deleteCard(' + c.id + ')" style="color:#dc3545;" title="Удалить">🗑</button></td>' +
|
||||
'<td><button class="btn btn-secondary btn-sm" onclick="editCard(' + c.id + ')" title="Редактировать">✎</button> <button class="btn btn-danger btn-sm" onclick="openConfirmDeleteKB(' + c.id + ', \'' + escapeHtml(c.question).replace(/'/g, "\\'") + '\')" title="Удалить">✕</button></td>' +
|
||||
'</tr>';
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
if(typeof sortTables === 'function') sortTables();
|
||||
}
|
||||
|
||||
function filterKB(){
|
||||
@@ -173,8 +173,23 @@ function editCard(id){
|
||||
document.getElementById('kb-modal').classList.add('open');
|
||||
}
|
||||
|
||||
function deleteCard(id){
|
||||
if(!confirm('Удалить карточку?')) return;
|
||||
var deleteKBId = null;
|
||||
|
||||
function openConfirmDeleteKB(id, name){
|
||||
deleteKBId = id;
|
||||
document.getElementById('delete-kb-name').textContent = name || '#' + id;
|
||||
document.getElementById('confirm-delete-kb').classList.add('open');
|
||||
}
|
||||
|
||||
function closeConfirmDeleteKB(){
|
||||
document.getElementById('confirm-delete-kb').classList.remove('open');
|
||||
deleteKBId = null;
|
||||
}
|
||||
|
||||
function confirmDeleteKB(){
|
||||
if(!deleteKBId) return;
|
||||
var id = deleteKBId;
|
||||
closeConfirmDeleteKB();
|
||||
fetch('/api/bot/kb/' + id, { method: 'DELETE' }).then(function(r){ return r.json() }).then(function(d){
|
||||
if(d.ok) { showNotification('Карточка удалена', 'success'); loadKB(); }
|
||||
else showNotification('Ошибка удаления', 'error');
|
||||
|
||||
@@ -31,6 +31,14 @@ body { font-family:'Inter','Segoe UI',sans-serif; background:var(--bg-body, #1B2
|
||||
.test-chat-messages::-webkit-scrollbar-track { background:var(--bg-body, #0D1B2A); border-radius:3px; }
|
||||
.test-chat-messages::-webkit-scrollbar-thumb { background:var(--border, #253248); border-radius:3px; }
|
||||
.test-chat-messages::-webkit-scrollbar-thumb:hover { background:var(--text-muted, #6B8299); }
|
||||
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:1000; align-items:center; justify-content:center; }
|
||||
.modal-overlay.open { display:flex; }
|
||||
.modal { background:var(--bg-card, #0A1628); border:1px solid var(--border, #253248); border-radius:12px; padding:24px; width:420px; max-width:90vw; }
|
||||
.modal h2 { color:var(--text-primary, #E8EDF2); }
|
||||
.modal p { color:var(--text-muted, #6B8299); }
|
||||
.form-actions { display:flex; gap:10px; }
|
||||
.btn-danger { background:var(--danger, #dc3545); color:#fff; border:none; padding:8px 16px; border-radius:6px; cursor:pointer; font-weight:600; }
|
||||
.btn { background:var(--bg-input, #253248); color:var(--text-primary, #E8EDF2); border:1px solid var(--border, #3a4a60); padding:8px 16px; border-radius:6px; cursor:pointer; }
|
||||
</style>
|
||||
<script>
|
||||
(function(){function g(){return window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'}var s=localStorage.getItem('aegisone_service_theme')||'system',r=s==='system'?g():s;document.documentElement.setAttribute('data-theme',r);})();
|
||||
@@ -43,7 +51,7 @@ body { font-family:'Inter','Segoe UI',sans-serif; background:var(--bg-body, #1B2
|
||||
<button onclick="runScenario('consent_no')">Отказ согласия</button>
|
||||
<button onclick="runScenario('question')">Общий вопрос</button>
|
||||
<button onclick="clearTest()">Очистить</button>
|
||||
<button onclick="clearTestData()" style="background:var(--danger,#dc3545) !important;border-color:var(--danger,#dc3545) !important;">Сбросить все тестовые данные</button>
|
||||
<button onclick="openConfirmClearTest()" style="background:var(--danger,#dc3545) !important;border-color:var(--danger,#dc3545) !important;">Сбросить все тестовые данные</button>
|
||||
</div>
|
||||
<div class="test-container">
|
||||
<div class="test-chat">
|
||||
@@ -56,6 +64,21 @@ body { font-family:'Inter','Segoe UI',sans-serif; background:var(--bg-body, #1B2
|
||||
</div>
|
||||
<div class="test-log" id="test-log"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="confirm-clear-test">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Сбросить тестовые данные?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
Удалить все тестовые диалоги, сообщения и логи обработки?
|
||||
Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirmClearTest()">Сбросить</button>
|
||||
<button type="button" class="btn" onclick="closeConfirmClearTest()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var testUserId = 999999001;
|
||||
function log(event, detail){
|
||||
@@ -156,8 +179,14 @@ function clearTest(){
|
||||
document.getElementById('test-input').value = '';
|
||||
log('info', 'Тест очищен');
|
||||
}
|
||||
function clearTestData(){
|
||||
if(!confirm('Удалить все тестовые диалоги, сообщения и логи обработки? Это действие нельзя отменить.')) return;
|
||||
function openConfirmClearTest(){
|
||||
document.getElementById('confirm-clear-test').classList.add('open');
|
||||
}
|
||||
function closeConfirmClearTest(){
|
||||
document.getElementById('confirm-clear-test').classList.remove('open');
|
||||
}
|
||||
function confirmClearTest(){
|
||||
closeConfirmClearTest();
|
||||
log('info', 'Очистка тестовых данных...');
|
||||
fetch('/api/bot/test/clear', {method: 'POST'}).then(function(r){ return r.json() }).then(function(d){
|
||||
if(d && d.ok){
|
||||
|
||||
@@ -121,6 +121,7 @@ function renderTickets(tickets){
|
||||
'</tr>';
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
if(typeof sortTables === 'function') sortTables();
|
||||
}
|
||||
|
||||
function filterTickets(){
|
||||
|
||||
@@ -10,16 +10,6 @@
|
||||
.badge.auto_added { background:#cce5ff; color:#004085; }
|
||||
.badge.reviewed { background:#d4edda; color:#155724; }
|
||||
.badge.ignored { background:#f8f9fa; color:#6c757d; }
|
||||
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.4); z-index:1000; align-items:center; justify-content:center; }
|
||||
.modal-overlay.open { display:flex; }
|
||||
.modal-box { background:var(--bg-card); border-radius:12px; padding:30px; width:650px; max-width:90vw; max-height:85vh; overflow-y:auto; }
|
||||
.modal-box h3 { margin-bottom:20px; }
|
||||
.form-group { margin-bottom:14px; }
|
||||
.form-group label { display:block; font-weight:600; margin-bottom:4px; font-size:13px; color:var(--text-muted); }
|
||||
.form-group input, .form-group textarea, .form-group select {
|
||||
width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:14px; }
|
||||
.form-group textarea { min-height:80px; resize:vertical; }
|
||||
.modal-actions { display:flex; gap:12px; margin-top:16px; }
|
||||
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; }
|
||||
.filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
|
||||
</style>
|
||||
@@ -55,8 +45,9 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="uq-modal">
|
||||
<div class="modal-box">
|
||||
<h3 id="uq-modal-title">Проверить вопрос</h3>
|
||||
<div class="modal" style="max-width:650px;">
|
||||
<button type="button" class="modal-close" onclick="closeModal()">✕</button>
|
||||
<h2 id="uq-modal-title">Проверить вопрос</h2>
|
||||
<form id="uq-form">
|
||||
<input type="hidden" id="uq-edit-id">
|
||||
<div class="form-group">
|
||||
@@ -88,7 +79,7 @@
|
||||
<option value="8">Отрасли и кейсы</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="submit" class="btn btn-primary">Опубликовать в базу знаний</button>
|
||||
<button type="button" class="btn" onclick="closeModal()">Отмена</button>
|
||||
</div>
|
||||
@@ -96,6 +87,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="confirm-ignore-uq">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Игнорировать вопрос?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
Вопрос <strong id="ignore-uq-text"></strong> будет отмечен как игнорированный.
|
||||
</p>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirmIgnoreUQ()">Игнорировать</button>
|
||||
<button type="button" class="btn" onclick="closeConfirmIgnoreUQ()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var allUQ = [];
|
||||
|
||||
@@ -132,12 +136,13 @@ function renderUQ(items){
|
||||
'<td style="font-size:12px;">' + (q.created_at ? q.created_at.substring(0,10) : '—') + '</td>' +
|
||||
'<td>';
|
||||
if(q.status === 'new' || q.status === 'auto_added'){
|
||||
html += '<button class="btn btn-sm" onclick="openReview(' + q.id + ')" title="Проверить">✏️</button> ';
|
||||
html += '<button class="btn btn-sm" onclick="ignoreUQ(' + q.id + ')" style="color:#dc3545;" title="Игнорировать">🗑</button>';
|
||||
html += '<button class="btn btn-secondary btn-sm" onclick="openReview(' + q.id + ')" title="Проверить">✎</button> ';
|
||||
html += '<button class="btn btn-danger btn-sm" onclick="openConfirmIgnoreUQ(' + q.id + ', \'' + esc(q.question_text).replace(/'/g, "\\'") + '\')" title="Игнорировать">✕</button>';
|
||||
}
|
||||
html += '</td></tr>';
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
if(typeof sortTables === 'function') sortTables();
|
||||
}
|
||||
|
||||
function openReview(id){
|
||||
@@ -175,8 +180,23 @@ document.getElementById('uq-form').addEventListener('submit', function(e){
|
||||
});
|
||||
});
|
||||
|
||||
function ignoreUQ(id){
|
||||
if(!confirm('Игнорировать вопрос?')) return;
|
||||
var ignoreUQId = null;
|
||||
|
||||
function openConfirmIgnoreUQ(id, text){
|
||||
ignoreUQId = id;
|
||||
document.getElementById('ignore-uq-text').textContent = text || '#' + id;
|
||||
document.getElementById('confirm-ignore-uq').classList.add('open');
|
||||
}
|
||||
|
||||
function closeConfirmIgnoreUQ(){
|
||||
document.getElementById('confirm-ignore-uq').classList.remove('open');
|
||||
ignoreUQId = null;
|
||||
}
|
||||
|
||||
function confirmIgnoreUQ(){
|
||||
if(!ignoreUQId) return;
|
||||
var id = ignoreUQId;
|
||||
closeConfirmIgnoreUQ();
|
||||
fetch('/api/bot/unknown-questions/' + id + '/ignore', { method: 'PUT' })
|
||||
.then(function(r){ return r.json() }).then(function(d){
|
||||
if(d.ok){ loadUQ(); showNotification('Игнорировано', 'success'); }
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<td><span class="badge badge-{{ 'active' if c.is_active else 'inactive' }}">{{ 'Да' if c.is_active else 'Нет' }}</span></td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="editCase({{ c.id }},'{{ c.title|e }}','{{ c.text|e }}','{{ c.effect|e }}',{{ c.sort_order }},{{ 'true' if c.is_active else 'false' }})">✎</button>
|
||||
<form method="post" action="/service/cases/delete" style="display:inline" onsubmit="return confirm('Удалить?')"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-danger btn-sm">✕</button></form>
|
||||
<form method="post" action="/service/cases/delete" style="display:inline" id="delete-case-form-{{ c.id }}"><input type="hidden" name="id" value="{{ c.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteCase({{ c.id }},'{{ c.title|e }}')" title="Удалить">✕</button></form>
|
||||
</td>
|
||||
</tr>{% endfor %}</tbody></table>
|
||||
</div>
|
||||
@@ -31,7 +31,34 @@
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
|
||||
</form>
|
||||
</div></div>
|
||||
<div class="modal-overlay" id="confirm-delete-case">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Удалить?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
<strong id="delete-case-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirmDeleteCase()">Удалить</button>
|
||||
<button type="button" class="btn" onclick="closeConfirmDeleteCase()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var deleteCaseId = null;
|
||||
function openConfirmDeleteCase(id, name){
|
||||
deleteCaseId = id;
|
||||
document.getElementById('delete-case-name').textContent = name || '#' + id;
|
||||
document.getElementById('confirm-delete-case').classList.add('open');
|
||||
}
|
||||
function closeConfirmDeleteCase(){
|
||||
document.getElementById('confirm-delete-case').classList.remove('open');
|
||||
deleteCaseId = null;
|
||||
}
|
||||
function confirmDeleteCase(){
|
||||
if(!deleteCaseId) return;
|
||||
document.getElementById('delete-case-form-' + deleteCaseId).submit();
|
||||
closeConfirmDeleteCase();
|
||||
}
|
||||
function editCase(id,title,text,effect,order,active){
|
||||
document.getElementById('modal-case').classList.add('open');
|
||||
document.getElementById('modal-case-title').textContent = 'Редактировать';
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
{% extends "page.html" %}
|
||||
{% block page_content %}
|
||||
<style>
|
||||
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:1000; align-items:center; justify-content:center; }
|
||||
.modal-overlay.open { display:flex; }
|
||||
.modal { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:24px; width:420px; max-width:90vw; }
|
||||
.modal h2 { color:var(--text-primary); }
|
||||
.modal p { color:var(--text-secondary); }
|
||||
.form-actions { display:flex; gap:10px; }
|
||||
.btn-danger { background:var(--danger, #dc3545); color:#fff; border:none; padding:8px 16px; border-radius:6px; cursor:pointer; font-weight:600; }
|
||||
.btn-primary { background:var(--accent, #00ADEF); color:#fff; border:none; padding:8px 16px; border-radius:6px; cursor:pointer; font-weight:600; }
|
||||
.btn { background:var(--bg-input); color:var(--text-primary); border:1px solid var(--border); padding:8px 16px; border-radius:6px; cursor:pointer; }
|
||||
.form-group { margin-bottom:0; }
|
||||
</style>
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Графики и диаграммы</h2></div>
|
||||
<div class="filter-bar" style="flex-wrap:wrap;gap:8px;">
|
||||
@@ -30,21 +42,49 @@
|
||||
</div>
|
||||
<div class="filter-bar" style="margin-top:8px;flex-wrap:wrap;gap:6px;">
|
||||
<span style="font-size:12px;color:var(--text-muted);">Пользовательские графики:</span>
|
||||
<button class="btn btn-secondary btn-xs" onclick="saveCustomChart()">Сохранить текущий</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="saveCustomChart()">Сохранить текущий</button>
|
||||
<select id="custom-chart-select" onchange="loadCustomChart(this.value)">
|
||||
<option value="">— Загрузить —</option>
|
||||
</select>
|
||||
<button class="btn btn-danger btn-xs" onclick="deleteCustomChart()">Удалить</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteCustomChart()">Удалить</button>
|
||||
</div>
|
||||
<div style="position:relative;height:400px;">
|
||||
<canvas id="chart-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="chart-name-modal">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Название графика</h2>
|
||||
<div class="form-group">
|
||||
<input type="text" id="chart-name-input" placeholder="Введите название..." style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:6px;font-size:14px;background:var(--bg-card);color:var(--text);box-sizing:border-box;">
|
||||
</div>
|
||||
<div class="form-actions" style="justify-content:flex-end;margin-top:16px;">
|
||||
<button type="button" class="btn btn-primary" onclick="confirmSaveChart()">Сохранить</button>
|
||||
<button type="button" class="btn" onclick="closeChartNameModal()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="confirm-delete-chart">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Удалить график?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
<strong id="delete-chart-name"></strong> будет удалён навсегда.
|
||||
Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirmDeleteChart()">Удалить</button>
|
||||
<button type="button" class="btn" onclick="closeConfirmDeleteChart()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
var chartInstance = null;
|
||||
var currentChartConfig = null;
|
||||
var pendingDeleteChartName = null;
|
||||
|
||||
function loadChart(){
|
||||
var metric = document.getElementById('metric-select').value;
|
||||
@@ -107,8 +147,19 @@ function exportChart(){
|
||||
|
||||
function saveCustomChart(){
|
||||
if(!currentChartConfig) return;
|
||||
var name = prompt('Название графика:');
|
||||
if(!name) return;
|
||||
document.getElementById('chart-name-input').value = '';
|
||||
document.getElementById('chart-name-modal').classList.add('open');
|
||||
document.getElementById('chart-name-input').focus();
|
||||
}
|
||||
|
||||
function closeChartNameModal(){
|
||||
document.getElementById('chart-name-modal').classList.remove('open');
|
||||
}
|
||||
|
||||
function confirmSaveChart(){
|
||||
var name = document.getElementById('chart-name-input').value.trim();
|
||||
if(!name){ return; }
|
||||
closeChartNameModal();
|
||||
var saved = JSON.parse(localStorage.getItem('custom_charts') || '[]');
|
||||
saved.push({ name: name, config: currentChartConfig });
|
||||
localStorage.setItem('custom_charts', JSON.stringify(saved));
|
||||
@@ -131,7 +182,20 @@ function deleteCustomChart(){
|
||||
var sel = document.getElementById('custom-chart-select');
|
||||
var name = sel.value;
|
||||
if(!name) return;
|
||||
if(!confirm('Удалить "' + name + '"?')) return;
|
||||
pendingDeleteChartName = name;
|
||||
document.getElementById('delete-chart-name').textContent = name;
|
||||
document.getElementById('confirm-delete-chart').classList.add('open');
|
||||
}
|
||||
|
||||
function closeConfirmDeleteChart(){
|
||||
document.getElementById('confirm-delete-chart').classList.remove('open');
|
||||
pendingDeleteChartName = null;
|
||||
}
|
||||
|
||||
function confirmDeleteChart(){
|
||||
var name = pendingDeleteChartName;
|
||||
closeConfirmDeleteChart();
|
||||
var sel = document.getElementById('custom-chart-select');
|
||||
var saved = JSON.parse(localStorage.getItem('custom_charts') || '[]');
|
||||
saved = saved.filter(function(c){ return c.name !== name; });
|
||||
localStorage.setItem('custom_charts', JSON.stringify(saved));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% block page_content %}
|
||||
{% for t in tasks %}
|
||||
<div class="card">
|
||||
<h3>{{ t.title }}</h3>
|
||||
<div class="card-header"><h3>{{ t.title }}</h3></div>
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;">
|
||||
{{ t.object.name if t.object else '' }} — {{ t.object.address if t.object else '' }}
|
||||
<span class="badge badge-{{ {'P1':'critical','P2':'high','P3':'medium','P4':'low'}[t.priority] }}" style="margin-left:8px;">{{ t.priority }}</span>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<td><small>{{ c.formula_ref }}</small></td>
|
||||
<td><form method="post" action="/service/coefficients/toggle"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-{{ 'success' if c.is_active else 'secondary' }} btn-sm">{{ '✓' if c.is_active else '✕' }}</button></form></td>
|
||||
<td class="actions"><button class="btn btn-secondary btn-sm" onclick="editCoeff({{ c.id }},'{{ c.key|e }}','{{ c.value }}','{{ c.description|e }}','{{ c.formula_ref|e }}')">✎</button>
|
||||
<form method="post" action="/service/coefficients/delete" style="display:inline" onsubmit="return confirm('Удалить коэффициент?')"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-danger btn-sm">✕</button></form>
|
||||
<form method="post" action="/service/coefficients/delete" style="display:inline" id="delete-coeff-form-{{ c.id }}"><input type="hidden" name="id" value="{{ c.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteCoeff({{ c.id }},'{{ c.key|e }}')" title="Удалить">✕</button></form>
|
||||
</td>
|
||||
</tr>{% endfor %}</tbody></table>
|
||||
</div>
|
||||
@@ -65,7 +65,35 @@
|
||||
<div id="formula-info-content" style="margin-top:12px"></div>
|
||||
</div></div>
|
||||
|
||||
<div class="modal-overlay" id="confirm-delete-coeff">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Удалить коэффициент?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
<strong id="delete-coeff-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirmDeleteCoeff()">Удалить</button>
|
||||
<button type="button" class="btn" onclick="closeConfirmDeleteCoeff()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var deleteCoeffId = null;
|
||||
function openConfirmDeleteCoeff(id, name){
|
||||
deleteCoeffId = id;
|
||||
document.getElementById('delete-coeff-name').textContent = name || '#' + id;
|
||||
document.getElementById('confirm-delete-coeff').classList.add('open');
|
||||
}
|
||||
function closeConfirmDeleteCoeff(){
|
||||
document.getElementById('confirm-delete-coeff').classList.remove('open');
|
||||
deleteCoeffId = null;
|
||||
}
|
||||
function confirmDeleteCoeff(){
|
||||
if(!deleteCoeffId) return;
|
||||
document.getElementById('delete-coeff-form-' + deleteCoeffId).submit();
|
||||
closeConfirmDeleteCoeff();
|
||||
}
|
||||
function toggleAccordion(el) {
|
||||
var arrow = el.querySelector('.accordion-arrow');
|
||||
var body = el.nextElementSibling;
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Клиенты</h2>
|
||||
{% if error %}<div class="alert alert-error" style="margin-bottom:12px;padding:8px 12px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:6px;color:var(--danger);font-size:13px;">{{ error }}</div>{% endif %}
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-customer').classList.add('open')">+ Добавить</button>
|
||||
</div>
|
||||
<form method="get" class="search-box"><input type="text" name="search" placeholder="Поиск по названию или ИНН..." value="{{ search }}"><button class="btn btn-primary btn-sm">Найти</button></form>
|
||||
<form method="get" class="filter-bar"><input type="text" name="search" placeholder="Поиск по названию или ИНН..." value="{{ search }}"><button class="btn btn-primary btn-sm">Найти</button></form>
|
||||
<div class="table-wrap">
|
||||
<table><thead><tr><th>Название</th><th>ИНН</th><th>Контакты</th><th>Статус</th><th>Объекты</th><th>Активные SLA</th><th class="actions"></th></tr></thead>
|
||||
<tbody>{% for d in customers %}<tr>
|
||||
@@ -18,8 +17,8 @@
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="editCustomer({{ d.c.id }},'{{ d.c.name|e }}','{{ d.c.inn|e }}','{{ d.c.kpp|e }}','{{ d.c.legal_address|e }}','{{ d.c.contact_person|e }}','{{ d.c.contact_phone|e }}','{{ d.c.contact_email|e }}','{{ d.c.status }}','{{ d.c.notes|e }}')">✎</button>
|
||||
{% if user.role == 'owner' %}
|
||||
<form method="post" action="/service/customers/delete" style="display:inline" onsubmit="return confirm('Удалить клиента?')">
|
||||
<input type="hidden" name="id" value="{{ d.c.id }}"><button class="btn btn-danger btn-sm">✕</button>
|
||||
<form method="post" action="/service/customers/delete" style="display:inline" id="delete-customer-form-{{ d.c.id }}">
|
||||
<input type="hidden" name="id" value="{{ d.c.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteCustomer({{ d.c.id }},'{{ d.c.name|e }}')" title="Удалить">✕</button>
|
||||
</form>{% endif %}
|
||||
</td>
|
||||
</tr>{% endfor %}</tbody></table>
|
||||
@@ -53,7 +52,34 @@
|
||||
</div>
|
||||
</form>
|
||||
</div></div>
|
||||
<div class="modal-overlay" id="confirm-delete-customer">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Удалить клиента?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
<strong id="delete-customer-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirmDeleteCustomer()">Удалить</button>
|
||||
<button type="button" class="btn" onclick="closeConfirmDeleteCustomer()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var deleteCustomerId = null;
|
||||
function openConfirmDeleteCustomer(id, name){
|
||||
deleteCustomerId = id;
|
||||
document.getElementById('delete-customer-name').textContent = name || '#' + id;
|
||||
document.getElementById('confirm-delete-customer').classList.add('open');
|
||||
}
|
||||
function closeConfirmDeleteCustomer(){
|
||||
document.getElementById('confirm-delete-customer').classList.remove('open');
|
||||
deleteCustomerId = null;
|
||||
}
|
||||
function confirmDeleteCustomer(){
|
||||
if(!deleteCustomerId) return;
|
||||
document.getElementById('delete-customer-form-' + deleteCustomerId).submit();
|
||||
closeConfirmDeleteCustomer();
|
||||
}
|
||||
function phoneMask(input){
|
||||
var x = input.value.replace(/\D/g, '').match(/(\d{0,1})(\d{0,3})(\d{0,3})(\d{0,2})(\d{0,2})/);
|
||||
if (!x) return;
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
<td><small>{{ c.formula_ref }}</small></td>
|
||||
<td><form method="post" action="/service/coefficients/toggle"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-{{ 'success' if c.is_active else 'secondary' }} btn-sm">{{ '✓' if c.is_active else '✕' }}</button></form></td>
|
||||
<td class="actions"><button class="btn btn-secondary btn-sm" onclick="editCoeff({{ c.id }},'{{ c.key|e }}','{{ c.value }}','{{ c.description|e }}','{{ c.formula_ref|e }}')">✎</button>
|
||||
<form method="post" action="/service/coefficients/delete" style="display:inline" onsubmit="return confirm('Удалить коэффициент?')"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-danger btn-sm">✕</button></form>
|
||||
<form method="post" action="/service/coefficients/delete" style="display:inline" id="delete-formula-coeff-form-{{ c.id }}"><input type="hidden" name="id" value="{{ c.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteFormulaCoeff({{ c.id }},'{{ c.key|e }}')" title="Удалить">✕</button></form>
|
||||
</td>
|
||||
</tr>{% endfor %}</tbody></table>
|
||||
</div>
|
||||
@@ -199,7 +199,35 @@
|
||||
<div id="formula-info-content" style="margin-top:12px"></div>
|
||||
</div></div>
|
||||
|
||||
<div class="modal-overlay" id="confirm-delete-formula-coeff">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Удалить коэффициент?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
<strong id="delete-formula-coeff-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirmDeleteFormulaCoeff()">Удалить</button>
|
||||
<button type="button" class="btn" onclick="closeConfirmDeleteFormulaCoeff()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var deleteFormulaCoeffId = null;
|
||||
function openConfirmDeleteFormulaCoeff(id, name){
|
||||
deleteFormulaCoeffId = id;
|
||||
document.getElementById('delete-formula-coeff-name').textContent = name || '#' + id;
|
||||
document.getElementById('confirm-delete-formula-coeff').classList.add('open');
|
||||
}
|
||||
function closeConfirmDeleteFormulaCoeff(){
|
||||
document.getElementById('confirm-delete-formula-coeff').classList.remove('open');
|
||||
deleteFormulaCoeffId = null;
|
||||
}
|
||||
function confirmDeleteFormulaCoeff(){
|
||||
if(!deleteFormulaCoeffId) return;
|
||||
document.getElementById('delete-formula-coeff-form-' + deleteFormulaCoeffId).submit();
|
||||
closeConfirmDeleteFormulaCoeff();
|
||||
}
|
||||
function switchTab(name){
|
||||
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); });
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<button class="btn btn-secondary btn-sm" title="Переключить статус">↻</button>
|
||||
</form>
|
||||
<button class="btn btn-secondary btn-sm" onclick="editIdea({{ idea.id }},{{ idea.title|tojson }},{{ idea.description|tojson }})">✎</button>
|
||||
<form method="post" action="/service/ideas/delete" style="display:inline" onsubmit="return confirm('Удалить идею?')">
|
||||
<form method="post" action="/service/ideas/delete" style="display:inline" id="delete-idea-form-{{ idea.id }}">
|
||||
<input type="hidden" name="id" value="{{ idea.id }}">
|
||||
<button class="btn btn-danger btn-sm">✕</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteIdea({{ idea.id }},{{ idea.title|tojson }})" title="Удалить">✕</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>{% endfor %}</tbody></table>
|
||||
@@ -38,7 +38,35 @@
|
||||
</form>
|
||||
</div></div>
|
||||
|
||||
<div class="modal-overlay" id="confirm-delete-idea">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Удалить идею?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
<strong id="delete-idea-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirmDeleteIdea()">Удалить</button>
|
||||
<button type="button" class="btn" onclick="closeConfirmDeleteIdea()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var deleteIdeaId = null;
|
||||
function openConfirmDeleteIdea(id, name){
|
||||
deleteIdeaId = id;
|
||||
document.getElementById('delete-idea-name').textContent = name || '#' + id;
|
||||
document.getElementById('confirm-delete-idea').classList.add('open');
|
||||
}
|
||||
function closeConfirmDeleteIdea(){
|
||||
document.getElementById('confirm-delete-idea').classList.remove('open');
|
||||
deleteIdeaId = null;
|
||||
}
|
||||
function confirmDeleteIdea(){
|
||||
if(!deleteIdeaId) return;
|
||||
document.getElementById('delete-idea-form-' + deleteIdeaId).submit();
|
||||
closeConfirmDeleteIdea();
|
||||
}
|
||||
function editIdea(id,title,description){
|
||||
document.getElementById('modal-idea').classList.add('open');
|
||||
document.getElementById('modal-idea-title').textContent = 'Редактировать идею';
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="editObject({{ o.id }},'{{ o.name|e }}','{{ o.customer_id or '' }}','{{ o.object_type|e }}','{{ o.address|e }}','{{ o.contact_person|e }}','{{ o.contact_phone|e }}','{{ o.status }}','{{ o.notes|e }}')">✎</button>
|
||||
{% if user.role == 'owner' %}
|
||||
<form method="post" action="/service/objects/delete" style="display:inline" onsubmit="return confirm('Удалить объект?')"><input type="hidden" name="id" value="{{ o.id }}"><button class="btn btn-danger btn-sm">✕</button></form>
|
||||
<form method="post" action="/service/objects/delete" style="display:inline" id="delete-object-form-{{ o.id }}"><input type="hidden" name="id" value="{{ o.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteObject({{ o.id }},'{{ o.name|e }}')" title="Удалить">✕</button></form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>{% endfor %}</tbody></table>
|
||||
@@ -48,7 +48,34 @@
|
||||
</div>
|
||||
</form>
|
||||
</div></div>
|
||||
<div class="modal-overlay" id="confirm-delete-object">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Удалить объект?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
<strong id="delete-object-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirmDeleteObject()">Удалить</button>
|
||||
<button type="button" class="btn" onclick="closeConfirmDeleteObject()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var deleteObjectId = null;
|
||||
function openConfirmDeleteObject(id, name){
|
||||
deleteObjectId = id;
|
||||
document.getElementById('delete-object-name').textContent = name || '#' + id;
|
||||
document.getElementById('confirm-delete-object').classList.add('open');
|
||||
}
|
||||
function closeConfirmDeleteObject(){
|
||||
document.getElementById('confirm-delete-object').classList.remove('open');
|
||||
deleteObjectId = null;
|
||||
}
|
||||
function confirmDeleteObject(){
|
||||
if(!deleteObjectId) return;
|
||||
document.getElementById('delete-object-form-' + deleteObjectId).submit();
|
||||
closeConfirmDeleteObject();
|
||||
}
|
||||
function editObject(id,name,cid,type,addr,person,phone,status,notes){
|
||||
document.getElementById('modal-object').classList.add('open');
|
||||
document.getElementById('modal-object-title').textContent = 'Редактировать объект';
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<td><form method="post" action="/service/questionnaire-config/toggle"><input type="hidden" name="id" value="{{ qi.id }}"><button class="btn btn-{{ 'success' if qi.is_active else 'secondary' }} btn-sm">{{ '✓' if qi.is_active else '✕' }}</button></form></td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="editQItem({{ qi.id }},{{ qi.step }},'{{ qi.section|e }}','{{ qi.question_key|e }}','{{ qi.label|e }}','{{ qi.type }}','{{ qi.required|lower }}','{{ qi.sort_order }}','{{ qi.help_text|e }}')">✎</button>
|
||||
<form method="post" action="/service/questionnaire-config/delete" style="display:inline" onsubmit="return confirm('Удалить вопрос?')"><input type="hidden" name="id" value="{{ qi.id }}"><button class="btn btn-danger btn-sm">✕</button></form>
|
||||
<form method="post" action="/service/questionnaire-config/delete" style="display:inline" id="delete-qitem-form-{{ qi.id }}"><input type="hidden" name="id" value="{{ qi.id }}"><button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteQitem({{ qi.id }},'{{ qi.label|e }}')" title="Удалить">✕</button></form>
|
||||
</td>
|
||||
</tr>{% endfor %}</tbody></table>
|
||||
</div>
|
||||
@@ -40,7 +40,34 @@
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
|
||||
</form>
|
||||
</div></div>
|
||||
<div class="modal-overlay" id="confirm-delete-qitem">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Удалить вопрос?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
<strong id="delete-qitem-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirmDeleteQitem()">Удалить</button>
|
||||
<button type="button" class="btn" onclick="closeConfirmDeleteQitem()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var deleteQitemId = null;
|
||||
function openConfirmDeleteQitem(id, name){
|
||||
deleteQitemId = id;
|
||||
document.getElementById('delete-qitem-name').textContent = name || '#' + id;
|
||||
document.getElementById('confirm-delete-qitem').classList.add('open');
|
||||
}
|
||||
function closeConfirmDeleteQitem(){
|
||||
document.getElementById('confirm-delete-qitem').classList.remove('open');
|
||||
deleteQitemId = null;
|
||||
}
|
||||
function confirmDeleteQitem(){
|
||||
if(!deleteQitemId) return;
|
||||
document.getElementById('delete-qitem-form-' + deleteQitemId).submit();
|
||||
closeConfirmDeleteQitem();
|
||||
}
|
||||
function editQItem(id,step,section,key,label,type,required,order,help){
|
||||
document.getElementById('modal-qitem').classList.add('open');
|
||||
document.getElementById('modal-qitem-title').textContent = 'Редактировать вопрос';
|
||||
|
||||
@@ -99,7 +99,7 @@ function loadPermissions(){
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({role: role, permissions: perms})
|
||||
}).then(function(r){ return r.json() }).then(function(d){
|
||||
if(d.ok) alert('Настройки роли "' + role + '" сохранены');
|
||||
if(d.ok) showNotification('Настройки роли "' + role + '" сохранены', 'success');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<td><span class="badge badge-{{ t.status }}">{{ {'open':'Открыта','in_progress':'В работе','completed':'Завершена','cancelled':'Отменена'}[t.status] }}</span></td>
|
||||
<td><small>{{ t.deadline.strftime('%d.%m.%Y') if t.deadline else '' }}</small></td>
|
||||
<td class="actions">{% if t.status in ['open','in_progress'] and (user.role in ['owner','engineer'] or t.assigned_to == user.id) %}
|
||||
<form method="post" action="/service/tasks/close" style="display:inline" onsubmit="return confirm('Завершить задачу?')"><input type="hidden" name="id" value="{{ t.id }}"><button class="btn btn-success btn-sm">✓ Закрыть</button></form>
|
||||
<form method="post" action="/service/tasks/close" style="display:inline" id="close-task-form-{{ t.id }}"><input type="hidden" name="id" value="{{ t.id }}"><button type="button" class="btn btn-success btn-sm" onclick="openConfirmCloseTask({{ t.id }},'{{ t.title|e }}')" title="Завершить">✓ Закрыть</button></form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>{% endfor %}</tbody></table>
|
||||
@@ -43,4 +43,33 @@
|
||||
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
|
||||
</form>
|
||||
</div></div>
|
||||
<div class="modal-overlay" id="confirm-close-task">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Завершить задачу?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
<strong id="delete-task-name"></strong> будет завершена. Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-success" onclick="confirmCloseTask()">Завершить</button>
|
||||
<button type="button" class="btn" onclick="closeConfirmCloseTask()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var closeTaskId = null;
|
||||
function openConfirmCloseTask(id, name){
|
||||
closeTaskId = id;
|
||||
document.getElementById('delete-task-name').textContent = name || '#' + id;
|
||||
document.getElementById('confirm-close-task').classList.add('open');
|
||||
}
|
||||
function closeConfirmCloseTask(){
|
||||
document.getElementById('confirm-close-task').classList.remove('open');
|
||||
closeTaskId = null;
|
||||
}
|
||||
function confirmCloseTask(){
|
||||
if(!closeTaskId) return;
|
||||
document.getElementById('close-task-form-' + closeTaskId).submit();
|
||||
closeConfirmCloseTask();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="editUser({{ u.id }},'{{ u.login }}','{{ u.full_name }}','{{ u.role }}','{{ u.phone }}','{{ u.email }}','{{ u.max_user_id or '' }}',{{ 'true' if u.is_active else 'false' }})">✎</button>
|
||||
{% if u.role != 'owner' %}
|
||||
<form method="post" action="/service/users/delete" style="display:inline" onsubmit="return confirm('Удалить пользователя?')">
|
||||
<form method="post" action="/service/users/delete" style="display:inline" id="delete-user-form-{{ u.id }}">
|
||||
<input type="hidden" name="id" value="{{ u.id }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">✕</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="openConfirmDeleteUser({{ u.id }},'{{ u.login|e }}')" title="Удалить">✕</button>
|
||||
</form>{% endif %}
|
||||
</td>
|
||||
</tr>{% endfor %}</tbody></table>
|
||||
@@ -53,7 +53,34 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-overlay" id="confirm-delete-user">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<h2 style="font-size:16px;margin-bottom:8px;">Удалить пользователя?</h2>
|
||||
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:20px;">
|
||||
<strong id="delete-user-name"></strong> будет удалён навсегда. Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class="form-actions" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirmDeleteUser()">Удалить</button>
|
||||
<button type="button" class="btn" onclick="closeConfirmDeleteUser()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var deleteUserId = null;
|
||||
function openConfirmDeleteUser(id, name){
|
||||
deleteUserId = id;
|
||||
document.getElementById('delete-user-name').textContent = name || '#' + id;
|
||||
document.getElementById('confirm-delete-user').classList.add('open');
|
||||
}
|
||||
function closeConfirmDeleteUser(){
|
||||
document.getElementById('confirm-delete-user').classList.remove('open');
|
||||
deleteUserId = null;
|
||||
}
|
||||
function confirmDeleteUser(){
|
||||
if(!deleteUserId) return;
|
||||
document.getElementById('delete-user-form-' + deleteUserId).submit();
|
||||
closeConfirmDeleteUser();
|
||||
}
|
||||
function editUser(id,login,name,role,phone,email,maxId,active){
|
||||
document.getElementById('modal-user').classList.add('open');
|
||||
document.getElementById('modal-user-title').textContent = 'Редактировать сотрудника';
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
-- Миграция 001: Инфраструктура для фазы лидов и звонков.
|
||||
-- Дата: 04.06.2026
|
||||
-- Описание: Расширение существующих таблиц + создание новых.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════
|
||||
-- 1. Новые таблицы (создаём ПЕРВЫМИ, чтобы FK ссылались)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- 1.1 Таблица лидов
|
||||
CREATE TABLE IF NOT EXISTS leads (
|
||||
id SERIAL PRIMARY KEY,
|
||||
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||
company_name VARCHAR(255) DEFAULT '',
|
||||
contact_name VARCHAR(255) DEFAULT '',
|
||||
contact_phone VARCHAR(50) DEFAULT '',
|
||||
contact_email VARCHAR(255) DEFAULT '',
|
||||
inn VARCHAR(12) DEFAULT '',
|
||||
title VARCHAR(500) DEFAULT '',
|
||||
service_type VARCHAR(255) DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
urgency VARCHAR(10) DEFAULT 'medium',
|
||||
status VARCHAR(30) DEFAULT 'new',
|
||||
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
bot_ticket_id BIGINT,
|
||||
deal_amount NUMERIC(12, 2) DEFAULT 0,
|
||||
expected_close_date DATE,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_leads_customer ON leads(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_leads_status ON leads(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_leads_source ON leads(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_leads_assigned ON leads(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_leads_created ON leads(created_at);
|
||||
|
||||
-- 1.2 Таблица звонков (CDR из Yeastar S20)
|
||||
CREATE TABLE IF NOT EXISTS call_records (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cdr_uniqueid VARCHAR(32) UNIQUE,
|
||||
call_datetime TIMESTAMP NOT NULL,
|
||||
caller_number VARCHAR(80) DEFAULT '',
|
||||
callee_number VARCHAR(80) DEFAULT '',
|
||||
trunk VARCHAR(80) DEFAULT '',
|
||||
duration INTEGER DEFAULT 0,
|
||||
billable_seconds INTEGER DEFAULT 0,
|
||||
disposition VARCHAR(30) DEFAULT '',
|
||||
call_type VARCHAR(20) DEFAULT '',
|
||||
queue_name VARCHAR(30) DEFAULT '',
|
||||
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL,
|
||||
answered_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
answered_by_name VARCHAR(100) DEFAULT '',
|
||||
has_recording BOOLEAN DEFAULT FALSE,
|
||||
recording_path VARCHAR(500) DEFAULT '',
|
||||
recording_filename VARCHAR(200) DEFAULT '',
|
||||
notification_sent BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_call_records_customer ON call_records(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_call_records_datetime ON call_records(call_datetime);
|
||||
CREATE INDEX IF NOT EXISTS idx_call_records_caller ON call_records(caller_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_call_records_disposition ON call_records(disposition);
|
||||
CREATE INDEX IF NOT EXISTS idx_call_records_uniqueid ON call_records(cdr_uniqueid);
|
||||
|
||||
-- 1.3 Таблица уведомлений
|
||||
CREATE TABLE IF NOT EXISTS client_notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL,
|
||||
call_record_id INTEGER REFERENCES call_records(id) ON DELETE SET NULL,
|
||||
type VARCHAR(30) NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
message TEXT DEFAULT '',
|
||||
is_read BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user ON client_notifications(user_id, is_read);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON client_notifications(is_read, created_at);
|
||||
|
||||
-- 1.4 Таблица настроек АТС (всегда одна запись)
|
||||
CREATE TABLE IF NOT EXISTS pbx_settings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
mysql_host VARCHAR(255) DEFAULT '192.168.1.150',
|
||||
mysql_port INTEGER DEFAULT 3306,
|
||||
mysql_user VARCHAR(100) DEFAULT '',
|
||||
mysql_password VARCHAR(255) DEFAULT '',
|
||||
mysql_database VARCHAR(100) DEFAULT 'cdr',
|
||||
ami_host VARCHAR(255) DEFAULT '192.168.1.150',
|
||||
ami_port INTEGER DEFAULT 5038,
|
||||
ami_user VARCHAR(100) DEFAULT '',
|
||||
ami_password VARCHAR(255) DEFAULT '',
|
||||
ftp_host VARCHAR(255) DEFAULT '192.168.1.150',
|
||||
ftp_port INTEGER DEFAULT 21,
|
||||
ftp_user VARCHAR(100) DEFAULT '',
|
||||
ftp_password VARCHAR(255) DEFAULT '',
|
||||
web_host VARCHAR(255) DEFAULT '192.168.1.150',
|
||||
web_port INTEGER DEFAULT 8088,
|
||||
web_user VARCHAR(100) DEFAULT '',
|
||||
web_password VARCHAR(255) DEFAULT '',
|
||||
serial_number VARCHAR(50) DEFAULT '',
|
||||
actual_serial VARCHAR(50) DEFAULT '',
|
||||
mysql_status VARCHAR(20) DEFAULT 'unknown',
|
||||
ami_status VARCHAR(20) DEFAULT 'unknown',
|
||||
ftp_status VARCHAR(20) DEFAULT 'unknown',
|
||||
serial_match BOOLEAN DEFAULT FALSE,
|
||||
last_sync_at TIMESTAMP,
|
||||
sync_interval INTEGER DEFAULT 5,
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO pbx_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════
|
||||
-- 2. Изменения существующих таблиц
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- 2.1 Users: внутренний номер АТС
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS extension VARCHAR(10) DEFAULT '';
|
||||
|
||||
-- 2.2 Customers: поля CRM + расширенный статус
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS lead_source VARCHAR(20) DEFAULT '';
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_call_at TIMESTAMP;
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS total_calls INTEGER DEFAULT 0;
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS total_leads INTEGER DEFAULT 0;
|
||||
ALTER TABLE customers DROP CONSTRAINT IF EXISTS ck_customers_status;
|
||||
ALTER TABLE customers ADD CONSTRAINT ck_customers_status
|
||||
CHECK (status IN ('active','inactive','prospect','draft','merged'));
|
||||
|
||||
-- 2.3 Tasks: связь с лидом
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL;
|
||||
|
||||
-- 2.4 AuditLog: расширенное аудирование
|
||||
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS entity_type VARCHAR(50) DEFAULT '';
|
||||
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS entity_id INTEGER DEFAULT 0;
|
||||
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS field VARCHAR(100) DEFAULT '';
|
||||
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS old_value TEXT DEFAULT '';
|
||||
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS new_value TEXT DEFAULT '';
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
|
||||
@@ -11,6 +11,7 @@ itsdangerous==2.2.0
|
||||
markdown==3.7
|
||||
pydantic==2.10.3
|
||||
pydantic-settings==2.7.0
|
||||
cryptography>=44.0.0
|
||||
|
||||
# Test
|
||||
pytest>=9.0.0
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.8.2
|
||||
1.9.0
|
||||
|
||||
Reference in New Issue
Block a user