v1.7.0: refactor max_bot to flat structure, add VCF+UserModel+NLP history context, portal pages and proxy fixes

- Refactored max_bot from nested packages to flat module structure
- Q2: Extended BotUser model (patronymic, email, org, address, vcf_raw, contact_hash, phone_verified, email_verified, last_interaction, total_conversations, total_tickets)
- Q2: VCF parser (FN, N, TEL, EMAIL, ORG, ADR), upsert on re-contact, NLP history context (_get_user_history_context -> YandexGPT)
- Q1: Broadcast preview modal with 10s confirmation timer
- Q3: CSS var(--white)->var(--bg-card), var(--text)->var(--text-primary)
- Q4: bot_settings showNotification(), editable max_bot_id
- Q5: Webhook secret passthrough via X-Max-Bot-Api-Secret
- Masking sensitive keys, dialog_cleared handler, migrate via _add_column_if_not_exists()
- Rate limit (asyncio.sleep 0.5 per 10), dead code removed, conv.intent context in contact.py
- Portal pages: bot_consent, bot_kb (edit), bot_settings, bot_test, bot_tickets, portal_settings
- Tests: 21/21 passing, added test_yandex_gpt.py, test_email_sender.py
- Deploy: deploy_full.sh, schema.sql, seed_knowledge_base.sql
This commit is contained in:
2026-05-29 02:30:30 +03:00
parent 493e0b37a1
commit 72b6879f4b
234 changed files with 26768 additions and 6240 deletions
+6
View File
@@ -0,0 +1,6 @@
docs/
permissions/
uploads/
.git/
__pycache__/
*.pyc
+1
View File
@@ -3,3 +3,4 @@ SECRET_KEY=change-me-to-random-string
SESSION_TTL=3600
APP_ENV=development
LOG_LEVEL=debug
PORTAINER_URL=http://81.177.141.34:9001
+57
View File
@@ -1,6 +1,63 @@
# Changelog — AegisOne Service Portal
## 1.7.0 (29.05.2026)
### Новые функции
- **Прокси-роуты для бота:** /api/bot/webhook с пробросом X-Max-Bot-Api-Secret
- **Страницы бота:** bot_consent.html (превью + таймер 10 сек), bot_kb.html (✏️ редактирование), bot_settings.html (showNotification, max_bot_id)
- **Тосты:** глобальная showNotification() в base.html, CSS-классы .toast-* в service.css
- **CSS-переменные:** var(--white)→var(--bg-card), var(--text)→var(--text-primary) во всех шаблонах
### Исправления
- Проброс secret через webhook-прокси
## 1.5.5 (23.05.2026)
### Новые функции
- Этап 4 завершён: все 8 feature-обработчиков (risk_score, sla_status, photo_report, appointment, quality_rate, commercial, my_objects, sla_ticket)
- max_notifications.py: notify_operator, notify_level2, notify_client, notify_handoff_escalation, notify_ticket_created
- emotion_detector.py + categorizer.py: GPT с fallback на keywords
- Handoff-диалоги (/service/bot-settings/handoffs) с polling и кнопкой "Взять в работу"
- Уведомления бота (/service/bot-settings/notifications) + mark-read
- API: handoffs, notifications, risk-questions (CRUD), tickets (list/get/create/message/status), engineers
- Страница "Вопросы риск-инжиниринга" (bot_risk_questions.html) — управление через портал
- Страница "Заявки" (bot_tickets.html) — просмотр, создание, детали, сообщения, смена статуса
- risk_score.py переписан: чтение вопросов из БД (BotRiskQuestion) вместо хардкода
- SQL миграция v1.5.5: CREATE TABLE для bot_notifications, bot_notification_subscriptions, bot_risk_questions, bot_tickets, bot_ticket_messages, bot_ticket_statuses + seed-данные
- MaxAPIClient.test_mode — заглушка для тестового прогона без отправки в MAX
- /api/bot/test-run — вызывает handle_update с test_mode, симулируя настоящий диалог
- portal_settings.html: кнопка тестового прогона с живой симуляцией
- role_settings.html: новые permission (bot_tickets, bot_notifications, bot_risk_questions)
- Сайдбар: добавлены "Заявки" и "Вопросы риск-инжиниринга" в раздел Чат-бот Max
- Пользователи (users.html): колонка MAX ID + поле в форме создания/редактирования
### Исправления и улучшения
- bot_engine.py: callback `rate_*` → прямой вызов quality_rate handler
- bot_engine.py: добавлен вызов notify_handoff_escalation при негативных эмоциях
- Users stub: login, full_name, role, is_active, max_user_id
- Proxy-роуты py_service: добавлены все API для risk-questions, tickets, engineers
- Тесты max_bot: проверка всех роутов
- **Добавлена фича "Напоминания"** — reminders.py handler, состояние FEATURE_REMINDERS в FSM, кнопка в меню
- **broadcasts.py зарегистрирован** — добавлен в _handle_feature (main_menu.py) и в build_main_menu_buttons (inline.py)
- **FSM.transition("done")** — все feature-хендлеры теперь возвращают в MAIN_MENU вместо IDLE, показывают меню
- **FSM изоляция** — risk_score, appointment, commercial переписаны с set_temp/get_temp вместо прямого доступа к _states
- **settings_cache.py** — исправлены get_phones() (парсит phone_1/phone_2) и get_email() (читает support_email)
- **Delete forms** — bot_categories.html, bot_kb.html, bot_settings.html: заменены form POST на fetch() с JSON
- **SQL миграция v1.5.5 исправлена** — bot_notifications + recipient_max_user_id/related_*, подписки с boolean-колонками
- **Версия обновлена до 1.5.5
## 1.5.4 (22.05.2026)
### Новые функции
- Страница Handoff-диалогов (bot_handoffs.html) с polling
- Страница Уведомлений бота (bot_notifications.html) с mark-read
- API: handoffs, notifications, risk-questions, tickets, engineers (max_bot/main.py)
- max_notifications.py: уведомления операторам, level2, клиентам, эскалация
- emotion_detector.py + categorizer.py: модули GPT + keyword fallback
- risk_score.py: анкета 7 вопросов с in-memory хранением через FSM
- Уведомления FSM (notifications.py + broadcasts.py): подписка / отписка
- Users stub (login, full_name, role, max_user_id)
- Пользователи: колонка MAX ID + поле в форме (users.html)
- SQL миграция v1.5.5 (черновик)
### Исправления и улучшения
- risk_score.py: переключение с хардкода на БД (BotRiskQuestion)
- bot_engine.py: маршрутизация feature-состояний через _feature_handlers
- bot_engine.py: callback rate_* → прямой вызов handle_quality_rate
## 1.5.3 (22.05.2026)
### Новые функции
+1 -6
View File
@@ -2,13 +2,8 @@ FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir --only-binary :all: -r requirements.txt
COPY . .
+3 -3
View File
@@ -1,5 +1,5 @@
from fastapi import Request, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi import Request, Depends, HTTPException
from fastapi.responses import RedirectResponse
def get_current_user(request: Request) -> dict | None:
@@ -11,6 +11,6 @@ def require_role(*roles: str):
if not user:
return RedirectResponse(url="/service/login", status_code=302)
if roles and user["role"] not in roles:
return HTMLResponse(content="Доступ запрещён", status_code=403)
raise HTTPException(status_code=403, detail="Доступ запрещён")
return user
return dependency
+163 -154
View File
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from datetime import datetime
from app.config import get_settings
from app.services.docs_permissions import load_permissions, save_permissions, discover_docs, can_access_doc, get_allowed_docs
from app.dependencies import get_current_user, require_role
@@ -15,6 +16,22 @@ router = APIRouter(prefix="/service/documents", tags=["documents"])
from app.templating import templates
def now_str():
return datetime.now().strftime("%d.%m.%Y %H:%M")
def _doc_ctx(request, user, **kw):
original_user = request.session.get("original_user")
available_roles = {"owner": ["owner", "engineer", "technician"], "engineer": ["engineer", "technician"]}
c = {"request": request, "user": user, "now": now_str(),
"role_permissions": getattr(request.state, 'role_permissions', {}),
"role_override_active": original_user is not None,
"original_role": original_user["role"] if original_user else None,
"available_roles": available_roles.get(user["role"], [user["role"]]) if user else []}
c.update(kw)
return c
def transliterate_slug(text: str) -> str:
translit_map = {
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', 'ж': 'zh',
@@ -41,157 +58,7 @@ async def documents_list(
user: dict = Depends(get_current_user),
):
docs = get_allowed_docs(user["role"])
return templates.TemplateResponse("documents/list.html", {
"request": request, "user": user, "docs": docs,
})
@router.get("/{slug}")
async def document_view(
slug: str,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if not can_access_doc(user["role"], slug, "view"):
return HTMLResponse(content="Доступ запрещён", status_code=403)
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
if not os.path.exists(filepath):
return HTMLResponse(content="Файл не найден", status_code=404)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
html_content = render_markdown(content)
return templates.TemplateResponse("documents/view.html", {
"request": request, "user": user, "doc": doc, "content": html_content,
"can_edit": can_access_doc(user["role"], slug, "edit"),
"can_download": user["role"] == "owner",
})
@router.get("/{slug}/edit")
async def document_edit(
slug: str,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if not can_access_doc(user["role"], slug, "edit"):
return HTMLResponse(content="Доступ запрещён", status_code=403)
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
if not os.path.exists(filepath):
return HTMLResponse(content="Файл не найден", status_code=404)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
return templates.TemplateResponse("documents/edit.html", {
"request": request, "user": user, "doc": doc, "content": content,
})
@router.post("/{slug}/edit")
async def document_update(
slug: str,
request: Request,
content: str = Form(...),
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if not can_access_doc(user["role"], slug, "edit"):
return HTMLResponse(content="Доступ запрещён", status_code=403)
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
return RedirectResponse(url=f"/service/documents/{slug}", status_code=302)
@router.get("/tech-access")
async def tech_access_page(
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "engineer":
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
perms = discover_docs()
editable_docs = [
doc for slug, doc in perms.items()
if doc.get("permissions", {}).get("engineer", {}).get("edit", False)
]
return templates.TemplateResponse("documents/tech_access.html", {
"request": request, "user": user, "docs": editable_docs, "perms": perms,
})
@router.post("/tech-access")
async def tech_access_update(
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "engineer":
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
form = await request.form()
perms = discover_docs()
for slug in perms.keys():
if not perms[slug].get("permissions", {}).get("engineer", {}).get("edit", False):
continue
technician_view = form.get(f"{slug}_technician_view") == "on"
perms[slug]["permissions"]["technician"] = {"view": technician_view}
save_permissions(perms)
return RedirectResponse(url="/service/documents/tech-access", status_code=302)
@router.get("/{slug}/download")
async def document_download(
slug: str,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "owner":
return HTMLResponse(content="Скачивание доступно только владельцу", status_code=403)
from fastapi.responses import FileResponse
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
if not os.path.exists(filepath):
return HTMLResponse(content="Файл не найден", status_code=404)
return FileResponse(filepath, filename=doc["filename"], media_type="text/markdown")
return templates.TemplateResponse("documents/list.html", _doc_ctx(request, user, docs=docs))
@router.get("/admin/permissions")
@@ -204,9 +71,7 @@ async def permissions_page(
return HTMLResponse(content="Доступ запрещён", status_code=403)
perms = discover_docs()
return templates.TemplateResponse("documents/permissions.html", {
"request": request, "user": user, "perms": perms,
})
return templates.TemplateResponse("documents/permissions.html", _doc_ctx(request, user, perms=perms))
@router.post("/admin/permissions")
@@ -280,3 +145,147 @@ async def document_create(
save_permissions(perms)
return RedirectResponse(url=f"/service/documents/{slug}/edit", status_code=302)
@router.get("/tech-access")
async def tech_access_page(
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "engineer":
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
perms = discover_docs()
editable_docs = [
doc for slug, doc in perms.items()
if doc.get("permissions", {}).get("engineer", {}).get("edit", False)
]
return templates.TemplateResponse("documents/tech_access.html", _doc_ctx(request, user, docs=editable_docs, perms=perms))
@router.post("/tech-access")
async def tech_access_update(
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "engineer":
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
form = await request.form()
perms = discover_docs()
for slug in perms.keys():
if not perms[slug].get("permissions", {}).get("engineer", {}).get("edit", False):
continue
technician_view = form.get(f"{slug}_technician_view") == "on"
perms[slug]["permissions"]["technician"] = {"view": technician_view}
save_permissions(perms)
return RedirectResponse(url="/service/documents/tech-access", status_code=302)
@router.get("/{slug}")
async def document_view(
slug: str,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if not can_access_doc(user["role"], slug, "view"):
return HTMLResponse(content="Доступ запрещён", status_code=403)
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
if not os.path.exists(filepath):
return HTMLResponse(content="Файл не найден", status_code=404)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
html_content = render_markdown(content)
return templates.TemplateResponse("documents/view.html", _doc_ctx(request, user,
doc=doc, content=html_content,
can_edit=can_access_doc(user["role"], slug, "edit"),
can_download=user["role"] == "owner",
))
@router.get("/{slug}/edit")
async def document_edit(
slug: str,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if not can_access_doc(user["role"], slug, "edit"):
return HTMLResponse(content="Доступ запрещён", status_code=403)
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
if not os.path.exists(filepath):
return HTMLResponse(content="Файл не найден", status_code=404)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
return templates.TemplateResponse("documents/edit.html", _doc_ctx(request, user, doc=doc, content=content))
@router.post("/{slug}/edit")
async def document_update(
slug: str,
request: Request,
content: str = Form(...),
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if not can_access_doc(user["role"], slug, "edit"):
return HTMLResponse(content="Доступ запрещён", status_code=403)
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
return RedirectResponse(url=f"/service/documents/{slug}", status_code=302)
@router.get("/{slug}/download")
async def document_download(
slug: str,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "owner":
return HTMLResponse(content="Скачивание доступно только владельцу", status_code=403)
from fastapi.responses import FileResponse
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
if not os.path.exists(filepath):
return HTMLResponse(content="Файл не найден", status_code=404)
return FileResponse(filepath, filename=doc["filename"], media_type="text/markdown")
+54 -1
View File
@@ -1,4 +1,5 @@
import os
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Depends
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
@@ -63,7 +64,7 @@ SEED_COEFFICIENTS = [
async def seed_initial_data():
from sqlalchemy import text, select, func
from app.models.models import FormulaCoefficient, User
from app.models.models import FormulaCoefficient, User, RoleMenuPermission
async with async_session() as db:
try:
@@ -74,6 +75,24 @@ async def seed_initial_data():
db.add(FC(key=key, value=value, description=desc, formula_ref=ref))
await db.commit()
# Seed default role menu permissions if table is empty
perm_count = await db.execute(select(func.count(RoleMenuPermission.id)))
if perm_count.scalar() == 0:
DEFAULT_PERMISSIONS = {
"engineer": [
"charts", "customers", "objects", "sla", "questionnaire", "passports",
"tasks", "reports", "incidents", "tech_access", "docs",
],
"technician": [
"tasks", "reports", "incidents", "checklist",
],
}
for role, keys in DEFAULT_PERMISSIONS.items():
for key in keys:
db.add(RoleMenuPermission(role=role, menu_key=key, visible=True))
await db.commit()
await db.execute(text("DELETE FROM users WHERE login = 'admin'"))
await db.commit()
except Exception:
@@ -91,6 +110,7 @@ async def lifespan(app: FastAPI):
from app.calculations.engine import reload_coefficients
await reload_coefficients(db)
discover_docs()
yield
@@ -116,6 +136,32 @@ class RoleGuardMiddleware(BaseHTTPMiddleware):
app.add_middleware(RoleGuardMiddleware)
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY, max_age=settings.SESSION_TTL)
class PermissionsMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if "session" not in request.scope:
request.state.role_permissions = {}
return await call_next(request)
from app.models.models import RoleMenuPermission
from sqlalchemy import select
user = request.session.get("user")
if user and user.get("role") in ("engineer", "technician"):
from app.database import async_session
async with async_session() as db:
result = await db.execute(
select(RoleMenuPermission).where(RoleMenuPermission.role == user["role"])
)
request.state.role_permissions = {
row.menu_key: row.visible for row in result.scalars().all()
}
else:
request.state.role_permissions = {}
response = await call_next(request)
return response
app.add_middleware(PermissionsMiddleware)
app.include_router(documents_router)
from app.routers.service_pages import router as service_pages_router
@@ -210,8 +256,15 @@ async def dashboard(request: Request, db: AsyncSession = Depends(get_db), user:
shs_alert = f"SHS ниже 70 ({stats['shs']['shs_score']}) — {stats['shs']['shs_status']}"
now = __import__("datetime").datetime.now().strftime("%d.%m.%Y %H:%M")
original_user = request.session.get("original_user")
available_roles = {"owner": ["owner", "engineer", "technician"], "engineer": ["engineer", "technician"]}
return templates.TemplateResponse("dashboard.html", {
"request": request, "stats": stats, "shs_alert": shs_alert, "now": now, "user": user,
"active_page": "dashboard",
"role_permissions": getattr(request.state, 'role_permissions', {}),
"role_override_active": original_user is not None,
"original_role": original_user["role"] if original_user else None,
"available_roles": available_roles.get(user["role"], [user["role"]]) if user else [],
})
+17
View File
@@ -17,6 +17,7 @@ class User(Base):
full_name = Column(String(255), nullable=False)
phone = Column(String(50), default="")
email = Column(String(255), default="")
max_user_id = Column(BigInteger, nullable=True)
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()")
@@ -495,3 +496,19 @@ class QuickMenuItem(Base):
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
creator = relationship("User")
class RoleMenuPermission(Base):
__tablename__ = "role_menu_permissions"
id = Column(Integer, primary_key=True, autoincrement=True)
role = Column(String(20), nullable=False)
menu_key = Column(String(50), nullable=False)
visible = Column(Boolean, nullable=False, default=True)
__table_args__ = (
CheckConstraint("role IN ('engineer','technician')", name="ck_role_menu_role"),
)
+110 -335
View File
@@ -6,6 +6,7 @@ from fastapi.responses import RedirectResponse, JSONResponse, HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, delete, or_, and_, Integer, text
from sqlalchemy.orm import selectinload, joinedload
import httpx
from app.database import get_db
from app.dependencies import get_current_user, require_role
from app.auth import audit_log
@@ -1135,45 +1136,6 @@ async def cases_delete(request: Request, db: AsyncSession = Depends(get_db), use
return RedirectResponse(url="/service/cases", status_code=302)
# ===================== BOT SETTINGS =====================
@router.get("/service/bot-settings")
async def bot_settings(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_settings.html", ctx(request, user=user, active_page="bot-settings", title="Настройки бота"))
@router.get("/service/bot-settings/responses")
async def bot_responses(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_responses.html", ctx(request, user=user, active_page="bot-responses", title="Шаблоны ответов бота"))
@router.get("/service/bot-settings/features")
async def bot_features(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_features.html", ctx(request, user=user, active_page="bot-features", title="Функции бота"))
@router.get("/service/bot-settings/categories")
async def bot_categories(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_categories.html", ctx(request, user=user, active_page="bot-categories", title="Категории обращений"))
@router.get("/service/bot-settings/kb")
async def bot_kb(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_kb.html", ctx(request, user=user, active_page="bot-kb", title="База знаний бота"))
@router.get("/service/bot-settings/conversations")
async def bot_conversations(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_conversations.html", ctx(request, user=user, active_page="bot-conversations", title="Диалоги бота"))
@router.get("/service/bot-settings/users")
async def bot_users(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_users.html", ctx(request, user=user, active_page="bot-users", title="Пользователи бота"))
@router.get("/service/bot-settings/test-runner")
async def bot_test_runner(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_test_runner.html", ctx(request, user=user, active_page="bot-test-runner", title="Тестовый прогон бота"))
@router.get("/service/bot-settings/analytics")
async def bot_analytics(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_analytics.html", ctx(request, user=user, active_page="bot-analytics", title="Аналитика бота"))
@router.get("/service/portal-settings")
async def portal_settings_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/portal_settings.html", ctx(request, user=user, active_page="portal-settings", title="Настройка портала"))
@@ -1184,6 +1146,40 @@ async def role_settings_page(request: Request, db: AsyncSession = Depends(get_db
return templates.TemplateResponse("pages/role_settings.html", ctx(request, user=user, active_page="role-settings", title="Настройка Ролей"))
# ===================== CHAT-BOT MAX PAGES =====================
@router.get("/service/bot-settings")
async def bot_settings_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
return templates.TemplateResponse("pages/bot_settings.html", ctx(request, user=user, active_page="bot-settings", title="Настройки чат-бота MAX"))
@router.get("/service/bot-kb")
async def bot_kb_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
return templates.TemplateResponse("pages/bot_kb.html", ctx(request, user=user, active_page="bot-kb", title="База знаний чат-бота"))
@router.get("/service/bot-test")
async def bot_test_page_redirect():
return RedirectResponse(url="/service/bot-settings", status_code=302)
@router.get("/service/bot-test-content")
async def bot_test_content(request: Request, user: dict = Depends(get_current_user)):
if not user:
return HTMLResponse("Not authenticated")
return templates.TemplateResponse("pages/bot_test.html", {"request": request})
@router.get("/service/bot-consent")
async def bot_consent_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
return templates.TemplateResponse("pages/bot_consent.html", ctx(request, user=user, active_page="bot-consent", title="Согласия ФЗ-152 и рассылка"))
@router.get("/service/bot-tickets")
async def bot_tickets_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
return templates.TemplateResponse("pages/bot_tickets.html", ctx(request, user=user, active_page="bot-tickets", title="Заявки от чат-бота"))
# ---- Role permissions API ----
@router.get("/service/api/role-permissions")
async def api_role_permissions_get(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
@@ -1242,332 +1238,111 @@ async def api_role_reset(request: Request, user: dict = Depends(get_current_user
return JSONResponse({"ok": True, "role": request.session["user"]["role"]})
# ===================== BOT API (proxied from max_bot) =====================
# ===================== BOT API PROXY =====================
BOT_SERVICE_URL = "http://127.0.0.1:8002"
import aiohttp
MAX_BOT_URL = "http://127.0.0.1:8002"
async def _proxy(method, path, json_body=None):
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
async with session.request(method, f"{MAX_BOT_URL}{path}", json=json_body) as resp:
return JSONResponse(await resp.json())
except aiohttp.ClientError as e:
return JSONResponse({"error": f"Bot service unavailable: {e}", "ok": False}, status_code=503)
except Exception as e:
return JSONResponse({"error": str(e), "ok": False}, status_code=500)
async def _proxy(method: str, path: str, body=None):
async with httpx.AsyncClient() as client:
if body is not None:
resp = await client.request(method, f"{BOT_SERVICE_URL}{path}", json=body)
else:
resp = await client.request(method, f"{BOT_SERVICE_URL}{path}")
return JSONResponse(resp.json(), status_code=resp.status_code)
@router.get("/service/api/bot/settings")
async def api_bot_settings(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
async def proxy_bot_settings_get(user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
return await _proxy("GET", "/api/bot/settings")
@router.post("/service/api/bot/settings/save")
async def api_bot_settings_save(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
async def proxy_bot_settings_save(request: Request, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
body = await request.json()
return await _proxy("POST", "/api/bot/settings/save", body)
@router.get("/service/api/bot/templates")
async def api_bot_templates(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/templates")
@router.post("/service/api/bot/templates/save")
async def api_bot_templates_save(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/templates/save", body)
@router.get("/service/api/bot/features")
async def api_bot_features(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/features")
@router.post("/service/api/bot/features/toggle")
async def api_bot_features_toggle(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/features/toggle", body)
@router.get("/service/api/bot/categories")
async def api_bot_categories(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/categories")
@router.post("/service/api/bot/categories/create")
async def api_bot_categories_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/categories/create", body)
@router.post("/service/api/bot/categories/edit")
async def api_bot_categories_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/categories/edit", body)
@router.post("/service/api/bot/categories/delete")
async def api_bot_categories_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/categories/delete", body)
@router.get("/service/api/bot/kb")
async def api_bot_kb(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
@router.get("/api/bot/kb")
async def proxy_bot_kb_list(user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
return await _proxy("GET", "/api/bot/kb")
@router.post("/service/api/bot/kb/create")
async def api_bot_kb_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
@router.post("/api/bot/kb")
async def proxy_bot_kb_create(request: Request, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
body = await request.json()
return await _proxy("POST", "/api/bot/kb/create", body)
return await _proxy("POST", "/api/bot/kb", body)
@router.post("/service/api/bot/kb/edit")
async def api_bot_kb_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
@router.delete("/api/bot/kb/{card_id}")
async def proxy_bot_kb_delete(card_id: int, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
return await _proxy("DELETE", f"/api/bot/kb/{card_id}")
@router.get("/api/bot/tickets")
async def proxy_bot_tickets_list(user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
return await _proxy("GET", "/api/bot/tickets")
@router.patch("/api/bot/tickets/{ticket_id}")
async def proxy_bot_tickets_update(ticket_id: int, request: Request, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
body = await request.json()
return await _proxy("POST", "/api/bot/kb/edit", body)
return await _proxy("PATCH", f"/api/bot/tickets/{ticket_id}", body)
@router.post("/service/api/bot/kb/delete")
async def api_bot_kb_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
@router.put("/api/bot/kb/{card_id}")
async def proxy_bot_kb_update(card_id: int, request: Request, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
body = await request.json()
return await _proxy("POST", "/api/bot/kb/delete", body)
return await _proxy("PUT", f"/api/bot/kb/{card_id}", body)
@router.get("/service/api/bot/conversations")
async def api_bot_conversations(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/conversations")
@router.get("/service/api/bot/users")
async def api_bot_users(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")),
page: int = Query(1), limit: int = Query(20),
sort_by: str = Query("created_at"), sort_dir: str = Query("desc"),
consent: str = Query(None)):
params = f"?page={page}&limit={limit}&sort_by={sort_by}&sort_dir={sort_dir}"
if consent:
params += f"&consent={consent}"
return await _proxy("GET", f"/api/bot/users{params}")
@router.get("/api/bot/users/consented")
async def proxy_bot_users_consented(user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
return await _proxy("GET", "/api/bot/users/consented")
@router.get("/service/api/bot/analytics")
async def api_bot_analytics(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/analytics")
@router.post("/service/api/bot/test-run")
async def api_bot_test_run(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/test-run", body)
@router.get("/service/api/bot/broadcast/recipients")
async def api_bot_broadcast_recipients(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")),
q: str = Query(None), page: int = Query(1), limit: int = Query(200)):
params = f"?page={page}&limit={limit}"
if q:
params += f"&q={q}"
return await _proxy("GET", f"/api/bot/broadcast/recipients{params}")
@router.get("/service/api/bot/broadcast/history")
async def api_bot_broadcast_history(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")),
page: int = Query(1), limit: int = Query(20)):
return await _proxy("GET", f"/api/bot/broadcast/history?page={page}&limit={limit}")
@router.post("/service/api/bot/broadcast")
async def api_bot_broadcast(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
@router.post("/api/bot/broadcast")
async def proxy_bot_broadcast(request: Request, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
body = await request.json()
return await _proxy("POST", "/api/bot/broadcast", body)
@router.get("/service/bot-settings/handoffs")
async def bot_handoffs_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_handoffs.html", ctx(request, user=user, active_page="bot-handoffs", title="Handoff-диалоги"))
@router.get("/service/bot-settings/handoffs/poll")
async def bot_handoffs_poll(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/handoffs")
@router.post("/service/bot-settings/handoffs/{handoff_id}/take")
async def bot_handoff_take(handoff_id: int, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("POST", f"/api/bot/handoffs/{handoff_id}/take")
@router.get("/service/bot-settings/notifications")
async def bot_notifications_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_notifications.html", ctx(request, user=user, active_page="bot-notifications", title="Уведомления бота"))
@router.get("/service/bot-settings/notifications/data")
async def bot_notifications_data(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/notifications")
@router.post("/service/bot-settings/notifications/{notif_id}/read")
async def bot_notification_read(notif_id: int, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("POST", f"/api/bot/notifications/{notif_id}/read")
# ===================== BOT RISK QUESTIONS =====================
@router.get("/service/bot-settings/risk-questions")
async def bot_risk_questions_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_risk_questions.html", ctx(request, user=user, active_page="bot-risk-questions", title="Вопросы риск-инжиниринга"))
@router.get("/service/bot-settings/storage")
async def bot_storage_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_storage.html", ctx(request, user=user, active_page="bot-storage", title="Яндекс.Диск"))
@router.get("/service/bot-settings/broadcasts")
async def bot_broadcasts_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_broadcasts.html", ctx(request, user=user, active_page="bot-broadcasts", title="Рассылки"))
@router.get("/service/api/bot/risk-questions")
async def api_bot_risk_questions(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/risk-questions")
@router.post("/service/api/bot/risk-questions/create")
async def api_bot_risk_questions_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
@router.post("/api/bot/broadcast/preview")
async def proxy_bot_broadcast_preview(request: Request, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
body = await request.json()
return await _proxy("POST", "/api/bot/risk-questions/create", body)
return await _proxy("POST", "/api/bot/broadcast/preview", body)
@router.post("/service/api/bot/risk-questions/edit")
async def api_bot_risk_questions_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
@router.post("/api/bot/webhook")
async def proxy_bot_webhook(request: Request):
body = await request.json()
return await _proxy("POST", "/api/bot/risk-questions/edit", body)
headers = {"Content-Type": "application/json"}
secret = request.headers.get("X-Max-Bot-Api-Secret")
if secret:
headers["X-Max-Bot-Api-Secret"] = secret
async with httpx.AsyncClient() as client:
resp = await client.post(f"{BOT_SERVICE_URL}/webhook", json=body, headers=headers)
return JSONResponse(resp.json(), status_code=resp.status_code)
@router.post("/service/api/bot/risk-questions/toggle")
async def api_bot_risk_questions_toggle(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/risk-questions/toggle", body)
@router.post("/service/api/bot/risk-questions/delete")
async def api_bot_risk_questions_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/risk-questions/delete", body)
# ===================== BOT TICKETS =====================
@router.get("/service/bot-settings/tickets")
async def bot_tickets_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_tickets.html", ctx(request, user=user, active_page="bot-tickets", title="Заявки"))
@router.get("/service/api/bot/tickets")
async def api_bot_tickets(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")),
status: str = Query(None), priority: str = Query(None)):
params = ""
if status: params += f"status={status}&"
if priority: params += f"priority={priority}"
return await _proxy("GET", f"/api/bot/tickets?{params}")
@router.get("/service/api/bot/tickets/{ticket_id}")
async def api_bot_ticket_get(ticket_id: int, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", f"/api/bot/tickets/{ticket_id}")
@router.post("/service/api/bot/tickets/create")
async def api_bot_tickets_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/tickets/create", body)
@router.post("/service/api/bot/tickets/{ticket_id}/message")
async def api_bot_tickets_message(ticket_id: int, request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", f"/api/bot/tickets/{ticket_id}/message", body)
@router.post("/service/api/bot/tickets/{ticket_id}/status")
async def api_bot_tickets_status(ticket_id: int, request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", f"/api/bot/tickets/{ticket_id}/status", body)
@router.get("/service/api/bot/engineers")
async def api_bot_engineers(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/engineers")
# ===================== BOT STORAGE (Yandex Disk) =====================
async def _get_yandex_client(db: AsyncSession):
from app.yandex_disk import YandexDiskClient
result = await db.execute(text("SELECT value FROM bot_settings WHERE key = 'yandex_disk_token'"))
row = result.scalar_one_or_none()
if not row:
return None
return YandexDiskClient(row)
@router.get("/service/api/bot/storage/list")
async def api_bot_storage_list(path: str = Query(""), db: AsyncSession = Depends(get_db),
user: dict = Depends(require_role("owner"))):
client = await _get_yandex_client(db)
if not client:
return JSONResponse({"error": "Yandex Disk not configured"}, status_code=400)
try:
items = await client.list_folder(path)
return JSONResponse(items)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
finally:
await client.close()
@router.get("/service/api/bot/storage/search")
async def api_bot_storage_search(query: str = Query(""), db: AsyncSession = Depends(get_db),
user: dict = Depends(require_role("owner"))):
client = await _get_yandex_client(db)
if not client:
return JSONResponse({"error": "Yandex Disk not configured"}, status_code=400)
try:
items = await client.search(query)
return JSONResponse(items)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
finally:
await client.close()
@router.get("/service/api/bot/storage/public-link")
async def api_bot_storage_public_link(path: str = Query(""), expiry: int = Query(30),
db: AsyncSession = Depends(get_db),
user: dict = Depends(require_role("owner"))):
client = await _get_yandex_client(db)
if not client:
return JSONResponse({"error": "Yandex Disk not configured"}, status_code=400)
try:
url = await client.get_public_url(path, expiry)
return JSONResponse({"url": url})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
finally:
await client.close()
@router.get("/service/api/bot/storage/download")
async def api_bot_storage_download(path: str = Query(""), db: AsyncSession = Depends(get_db),
user: dict = Depends(require_role("owner"))):
client = await _get_yandex_client(db)
if not client:
return JSONResponse({"error": "Yandex Disk not configured"}, status_code=400)
try:
info = await client.get_file_info(path)
dl_url = info.get("public_url", "")
if not dl_url:
dl_info = await client._request("GET", "/resources/download", params={"path": path})
dl_url = dl_info.get("href", "")
return JSONResponse({"url": dl_url})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
finally:
await client.close()
# ===================== HANDOFF CLOSE =====================
@router.post("/service/api/bot/handoffs/{handoff_id}/close")
async def api_bot_handoff_close(handoff_id: int, db: AsyncSession = Depends(get_db),
user: dict = Depends(require_role("owner"))):
return await _proxy("POST", f"/api/bot/handoffs/{handoff_id}/close")
+1 -5
View File
@@ -98,11 +98,7 @@ body {
border-color: var(--accent);
}
.theme-label {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
}
/* EasyMDE dark theme overrides */
[data-theme="dark"] .EasyMDEContainer .editor-toolbar {
+72
View File
@@ -52,8 +52,14 @@ img { max-width:100%; height:auto; }
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg-body);
}
[data-theme="dark"] .login-page {
background: linear-gradient(135deg, #0a0e1a 0%, #0d1a30 50%, #0a0e1a 100%);
}
[data-theme="light"] .login-page {
background: linear-gradient(135deg, #e8ecf1 0%, #f0f4f8 50%, #e8ecf1 100%);
}
.login-box {
background: var(--bg-card);
@@ -965,6 +971,72 @@ table .actions button {
color: var(--text-muted) !important;
}
/* ---------- ROLE SWITCHER ---------- */
.role-switcher {
position: relative;
}
.role-btn {
display: flex;
align-items: center;
gap: 4px;
background: var(--accent-dim);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
font-size: 12px;
color: var(--text-secondary);
transition: all 0.2s;
white-space: nowrap;
}
.role-btn:hover {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.role-btn-label { font-weight: 600; text-transform: capitalize; }
.role-btn-arrow { font-size: 10px; }
.role-dropdown {
display: none;
position: absolute;
top: calc(100% + 4px);
right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
min-width: 160px;
box-shadow: var(--shadow);
z-index: 100;
overflow: hidden;
}
.role-dropdown.open { display: block; }
.role-dropdown-item {
display: block;
padding: 8px 14px;
font-size: 13px;
color: var(--text-primary);
text-decoration: none;
text-transform: capitalize;
transition: background 0.15s;
}
.role-dropdown-item:hover { background: var(--accent-dim); }
.role-dropdown-item.active { font-weight: 700; color: var(--accent); }
.role-dropdown-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.role-dropdown-reset { color: var(--danger); }
.role-dropdown-reset:hover { background: rgba(239,68,68,0.1) !important; }
/* ---------- USER NAME ---------- */
.navbar__user {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
white-space: nowrap;
}
/* ---------- DOCUMENTS ---------- */
.container {
max-width: 1200px;
+38
View File
@@ -0,0 +1,38 @@
(function() {
var ROLE_NAMES = { owner: 'Владелец', engineer: 'Инженер', technician: 'Техник' };
var EMOJIS = { owner: '👑', engineer: '🔧', technician: '🛠️' };
function closeDropdown() {
var dd = document.querySelector('.role-dropdown');
if (dd) dd.classList.remove('open');
}
function switchRole(role) {
closeDropdown();
fetch('/service/api/role/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: role })
}).then(function(r) { return r.json() }).then(function(d) {
if (d.ok) { window.location.reload(); }
});
}
function resetRole() {
closeDropdown();
fetch('/service/api/role/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}).then(function(r) { return r.json() }).then(function(d) {
if (d.ok) { window.location.reload(); }
});
}
// Close dropdown on outside click
document.addEventListener('click', function(e) {
var sw = document.querySelector('.role-switcher');
if (sw && !sw.contains(e.target)) closeDropdown();
});
window.roleSwitcher = { switchRole: switchRole, resetRole: resetRole, ROLE_NAMES: ROLE_NAMES, EMOJIS: EMOJIS };
})();
+3 -6
View File
@@ -9,15 +9,12 @@
function applyTheme(theme) {
var resolved = theme === 'system' ? getSystemTheme() : theme;
document.documentElement.setAttribute('data-theme', resolved);
var label = document.getElementById('theme-label');
if (label) {
var names = { system: 'Авто', dark: 'Тёмная', light: 'Светлая' };
label.textContent = names[theme] || 'Авто';
}
var names = { system: 'Авто', dark: 'Тёмная', light: 'Светлая' };
var icons = { system: '🖥', dark: '🌙', light: '☀️' };
var btn = document.getElementById('theme-btn');
if (btn) {
var icons = { system: '🖥', dark: '🌙', light: '☀️' };
btn.textContent = icons[theme] || '🖥';
btn.title = names[theme] || 'Авто';
}
}
+5 -11
View File
@@ -26,16 +26,10 @@
{% if role_permissions.get('checklist', True) != False %}<a href="/service/checklist" class="{% if active_page == 'checklist' %}active{% endif %}">Чек-лист</a>{% endif %}
{% if role_permissions.get('tech_access', True) != False %}<a href="/service/documents/tech-access" class="{% if active_page == 'tech-access' %}active{% endif %}">Доступ техников</a>{% endif %}
<div class="sidebar-section">Чат-бот Max</div>
{% if role_permissions.get('bot_analytics', True) != False %}<a href="/service/bot-settings/analytics" class="{% if active_page == 'bot-analytics' %}active{% endif %}">Аналитика</a>{% endif %}
{% if role_permissions.get('bot_users', True) != False %}<a href="/service/bot-settings/users" class="{% if active_page == 'bot-users' %}active{% endif %}">Пользователи бота</a>{% endif %}
{% if role_permissions.get('bot_conversations', True) != False %}<a href="/service/bot-settings/conversations" class="{% if active_page == 'bot-conversations' %}active{% endif %}">Диалоги бота</a>{% endif %}
{% if role_permissions.get('bot_tickets', True) != False %}<a href="/service/bot-settings/tickets" class="{% if active_page == 'bot-tickets' %}active{% endif %}">Заявки</a>{% endif %}
{% if role_permissions.get('bot_handoffs', True) != False %}<a href="/service/bot-settings/handoffs" class="{% if active_page == 'bot-handoffs' %}active{% endif %}">Handoff-диалоги</a>{% endif %}
{% if role_permissions.get('bot_notifications', True) != False %}<a href="/service/bot-settings/notifications" class="{% if active_page == 'bot-notifications' %}active{% endif %}">Уведомления</a>{% endif %}
{% if role_permissions.get('bot_risk_questions', True) != False %}<a href="/service/bot-settings/risk-questions" class="{% if active_page == 'bot-risk-questions' %}active{% endif %}">Вопросы риск-инжиниринга</a>{% endif %}
{% if role_permissions.get('bot_storage', True) != False %}<a href="/service/bot-settings/storage" class="{% if active_page == 'bot-storage' %}active{% endif %}">Яндекс.Диск</a>{% endif %}
{% if role_permissions.get('bot_broadcasts', True) != False %}<a href="/service/bot-settings/broadcasts" class="{% if active_page == 'bot-broadcasts' %}active{% endif %}">Рассылки</a>{% endif %}
<div class="sidebar-section">Чат-бот MAX</div>
{% if role_permissions.get('bot_consent', True) != False %}<a href="/service/bot-consent" class="{% if active_page == 'bot-consent' %}active{% endif %}">Согласия ФЗ-152</a>{% endif %}
{% if role_permissions.get('bot_tickets', True) != False %}<a href="/service/bot-tickets" class="{% if active_page == 'bot-tickets' %}active{% endif %}">Заявки от бота</a>{% endif %}
{% if role_permissions.get('bot_kb', True) != False %}<a href="/service/bot-kb" class="{% if active_page == 'bot-kb' %}active{% endif %}">База знаний</a>{% endif %}
<div class="sidebar-section">Контент</div>
{% if role_permissions.get('blog', True) != False %}<a href="/service/blog" class="{% if active_page == 'blog' %}active{% endif %}">Управление блогом</a>{% endif %}
@@ -48,9 +42,9 @@
<div class="sidebar-section">Настройки</div>
<a href="/service/questionnaire-config" class="{% if active_page == 'questionnaire-config' %}active{% endif %}">Настройка опросника</a>
<a href="/service/formulas" class="{% if active_page == 'formulas' %}active{% endif %}">Формулы</a>
<a href="/service/bot-settings" class="{% if active_page == 'bot-settings' %}active{% endif %}">Чат-бот Max</a>
<a href="/service/portal-settings" class="{% if active_page == 'portal-settings' %}active{% endif %}">Настройка портала</a>
<a href="/service/role-settings" class="{% if active_page == 'role-settings' %}active{% endif %}">Настройка Ролей</a>
{% if role_permissions.get('bot_settings', True) != False %}<a href="/service/bot-settings" class="{% if active_page == 'bot-settings' %}active{% endif %}">Настройки чат-бота</a>{% endif %}
<a href="/service/ideas" class="{% if active_page == 'ideas' %}active{% endif %}">Идеи</a>
</nav>
<div class="sidebar-footer">
+39 -3
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<script>(function(){var t=localStorage.getItem('aegisone_service_theme')||'system';if(t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme:dark)').matches))document.documentElement.setAttribute('data-theme','dark');else if(t==='light'||(t==='system'&&matchMedia('(prefers-color-scheme:light)').matches))document.documentElement.setAttribute('data-theme','light');})();</script>
<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">
@@ -22,15 +23,50 @@
<a href="/service/dashboard">AegisOne Service</a>
</div>
<ul class="navbar__menu">
<li class="theme-switcher" data-tooltip="Переключить тему">
<button id="theme-btn" onclick="toggleTheme()">🖥</button>
<span class="theme-label" id="theme-label">Авто</span>
{% if available_roles|length > 1 %}
<li class="role-switcher">
<button class="role-btn" onclick="document.querySelector('.role-dropdown').classList.toggle('open')">
<span class="role-btn-label">{{ role_override_active and '⚡' or '' }} {{ user.role }}</span>
<span class="role-btn-arrow"></span>
</button>
<div class="role-dropdown">
{% for r in available_roles %}
<a href="#" class="role-dropdown-item {% if r == user.role %}active{% endif %}" onclick="event.preventDefault(); roleSwitcher.switchRole('{{ r }}')">
{{ r }}
</a>
{% endfor %}
{% if role_override_active %}
<div class="role-dropdown-divider"></div>
<a href="#" class="role-dropdown-item role-dropdown-reset" onclick="event.preventDefault(); roleSwitcher.resetRole()">
← Сбросить ({{ original_role }})
</a>
{% endif %}
</div>
</li>
{% endif %}
<li class="theme-switcher">
<button id="theme-btn" onclick="toggleTheme()" title="Авто">🖥</button>
</li>
</ul>
</nav>
{% endif %}
{% block content %}{% endblock %}
<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>
function showNotification(msg, type){
type = type || 'success';
var container = document.getElementById('toast-container');
if(!container){ container = document.createElement('div'); container.className='toast-container'; container.id='toast-container'; document.body.appendChild(container); }
var t = document.createElement('div');
t.className = 'toast toast-' + type;
t.textContent = msg;
container.appendChild(t);
setTimeout(function(){ t.style.opacity='0'; t.style.transition='opacity 0.3s'; setTimeout(function(){ t.remove() },300) }, 3000);
}
</script>
{% block extra_scripts %}{% endblock %}
</body>
</html>
+1 -143
View File
@@ -3,78 +3,7 @@
{% block content %}
<button class="sidebar-toggle" onclick="document.querySelector('.sidebar').classList.toggle('open')"></button>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo sidebar-logo-text">Aegis<span>One</span></div>
<div style="font-size:12px;color:var(--text-muted);letter-spacing:0.05em;">Service Portal</div>
</div>
<nav class="sidebar-nav">
<div class="sidebar-section">Панель</div>
<a href="/service/dashboard" class="active">Дашборд</a>
{% if user.role == 'owner' %}
<div class="sidebar-section">Управление</div>
<a href="/service/users">Сотрудники</a>
<a href="/service/customers">Клиенты</a>
<a href="/service/objects">Объекты</a>
<a href="/service/assignments">Назначение сотрудников</a>
<a href="/service/sla">SLA контракты</a>
<a href="/service/ceo">CEO дашборд</a>
<a href="/service/coefficients">Коэффициенты</a>
<a href="/service/charts">Графики</a>
<a href="/service/ideas">Идеи</a>
{% elif user.role == 'engineer' %}
<div class="sidebar-section">Управление</div>
<a href="/service/customers">Клиенты</a>
<a href="/service/objects">Объекты</a>
<a href="/service/sla">SLA контракты</a>
<a href="/service/documents/tech-access">Доступ техников к документам</a>
{% endif %}
<div class="sidebar-section">Работа</div>
{% if user.role in ['engineer', 'technician'] %}
<a href="/service/tasks">Задачи</a>
<a href="/service/reports">Отчёты</a>
<a href="/service/incidents">Инциденты</a>
{% endif %}
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/questionnaire">Опросник</a>
{% endif %}
{% if user.role == 'owner' %}
<a href="/service/questionnaire-config">Настройка опросника</a>
{% endif %}
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/passports">Паспорта объектов</a>
{% endif %}
{% if user.role == 'technician' %}
<a href="/service/checklist">Чек-лист</a>
{% endif %}
{% if user.role == 'owner' %}
<div class="sidebar-section">Внешние системы</div>
<a href="https://git.aegisone.ru" target="_blank" rel="noopener">Gitea</a>
<a href="{{ PORTAINER_URL }}" target="_blank" rel="noopener">Portainer</a>
{% endif %}
<div class="sidebar-section">Блог</div>
{% if user.role == 'owner' %}
<a href="/service/blog">Управление блогом</a>
{% endif %}
<a href="/blog/">Публичный блог</a>
<div class="sidebar-section">Контент</div>
{% if user.role == 'owner' %}
<a href="/service/cases">Примеры из практики</a>
{% endif %}
<div class="sidebar-section">Документация</div>
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/documents/">Просмотр документации</a>
{% endif %}
{% if user.role == 'owner' %}
<a href="/service/documents/admin/permissions">Управление документами</a>
{% endif %}
</nav>
<div class="sidebar-footer">
<div class="user-name">{{ user.full_name }}</div>
<a href="/service/logout" style="display:inline-block;margin:6px 0;font-size:12px;">Выйти</a>
<div class="version-pill" onclick="openChangelog()" title="История изменений">v{{ app_version }}</div>
</div>
</aside>
{% include "_sidebar.html" %}
<main class="main">
<div class="main-header">
<div>
@@ -182,75 +111,4 @@ document.addEventListener('DOMContentLoaded', applyDashSettings);
.card-link { text-decoration:none; color:inherit; display:block; transition:transform 0.2s, box-shadow 0.2s; }
.card-link:hover { transform:translateY(-2px); box-shadow:var(--shadow); cursor:pointer; }
</style>
<div class="modal-overlay" id="changelog-modal"><div class="modal">
<button type="button" class="modal-close" onclick="closeChangelog()"></button>
<h2>История изменений</h2>
<div id="changelog-content" class="doc-content" style="padding:0;background:transparent;border:none;"></div>
</div></div>
<script>
function openChangelog(){
var modal = document.getElementById('changelog-modal');
modal.classList.add('open');
var content = document.getElementById('changelog-content');
if(!content.dataset.loaded){
content.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
fetch('/service/api/changelog').then(function(r){ return r.json() }).then(function(data){
content.innerHTML = data.html || '<p>Нет данных</p>';
content.dataset.loaded = '1';
});
}
}
function closeChangelog(){
document.getElementById('changelog-modal').classList.remove('open');
}
document.addEventListener('DOMContentLoaded', function(){
var nav = document.querySelector('.navbar__menu');
if(!nav) return;
var sidebarLinks = document.querySelectorAll('.sidebar-nav a[href]');
var saved = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
sidebarLinks.forEach(function(link){
var star = document.createElement('span');
star.className = 'sidebar-star';
var url = link.getAttribute('href');
star.textContent = saved.indexOf(url) !== -1 ? '\u2605' : '\u2606';
star.style.cssText = 'cursor:pointer;margin-right:6px;font-size:14px;user-select:none;';
star.addEventListener('click', function(e){
e.preventDefault(); e.stopPropagation();
var favs = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
var idx = favs.indexOf(url);
if(idx !== -1){ favs.splice(idx,1); star.textContent = '\u2606'; }
else { favs.push(url); star.textContent = '\u2605'; }
localStorage.setItem('sidebar_favorites', JSON.stringify(favs));
renderFavorites();
});
link.insertBefore(star, link.firstChild);
});
function renderFavorites(){
var existing = nav.querySelectorAll('.nav-fav');
existing.forEach(function(e){ e.remove(); });
var favs = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
favs.forEach(function(url){
var orig = document.querySelector('.sidebar-nav a[href="' + url + '"]');
if(!orig) return;
var li = document.createElement('li');
li.className = 'nav-fav';
var a = document.createElement('a');
a.href = url; a.textContent = orig.textContent.replace(/^[☆★]/, '').trim();
li.appendChild(a);
nav.insertBefore(li, nav.lastElementChild);
});
}
renderFavorites();
// Restore sidebar scroll position
var sidebarNav = document.querySelector('.sidebar-nav');
var savedScroll = sessionStorage.getItem('sidebar_scroll');
if (sidebarNav && savedScroll) sidebarNav.scrollTop = parseInt(savedScroll, 10);
// Save scroll position before navigating
sidebarNav && sidebarNav.addEventListener('click', function(){
sessionStorage.setItem('sidebar_scroll', this.scrollTop);
});
});
</script>
{% endblock %}
+1
View File
@@ -24,5 +24,6 @@
<button type="submit" class="btn btn-primary btn-block">Войти</button>
</form>
<p class="login-box__footnote">* Сервисный портал для сотрудников AegisOne Engineering</p>
<div style="text-align:right;margin-top:12px;font-size:11px;color:var(--text-muted);">v{{ app_version }}</div>
</div>
{% endblock %}
+1 -142
View File
@@ -3,78 +3,7 @@
{% block content %}
<button class="sidebar-toggle" onclick="document.querySelector('.sidebar').classList.toggle('open')"></button>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo sidebar-logo-text">Aegis<span>One</span></div>
<div style="font-size:12px;color:var(--text-muted);letter-spacing:0.05em;">Service Portal</div>
</div>
<nav class="sidebar-nav">
<div class="sidebar-section">Панель</div>
<a href="/service/dashboard" class="{% if active_page == 'dashboard' %}active{% endif %}">Дашборд</a>
{% if user.role == 'owner' %}
<div class="sidebar-section">Управление</div>
<a href="/service/users" class="{% if active_page == 'users' %}active{% endif %}">Сотрудники</a>
<a href="/service/customers" class="{% if active_page == 'customers' %}active{% endif %}">Клиенты</a>
<a href="/service/objects" class="{% if active_page == 'objects' %}active{% endif %}">Объекты</a>
<a href="/service/assignments" class="{% if active_page == 'assignments' %}active{% endif %}">Назначение сотрудников</a>
<a href="/service/sla" class="{% if active_page == 'sla' %}active{% endif %}">SLA контракты</a>
<a href="/service/ceo" class="{% if active_page == 'ceo' %}active{% endif %}">CEO дашборд</a>
<a href="/service/coefficients" class="{% if active_page == 'coefficients' %}active{% endif %}">Коэффициенты</a>
<a href="/service/coefficients/test" class="{% if active_page == 'coefficients-test' %}active{% endif %}">Тест формул</a>
<a href="/service/charts" class="{% if active_page == 'charts' %}active{% endif %}">Графики</a>
<a href="/service/ideas" class="{% if active_page == 'ideas' %}active{% endif %}">Идеи</a>
{% elif user.role == 'engineer' %}
<div class="sidebar-section">Управление</div>
<a href="/service/customers" class="{% if active_page == 'customers' %}active{% endif %}">Клиенты</a>
<a href="/service/objects" class="{% if active_page == 'objects' %}active{% endif %}">Объекты</a>
<a href="/service/sla" class="{% if active_page == 'sla' %}active{% endif %}">SLA контракты</a>
<a href="/service/documents/tech-access" class="{% if active_page == 'tech-access' %}active{% endif %}">Доступ техников к документам</a>
{% endif %}
<div class="sidebar-section">Работа</div>
{% if user.role in ['engineer', 'technician'] %}
<a href="/service/tasks" class="{% if active_page == 'tasks' %}active{% endif %}">Задачи</a>
<a href="/service/reports" class="{% if active_page == 'reports' %}active{% endif %}">Отчёты</a>
<a href="/service/incidents" class="{% if active_page == 'incidents' %}active{% endif %}">Инциденты</a>
{% endif %}
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/questionnaire" class="{% if active_page == 'questionnaire' %}active{% endif %}">Опросник</a>
{% endif %}
{% if user.role == 'owner' %}
<a href="/service/questionnaire-config" class="{% if active_page == 'questionnaire-config' %}active{% endif %}">Настройка опросника</a>
{% endif %}
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/passports" class="{% if active_page == 'passports' %}active{% endif %}">Паспорта объектов</a>
{% endif %}
{% if user.role == 'technician' %}
<a href="/service/checklist" class="{% if active_page == 'checklist' %}active{% endif %}">Чек-лист</a>
{% endif %}
{% if user.role == 'owner' %}
<div class="sidebar-section">Внешние системы</div>
<a href="https://git.aegisone.ru" target="_blank" rel="noopener">Gitea</a>
<a href="{{ PORTAINER_URL }}" target="_blank" rel="noopener">Portainer</a>
{% endif %}
<div class="sidebar-section">Блог</div>
{% if user.role == 'owner' %}
<a href="/service/blog" class="{% if active_page == 'blog' %}active{% endif %}">Управление блогом</a>
{% endif %}
<div class="sidebar-section">Контент</div>
{% if user.role == 'owner' %}
<a href="/service/cases" class="{% if active_page == 'cases' %}active{% endif %}">Примеры из практики</a>
{% endif %}
<div class="sidebar-section">Документация</div>
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/documents/" class="{% if active_page == 'docs' %}active{% endif %}">Просмотр документации</a>
{% endif %}
{% if user.role == 'owner' %}
<a href="/service/documents/admin/permissions" class="{% if active_page == 'docs-manage' %}active{% endif %}">Управление документами</a>
{% endif %}
</nav>
<div class="sidebar-footer">
<div class="user-name">{{ user.full_name }}</div>
<a href="/service/logout" style="display:inline-block;margin:6px 0;font-size:12px;">Выйти</a>
<div class="version-pill" onclick="openChangelog()" title="История изменений">v{{ app_version }}</div>
</div>
</aside>
{% include "_sidebar.html" %}
<main class="main">
<div class="main-header">
<div>
@@ -91,75 +20,5 @@
{% block page_content %}{% endblock %}
</main>
</div>
<div class="modal-overlay" id="changelog-modal"><div class="modal">
<button type="button" class="modal-close" onclick="closeChangelog()"></button>
<h2>История изменений</h2>
<div id="changelog-content" class="doc-content" style="padding:0;background:transparent;border:none;"></div>
</div></div>
<script>
function openChangelog(){
var modal = document.getElementById('changelog-modal');
modal.classList.add('open');
var content = document.getElementById('changelog-content');
if(!content.dataset.loaded){
content.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
fetch('/service/api/changelog').then(function(r){ return r.json() }).then(function(data){
content.innerHTML = data.html || '<p>Нет данных</p>';
content.dataset.loaded = '1';
});
}
}
function closeChangelog(){
document.getElementById('changelog-modal').classList.remove('open');
}
document.addEventListener('DOMContentLoaded', function(){
var nav = document.querySelector('.navbar__menu');
if(!nav) return;
var sidebarLinks = document.querySelectorAll('.sidebar-nav a[href]');
var saved = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
sidebarLinks.forEach(function(link){
var star = document.createElement('span');
star.className = 'sidebar-star';
var url = link.getAttribute('href');
star.textContent = saved.indexOf(url) !== -1 ? '\u2605' : '\u2606';
star.style.cssText = 'cursor:pointer;margin-right:6px;font-size:14px;user-select:none;';
star.addEventListener('click', function(e){
e.preventDefault(); e.stopPropagation();
var favs = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
var idx = favs.indexOf(url);
if(idx !== -1){ favs.splice(idx,1); star.textContent = '\u2606'; }
else { favs.push(url); star.textContent = '\u2605'; }
localStorage.setItem('sidebar_favorites', JSON.stringify(favs));
renderFavorites();
});
link.insertBefore(star, link.firstChild);
});
function renderFavorites(){
var existing = nav.querySelectorAll('.nav-fav');
existing.forEach(function(e){ e.remove(); });
var favs = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
favs.forEach(function(url){
var orig = document.querySelector('.sidebar-nav a[href="' + url + '"]');
if(!orig) return;
var li = document.createElement('li');
li.className = 'nav-fav';
var a = document.createElement('a');
a.href = url; a.textContent = orig.textContent.replace(/^[☆★]/, '').trim();
li.appendChild(a);
nav.insertBefore(li, nav.lastElementChild);
});
}
renderFavorites();
// Restore sidebar scroll position
var sidebarNav = document.querySelector('.sidebar-nav');
var savedScroll = sessionStorage.getItem('sidebar_scroll');
if (sidebarNav && savedScroll) sidebarNav.scrollTop = parseInt(savedScroll, 10);
// Save scroll position before navigating
sidebarNav && sidebarNav.addEventListener('click', function(){
sessionStorage.setItem('sidebar_scroll', this.scrollTop);
});
});
</script>
{% endblock %}
{% block extra_scripts %}{% endblock %}
@@ -1,241 +0,0 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.broadcast-layout { display:flex; gap:24px; margin-top:16px; flex-wrap:wrap; }
.broadcast-left { flex:1; min-width:300px; }
.broadcast-right { flex:1; min-width:300px; }
.msg-preview { background:#e3f2fd; border-radius:12px; padding:12px 16px; margin-top:8px;
white-space:pre-wrap; word-break:break-word; line-height:1.5; font-size:14px; }
.msg-preview.empty { color:#999; font-style:italic; }
.recipient-item { display:flex; align-items:center; gap:8px; padding:6px 8px;
border-bottom:1px solid #eee; cursor:pointer; }
.recipient-item:hover { background:#f8f9fa; }
.recipient-item .name { flex:1; }
.recipient-item .phone { color:#666; font-size:13px; }
.recipient-list { max-height:300px; overflow-y:auto; border:1px solid #ddd; border-radius:8px;
margin-top:8px; }
.recipient-list .select-all { padding:8px; border-bottom:1px solid #eee; background:#f8f9fa;
font-weight:600; cursor:pointer; user-select:none; }
.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.active { display:flex; }
.modal-box { background:#fff; border-radius:12px; padding:24px; max-width:600px;
width:90%; max-height:80vh; overflow-y:auto; }
.modal-box h3 { margin:0 0 16px; }
.modal-box .actions { display:flex; gap:12px; justify-content:flex-end; margin-top:20px; }
.broadcast-history { margin-top:24px; }
.broadcast-history table { width:100%; }
.broadcast-history td, .broadcast-history th { padding:8px 12px; text-align:left; border-bottom:1px solid #eee; }
.broadcast-history .text-col { max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
</style>
<div class="card">
<div class="card-header"><h2>Рассылки</h2></div>
<div class="broadcast-layout">
<div class="broadcast-left">
<div class="form-group">
<label>Текст сообщения</label>
<textarea id="broadcast-text" rows="5" style="width:100%;" oninput="updatePreview()" placeholder="Введите текст рассылки... Markdown поддерживается"></textarea>
</div>
<div>
<label>📱 Предпросмотр сообщения</label>
<div id="live-preview" class="msg-preview empty">Текст сообщения появится здесь...</div>
</div>
</div>
<div class="broadcast-right">
<div class="form-group">
<label>Получатели</label>
<input type="text" id="recipient-search" placeholder="Поиск..." style="width:100%;margin-bottom:8px;" oninput="loadRecipients()">
</div>
<div class="recipient-list" id="recipient-list">
<div class="select-all" onclick="toggleSelectAll()">
<span id="select-all-checkbox"></span> Выбрать всех согласных
<span style="float:right;color:#666;font-weight:400;" id="recipient-count"></span>
</div>
<div id="recipient-items"></div>
</div>
<div style="display:flex;gap:12px;margin-top:12px;">
<button class="btn btn-primary" onclick="previewBroadcast()">👁 Предпросмотр</button>
<button class="btn btn-primary" onclick="sendBroadcast()">📨 Отправить</button>
</div>
</div>
</div>
</div>
<div class="card broadcast-history">
<div class="card-header"><h3>История рассылок</h3></div>
<div class="table-wrap"><table>
<thead><tr><th>ID</th><th>Текст</th><th>Получателей</th><th>Дата</th></tr></thead>
<tbody id="history-list"></tbody>
</table></div>
</div>
<div class="modal-overlay" id="preview-modal">
<div class="modal-box">
<h3>👁 Предпросмотр рассылки</h3>
<div style="background:#e3f2fd;border-radius:12px;padding:16px;white-space:pre-wrap;line-height:1.5;" id="preview-text"></div>
<div style="margin-top:16px;padding:12px;background:#f8f9fa;border-radius:8px;">
<strong>Получателей:</strong> <span id="preview-count"></span>
<div style="margin-top:8px;max-height:150px;overflow-y:auto;" id="preview-recipients"></div>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="closePreview()">Отмена</button>
<button class="btn btn-primary" id="preview-send-btn" onclick="confirmSend()">📨 Отправить</button>
</div>
</div>
</div>
<script>
var selectedUserIds = new Set();
var allRecipients = [];
var currentDraftId = null;
function updatePreview(){
var text = document.getElementById('broadcast-text').value.trim();
var preview = document.getElementById('live-preview');
if(text){
preview.textContent = text;
preview.className = 'msg-preview';
} else {
preview.textContent = 'Текст сообщения появится здесь...';
preview.className = 'msg-preview empty';
}
}
function loadRecipients(){
var q = document.getElementById('recipient-search').value.trim();
var url = '/service/api/bot/broadcast/recipients?limit=200';
if(q) url += '&q=' + encodeURIComponent(q);
fetch(url).then(function(r){ return r.json() }).then(function(d){
allRecipients = d.users || [];
renderRecipients();
});
}
function renderRecipients(){
var container = document.getElementById('recipient-items');
var countEl = document.getElementById('recipient-count');
container.innerHTML = '';
countEl.textContent = allRecipients.length + ' чел.';
allRecipients.forEach(function(u){
var div = document.createElement('div');
div.className = 'recipient-item';
var checked = selectedUserIds.has(u.max_user_id) ? '☑' : '☐';
var name = (u.first_name || '') + ' ' + (u.last_name || '');
if(!name.trim()) name = 'ID ' + u.max_user_id;
div.innerHTML = '<span class="cb">' + checked + '</span>' +
'<span class="name">' + name + '</span>' +
'<span class="phone">' + (u.phone || '') + '</span>';
div.onclick = function(){ toggleRecipient(u.max_user_id); };
container.appendChild(div);
});
updateSelectAllCheckbox();
}
function toggleRecipient(id){
if(selectedUserIds.has(id)){
selectedUserIds['delete'](id);
} else {
selectedUserIds.add(id);
}
renderRecipients();
}
function toggleSelectAll(){
if(selectedUserIds.size === allRecipients.length){
selectedUserIds.clear();
} else {
selectedUserIds = new Set(allRecipients.map(function(u){ return u.max_user_id; }));
}
renderRecipients();
}
function updateSelectAllCheckbox(){
var cb = document.getElementById('select-all-checkbox');
if(allRecipients.length > 0 && selectedUserIds.size === allRecipients.length){
cb.textContent = '☑';
} else {
cb.textContent = '☐';
}
}
function previewBroadcast(){
var text = document.getElementById('broadcast-text').value.trim();
if(!text){ alert('Введите текст рассылки'); return; }
var userIds = Array.from(selectedUserIds);
fetch('/service/api/bot/broadcast', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: text, user_ids: userIds, action: 'preview', draft_id: currentDraftId})
}).then(function(r){ return r.json() }).then(function(d){
if(d.error){ alert(d.error); return; }
currentDraftId = d.draft_id;
document.getElementById('preview-text').textContent = d.text;
document.getElementById('preview-count').textContent = d.recipient_count + ' чел.';
var recEl = document.getElementById('preview-recipients');
recEl.innerHTML = d.recipients.join(', ');
document.getElementById('preview-modal').className = 'modal-overlay active';
});
}
function closePreview(){
document.getElementById('preview-modal').className = 'modal-overlay';
}
function confirmSend(){
var text = document.getElementById('broadcast-text').value.trim();
if(!currentDraftId){ alert('Сначала сделайте предпросмотр'); return; }
if(!confirm('Отправить рассылку?')) return;
var userIds = Array.from(selectedUserIds);
document.getElementById('preview-send-btn').disabled = true;
fetch('/service/api/bot/broadcast', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: text, user_ids: userIds, action: 'send', draft_id: currentDraftId})
}).then(function(r){ return r.json() }).then(function(d){
closePreview();
if(d.ok){
alert('Рассылка отправлена: ' + d.sent_to + ' из ' + d.total_recipients + ' получателей');
document.getElementById('broadcast-text').value = '';
document.getElementById('live-preview').textContent = 'Текст сообщения появится здесь...';
document.getElementById('live-preview').className = 'msg-preview empty';
selectedUserIds.clear();
renderRecipients();
currentDraftId = null;
loadHistory();
} else {
alert('Ошибка: ' + (d.error || 'неизвестная'));
}
document.getElementById('preview-send-btn').disabled = false;
});
}
function sendBroadcast(){
var text = document.getElementById('broadcast-text').value.trim();
if(!text){ alert('Введите текст рассылки'); return; }
var userIds = Array.from(selectedUserIds);
if(userIds.length === 0 && !confirm('Не выбрано ни одного получателя. Отправить всем согласным?')) return;
previewBroadcast();
}
function loadHistory(){
fetch('/service/api/bot/broadcast/history').then(function(r){ return r.json() }).then(function(d){
var tbody = document.getElementById('history-list');
tbody.innerHTML = '';
(d.broadcasts || []).forEach(function(b){
var tr = document.createElement('tr');
tr.innerHTML = '<td>' + b.id + '</td>' +
'<td class="text-col" title="' + b.text.replace(/"/g,'&quot;') + '">' + b.text + '</td>' +
'<td>' + b.recipient_count + '</td>' +
'<td>' + b.sent_at + '</td>';
tbody.appendChild(tr);
});
if(!d.broadcasts || d.broadcasts.length === 0){
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:#999;">Рассылок ещё не было</td></tr>';
}
});
}
loadRecipients();
loadHistory();
</script>
{% endblock %}
@@ -0,0 +1,220 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.consent-table { width:100%; border-collapse:collapse; }
.consent-table th, .consent-table td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:13px; }
.consent-table th { font-weight:600; color:var(--text-muted); background:var(--light-bg); }
.consent-table td { vertical-align:middle; }
.consent-yes { color:#155724; font-weight:600; }
.consent-no { color:#721c24; font-weight:600; }
.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; }
.broadcast-card { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:20px; margin-top:16px; }
.broadcast-card h3 { margin-bottom:12px; }
.broadcast-card textarea { width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:14px; min-height:100px; resize:vertical; margin-bottom:10px; box-sizing:border-box; }
.broadcast-actions { display:flex; gap:10px; flex-wrap:wrap; }
.recipient-count { font-size:12px; color:var(--text-muted); margin-bottom:8px; }
.preview-area { display:none; margin-top:12px; padding:12px; border:1px solid var(--border); border-radius:8px; background:var(--bg-input); white-space:pre-wrap; font-size:14px; line-height:1.5; }
.preview-area.open { display:block; }
.preview-area .preview-text { margin-bottom:10px; }
.preview-area .preview-actions { display:flex; gap:10px; }
.confirm-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; }
.confirm-overlay.open { display:flex; }
.confirm-modal { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:24px; width:500px; max-width:90vw; }
.confirm-modal h3 { margin-bottom:8px; }
.confirm-modal .confirm-text { padding:10px; border:1px solid var(--border); border-radius:6px; background:var(--bg-input); max-height:200px; overflow-y:auto; margin:12px 0; white-space:pre-wrap; font-size:13px; }
.confirm-modal .confirm-count { font-size:12px; color:var(--text-muted); margin-bottom:12px; }
.confirm-modal .confirm-timer { font-size:24px; font-weight:700; text-align:center; color:var(--accent); margin:12px 0; }
.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; }
</style>
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h2>Согласия ФЗ-152</h2>
<span id="consent-count" style="font-size:13px;color:var(--text-muted);">0 пользователей</span>
</div>
<div class="filter-bar">
<input type="text" id="consent-search" placeholder="Поиск по имени, телефону..." oninput="filterConsents()">
</div>
<table class="consent-table">
<thead>
<tr>
<th>ID</th>
<th>Имя</th>
<th>Телефон</th>
<th>Email</th>
<th>Организация</th>
<th>Username</th>
<th>Согласие</th>
<th>Дата</th>
</tr>
</thead>
<tbody id="consent-table-body">
<tr><td colspan="8" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div class="broadcast-card">
<h3>Рассылка пользователям MAX</h3>
<div class="recipient-count" id="recipient-count">Получателей: 0</div>
<textarea id="broadcast-text" placeholder="Введите текст сообщения..." oninput="clearPreview()"></textarea>
<div class="broadcast-actions">
<button class="btn" onclick="showPreview()">Предпросмотр</button>
<button class="btn btn-primary" id="send-btn" onclick="openConfirm()" disabled>Отправить всем</button>
</div>
<div class="preview-area" id="preview-area">
<div class="preview-text" id="preview-text"></div>
<div class="preview-actions">
<button class="btn btn-primary" onclick="openConfirm()">Отправить</button>
<button class="btn" onclick="hidePreview()">Редактировать</button>
</div>
</div>
</div>
<div class="confirm-overlay" id="confirm-overlay">
<div class="confirm-modal">
<h3>Подтверждение рассылки</h3>
<div class="confirm-count" id="confirm-count">Получателей: 0</div>
<div class="confirm-text" id="confirm-text"></div>
<div class="confirm-timer" id="confirm-timer">10</div>
<div class="confirm-actions">
<button class="btn btn-primary" id="confirm-yes" onclick="executeBroadcast()">Подтвердить</button>
<button class="btn" onclick="closeConfirm()">Отмена</button>
</div>
</div>
</div>
<script>
var allConsents = [];
var confirmTimer = null;
var confirmSeconds = 10;
function loadConsents(){
var tbody = document.getElementById('consent-table-body');
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>';
fetch('/api/bot/users/consented').then(function(r){ return r.json() }).then(function(data){
allConsents = data;
document.getElementById('consent-count').textContent = data.length + ' пользователей';
document.getElementById('recipient-count').textContent = 'Получателей: ' + data.length;
document.getElementById('send-btn').disabled = data.length === 0;
renderConsents(data);
}).catch(function(err){
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:30px;color:var(--text-muted);">Ошибка загрузки</td></tr>';
showNotification('Ошибка загрузки согласий', 'error');
});
}
function renderConsents(list){
var tbody = document.getElementById('consent-table-body');
if(!list.length){
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:30px;color:var(--text-muted);">Нет данных</td></tr>';
return;
}
var html = '';
list.forEach(function(u){
var name = [u.first_name, u.last_name].filter(Boolean).join(' ') || '—';
var dateStr = u.consent_date ? new Date(u.consent_date).toLocaleString() : '—';
html += '<tr>' +
'<td>' + u.id + '</td>' +
'<td>' + escapeHtml(name) + '</td>' +
'<td>' + escapeHtml(u.phone || '—') + '</td>' +
'<td>' + escapeHtml(u.email || '—') + '</td>' +
'<td>' + escapeHtml(u.organization || '—') + '</td>' +
'<td>' + escapeHtml(u.username || '—') + '</td>' +
'<td class="consent-yes">Да</td>' +
'<td style="font-size:12px;color:var(--text-muted);">' + dateStr + '</td>' +
'</tr>';
});
tbody.innerHTML = html;
}
function filterConsents(){
var search = document.getElementById('consent-search').value.toLowerCase();
var filtered = allConsents.filter(function(u){
if(!search) return true;
var name = [u.first_name, u.last_name].filter(Boolean).join(' ').toLowerCase();
var phone = (u.phone || '').toLowerCase();
var email = (u.email || '').toLowerCase();
var username = (u.username || '').toLowerCase();
return name.includes(search) || phone.includes(search) || email.includes(search) || username.includes(search);
});
renderConsents(filtered);
}
function clearPreview(){
document.getElementById('preview-area').classList.remove('open');
}
function showPreview(){
var text = document.getElementById('broadcast-text').value;
if(!text.trim()){ showNotification('Введите текст сообщения', 'warning'); return; }
document.getElementById('preview-text').textContent = text;
document.getElementById('preview-area').classList.add('open');
}
function hidePreview(){
document.getElementById('preview-area').classList.remove('open');
}
function openConfirm(){
if(confirmTimer) return;
var text = document.getElementById('broadcast-text').value.trim();
if(!text){ showNotification('Введите текст сообщения', 'warning'); return; }
closeConfirm();
document.getElementById('confirm-text').textContent = text;
document.getElementById('confirm-count').textContent = 'Получателей: ' + allConsents.length;
document.getElementById('confirm-overlay').classList.add('open');
confirmSeconds = 10;
document.getElementById('confirm-timer').textContent = confirmSeconds;
document.getElementById('confirm-timer').className = 'confirm-timer';
document.getElementById('confirm-yes').disabled = false;
confirmTimer = setInterval(function(){
confirmSeconds--;
document.getElementById('confirm-timer').textContent = confirmSeconds;
if(confirmSeconds <= 3) document.getElementById('confirm-timer').className = 'confirm-timer timer-danger';
if(confirmSeconds <= 0){
clearInterval(confirmTimer);
confirmTimer = null;
closeConfirm();
showNotification('Время подтверждения истекло. Нажмите «Отправить всем» повторно.', 'warning');
}
}, 1000);
}
function closeConfirm(){
if(confirmTimer){ clearInterval(confirmTimer); confirmTimer = null; }
document.getElementById('confirm-overlay').classList.remove('open');
}
function executeBroadcast(){
if(confirmTimer){ clearInterval(confirmTimer); confirmTimer = null; }
var text = document.getElementById('confirm-text').textContent;
document.getElementById('confirm-yes').disabled = true;
fetch('/api/bot/broadcast', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: text, all: true})
}).then(function(r){ return r.json() }).then(function(d){
closeConfirm();
if(d.ok) showNotification('Отправлено: ' + d.sent + ', ошибок: ' + d.errors, d.errors ? 'warning' : 'success');
else showNotification('Ошибка: ' + (d.error || 'неизвестная'), 'error');
}).catch(function(){
closeConfirm();
showNotification('Ошибка соединения', 'error');
});
}
function escapeHtml(s){
if(!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
loadConsents();
</script>
{% endblock %}
+239
View File
@@ -0,0 +1,239 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.kb-table { width:100%; border-collapse:collapse; }
.kb-table th, .kb-table td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:13px; }
.kb-table th { font-weight:600; color:var(--text-muted); background:var(--light-bg); }
.kb-table td { vertical-align:top; }
.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:#d4edda; color:#155724; }
.kb-table .active-badge.no { background:#f8d7da; color:#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>
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h2>База знаний чат-бота</h2>
<button class="btn btn-primary" onclick="openModal()">+ Добавить карточку</button>
</div>
<div class="filter-bar">
<input type="text" id="kb-search" placeholder="Поиск по вопросу..." oninput="filterKB()">
<select id="kb-category-filter" onchange="filterKB()">
<option value="">Все категории</option>
</select>
<select id="kb-active-filter" onchange="filterKB()">
<option value="">Все статусы</option>
<option value="true">Активные</option>
<option value="false">Неактивные</option>
</select>
</div>
<table class="kb-table">
<thead>
<tr>
<th>ID</th>
<th>Вопрос</th>
<th>Категория</th>
<th>Статус</th>
<th>Источник</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="kb-table-body">
<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div class="kb-modal" id="kb-modal">
<div class="kb-modal-content">
<h3 id="modal-title">Новая карточка</h3>
<form id="kb-form">
<div class="form-group">
<label>Категория</label>
<select name="category_id" id="kb-category-id"></select>
</div>
<div class="form-group">
<label>Вопрос *</label>
<input type="text" name="question" id="kb-question" required>
</div>
<div class="form-group">
<label>Ответ (оставьте пустым, если нет ответа)</label>
<textarea name="answer" id="kb-answer"></textarea>
</div>
<div class="form-group">
<label>Ключевые слова (через запятую)</label>
<input type="text" name="keywords" id="kb-keywords" placeholder="слово1, слово2, слово3">
</div>
<div class="form-group">
<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">
<button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn" onclick="closeModal()">Отмена</button>
</div>
</form>
</div>
</div>
<script>
var categories = {};
var allCards = [];
fetch('/service/api/bot/settings').then(function(r){ return r.json() }).then(function(d){});
function loadKB(){
fetch('/api/bot/kb').then(function(r){ return r.json() }).then(function(data){
allCards = data;
renderKB(data);
});
}
function renderKB(cards){
var tbody = document.getElementById('kb-table-body');
if(!cards.length){
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Нет карточек</td></tr>';
return;
}
var html = '';
cards.forEach(function(c){
var catName = categories[c.category_id] || 'Без категории';
var activeClass = c.is_active ? 'yes' : 'no';
var activeLabel = c.is_active ? 'Активна' : 'Неактивна';
var answerPreview = c.answer ? c.answer.substring(0, 50) + '...' : '<em style="color:var(--text-muted)">Нет ответа</em>';
html += '<tr>' +
'<td>' + c.id + '</td>' +
'<td><strong>' + escapeHtml(c.question) + '</strong><br><small style="color:var(--text-muted)">' + answerPreview + '</small></td>' +
'<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>' +
'</tr>';
});
tbody.innerHTML = html;
}
function filterKB(){
var search = document.getElementById('kb-search').value.toLowerCase();
var catFilter = document.getElementById('kb-category-filter').value;
var activeFilter = document.getElementById('kb-active-filter').value;
var filtered = allCards.filter(function(c){
if(search && !c.question.toLowerCase().includes(search)) return false;
if(catFilter && String(c.category_id) !== catFilter) return false;
if(activeFilter !== '' && String(c.is_active) !== activeFilter) return false;
return true;
});
renderKB(filtered);
}
function openModal(){
editingId = null;
document.getElementById('kb-modal').classList.add('open');
document.getElementById('modal-title').textContent = 'Новая карточка';
document.getElementById('kb-form').reset();
document.getElementById('kb-is-active').checked = true;
}
function closeModal(){
document.getElementById('kb-modal').classList.remove('open');
}
var editingId = null;
function editCard(id){
var card = allCards.find(function(c){ return c.id === id; });
if(!card) return;
editingId = id;
document.getElementById('modal-title').textContent = 'Редактировать карточку #' + id;
document.getElementById('kb-question').value = card.question || '';
document.getElementById('kb-answer').value = card.answer || '';
document.getElementById('kb-keywords').value = (card.keywords || []).join(', ');
document.getElementById('kb-source-url').value = card.source_url || '';
document.getElementById('kb-is-active').checked = card.is_active;
document.getElementById('kb-category-id').value = card.category_id || '';
document.getElementById('kb-modal').classList.add('open');
}
function deleteCard(id){
if(!confirm('Удалить карточку?')) return;
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');
});
}
document.getElementById('kb-form').addEventListener('submit', function(e){
e.preventDefault();
var data = {
category_id: parseInt(document.getElementById('kb-category-id').value) || null,
question: document.getElementById('kb-question').value,
answer: document.getElementById('kb-answer').value || null,
keywords: document.getElementById('kb-keywords').value.split(',').map(function(s){ return s.trim(); }).filter(Boolean),
source_url: document.getElementById('kb-source-url').value || null,
is_active: document.getElementById('kb-is-active').checked,
sort_order: 0,
};
var url = '/api/bot/kb';
var method = 'POST';
if(editingId) { url += '/' + editingId; method = 'PUT'; }
fetch(url, {
method: method, headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
}).then(function(r){ return r.json() }).then(function(d){
if(d.ok) { closeModal(); loadKB(); showNotification(editingId ? 'Карточка обновлена' : 'Карточка создана', 'success'); }
else showNotification('Ошибка сохранения', 'error');
});
});
function escapeHtml(s){
if(!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Load categories
fetch('/api/bot/kb').then(function(r){ return r.json() }).then(function(data){
// Extract unique categories
var catSet = {};
data.forEach(function(c){
if(c.category_id) catSet[c.category_id] = true;
});
var catSelect = document.getElementById('kb-category-id');
var catFilter = document.getElementById('kb-category-filter');
var catOptions = {
1: 'О компании', 2: 'Аудит безопасности', 3: 'Сервисное обслуживание (SLA)',
4: 'Реагирование на инциденты', 5: 'Технический надзор',
6: 'Документация и нормативы', 7: 'Риск-инжиниринг', 8: 'Отрасли и кейсы'
};
categories = catOptions;
Object.keys(catOptions).forEach(function(id){
var opt1 = document.createElement('option');
opt1.value = id; opt1.textContent = catOptions[id];
catSelect.appendChild(opt1);
var opt2 = document.createElement('option');
opt2.value = id; opt2.textContent = catOptions[id];
catFilter.appendChild(opt2);
});
});
loadKB();
</script>
{% endblock %}
@@ -0,0 +1,122 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.tab-bar { display:flex; gap:0; margin-bottom:20px; border-bottom:2px solid var(--border); }
.tab-bar .tab { padding:10px 20px; cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-2px; color:var(--text-muted); font-weight:500; }
.tab-bar .tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-content { display:none; }
.tab-content.active { display:block; }
.bot-settings-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
.bot-settings-grid .full-width { grid-column:1/-1; }
.form-group { margin-bottom:16px; }
.form-group label { display:block; font-weight:600; margin-bottom:6px; font-size:13px; color:var(--text-muted); }
.form-group input, .form-group textarea, .form-group select {
width:100%; padding:10px 14px; border:1px solid var(--border);
border-radius:8px; font-size:14px; background:var(--bg-input); color:var(--text-primary);
transition:border-color 0.2s;
}
.form-group input:focus, .form-group textarea:focus {
outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(0,150,200,0.1);
}
.form-group textarea { min-height:100px; resize:vertical; }
.form-actions { grid-column:1/-1; display:flex; gap:12px; margin-top:8px; }
.form-actions .btn { min-width:160px; }
.hint { font-size:12px; color:var(--text-muted); margin-top:4px; }
.test-iframe { width:100%; height:650px; border:none; border-radius:8px; }
</style>
<div class="card">
<div class="card-header"><h2>Настройки чат-бота MAX — «София»</h2></div>
<div class="tab-bar">
<div class="tab active" onclick="switchTab('main')">Основные</div>
<div class="tab" onclick="switchTab('test')">Тестовый прогон</div>
</div>
<div class="tab-content active" id="tab-main">
<form id="bot-settings-form">
<div class="bot-settings-grid">
<div class="form-group full-width">
<label>Имя ассистента</label>
<input type="text" name="assistant_name" id="assistant_name" placeholder="Например: София">
</div>
<div class="form-group full-width">
<label>Роль ассистента</label>
<textarea name="assistant_role" id="assistant_role" placeholder="Описание роли бота..."></textarea>
<div class="hint">Будет использована для формирования системного промпта YandexGPT</div>
</div>
<div class="form-group">
<label>MAX-бот ID</label>
<input type="text" name="max_bot_id" id="max_bot_id" placeholder="Токен бота">
<div class="hint">Токен чат-бота из платформы MAX</div>
</div>
<div class="form-group">
<label>Email поддержки</label>
<input type="email" name="support_email" id="support_email" placeholder="zakaz@aegisone.ru">
<div class="hint">На этот email приходят уведомления о новых заявках</div>
</div>
<div class="form-group full-width">
<label>Тема письма</label>
<input type="text" name="email_subject" id="email_subject" placeholder="Обращение через Виртуального помощника">
<div class="hint">Тема email-уведомления об эскалации</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить настройки</button>
</div>
</div>
</form>
</div>
<div class="tab-content" id="tab-test">
<p style="margin-bottom:12px;color:var(--text-muted);font-size:13px;">Симуляция диалога с ботом. Отправляет запросы напрямую на вебхук бота.</p>
<iframe src="/service/bot-test-content" class="test-iframe" id="test-iframe"></iframe>
</div>
</div>
<div class="card" style="margin-top:20px;">
<div class="card-header"><h2>Общие настройки портала</h2></div>
<div class="hint" style="margin-bottom:16px;">
Yandex GPT и Yandex Disk настраиваются в разделе
<a href="/service/portal-settings">Настройка портала</a>.
Бот использует эти данные автоматически.
</div>
</div>
<script>
function switchTab(name){
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
document.querySelectorAll('.tab-bar .tab').forEach(function(t){ t.classList.remove('active'); });
document.getElementById('tab-' + name).classList.add('active');
document.querySelector('.tab-bar .tab[onclick*="' + name + '"]').classList.add('active');
if(name === 'test'){
document.getElementById('test-iframe').src = '/service/bot-test-content';
}
}
document.getElementById('bot-settings-form').addEventListener('submit', function(e){
e.preventDefault();
var data = {};
this.querySelectorAll('input, textarea').forEach(function(el){
if(el.name) data[el.name] = el.value;
});
fetch('/service/api/bot/settings/save', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
}).then(function(r){ return r.json() }).then(function(d){
if(d.ok) showNotification('Настройки сохранены', 'success');
else showNotification('Ошибка: ' + (d.error || 'сохранения'), 'error');
}).catch(function(){
showNotification('Ошибка соединения', 'error');
});
});
fetch('/service/api/bot/settings').then(function(r){ return r.json() }).then(function(d){
['assistant_name','assistant_role','support_email','email_subject','max_bot_id'].forEach(function(k){
var el = document.getElementById(k);
if(el) el.value = d[k] || '';
});
if(document.getElementById('max_bot_id') && !document.getElementById('max_bot_id').value){
document.getElementById('max_bot_id').value = 'f9LHodD0cOKkk03DYXj906MdIeRs...';
}
});
</script>
{% endblock %}
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Inter','Segoe UI',sans-serif; background:#1B2838; color:#E8EDF2; font-size:13px; height:100vh; display:flex; flex-direction:column; }
.test-container { display:flex; flex:1; gap:12px; padding:12px; overflow:hidden; }
.test-chat { flex:1; border:1px solid #253248; border-radius:10px; display:flex; flex-direction:column; overflow:hidden; }
.test-chat-header { background:#0A1628; padding:10px 14px; font-weight:600; border-bottom:1px solid #253248; font-size:12px; }
.test-chat-messages { flex:1; overflow-y:auto; padding:10px 14px; }
.test-chat-input { display:flex; border-top:1px solid #253248; }
.test-chat-input input { flex:1; border:none; padding:10px 14px; font-size:13px; outline:none; background:#1B2838; color:#E8EDF2; }
.test-chat-input button { padding:10px 16px; border:none; background:#00ADEF; color:#fff; cursor:pointer; font-weight:600; }
.test-log { width:300px; border:1px solid #253248; border-radius:10px; padding:10px; overflow-y:auto; font-family:monospace; font-size:11px; line-height:1.5; background:#0A1628; }
.test-log .log-entry { margin-bottom:4px; padding:3px 0; border-bottom:1px solid #1e3044; }
.test-log .log-entry .time { color:#6B8299; }
.test-log .log-entry .event { color:#00ADEF; font-weight:600; }
.msg-bot { background:#1e3044; padding:7px 12px; border-radius:8px 8px 8px 0; margin-bottom:6px; max-width:85%; }
.msg-user { background:#00ADEF; color:#fff; padding:7px 12px; border-radius:8px 8px 0 8px; margin-bottom:6px; max-width:85%; margin-left:auto; }
.test-scenarios { display:flex; gap:6px; padding:8px 12px; flex-wrap:wrap; border-bottom:1px solid #253248; }
.test-scenarios button { font-size:11px; padding:4px 10px; background:#253248; color:#E8EDF2; border:1px solid #3a4a60; border-radius:4px; cursor:pointer; }
.test-scenarios button:hover { background:#00ADEF; color:#fff; }
</style>
</head>
<body>
<div class="test-scenarios">
<button onclick="runScenario('greeting')">Приветствие</button>
<button onclick="runScenario('consent_yes')">Согласие → заявка</button>
<button onclick="runScenario('consent_no')">Отказ согласия</button>
<button onclick="runScenario('question')">Общий вопрос</button>
<button onclick="clearTest()">Очистить</button>
</div>
<div class="test-container">
<div class="test-chat">
<div class="test-chat-header">Чат с ботом (симуляция)</div>
<div class="test-chat-messages" id="test-messages"></div>
<div class="test-chat-input">
<input type="text" id="test-input" placeholder="Введите сообщение..." onkeypress="if(event.key==='Enter') sendTestMessage()">
<button onclick="sendTestMessage()">Отправить</button>
</div>
</div>
<div class="test-log" id="test-log"></div>
</div>
<script>
var testUserId = 999999001;
function log(event, detail){
var logDiv = document.getElementById('test-log');
var time = new Date().toLocaleTimeString();
var entry = document.createElement('div');
entry.className = 'log-entry';
entry.innerHTML = '<span class="time">[' + time + ']</span> <span class="event">' + event + '</span> ' + (detail || '');
logDiv.appendChild(entry);
logDiv.scrollTop = logDiv.scrollHeight;
}
function addMessage(text, from){
var msgs = document.getElementById('test-messages');
var div = document.createElement('div');
div.className = from === 'bot' ? 'msg-bot' : 'msg-user';
div.textContent = text;
msgs.appendChild(div);
msgs.scrollTop = msgs.scrollHeight;
}
function sendTestMessage(){
var input = document.getElementById('test-input');
var text = input.value.trim();
if(!text) return;
input.value = '';
addMessage(text, 'user');
log('message_created', 'Пользователь: ' + text);
simulateWebhook('message_created', text);
}
function runScenario(scenario){
clearTest();
switch(scenario){
case 'greeting':
log('bot_started', 'Пользователь начал диалог');
simulateWebhook('bot_started', null);
break;
case 'consent_yes':
log('bot_started', 'Пользователь начал диалог');
simulateWebhook('bot_started', null);
setTimeout(function(){
addMessage('Здравствуйте! Я хочу оставить заявку.', 'user');
log('message_created', 'Пользователь: хочу оставить заявку');
simulateWebhook('message_created', 'Здравствуйте! Я хочу оставить заявку.');
}, 1000);
break;
case 'consent_no':
log('bot_started', 'Пользователь начал диалог');
simulateWebhook('bot_started', null);
break;
case 'question':
log('bot_started', 'Пользователь начал диалог');
simulateWebhook('bot_started', null);
setTimeout(function(){
addMessage('Какие услуги вы предоставляете?', 'user');
log('message_created', 'Пользователь: какие услуги?');
simulateWebhook('message_created', 'Какие услуги вы предоставляете?');
}, 1000);
break;
}
}
function simulateWebhook(type, text){
var payload = {update_type: type};
if(type === 'bot_started'){
payload.user = {user_id: testUserId, first_name: 'Тест', last_name: 'Пользователь'};
payload.chat_id = testUserId;
} else if(type === 'message_created'){
payload.sender = {user_id: testUserId};
payload.chat_id = testUserId;
payload.body = {text: text, attachments: []};
}
log('webhook', 'Отправка на /webhook (' + type + ')');
fetch('/api/bot/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Max-Bot-Api-Secret': 'aegisone_bot_secret_2026',
},
body: JSON.stringify(payload)
}).then(function(r){ return r.json() }).then(function(d){
log('response', JSON.stringify(d));
if(d && d.ok) log('success', 'Событие обработано');
else log('error', 'Ошибка: ' + JSON.stringify(d));
}).catch(function(err){
log('error', 'Сетевая ошибка: ' + err);
});
}
function clearTest(){
document.getElementById('test-messages').innerHTML = '';
document.getElementById('test-log').innerHTML = '';
document.getElementById('test-input').value = '';
log('info', 'Тест очищен');
}
</script>
</body>
</html>
@@ -0,0 +1,179 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.tickets-table { width:100%; border-collapse:collapse; }
.tickets-table th, .tickets-table td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:13px; }
.tickets-table th { font-weight:600; color:var(--text-muted); background:var(--light-bg); }
.tickets-table td { vertical-align:middle; }
.status-badge { display:inline-block; padding:3px 10px; border-radius:12px; font-size:12px; font-weight:600; }
.status-nova { background:#cce5ff; color:#004085; }
.status-v-rabote { background:#fff3cd; color:#856404; }
.status-zakryta { background:#d4edda; color:#155724; }
.status-select { padding:4px 8px; border-radius:6px; border:1px solid var(--border); font-size:12px; }
.ticket-detail-card { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:20px; margin-top:16px; display:none; }
.ticket-detail-card.open { display:block; }
.ticket-detail-card h3 { margin-bottom:12px; }
.ticket-detail-card .field { margin-bottom:8px; }
.ticket-detail-card .field label { font-weight:600; font-size:12px; color:var(--text-muted); display:block; }
.ticket-detail-card .field .value { font-size:14px; }
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; flex-wrap:wrap; }
.filter-bar select, .filter-bar input { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
</style>
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h2>Заявки от чат-бота</h2>
<span id="ticket-count" style="font-size:13px;color:var(--text-muted);">0 заявок</span>
</div>
<div class="filter-bar">
<select id="status-filter" onchange="filterTickets()">
<option value="">Все статусы</option>
<option value="Новая">Новая</option>
<option value="В работе">В работе</option>
<option value="Закрыта">Закрыта</option>
</select>
<input type="text" id="ticket-search" placeholder="Поиск..." oninput="filterTickets()">
<button class="btn btn-sm" onclick="loadTickets()">Обновить</button>
</div>
<table class="tickets-table">
<thead>
<tr>
<th>ID</th>
<th>Заголовок</th>
<th>Приоритет</th>
<th>Статус</th>
<th>Дата</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="tickets-body">
<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div class="ticket-detail-card" id="ticket-detail">
<h3 id="ticket-detail-title">Заявка #</h3>
<div class="field"><label>Описание</label><div class="value" id="ticket-detail-desc"></div></div>
<div class="field"><label>Приоритет</label><div class="value" id="ticket-detail-priority"></div></div>
<div class="field"><label>Статус</label>
<select id="ticket-detail-status" onchange="updateTicketStatus()">
<option value="Новая">Новая</option>
<option value="В работе">В работе</option>
<option value="Закрыта">Закрыта</option>
</select>
</div>
<div class="field"><label>Дата</label><div class="value" id="ticket-detail-date"></div></div>
<button class="btn" onclick="closeDetail()">Закрыть</button>
</div>
<script>
var allTickets = [];
var currentTicketId = null;
function loadTickets(){
var tbody = document.getElementById('tickets-body');
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>';
fetch('/api/bot/tickets').then(function(r){ return r.json() }).then(function(data){
allTickets = data;
document.getElementById('ticket-count').textContent = data.length + ' заявок';
renderTickets(data);
});
}
function renderTickets(tickets){
var tbody = document.getElementById('tickets-body');
if(!tickets.length){
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Нет заявок</td></tr>';
return;
}
var html = '';
tickets.forEach(function(t){
var statusClass = 'status-nova';
if(t.status === 'В работе') statusClass = 'status-v-rabote';
else if(t.status === 'Закрыта') statusClass = 'status-zakryta';
var dateStr = t.created_at ? new Date(t.created_at).toLocaleString() : '';
html += '<tr>' +
'<td>' + t.id + '</td>' +
'<td><a href="javascript:void(0)" onclick="showDetail(' + t.id + ')">' + escapeHtml(t.title) + '</a></td>' +
'<td>' + t.priority + '</td>' +
'<td><span class="status-badge ' + statusClass + '">' + t.status + '</span></td>' +
'<td style="font-size:12px;color:var(--text-muted);">' + dateStr + '</td>' +
'<td>' +
' <select class="status-select" onchange="changeStatus(' + t.id + ', this.value)">' +
' <option value="">Сменить статус</option>' +
' <option value="Новая">Новая</option>' +
' <option value="В работе">В работе</option>' +
' <option value="Закрыта">Закрыта</option>' +
' </select>' +
'</td>' +
'</tr>';
});
tbody.innerHTML = html;
}
function filterTickets(){
var statusFilter = document.getElementById('status-filter').value;
var search = document.getElementById('ticket-search').value.toLowerCase();
var filtered = allTickets.filter(function(t){
if(statusFilter && t.status !== statusFilter) return false;
if(search && !t.title.toLowerCase().includes(search) && !(t.description || '').toLowerCase().includes(search)) return false;
return true;
});
renderTickets(filtered);
}
function showDetail(id){
currentTicketId = id;
var ticket = allTickets.find(function(t){ return t.id === id; });
if(!ticket) return;
document.getElementById('ticket-detail-title').textContent = 'Заявка #' + ticket.id;
document.getElementById('ticket-detail-desc').textContent = ticket.description || '—';
document.getElementById('ticket-detail-priority').textContent = ticket.priority;
document.getElementById('ticket-detail-status').value = ticket.status;
document.getElementById('ticket-detail-date').textContent = ticket.created_at ? new Date(ticket.created_at).toLocaleString() : '—';
document.getElementById('ticket-detail').classList.add('open');
}
function closeDetail(){
document.getElementById('ticket-detail').classList.remove('open');
currentTicketId = null;
}
function changeStatus(id, status){
if(!status) return;
updateTicketStatusApi(id, status);
}
function updateTicketStatus(){
if(currentTicketId){
var status = document.getElementById('ticket-detail-status').value;
updateTicketStatusApi(currentTicketId, status);
}
}
function updateTicketStatusApi(id, status){
fetch('/api/bot/tickets/' + id, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({status: status})
}).then(function(r){ return r.json() }).then(function(d){
if(d.ok){
loadTickets();
if(currentTicketId === id) showDetail(id);
} else {
showNotification('Ошибка: ' + (d.error || 'неизвестная'), 'error');
}
});
}
function escapeHtml(s){
if(!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
loadTickets();
</script>
{% endblock %}
@@ -0,0 +1,281 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.tab-bar { display:flex; gap:0; margin-bottom:24px; border-bottom:2px solid var(--border); }
.tab-bar .tab { padding:10px 20px; cursor:pointer; border-bottom:2px solid transparent;
margin-bottom:-2px; color:var(--text-muted); font-weight:500; }
.tab-bar .tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-content { display:none; }
.tab-content.active { display:block; }
.formula-test-panel { margin-top: 16px; }
.formula-description {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 12px;
}
.formula-code {
background: rgba(0,0,0,0.3);
padding: 12px;
border-radius: 8px;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
color: var(--accent);
}
.formula-inputs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.formula-result {
margin-top: 12px;
padding: 12px 16px;
border-radius: var(--radius);
font-weight: 600;
font-size: 14px;
}
.formula-result-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid var(--success);
color: var(--success);
}
.formula-result-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--danger);
color: var(--danger);
}
.accordion { margin-bottom:8px; }
.accordion-header {
display:flex; justify-content:space-between; align-items:center;
padding:10px 14px; background:var(--bg-input); border:1px solid var(--border);
border-radius:var(--radius); cursor:pointer; user-select:none;
}
.accordion-header:hover { background:var(--bg-secondary); }
.accordion-header-left { display:flex; align-items:center; gap:8px; }
.accordion-arrow { font-size:10px; color:var(--text-muted); width:12px; }
.accordion-title { font-weight:600; font-size:13px; }
.accordion-badge {
font-size:11px; background:var(--accent); color:#fff;
border-radius:10px; padding:1px 8px;
}
.accordion-header-right { display:flex; align-items:center; gap:8px; }
.accordion-formula, .accordion-action {
cursor:pointer; font-size:16px; opacity:0.7;
}
.accordion-formula:hover, .accordion-action:hover { opacity:1; }
.accordion-body { display:none; padding:12px 0 4px; }
</style>
<div class="card">
<div class="card-header"><h2>Формулы</h2></div>
<div class="tab-bar">
<div class="tab active" onclick="switchTab('test')">Тест формул</div>
<div class="tab" onclick="switchTab('coeffs')">Коэффициенты</div>
</div>
<div class="tab-content active" id="tab-test">
<div class="form-group">
<label>Выберите формулу для тестирования</label>
<select id="formula-select" onchange="switchFormula(this.value)" style="max-width:400px">
{% for f in formulas %}
<option value="{{ f.key }}" {% if f.key == selected %}selected{% endif %}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
{% for f in formulas %}
<div id="formula-{{ f.key }}" class="formula-test-panel" {% if f.key != selected %}style="display:none"{% endif %}>
<div class="formula-description">
<div style="margin-bottom:8px"><strong>Формула:</strong></div>
<div class="formula-code">{{ f.formula }}</div>
<div style="margin-top:8px"><strong>Результат:</strong> {{ f.result }}</div>
</div>
{% if f.sandbox %}
<form class="formula-sandbox-form" onsubmit="return testFormula(event, '{{ f.key }}')">
<h3 style="margin:16px 0 12px;font-size:14px;">Параметры расчёта</h3>
<div class="formula-inputs">
{% for inp in f.sandbox.inputs %}
<div class="form-group">
<label>{{ inp.label }}</label>
{% if inp.type == "checkbox" %}
<input type="checkbox" name="{{ inp.key }}" value="1" style="width:20px;height:20px;accent-color:var(--accent)">
{% elif inp.type == "select" %}
<select name="{{ inp.key }}">
{% for ok, ol in inp.options.items() %}
<option value="{{ ok }}" {% if ok == inp.default %}selected{% endif %}>{{ ol }}</option>
{% endfor %}
</select>
{% else %}
<input type="number" name="{{ inp.key }}" value="{{ inp.default|default(0) }}" step="any">
{% endif %}
</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary">Рассчитать</button>
<div class="formula-result" id="result-{{ f.key }}" style="display:none"></div>
</form>
{% else %}
<div class="empty-state" style="padding:24px 0">
<p>Для этой формулы пока нет тестовой песочницы.</p>
</div>
{% endif %}
<h3 style="margin:20px 0 10px;font-size:13px;color:var(--text-muted)">Связанные коэффициенты</h3>
<div class="table-wrap"><table><thead><tr><th>Ключ</th><th>Значение</th><th>Описание</th></tr></thead>
<tbody>{% for c in f.coeffs %}<tr>
<td><code>{{ c.key }}</code></td>
<td><strong>{{ c.value }}</strong></td>
<td><small>{{ c.description }}</small></td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
{% endfor %}
</div>
<div class="tab-content" id="tab-coeffs">
{% if groups|length == 0 %}
<div class="card-body"><p class="empty">Нет коэффициентов</p></div>
{% else %}
{% for gkey, gdata in groups.items() %}
<div class="accordion">
<div class="accordion-header" onclick="toggleAccordion(this)">
<div class="accordion-header-left">
<span class="accordion-arrow"></span>
<span class="accordion-title">{{ gdata.info.name }}</span>
<span class="accordion-badge">{{ gdata.coeffs|length }}</span>
</div>
<div class="accordion-header-right">
<span class="accordion-formula" title="Формула" onclick="event.stopPropagation(); showFormulaPopover(this, '{{ gkey }}')" data-formula="{{ gdata.info.formula }}" data-result="{{ gdata.info.result }}">📐</span>
</div>
</div>
<div class="accordion-body">
<div class="table-wrap"><table><thead><tr><th>Ключ</th><th>Значение</th><th>Описание</th><th>Формула</th><th>Активен</th><th class="actions"></th></tr></thead>
<tbody>{% for c in gdata.coeffs %}<tr>
<td><code>{{ c.key }}</code></td>
<td><strong>{{ c.value }}</strong></td>
<td><small>{{ c.description }}</small></td>
<td><small>{{ c.formula_ref }}</small></td>
<td><form method="post" action="/service/coefficients/toggle"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-{{ 'success' if c.is_active else 'secondary' }} btn-sm">{{ '✓' if c.is_active else '✕' }}</button></form></td>
<td 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>
</td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
</div>
{% endfor %}
<div style="margin-top:16px;">
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-coeff').classList.add('open')">+ Добавить коэффициент</button>
</div>
{% endif %}
</div>
</div>
<!-- Coefficient edit modal -->
<div class="modal-overlay" id="modal-coeff"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2 id="modal-coeff-title">Новый коэффициент</h2>
<form method="post" id="modal-coeff-form" action="/service/coefficients/create">
<input type="hidden" name="id" id="coeff-id">
<div class="form-row">
<div class="form-group"><label>Ключ</label><input type="text" name="key" id="coeff-key" required></div>
<div class="form-group"><label>Значение</label><input type="text" name="value" id="coeff-value" required></div>
</div>
<div class="form-group"><label>Описание</label><input type="text" name="description" id="coeff-description"></div>
<div class="form-group"><label>Формула</label><input type="text" name="formula_ref" id="coeff-formula_ref"></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>
</form>
</div></div>
<!-- Formula info modal -->
<div class="modal-overlay" id="modal-formula-info"><div class="modal modal-sm">
<button type="button" class="modal-close" onclick="document.getElementById('modal-formula-info').classList.remove('open')"></button>
<h2 id="formula-info-title">Формула</h2>
<div id="formula-info-content" style="margin-top:12px"></div>
</div></div>
<script>
function switchTab(name){
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); });
document.getElementById('tab-' + name).classList.add('active');
document.querySelector('.tab[onclick*="' + name + '"]').classList.add('active');
}
function switchFormula(key) {
document.querySelectorAll('.formula-test-panel').forEach(function(el) {
el.style.display = 'none';
});
var panel = document.getElementById('formula-' + key);
if (panel) panel.style.display = 'block';
}
function testFormula(event, key) {
event.preventDefault();
var form = event.target;
var data = new URLSearchParams(new FormData(form));
var resultBox = document.getElementById('result-' + key);
resultBox.style.display = 'block';
resultBox.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
resultBox.className = 'formula-result';
fetch('/service/coefficients/test/' + key, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: data.toString()
}).then(function(r) { return r.json() }).then(function(resp) {
resultBox.textContent = 'Результат: ' + (resp.result || 'Ошибка');
resultBox.className = 'formula-result formula-result-success';
}).catch(function(err) {
resultBox.textContent = 'Ошибка: ' + err.message;
resultBox.className = 'formula-result formula-result-error';
});
return false;
}
function toggleAccordion(el) {
var arrow = el.querySelector('.accordion-arrow');
var body = el.nextElementSibling;
if (body.style.display === 'none' || body.style.display === '') {
body.style.display = 'block';
arrow.textContent = '▼';
} else {
body.style.display = 'none';
arrow.textContent = '▶';
}
}
function editCoeff(id,key,value,desc,ref){
document.getElementById('modal-coeff').classList.add('open');
document.getElementById('modal-coeff-title').textContent = 'Редактировать коэффициент';
document.getElementById('modal-coeff-form').action = '/service/coefficients/edit';
document.getElementById('coeff-id').value = id;
document.getElementById('coeff-key').value = key;
document.getElementById('coeff-value').value = value;
document.getElementById('coeff-description').value = desc;
document.getElementById('coeff-formula_ref').value = ref;
}
function showFormulaPopover(el, gkey) {
var formula = el.getAttribute('data-formula');
var result = el.getAttribute('data-result');
var title = el.closest('.accordion-header').querySelector('.accordion-title').textContent;
document.getElementById('formula-info-title').textContent = title;
document.getElementById('formula-info-content').innerHTML =
'<div style="margin-bottom:12px"><strong>Формула:</strong></div>' +
'<div style="background:var(--bg-input);padding:12px;border-radius:8px;font-family:monospace;font-size:13px;margin-bottom:12px;white-space:pre-wrap">' + formula + '</div>' +
'<div><strong>Результат:</strong> ' + result + '</div>';
document.getElementById('modal-formula-info').classList.add('open');
}
// Open first accordion by default
document.addEventListener('DOMContentLoaded', function() {
var first = document.querySelector('.accordion-body');
if (first) { first.style.display = 'block'; first.previousElementSibling.querySelector('.accordion-arrow').textContent = '▼'; }
});
</script>
{% endblock %}
@@ -0,0 +1,75 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.tab-bar { display:flex; gap:0; margin-bottom:24px; border-bottom:2px solid var(--border); }
.tab-bar .tab { padding:10px 20px; cursor:pointer; border-bottom:2px solid transparent;
margin-bottom:-2px; color:var(--text-muted); font-weight:500; }
.tab-bar .tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-content { display:none; }
.tab-content.active { display:block; }
</style>
<div class="card">
<div class="card-header"><h2>Настройка портала</h2></div>
<div class="tab-bar">
<div class="tab active" onclick="switchTab('general')">Общие</div>
<div class="tab" onclick="switchTab('documents')">Управление документами</div>
</div>
<div class="tab-content active" id="tab-general">
<form id="portal-settings-form" class="form-grid">
<h3 style="grid-column:1/-1;margin:0 0 8px;">Yandex GPT</h3>
<div class="form-group"><label>Ключ YandexGPT (пусто = ключевые слова)</label>
<input type="text" name="yandex_gpt_key" id="yandex_gpt_key" placeholder="Api-Key ..."></div>
<div class="form-group"><label>Yandex Folder ID</label>
<input type="text" name="yandex_folder_id" id="yandex_folder_id" placeholder="b1g..."></div>
<h3 style="grid-column:1/-1;margin:16px 0 8px;">Yandex Disk</h3>
<div class="form-group"><label>OAuth-токен Яндекс.Диска</label>
<input type="text" name="yandex_disk_token" id="yandex_disk_token"></div>
<div class="form-group"><label>Корневая папка</label>
<input type="text" name="yandex_disk_root" id="yandex_disk_root" placeholder="/AegisOne_Service"></div>
<div class="form-group"><label>Client ID</label>
<input type="text" name="yandex_disk_client_id" id="yandex_disk_client_id"></div>
<div class="form-group"><label>Client Secret</label>
<input type="text" name="yandex_disk_client_secret" id="yandex_disk_client_secret"></div>
<div class="form-actions" style="grid-column:1/-1;">
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
<div class="tab-content" id="tab-documents">
<p>Управление доступом к документам:</p>
<a href="/service/documents/admin/permissions" class="btn btn-primary">Перейти к управлению документами</a>
</div>
</div>
<script>
function switchTab(name){
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); });
document.getElementById('tab-' + name).classList.add('active');
document.querySelector('.tab[onclick*="' + name + '"]').classList.add('active');
}
document.getElementById('portal-settings-form').addEventListener('submit', function(e){
e.preventDefault();
var data = {};
this.querySelectorAll('input').forEach(function(el){
if(el.name) data[el.name] = el.value;
});
fetch('/service/api/bot/settings/save', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
}).then(function(r){ return r.json() }).then(function(d){
if(d.ok) showNotification('Настройки сохранены', 'success');
else showNotification('Ошибка сохранения', 'error');
});
});
fetch('/service/api/bot/settings').then(function(r){ return r.json() }).then(function(d){
['yandex_gpt_key','yandex_folder_id','yandex_disk_token','yandex_disk_root',
'yandex_disk_client_id','yandex_disk_client_secret'].forEach(function(k){
var el = document.getElementById(k);
if(el) el.value = d[k] || '';
});
});
</script>
{% endblock %}
@@ -0,0 +1,126 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.tab-bar { display:flex; gap:0; margin-bottom:24px; border-bottom:2px solid var(--border); }
.tab-bar .tab { padding:10px 20px; cursor:pointer; border-bottom:2px solid transparent;
margin-bottom:-2px; color:var(--text-muted); font-weight:500; }
.tab-bar .tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-content { display:none; }
.tab-content.active { display:block; }
.menu-group { margin-bottom:16px; }
.menu-group h4 { margin:0 0 8px; color:var(--text-muted); font-size:13px; text-transform:uppercase; }
.menu-item { display:flex; align-items:center; gap:8px; padding:8px 12px;
border:1px solid var(--border); border-radius:6px; margin-bottom:4px; cursor:pointer; }
.menu-item:hover { background:var(--bg-secondary); }
.menu-item input[type="checkbox"] { width:18px; height:18px; cursor:pointer; }
.menu-item .label { flex:1; }
</style>
<div class="card">
<div class="card-header"><h2>Настройка Ролей</h2></div>
<div class="tab-bar">
<div class="tab active" onclick="switchTab('engineer')">Инженер</div>
<div class="tab" onclick="switchTab('technician')">Техник</div>
</div>
<div class="tab-content active" id="tab-engineer">
<form id="role-form-engineer" class="menu-group">
<p style="margin:0 0 12px;color:var(--text-muted);">Отметьте пункты меню, доступные инженеру:</p>
<div id="menu-engineer"></div>
<div class="form-actions" style="margin-top:16px;">
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
<div class="tab-content" id="tab-technician">
<form id="role-form-technician" class="menu-group">
<p style="margin:0 0 12px;color:var(--text-muted);">Отметьте пункты меню, доступные технику:</p>
<div id="menu-technician"></div>
<div class="form-actions" style="margin-top:16px;">
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
<script>
var MENU_ITEMS = [
// Панель
{key: 'charts', label: 'Графики', section: 'Панель'},
{key: 'ceo', label: 'CEO дашборд', section: 'Панель'},
// Управление
{key: 'customers', label: 'Клиенты', section: 'Управление'},
{key: 'objects', label: 'Объекты', section: 'Управление'},
{key: 'sla', label: 'SLA контракты', section: 'Управление'},
{key: 'questionnaire', label: 'Опросник', section: 'Управление'},
{key: 'passports', label: 'Паспорта объектов', section: 'Управление'},
// Работа
{key: 'users', label: 'Сотрудники', section: 'Работа'},
{key: 'assignments', label: 'Назначение сотрудников', section: 'Работа'},
{key: 'docs', label: 'Просмотр документации', section: 'Работа'},
{key: 'tasks', label: 'Задачи', section: 'Работа'},
{key: 'reports', label: 'Отчёты', section: 'Работа'},
{key: 'incidents', label: 'Инциденты', section: 'Работа'},
{key: 'checklist', label: 'Чек-лист', section: 'Работа'},
{key: 'tech_access', label: 'Доступ техников к документам', section: 'Работа'},
// Контент
{key: 'blog', label: 'Управление блогом', section: 'Контент'},
{key: 'cases', label: 'Примеры из практики', section: 'Контент'},
];
function switchTab(name){
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); });
document.getElementById('tab-' + name).classList.add('active');
document.querySelector('.tab[onclick*="' + name + '"]').classList.add('active');
}
function loadPermissions(){
fetch('/service/api/role-permissions').then(function(r){ return r.json() }).then(function(data){
['engineer','technician'].forEach(function(role){
var container = document.getElementById('menu-' + role);
container.innerHTML = '';
var permMap = {};
(data[role] || []).forEach(function(p){ permMap[p.menu_key] = p.visible; });
var currentSection = '';
MENU_ITEMS.forEach(function(item){
if(item.section !== currentSection){
currentSection = item.section;
var hdr = document.createElement('h4');
hdr.textContent = item.section;
container.appendChild(hdr);
}
var div = document.createElement('div');
div.className = 'menu-item';
var checked = permMap[item.key] !== false;
div.innerHTML = '<input type="checkbox" name="' + item.key + '" ' + (checked?'checked':'') + '>' +
'<span class="label">' + item.label + '</span>';
div.querySelector('input[type="checkbox"]').addEventListener('change', function(){
div.style.opacity = this.checked ? '1' : '0.5';
});
div.style.opacity = checked ? '1' : '0.5';
container.appendChild(div);
});
});
});
}
['engineer','technician'].forEach(function(role){
document.getElementById('role-form-' + role).addEventListener('submit', function(e){
e.preventDefault();
var perms = [];
this.querySelectorAll('input[type="checkbox"]').forEach(function(cb){
perms.push({menu_key: cb.name, visible: cb.checked});
});
fetch('/service/api/role-permissions/save', {
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 + '" сохранены');
});
});
});
loadPermissions();
</script>
{% endblock %}
+8 -3
View File
@@ -5,15 +5,16 @@
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-user').classList.add('open')">+ Добавить</button>
</div>
<div class="table-wrap">
<table><thead><tr><th>Логин</th><th>ФИО</th><th>Роль</th><th>Телефон</th><th>Email</th><th>Статус</th><th>Создан</th><th class="actions"></th></tr></thead>
<table><thead><tr><th>Логин</th><th>ФИО</th><th>Роль</th><th>Телефон</th><th>Email</th><th>MAX ID</th><th>Статус</th><th>Создан</th><th class="actions"></th></tr></thead>
<tbody>{% for u in users %}<tr>
<td>{{ u.login }}</td><td>{{ u.full_name }}</td>
<td><span class="badge badge-{{ u.role }}">{{ {'owner':'Владелец','engineer':'Инженер','technician':'Техник'}[u.role] }}</span></td>
<td>{{ u.phone }}</td><td>{{ u.email }}</td>
<td>{{ u.max_user_id or '—' }}</td>
<td><span class="badge badge-{{ 'active' if u.is_active else 'inactive' }}">{{ 'Активен' if u.is_active else 'Неактивен' }}</span></td>
<td>{{ u.created_at.strftime('%d.%m.%Y') if u.created_at else '' }}</td>
<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 }}',{{ 'true' if u.is_active else 'false' }})"></button>
<button class="btn btn-secondary btn-sm" onclick="editUser({{ u.id }},'{{ u.login }}','{{ u.full_name }}','{{ u.role }}','{{ u.phone }}','{{ u.email }}','{{ u.max_user_id or '' }}',{{ 'true' if u.is_active else 'false' }})"></button>
{% if u.role != 'owner' %}
<form method="post" action="/service/users/delete" style="display:inline" onsubmit="return confirm('Удалить пользователя?')">
<input type="hidden" name="id" value="{{ u.id }}">
@@ -41,6 +42,9 @@
<div class="form-group"><label>Телефон</label><input type="text" name="phone" id="user-phone"></div>
<div class="form-group"><label>Email</label><input type="email" name="email" id="user-email"></div>
</div>
<div class="form-row">
<div class="form-group"><label>MAX ID (мессенджер)</label><input type="number" name="max_user_id" id="user-max_id" placeholder="ID пользователя в MAX"></div>
</div>
<div class="form-check"><input type="checkbox" name="is_active" id="user-is_active" value="1" checked><label for="user-is_active">Активен</label></div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
@@ -50,7 +54,7 @@
</div>
</div>
<script>
function editUser(id,login,name,role,phone,email,active){
function editUser(id,login,name,role,phone,email,maxId,active){
document.getElementById('modal-user').classList.add('open');
document.getElementById('modal-user-title').textContent = 'Редактировать сотрудника';
document.getElementById('modal-user-form').action = '/service/users/edit';
@@ -59,6 +63,7 @@ function editUser(id,login,name,role,phone,email,active){
document.getElementById('user-full_name').value = name;
document.getElementById('user-role').value = role;
document.getElementById('user-phone').value = phone; document.getElementById('user-email').value = email;
document.getElementById('user-max_id').value = maxId || '';
document.getElementById('user-is_active').checked = active;
document.getElementById('user-password').required = false;
}
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
set -e
echo "=========================================="
echo " AegisOne Documents Module Deploy"
echo "=========================================="
REMOTE="root@81.177.141.34"
REMOTE_DIR="/opt/projects/aegisone-py/app"
echo "[1/4] Копирование documents_router.py..."
scp app/documents_router.py $REMOTE:$REMOTE_DIR/
echo " ✅ documents_router.py скопирован"
echo "[2/4] Копирование шаблонов документов..."
ssh $REMOTE "mkdir -p $REMOTE_DIR/templates/documents"
scp app/templates/documents/list.html $REMOTE:$REMOTE_DIR/templates/documents/
scp app/templates/documents/view.html $REMOTE:$REMOTE_DIR/templates/documents/
scp app/templates/documents/edit.html $REMOTE:$REMOTE_DIR/templates/documents/
scp app/templates/documents/permissions.html $REMOTE:$REMOTE_DIR/templates/documents/
echo " ✅ Шаблоны скопированы"
echo "[3/4] Копирование docs и permissions..."
ssh $REMOTE "mkdir -p /opt/projects/aegisone-py/docs /opt/projects/aegisone-py/permissions"
scp -r docs/* $REMOTE:/opt/projects/aegisone-py/docs/
echo " ✅ Документы скопированы"
echo "[4/4] Обновление main.py и перезапуск..."
scp app/main.py $REMOTE:$REMOTE_DIR/
scp app/services/docs_permissions.py $REMOTE:$REMOTE_DIR/services/
ssh $REMOTE "cd /opt/projects/aegisone-py && docker compose build --no-cache app && docker compose up -d app"
echo " ✅ Приложение пересобрано и запущено"
echo ""
echo "=========================================="
echo " Готово! Проверка..."
echo "=========================================="
sleep 5
ssh $REMOTE "curl -s -o /dev/null -w 'HTTP %{http_code}' http://localhost:8000/service/documents/"
echo ""
echo "URL: https://service.aegisone.ru/service/documents/"
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
set -e
cd /opt/projects
BACKUP="aegisone-py-backup-$(date +%Y%m%d-%H%M%S)"
echo "Backing up current to $BACKUP..."
mv aegisone-py "$BACKUP" 2>/dev/null || true
echo "Extracting new version..."
mkdir -p aegisone-py
cd aegisone-py
unzip -q ../aegisone-py-deploy.zip
echo "Building and restarting..."
docker compose build --no-cache --network host
docker compose up -d
echo "Deploy complete"
+10 -17
View File
@@ -1,9 +1,8 @@
version: "3.8"
services:
postgres:
image: postgres:16-alpine
container_name: aegisone-postgres
network_mode: host
environment:
POSTGRES_DB: aegisone
POSTGRES_USER: aegisone
@@ -12,42 +11,36 @@ services:
- pgdata:/var/lib/postgresql/data
- ./sql/schema_postgresql.sql:/docker-entrypoint-initdb.d/01_schema.sql
- ./sql/seed_data.sql:/docker-entrypoint-initdb.d/02_seed.sql
ports:
- "5432:5432"
networks:
- aegisone-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aegisone"]
test: ["CMD-SHELL", "pg_isready -U aegisone -h localhost"]
interval: 10s
timeout: 5s
retries: 5
app:
build: .
build:
context: .
network: host
container_name: aegisone-app
network_mode: host
environment:
DATABASE_URL: postgresql+asyncpg://aegisone:aegisone_pass@postgres:5432/aegisone
DATABASE_URL: postgresql+asyncpg://aegisone:aegisone_pass@localhost:5432/aegisone
SECRET_KEY: ${SECRET_KEY:-change-me-to-random-string}
SESSION_TTL: 3600
APP_ENV: production
LOG_LEVEL: info
PORTAINER_URL: ${PORTAINER_URL:-http://81.177.141.34:9001}
depends_on:
postgres:
condition: service_healthy
ports:
- "8000:8000"
volumes:
- ./docs:/app/docs
- ./permissions:/app/permissions
- ./uploads:/app/uploads
networks:
- aegisone-net
- ./CHANGELOG.md:/app/CHANGELOG.md
- ./version.txt:/app/version.txt
restart: unless-stopped
volumes:
pgdata:
networks:
aegisone-net:
driver: bridge
@@ -0,0 +1,836 @@
# AegisOne Engineering — План миграции инфраструктуры и переписывания сервисной части
> **Статус:** План утверждён, готов к реализации
> **Дата:** 2026-05-17
> **Команда:** Владелец + AI-ассистент
> **Примечание:** Если при реализации возникнут вопросы — лучше уточнить, чем переделывать потом.
---
## 1. Текущее состояние
### 1.1. Публичная часть (aegisone.ru)
- **Технологии:** PHP 7.4, MySQL, inline CSS/JS
- **Хостинг:** Shared-хостинг (проблемы: HTTP 302-редирект, блокировка `/assets/`, отключённый `RewriteEngine`, PHP 7.4 тупик)
- **Страницы:** Главная, услуги, блог, контакты, форма обратной связи
- **SEO:** `robots.txt`, `sitemap.xml`, канонические URL
- **Решение:** **Не трогать.** Перенести на VPS как есть в Docker-контейнер.
### 1.2. Сервисная часть (service.aegisone.ru)
- **Технологии:** PHP 7.4, MySQL, inline CSS/JS
- **Функционал:** Админ-панель (управление пользователями, объектами, SLA, опросниками, блогом, документами), расчёты (Risk Score, Object Index, SLA price), паспорта объектов
- **Проблемы:** PHP 7.4 ограничивает развитие, нет автоматизации отчётов, нет интеграций
- **Решение:** **Полностью переписать на Python/FastAPI + Jinja2**
### 1.3. Инфраструктура
- **VPS:** Уже есть, на нём один проект (FastAPI + Uvicorn + Celery + Nginx + PostgreSQL + Redis + Alembic)
- **Хостинг:** Платный, планируется отказ
- **Git:** Нет централизованного сервера, работа на локальной машине
- **SSL:** От регистратора домена (статический, неудобно для мультидомена)
- **Домены:** `aegisone.ru` (публичная часть), планируется `service.aegisone.ru` (админка)
---
## 2. Целевая архитектура
```
┌─────────────────────────────────────────────────────────────────────┐
│ VPS │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Nginx (reverse proxy + SSL) │ │
│ │ порт 80/443, Let's Encrypt │ │
│ │ │ │
│ │ aegisone.ru ──────────────► php-fpm container (порт 9000) │ │
│ │ service.aegisone.ru ─────► fastapi container (порт 8000) │ │
│ │ other-project.ru ────────► project2 container (порт 8001) │ │
│ │ git.aegisone.ru ─────────► gitea container (порт 3000) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ AegisOne PHP │ │ AegisOne Python │ │ Other Project │ │
│ │ (публичная) │ │ (админка) │ │ (FastAPI) │ │
│ │ │ │ │ │ │ │
│ │ • Главная │ │ • Аутентификация│ │ • ... │ │
│ │ • Услуги │ │ • Пользователи │ │ │ │
│ │ • Блог │ │ • Объекты │ │ │ │
│ │ • Контакты │ │ • Клиенты │ │ │ │
│ │ • FAQ │ │ • SLA │ │ │ │
│ │ • Карусель │ │ • Опросники │ │ │ │
│ │ • Форма │ │ • Расчёты │ │ │ │
│ │ │ │ • Отчёты (PDF) │ │ │ │
│ │ │ │ • Документы │ │ │ │
│ │ │ │ • Yandex Disk │ │ │ │
│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │ │
│ └──────────┬──────────┘ │ │
│ │ │ │
│ ┌──────────▼──────────┐ ┌─────────▼────────┐ │
│ │ PostgreSQL │ │ PostgreSQL │ │
│ │ (AegisOne БД) │ │ (Project 2 БД) │ │
│ │ │ │ │ │
│ │ • users │ │ • ... │ │
│ │ • customers │ │ │ │
│ │ • objects │ │ │ │
│ │ • sla_contracts │ │ │ │
│ │ • questionnaire_* │ │ │ │
│ │ • blog_posts │ │ │ │
│ │ • cases │ │ │ │
│ │ • documents │ │ │ │
│ │ • photos │ │ │ │
│ │ • audit_log │ │ │ │
│ └─────────────────────┘ └──────────────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ Redis │ (кэш, Celery broker) │
│ └─────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Celery Workers (Python) │ │
│ │ │ │
│ │ • Генерация отчётов (PDF, Excel) │ │
│ │ • Загрузка фото на Yandex Disk │ │
│ │ • Интеграция с 1С (по мере необходимости) │ │
│ │ • Фоновые расчёты, уведомления │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Gitea │ │ Certbot │ │
│ │ (Git-сервер) │ │ (Let's Encrypt) │ │
│ │ │ │ │ │
│ │ • aegisone-php │ │ • aegisone.ru │ │
│ │ • aegisone-py │ │ • service.aeg.. │ │
│ │ • project2 │ │ • other.ru │ │
│ │ • CI/CD Actions │ │ • автообновление│ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Yandex Disk API (облачное хранилище) │ │
│ │ │ │
│ │ /AegisOne/ │ │
│ │ ├── objects/{object_id}/ │ │
│ │ │ ├── photos/{photo_id}.jpg │ │
│ │ │ └── documents/{doc_id}/ │ │
│ │ │ ├── {photo_id}.jpg │ │
│ │ │ └── report_{doc_id}.pdf │ │
│ │ └── ... │ │
│ │ │ │
│ │ В PostgreSQL хранятся: file_id, file_url, привязка к объекту│ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 3. Технологический стек
### 3.1. Публичная часть (без изменений)
| Компонент | Технология | Примечание |
|---|---|---|
| Язык | PHP 7.4 | Не обновлять, не трогать |
| БД | MySQL | Мигрировать на PostgreSQL позже (опционально) |
| Веб-сервер | Nginx + PHP-FPM | В Docker-контейнере |
| CSS/JS | Inline | Без изменений |
### 3.2. Сервисная часть (Python)
| Компонент | Технология | Примечание |
|---|---|---|
| Язык | Python 3.11+ | |
| Фреймворк | FastAPI | REST API + серверный рендеринг |
| Шаблоны | Jinja2 | Серверный рендеринг, как сейчас PHP |
| Веб-сервер | Uvicorn | ASGI-сервер |
| ORM | SQLAlchemy 2.0 + Alembic | Миграции БД |
| БД | PostgreSQL 15+ | Единая БД для всех проектов |
| Аутентификация | JWT (python-jose) | Токены для API и сессий |
| Фоновые задачи | Celery + Redis | Отчёты, загрузка файлов, интеграции |
| Генерация отчётов | WeasyPrint (PDF), openpyxl (Excel) | |
| Yandex Disk | yandex-disk SDK / REST API | OAuth 2.0 |
| Валидация | Pydantic | Схемы данных |
### 3.3. Инфраструктура
| Компонент | Технология | Примечание |
|---|---|---|
| Контейнеризация | Docker + Docker Compose | |
| Reverse proxy | Nginx | Маршрутизация по доменам |
| SSL | Let's Encrypt + Certbot | Автообновление |
| Git-сервер | Gitea | Лёгкий, встроенный CI/CD |
| CI/CD | Gitea Actions | Автоматический деплой |
| Мониторинг | (опционально) Uptime Kuma | |
### 3.4. Мобильные приложения (будущее)
| Компонент | Технология | Примечание |
|---|---|---|
| Фреймворк | Flutter | |
| API | FastAPI REST endpoints | Те же, что для веб-админки |
| Аутентификация | JWT | |
---
## 4. Почему Jinja2, а не SPA
### 4.1. Сравнение
| Критерий | Jinja2 (SSR) | SPA (React/Vue) |
|---|---|---|
| Серверная нагрузка | Рендерит HTML на сервере | Отдаёт статику + JSON API |
| Масштабирование | Горизонтальное: Nginx → N FastAPI инстансов | CDN для статики + Nginx → API |
| Сложность разработки | Один стек, один деплой | Два стека, два деплоя, CORS |
| Скорость разработки | Быстро | Медленнее |
| Команда | 1-2 Python-разработчика | Python + JS/TS разработчики |
| SEO | Не нужен (админка закрыта) | Не нужен |
| Мобильные приложения | API тот же — Flutter подключается | API тот же — Flutter подключается |
| Поддержка | Проще | Сложнее (два кодовых базы) |
### 4.2. Масштабирование Jinja2 по России
При росте нагрузки:
1. **Вертикальное:** Увеличить ресурсы VPS (CPU, RAM)
2. **Горизонтальное:** Несколько инстансов FastAPI за Nginx (load balancing)
3. **Кэширование:** Redis для кэширования тяжёлых запросов
4. **CDN:** Для статики (CSS, JS, изображения) — Cloudflare или аналог
Jinja2 **не является узким местом**. Узким местом будет БД или внешние API (Yandex Disk, 1С).
### 4.3. Вердикт
**Jinja2 — правильный выбор.** Для B2B-админки SPA — это оверинжиниринг. Jinja2 даёт:
- Быструю разработку
- Простую поддержку
- Легкое масштабирование
- Единую кодовую базу
- Готовность к мобильным приложениям (тот же API)
---
## 5. Этапы реализации
### Этап 1: Docker-фундамент на VPS (1-2 недели)
**Цель:** Подготовить VPS для мультипроектной работы с Docker.
#### 1.1. Установка Docker и Docker Compose
```bash
# На VPS (Ubuntu/Debian)
apt update && apt upgrade -y
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
```
#### 1.2. Базовая структура директорий на VPS
```
/opt/
├── nginx-proxy/ # Nginx reverse proxy + Certbot
│ ├── docker-compose.yml
│ ├── nginx.conf
│ └── certs/ # Let's Encrypt сертификаты
├── gitea/ # Git-сервер
│ ├── docker-compose.yml
│ └── data/ # Данные Gitea
├── aegisone-php/ # Публичная часть (PHP)
│ ├── docker-compose.yml
│ └── src/ # Исходный код (git clone)
├── aegisone-py/ # Сервисная часть (Python)
│ ├── docker-compose.yml
│ ├── app/ # FastAPI приложение
│ ├── migrations/ # Alembic миграции
│ └── requirements.txt
└── project2/ # Существующий проект
└── docker-compose.yml
```
#### 1.3. Nginx reverse proxy + Certbot
- Один Nginx-контейнер на портах 80/443
- Маршрутизация по `server_name`:
- `aegisone.ru``aegisone-php:80`
- `service.aegisone.ru``aegisone-py:8000`
- `git.aegisone.ru``gitea:3000`
- `other-project.ru``project2:8000`
- Certbot в отдельном контейнере или встроенный (nginx-proxy + acme-companion)
#### 1.4. Gitea (Git-сервер)
- Docker-образ `gitea/gitea:latest`
- Порт 3000 → `git.aegisone.ru`
- PostgreSQL для данных Gitea
- Встроенный CI/CD (Gitea Actions)
- Репозитории:
- `aegisone-php` (публичная часть, перенос с текущего хостинга)
- `aegisone-py` (новая сервисная часть)
- `project2` (существующий проект)
- `infra` (конфигурации Docker, Nginx, CI/CD)
#### 1.5. Обёртка существующего проекта в Docker
- Создать `docker-compose.yml` для project2
- Протестировать работоспособность
- Настроить домен `other-project.ru`
**Критерий завершения этапа:**
- [ ] Docker + Docker Compose установлены
- [ ] Nginx reverse proxy работает, маршрутизирует по доменам
- [ ] Certbot выдаёт SSL-сертификаты (Let's Encrypt)
- [ ] Gitea запущен на `git.aegisone.ru`
- [ ] Существующий проект работает в Docker
- [ ] Все домены доступны по HTTPS
---
### Этап 2: Перенос AegisOne на VPS (1 неделя)
**Цель:** Перенести публичную часть с хостинга на VPS, отключить хостинг.
#### 2.1. Docker-контейнер для PHP
```yaml
# /opt/aegisone-php/docker-compose.yml
services:
nginx:
image: nginx:alpine
ports:
- "9080:80" # Внутренний порт, внешний через reverse proxy
volumes:
- ./src:/var/www/html
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- php
php:
image: php:7.4-fpm
volumes:
- ./src:/var/www/html
environment:
- DB_HOST=mysql
- DB_NAME=aegisone
- DB_USER=aegisone
- DB_PASS=<password>
mysql:
image: mysql:5.7
environment:
MYSQL_ROOT_PASSWORD: <root_password>
MYSQL_DATABASE: aegisone
MYSQL_USER: aegisone
MYSQL_PASSWORD: <password>
volumes:
- mysql_data:/var/lib/mysql
volumes:
mysql_data:
```
#### 2.2. Перенос файлов
- Скачать все файлы с хостинга (FTP/SFTP)
- Загрузить в `/opt/aegisone-php/src/`
- Настроить `config.php` для нового окружения (DB_HOST=mysql)
#### 2.3. Миграция БД
- Экспорт MySQL с хостинга: `mysqldump -u user -p db_name > backup.sql`
- Импорт в Docker MySQL: `docker exec -i aegisone-php-mysql mysql -u aegisone -p aegisone < backup.sql`
- Проверить работоспособность
#### 2.4. Настройка домена
- DNS: `aegisone.ru` → IP VPS
- Nginx reverse proxy: `server_name aegisone.ru``aegisone-php:9080`
- SSL: Certbot для `aegisone.ru`
#### 2.5. Тестирование и отключение хостинга
- Проверить все страницы: главная, услуги, блог, контакты, форма
- Проверить карусель, FAQ, тему (светлая/тёмная)
- Проверить Яндекс.Карту с переключением темы
- После подтверждения — отключить хостинг
**Критерий завершения этапа:**
- [ ] Публичная часть работает на VPS
- [ ] Все страницы загружаются корректно
- [ ] Форма обратной связи работает
- [ ] Яндекс.Карта переключает тему
- [ ] Домен `aegisone.ru` указывает на VPS
- [ ] Хостинг отключён
---
### Этап 3: Python-админка (FastAPI + Jinja2) (3-5 недель)
**Цель:** Полностью переписать сервисную часть на Python.
#### 3.1. Структура проекта
```
aegisone-py/
├── docker-compose.yml
├── Dockerfile
├── requirements.txt
├── alembic.ini
├── migrations/ # Alembic миграции
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI приложение
│ ├── config.py # Настройки (env vars)
│ ├── database.py # SQLAlchemy engine, session
│ ├── models/ # SQLAlchemy модели
│ │ ├── __init__.py
│ │ ├── user.py
│ │ ├── customer.py
│ │ ├── object.py
│ │ ├── sla.py
│ │ ├── questionnaire.py
│ │ ├── blog.py
│ │ ├── case.py
│ │ ├── document.py
│ │ ├── photo.py
│ │ └── audit_log.py
│ ├── schemas/ # Pydantic схемы
│ │ ├── __init__.py
│ │ ├── user.py
│ │ ├── customer.py
│ │ └── ...
│ ├── api/ # API endpoints
│ │ ├── __init__.py
│ │ ├── auth.py # Логин, JWT
│ │ ├── users.py
│ │ ├── customers.py
│ │ ├── objects.py
│ │ ├── sla.py
│ │ ├── questionnaire.py
│ │ ├── blog.py
│ │ ├── cases.py
│ │ ├── documents.py
│ │ └── reports.py
│ ├── templates/ # Jinja2 шаблоны
│ │ ├── base.html # Базовый шаблон
│ │ ├── login.html
│ │ ├── dashboard.html
│ │ ├── users/
│ │ ├── customers/
│ │ ├── objects/
│ │ ├── sla/
│ │ ├── questionnaire/
│ │ ├── blog/
│ │ ├── documents/
│ │ └── reports/
│ ├── static/ # CSS, JS, изображения
│ │ ├── css/
│ │ │ ├── service.css # Стили админки
│ │ │ └── ...
│ │ ├── js/
│ │ │ ├── main.js
│ │ │ └── ...
│ │ └── img/
│ ├── services/ # Бизнес-логика
│ │ ├── __init__.py
│ │ ├── auth_service.py
│ │ ├── calc_service.py # Расчёты (Risk Score, Object Index, SLA)
│ │ ├── report_service.py # Генерация отчётов
│ │ ├── yandex_disk_service.py # Интеграция с Yandex Disk
│ │ └── ...
│ ├── tasks/ # Celery задачи
│ │ ├── __init__.py
│ │ ├── reports.py
│ │ ├── yandex_disk.py
│ │ └── ...
│ └── utils/ # Утилиты
│ ├── __init__.py
│ ├── password.py
│ └── ...
└── tests/ # Тесты (опционально)
```
#### 3.2. Миграция данных из MySQL в PostgreSQL
- Создать Alembic миграции для всех таблиц
- Написать скрипт миграции данных:
- `users``users`
- `customers``customers`
- `objects``objects`
- `sla_contracts``sla_contracts`
- `questionnaire_sessions`, `questionnaire_answers`, `questionnaire_items` → аналогично
- `blog_posts``blog_posts`
- `cases``cases`
- `audit_log``audit_log`
- Протестировать миграцию на копии БД
#### 3.3. Аутентификация
- JWT-токены (python-jose)
- Логин/пароль (bcrypt)
- Роли: `owner`, `engineer`, `technician`
- Сессии через cookies (для Jinja2) + JWT (для API)
- CSRF-защита для форм
#### 3.4. CRUD-модули (по порядку приоритета)
**3.4.1. Пользователи (`/service/users/`)**
- Список, создание, редактирование, удаление
- Роль `owner` нельзя удалить, нельзя изменить роль
- Блокировка/разблокировка
- Аудит действий
**3.4.2. Клиенты (`/service/customers/`)**
- Список, создание, редактирование, удаление
- ИНН, КПП, юридический адрес, контакты
- Связь с объектами и SLA
**3.4.3. Объекты (`/service/objects/`)**
- Список, создание, редактирование, удаление
- Привязка к клиенту
- Тип, адрес, площадь, сотрудники
- Risk Score, Complexity Index, Object Index (авторасчёт)
- SLA price (авторасчёт)
**3.4.4. SLA контракты (`/service/sla/`)**
- Список, создание, редактирование, удаление
- Привязка к клиенту и объекту
- Тарифы: Базовый, Оптимальный, Максимальный
- Время реакции, периодичность ТО
- Статус: active, expired, cancelled, negotiation
**3.4.5. Опросник (`/service/questionnaire/`)**
- Создание новой сессии
- 5 шагов: Коммерческий, Технический, Эксплуатация, Риски, Расчёт SLA
- Вопросы из БД (`questionnaire_items`)
- Сохранение ответов
- Авторасчёт: Risk Score, Complexity Index, Infrastructure Load, Service History, Object Index, SLA Price
- Формирование паспорта объекта
**3.4.6. Блог (`/service/blog/`)**
- Список, создание, редактирование, удаление
- Категории: audit, sla, incident, supervision, documentation, risk, cases
- HTML-редактор (toolbar)
- Автогенерация slug из заголовка
- Статус: draft, published
**3.4.7. Примеры из практики (`/service/cases/`)**
- Список, создание, редактирование, удаление
- Заголовок, текст, эффект
- Сортировка, активность
**3.4.8. Документы (`/service/documents/`)**
- Список документов (из `service/docs/*.md`)
- Ручная сортировка
- Переименование
- Гранулярные права: view, edit, cancel (по ролям)
- JSON-хранилище прав (`docs_permissions.json`)
#### 3.5. Расчёты (перенос из PHP)
- `calc_risk_score()` — Risk Score
- `calc_complexity()` — Complexity Index
- `calc_infrastructure_load()` — Infrastructure Load
- `calc_service_history()` — Service History
- `calc_object_index()` — Object Index
- `calc_sla_price()` — SLA Price
- `risk_multiplier()` — множитель риска
- `risk_label()` — текстовая метка риска
- `object_class()` — класс объекта
- `fmt_money()` — форматирование денег
Все функции переписать на Python, покрыть тестами.
#### 3.6. Дашборд (`/service/dashboard/`)
- Метрики: количество объектов, активных SLA, открытых задач
- Графики (Chart.js или аналог)
- Быстрые действия
- Последние активности (audit_log)
#### 3.7. Фронтенд (Jinja2)
- Базовый шаблон (`base.html`) с:
- Боковой панелью (sidebar) с навигацией
- Верхней панелью (header) с пользователем и переключателем темы
- Основным контентом
- Темы: system → dark → light (localStorage, как сейчас)
- Кастомные скроллбары
- Тултипы на кнопках
- Модальные окна (создание/редактирование)
- Таблицы с поиском и пагинацией
- Адаптивность (мобильная версия)
**Критерий завершения этапа:**
- [ ] Все CRUD-модули работают
- [ ] Расчёты корректны (сравнить с PHP-версией)
- [ ] Миграция данных завершена
- [ ] Фронтенд полностью функционален
- [ ] Темы переключаются
- [ ] Мобильная версия работает
- [ ] `service.aegisone.ru` доступен по HTTPS
---
### Этап 4: Интеграции (2-3 недели)
**Цель:** Добавить генерацию отчётов, интеграцию с Yandex Disk, фоновые задачи.
#### 4.1. Yandex Disk API
- Регистрация приложения в Yandex OAuth
- Получение OAuth-токена
- SDK: `yandex-disk` или REST API через `requests`
- Структура папок:
```
/AegisOne/
├── objects/{object_id}/
│ ├── photos/
│ │ └── {photo_id}.jpg
│ └── documents/{doc_id}/
│ ├── {photo_id}.jpg
│ └── report_{doc_id}.pdf
└── ...
```
- Эндпоинты:
- `POST /api/documents/{id}/upload-photo` — загрузка фото
- `GET /api/documents/{id}/photos` — список фото
- `DELETE /api/photos/{id}` — удаление фото
- В БД: таблица `photos` (id, document_id, object_id, yandex_file_id, yandex_file_url, uploaded_at, uploaded_by)
#### 4.2. Генерация отчётов
- **PDF:** WeasyPrint (HTML → PDF)
- Шаблон паспорта объекта
- Шаблон отчёта по аудиту
- Шаблон SLA-контракта
- **Excel:** openpyxl
- Экспорт списка объектов
- Экспорт SLA-контрактов
- Экспорт отчётов по KPI
- Фоновая генерация через Celery (для тяжёлых отчётов)
#### 4.3. Celery (фоновые задачи)
- Broker: Redis
- Задачи:
- Генерация PDF-отчётов
- Загрузка фото на Yandex Disk
- Отправка уведомлений (email, Telegram)
- Интеграция с 1С (по мере необходимости)
- Мониторинг: Flower (веб-интерфейс для Celery)
#### 4.4. Интеграция с 1С (опционально, по мере необходимости)
- REST API 1С или OData
- Синхронизация: клиенты, объекты, договоры
- Фоновая задача Celery
**Критерий завершения этапа:**
- [ ] Yandex Disk API интегрирован
- [ ] Фото загружаются и привязываются к документам
- [ ] PDF-отчёты генерируются
- [ ] Excel-экспорт работает
- [ ] Celery запущен, задачи выполняются
- [ ] (Опционально) Интеграция с 1С настроена
---
### Этап 5: CI/CD и автоматизация деплоя (1 неделя)
**Цель:** Настроить автоматический деплой при push в Git.
#### 5.1. Gitea Actions (CI/CD)
- Встроенный CI/CD в Gitea (совместим с GitHub Actions)
- Workflow для `aegisone-py`:
```yaml
name: Deploy AegisOne Python
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and push Docker image
run: |
docker build -t registry.git.aegisone.ru/aegisone-py:latest .
docker push registry.git.aegisone.ru/aegisone-py:latest
- name: Deploy to VPS
run: |
ssh user@vps "cd /opt/aegisone-py && docker compose pull && docker compose up -d"
```
#### 5.2. Структура репозиториев в Gitea
```
git.aegisone.ru/
├── aegisone/aegisone-php # Публичная часть (PHP)
├── aegisone/aegisone-py # Сервисная часть (Python)
├── aegisone/infra # Docker, Nginx, CI/CD конфиги
└── aegisone/project2 # Существующий проект
```
#### 5.3. Автоматический деплой
- Push в `main` → Gitea Actions → сборка Docker-образа → деплой на VPS
- Rollback: `docker compose down && docker compose up -d` с предыдущим образом
- Миграции БД: Alembic `upgrade head` автоматически при деплое
#### 5.4. Локальная разработка
- `docker-compose.dev.yml` для локальной разработки
- Hot-reload для Python (uvicorn --reload)
- Локальная БД (PostgreSQL в Docker)
- Синхронизация с VPS: `git push` → автоматический деплой
**Критерий завершения этапа:**
- [ ] Gitea Actions настроены для всех проектов
- [ ] Push в `main` → автоматический деплой
- [ ] Rollback работает
- [ ] Миграции БД применяются автоматически
- [ ] Локальная разработка через Docker
---
## 6. Миграция данных: MySQL → PostgreSQL
### 6.1. Таблицы для миграции
| MySQL таблица | PostgreSQL таблица | Примечание |
|---|---|---|
| `users` | `users` | Без изменений |
| `login_attempts` | `login_attempts` | Без изменений |
| `audit_log` | `audit_log` | Без изменений |
| `objects` | `objects` | + `customer_id` FK |
| `object_assignments` | `object_assignments` | Без изменений |
| `tasks` | `tasks` | Без изменений |
| `task_comments` | `task_comments` | Без изменений |
| `customers` | `customers` | Новая таблица |
| `sla_contracts` | `sla_contracts` | + `customer_id` FK, `contract_number`, `description`, `response_time_hours` |
| `incidents` | `incidents` | Без изменений |
| `engineer_kpi` | `engineer_kpi` | Без изменений |
| `questionnaire_sessions` | `questionnaire_sessions` | Без изменений |
| `questionnaire_answers` | `questionnaire_answers` | Без изменений |
| `questionnaire_items` | `questionnaire_items` | Новая таблица |
| `object_passports` | `object_passports` | Без изменений |
| `blog_posts` | `blog_posts` | ENUM категории изменён |
| `cases` | `cases` | Без изменений |
### 6.2. Скрипт миграции
```python
# scripts/migrate_mysql_to_postgres.py
import mysql.connector
import psycopg2
from psycopg2.extras import execute_batch
# Подключение к MySQL
mysql_conn = mysql.connector.connect(host='...', user='...', password='...', database='...')
mysql_cursor = mysql_conn.cursor(dictionary=True)
# Подключение к PostgreSQL
pg_conn = psycopg2.connect(host='...', user='...', password='...', database='...')
pg_cursor = pg_conn.cursor()
# Миграция каждой таблицы
tables = ['users', 'customers', 'objects', 'sla_contracts', ...]
for table in tables:
mysql_cursor.execute(f"SELECT * FROM {table}")
rows = mysql_cursor.fetchall()
if rows:
columns = rows[0].keys()
placeholders = ', '.join(['%s'] * len(columns))
cols = ', '.join(columns)
execute_batch(
pg_cursor,
f"INSERT INTO {table} ({cols}) VALUES ({placeholders}) ON CONFLICT DO NOTHING",
[tuple(row[col] for col in columns) for row in rows]
)
pg_conn.commit()
```
### 6.3. Проверка миграции
- Сравнить количество записей в MySQL и PostgreSQL
- Проверить FK-связи
- Протестировать CRUD-операции на PostgreSQL
- Откат: сохранить бэкап MySQL до миграции
---
## 7. Безопасность
### 7.1. Аутентификация и авторизация
- JWT-токены с expiration (1 час)
- Refresh tokens (7 дней)
- Роли: `owner`, `engineer`, `technician`
- Гранулярные права для документов (view, edit, cancel)
- CSRF-защита для форм
### 7.2. Защита данных
- Пароли: bcrypt (cost factor 12)
- HTTPS для всех доменов (Let's Encrypt)
- Firewall: только порты 80, 443, 22 (SSH)
- Docker: изоляция контейнеров, нет root в контейнерах
- БД: нет внешнего доступа, только из Docker-сети
### 7.3. Бэкапы
- PostgreSQL: `pg_dump` ежедневно (cron)
- Yandex Disk: файлы уже в облаке
- Gitea: бэкап репозиториев (tar)
- Хранение бэкапов: отдельный диск или облако
### 7.4. Мониторинг
- (Опционально) Uptime Kuma для мониторинга доступности
- Логи: Docker logs + ротация
- Алерты: email/Telegram при ошибках
---
## 8. Риски и митигация
| Риск | Вероятность | Влияние | Митигация |
|---|---|---|---|
| Ошибка миграции данных | Средняя | Высокое | Тестирование на копии, бэкап MySQL |
| Простои при деплое | Низкая | Среднее | Zero-downtime деплой (docker compose up -d) |
| Проблемы с SSL | Низкая | Среднее | Certbot автообновление, мониторинг |
| Yandex Disk API лимиты | Низкая | Низкое | Кэширование, retry logic |
| Нехватка ресурсов VPS | Средняя | Высокое | Мониторинг CPU/RAM, масштабирование |
| Ошибки в расчётах | Средняя | Высокое | Тесты, сравнение с PHP-версией |
---
## 9. Оценки времени
| Этап | Описание | Оценка |
|---|---|---|
| Этап 1 | Docker-фундамент | 1-2 недели |
| Этап 2 | Перенос AegisOne на VPS | 1 неделя |
| Этап 3 | Python-админка | 3-5 недель |
| Этап 4 | Интеграции | 2-3 недели |
| Этап 5 | CI/CD | 1 неделя |
| **Итого** | | **8-12 недель** |
---
## 10. Контрольные точки
| Точка | Описание | Критерий успеха |
|---|---|---|
| КП1 | Docker-фундамент готов | Все сервисы запущены, домены работают по HTTPS |
| КП2 | Публичная часть на VPS | aegisone.ru работает, хостинг отключён |
| КП3 | Python-админка MVP | CRUD для пользователей, объектов, SLA работает |
| КП4 | Python-админка полная | Все модули, расчёты, миграция данных завершены |
| КП5 | Интеграции | Yandex Disk, отчёты, Celery работают |
| КП6 | CI/CD | Автоматический деплой при push |
---
## 11. Открытые вопросы (уточнить перед реализацией)
> **Важно:** Если при реализации возникнут вопросы — лучше уточнить, чем переделывать потом.
1. **Миграция данных:** Переносим все данные из MySQL в PostgreSQL или начинаем с чистой БД? (Рекомендация: мигрировать)
2. **Домен для админки:** `service.aegisone.ru` или `app.aegisone.ru`? (Рекомендация: `service.aegisone.ru`)
3. **Yandex Disk OAuth:** Использовать токен приложения или OAuth с авторизацией пользователя? (Рекомендация: токен приложения для простоты)
4. **Генерация отчётов:** Какие именно отчёты нужны в первую очередь? (Паспорт объекта, отчёт по аудиту, SLA-контракт?)
5. **Интеграция с 1С:** Какая версия 1С? Какой метод интеграции (REST API, OData, файловый обмен)?
6. **Мониторинг:** Нужен ли Uptime Kuma или другой мониторинг? (Рекомендация: да, для продакшена)
7. **Бэкапы:** Где хранить бэкапы PostgreSQL? (Отдельный диск на VPS, облако, другой сервер?)
8. **Тестирование:** Покрывать ли код тестами? (Рекомендация: да, хотя бы критические расчёты)
9. **Документация:** Вести ли документацию по API? (Рекомендация: да, через FastAPI автоматическую /docs)
10. **Логирование:** Какой уровень логирования? Куда писать логи? (Рекомендация: INFO в stdout, Docker logs)
---
## 12. Следующие шаги
1. **Утвердить план** — ответить на открытые вопросы (раздел 11)
2. **Начать Этап 1** — Docker-фундамент на VPS
3. **Параллельно** — создать репозитории в Gitea, настроить CI/CD skeleton
4. **После Этапа 1** — приступить к Этапу 2 (перенос PHP)
5. **После Этапа 2** — приступить к Этапу 3 (Python-админка)
---
**Документ создан:** 2026-05-17
**Версия:** 1.0
**Автор:** AI-ассистент + Владелец
**Статус:** Готов к реализации после утверждения
+485
View File
@@ -0,0 +1,485 @@
Ниже — система KPI, которая превращает инженеров и техников из “выездных исполнителей” в управляемую часть инженерной SLA-модели AegisOne Engineering.
Главная цель системы:
> не “оценивать занятость”, а измерять качество эксплуатации, скорость реакции и снижение рисков объекта
---
0. ПРИНЦИП СИСТЕМЫ KPI
Ты НЕ измеряешь:
сколько выездов сделал инженер
сколько он “починил”
Ты измеряешь:
1) надежность объектов
2) соблюдение SLA
3) качество диагностики
4) снижение повторных инцидентов
---
1. СТРУКТУРА KPI (3 УРОВНЯ)
2. Операционный KPI (ежедневный)
3. SLA KPI (контрактный)
4. Инженерный KPI качества (экспертный)
---
2. ОПЕРАЦИОННЫЙ KPI (Execution KPI)
Это “что сделал инженер”.
---
KPI 1: Время реакции (Response Time)
Формула:
RT = (фактическое время реакции / SLA время реакции)
---
Оценка:
RT Оценка
≤ 1.0 норма
1.01.2 допустимо
> 1.2 нарушение
---
Пример:
SLA: 2 часа
факт: 3 часа
RT = 3 / 2 = 1.5 → ❌ нарушение
---
KPI 2: Время устранения (Resolution Time)
TTR = фактическое время устранения / нормативное время
---
Важно:
Норматив зависит от типа инцидента:
P1 (критический) — 2–6 часов
P2 — до 24 часов
P3 — до 3 дней
---
KPI 3: Закрытие заявок в SLA
SLA Compliance = (заявки в SLA / все заявки) × 100%
---
Норма:
≥ 95% — отлично
9095% — допустимо
< 90% — проблема инженера
---
KPI 4: Повторные обращения (Reopen Rate)
RR = (повторные заявки / общее число заявок) × 100%
---
Норма:
≤ 5% — хорошо
510% — средне
> 10% — плохая диагностика
---
3. SLA KPI (контрактный уровень)
---
KPI 5: Выполнение SLA по объектам
Object SLA = (объекты без нарушений SLA / все объекты) × 100%
---
Норма:
≥ 95% — стабильная сеть объектов
< 90% — системная проблема команды
---
KPI 6: Доступность систем (System Uptime)
Uptime = (время работы системы / общее время) × 100%
---
Цель:
99%+ для критических объектов
9799% допустимо
< 97% — риск SLA разрыва
---
4. ИНЖЕНЕРНЫЙ KPI (КАЧЕСТВО ЭКСПЕРТИЗЫ)
Это ключевой уровень, который отличает тебя от монтажников.
---
KPI 7: Точность диагностики
Accuracy = (подтвержденные неисправности / выявленные) × 100%
---
Пример:
выявил 10 проблем
9 подтвердились
→ 90%
---
Норма:
≥ 85% — хорошо
< 80% — инженер слабый
---
KPI 8: Глубина диагностики (Risk Coverage Score)
Оценивает, насколько инженер видит системные риски.
---
Формула (балльная система):
Каждый объект оценивается по 5 зонам:
видео
СКУД
пожарка
питание
сеть
RCS = (проверенные зоны / 5) × 100%
---
Норма:
100% — стандарт AegisOne
< 80% — поверхностная работа
---
KPI 9: Снижение повторных инцидентов
IRR = ((инциденты до - инциденты после) / до) × 100%
---
Это ключевой KPI бизнеса
---
5. ФИНАНСОВЫЙ KPI ИНЖЕНЕРА
---
KPI 10: Доход на инженера (Revenue per Engineer)
RPE = общий SLA доход / количество инженеров
---
Норма:
500k – слабая загрузка
800k норм
1.2M+ – сильная модель
---
6. ИТОГОВЫЙ ИНДЕКС ИНЖЕНЕРА (ENGINEER SCORE)
Это твой главный инструмент управления.
---
Формула:
ES =
(0.25 × SLA Compliance) +
(0.20 × Response Time Score) +
(0.20 × Resolution Time Score) +
(0.15 × Diagnosis Accuracy) +
(0.10 × Reopen Rate Score) +
(0.10 × Risk Coverage Score)
---
Интерпретация:
Score Уровень
90100 Senior Engineer
8089 Strong Engineer
7079 Middle
< 70 требует обучения
---
7. ДОПОЛНИТЕЛЬНЫЕ KPI (ОЧЕНЬ ВАЖНЫЕ)
---
KPI 11: Документирование (Documentation Quality)
DQ = (полные отчёты / все выезды) × 100%
---
KPI 12: Качество отчёта (Report Quality Score)
Оценивается по чек-листу:
есть ли риски
есть ли вывод
есть ли рекомендации
есть ли фото/фиксация
---
KPI 13: Самостоятельность инженера
Autonomy = (самостоятельные решения / всего решений) × 100%
---
8. КАК ЭТО ВЫГЛЯДИТ В CRM
---
Инженер:
- SLA Compliance: 96%
- RT: 1.1
- TTR: 0.9
- Diagnosis: 88%
- Reopen: 3%
- Score: 91 (Senior)
---
9. СИСТЕМА МОТИВАЦИИ (ВАЖНО)
---
БАЗОВАЯ ЛОГИКА:
фикс оклад
бонус за KPI
штрафы только за SLA нарушения
---
БОНУС:
Score 90+ → +20% к премии
Score 8089 → +10%
Score < 80 → без бонуса
---
10. ГЛАВНЫЙ ПРИНЦИП СИСТЕМЫ
Ты НЕ платишь за:
❌ “работу”
❌ “выезды”
Ты платишь за:
стабильность инфраструктуры клиента
---
11. КАК ЭТА СИСТЕМА МАСШТАБИРУЕТ БИЗНЕС
---
1 инженер = управляемая единица SLA
Ты можешь:
добавлять инженеров
сравнивать эффективность
масштабировать регионы
контролировать качество без присутствия
---
12. СВЯЗЬ С ТВОЕЙ БИЗНЕС-МОДЕЛЬЮ
---
KPI инженера
качество SLA
удержание клиентов
MRR рост
масштаб компании
---
13. СЛЕДУЮЩИЙ УРОВЕНЬ (если продолжать систему)
Я могу дальше собрать:
систему грейдов инженеров (Junior → Lead → Chief)
модель расчёта зарплаты под KPI
автоматическую таблицу KPI в Excel/Notion
SLA dashboard (как у IT-компаний)
систему контроля качества через аудит отчётов
---
ИТОГ
Ты получил не “мотивацию сотрудников”.
Ты получил:
> систему управления инженерной эксплуатационной компанией через измеримые риски и SLA
Это уровень компаний, которые продают не услуги — а надежность инфраструктуры бизнеса.
+520
View File
@@ -0,0 +1,520 @@
Ты сейчас описываешь переход от “владельца инженерной фирмы” к роли:
операционного директора инженерной системы (Engineering Operations System Owner)
В такой модели ты уже не контролируешь людей напрямую — ты контролируешь поток рисков, денег, SLA и загрузки системы.
Я разложу это как управленческую “панель управления” (CEO Dashboard), разделив на:
1. критически важные метрики (must have)
2. важные (should have)
3. полезные (nice to have)
4. формулы
5. графики и визуализации
6. как это связывается в одну систему управления
---
1. КРИТИЧЕСКИ ВАЖНЫЕ ПАРАМЕТРЫ (CEO CORE CONTROL LAYER)
Это то, без чего ты не управляешь компанией, а просто “наблюдаешь бизнес”.
---
1.1 MRR / ARR (регулярный доход SLA)
Формула:
MRR = Σ (все SLA контракты / 12)
ARR = MRR × 12
---
Почему это главное:
Ты продаёшь не монтаж, а:
> стабильность объектов
---
График:
📈 линия роста MRR по месяцам
📊 разбивка по типам объектов
---
Управленческая логика:
если MRR растёт → система здорова
если нет → маркетинг/воронка сломана
---
1.2 RETENTION RATE (удержание клиентов)
Формула:
Retention = (клиенты в конце периода / клиенты в начале) × 100%
---
Норма:
95100% = отлично
8595% = нормально
<85% = проблема в SLA
---
График:
📉 “утечка клиентов по месяцам”
---
Интерпретация:
Если падает retention — проблема не в продажах, а в:
инженерах
SLA
качестве реакции
---
1.3 SLA COMPLIANCE (исполнение контрактов)
Формула:
SLA Compliance = (выполненные заявки в SLA / все заявки) × 100%
---
Норма:
95–99% = система стабильна
<90% = начинаются разрывы контрактов
---
График:
📊 compliance по инженерам / регионам
---
1.4 COST PER SLA OBJECT (стоимость обслуживания объекта)
Формула:
Cost per Object = (ФОТ + выезды + оборудование + накладные) / количество объектов
---
Почему важно:
Ты должен видеть:
> объект приносит деньги или сжигает ресурс
---
График:
📊 распределение стоимости по типам объектов
---
1.5 ENGINEER PRODUCTIVITY (производительность инженера)
Формула:
EP = SLA Revenue / количество инженеров
---
Или глубже:
EP2 = (закрытые заявки × сложность) / часы работы
---
График:
📊 эффективность по инженерам (ranking)
---
2. ВАЖНЫЕ ПАРАМЕТРЫ (OPTIMIZATION LAYER)
Это влияет на прибыль, но не ломает систему сразу.
---
2.1 LEAD → SLA CONVERSION RATE
Формула:
Conversion = SLA contracts / all qualified leads
---
График:
📈 воронка: лиды → аудит → SLA
---
Диагностика:
низкая конверсия → проблема продаж/аудита
высокая → система сильная
---
2.2 AVERAGE SLA CONTRACT VALUE
Формула:
ARPC = Total SLA revenue / number of contracts
---
График:
📊 средний чек по объектам
---
2.3 INCIDENT RATE (частота аварий)
Формула:
IR = incidents / objects
---
Почему важно:
Показывает:
> реальное качество эксплуатации
---
2.4 REOPEN RATE (повторные заявки)
Reopen = повторные обращения / все обращения
---
Если растёт:
→ инженер плохо диагностирует
---
3. ПОЛЕЗНЫЕ ПАРАМЕТРЫ (NICE TO HAVE)
Это усиливает контроль, но не критично.
---
3.1 CUSTOMER SATISFACTION (CSAT)
CSAT = (положительные оценки / все оценки) × 100%
---
3.2 RESPONSE TIME DISTRIBUTION
Не среднее, а распределение:
📊 02 часа
📊 26
📊 624
---
3.3 EQUIPMENT HEALTH INDEX
EHI = (исправное оборудование / общее оборудование) × 100%
---
3.4 ENGINEER UTILIZATION RATE
Utilization = рабочие часы / доступные часы
---
4. ГЛАВНЫЙ CEO-ИНДЕКС (SYSTEM HEALTH SCORE)
Это твоя “панель управления бизнесом”.
---
Формула:
SHS =
(0.25 × MRR growth) +
(0.20 × SLA compliance) +
(0.20 × retention) +
(0.15 × engineer productivity) +
(0.10 × conversion rate) +
(0.10 × incident stability)
---
Интерпретация:
SHS состояние
85–100 масштабируемый рост
70–85 стабильный бизнес
50–70 проблемы в системе
<50 системный кризис
---
5. КЛЮЧЕВЫЕ ГРАФИКИ CEO (ДОСКА УПРАВЛЕНИЯ)
---
5.1 MRR Growth Curve
📈 рост повторного дохода
---
5.2 SLA Compliance Heatmap
🟥 красный = проблемные инженеры
🟩 зелёный = сильные
---
5.3 Client Retention Funnel
📊 где теряются клиенты
---
5.4 Engineer Performance Ranking
📊 топ / низ производительности
---
5.5 Incident Map
📍 какие объекты ломаются чаще
---
5.6 Cost vs Revenue per Object
📊 прибыльность каждого SLA
---
6. КРИТИЧЕСКАЯ ЛОГИКА УПРАВЛЕНИЯ
Ты управляешь не людьми.
Ты управляешь:
рисками → SLA → деньгами → нагрузкой инженеров
---
7. ЧТО ТЕБЕ ОБЯЗАТЕЛЬНО ВИДЕТЬ КАЖДЫЙ ДЕНЬ
---
DAILY CEO DASHBOARD:
SLA compliance
аварии за 24ч
новые SLA контракты
просрочки
перегруз инженеров
---
8. ЧТО ТЕБЕ НУЖНО ВИДЕТЬ РАЗ В НЕДЕЛЮ
---
MRR рост
retention
эффективность инженеров
конверсия аудита в SLA
---
9. ЧТО СМОТРЕТЬ РАЗ В МЕСЯЦ
---
прибыльность объектов
эффективность регионов
нагрузка команды
системные сбои
---
10. ГЛАВНЫЙ ПРИНЦИП
Ты не управляешь:
❌ монтажами
❌ выездами
❌ людьми
---
Ты управляешь:
системой инженерной надежности объектов бизнеса
---
11. ЕСЛИ СВЯЗАТЬ ВСЁ В ОДНУ МОДЕЛЬ
Аудит → Risk Score → SLA цена → контракт → MRR → KPI инженеров → retention → рост
---
12. ИТОГ
Ты как CEO должен видеть не “что происходит”, а:
> где именно в системе возникает прибыль или утечка денег/риска
---
13. ЕСЛИ ДАЛЬШЕ РАЗВИВАТЬ СИСТЕМУ
Я могу собрать:
полноценный CEO-dashboard (Excel / Notion / Power BI)
автоматическую систему расчёта SHS
дашборд инженеров в реальном времени
модель прогнозирования MRR на 3–6 месяцев
систему раннего предупреждения потерь клиентов
---
Если коротко:
ты строишь не компанию — ты строишь управляемую инженерную систему с финансовыми законами внутри неё.
+488
View File
@@ -0,0 +1,488 @@
Сейчас мы подходим к одному из самых важных этапов.
Потому что SEO для тебя — это НЕ “трафик ради трафика”.
Тебе не нужны:
- тысячи посетителей;
- B2C заявки;
- мусорные лиды.
Тебе нужен:
# узкий B2B SEO с высокой экспертностью.
И здесь у тебя огромный шанс, потому что рынок безопасности:
# SEO делает ужасно.
Большинство сайтов:
- про “монтаж камер”;
- про оборудование;
- про бренды;
- про “низкие цены”.
А ты можешь занять:
# нишу инженерной эксплуатации.
---
# ГЛАВНАЯ СТРАТЕГИЯ SEO
---
# НЕ ПРОДАВАТЬ:
- камеры;
- оборудование;
- монтаж.
---
# ПРОДАВАТЬ:
- эксплуатацию;
- SLA;
- аудит;
- контроль;
- сопровождение;
- надежность.
---
# ТВОЯ SEO-МОДЕЛЬ
---
# УРОВЕНЬ 1
# КОММЕРЧЕСКИЕ СТРАНИЦЫ
Это:
# страницы услуг.
Они приводят клиентов.
---
# УРОВЕНЬ 2
# ОТРАСЛЕВЫЕ СТРАНИЦЫ
Это:
# страницы под конкретный бизнес.
Они повышают доверие и SEO.
---
# УРОВЕНЬ 3
# ЭКСПЕРТНЫЙ БЛОГ
Это:
# двигатель доверия.
---
# СТРУКТУРА САЙТА
Вот идеальная структура для тебя.
---
# ГЛАВНАЯ
## URL:
`/`
---
## Цель:
- показать позиционирование;
- перевести из “монтажников”;
- дать B2B доверие.
---
# РАЗДЕЛ УСЛУГ
---
## `/services/`
Общая страница услуг.
---
# ОСНОВНЫЕ СТРАНИЦЫ
---
## `/services/audit/`
# Технический аудит систем безопасности
---
## `/services/sla/`
# SLA сопровождение объектов
---
## `/services/maintenance/`
# Техническое обслуживание систем безопасности
---
## `/services/external-engineer/`
# Внешний инженер безопасности
---
## `/services/recovery/`
# Восстановление проблемных объектов
---
## `/services/fire-alarm/`
# Обслуживание пожарной сигнализации
---
## `/services/video-surveillance/`
# Эксплуатация видеонаблюдения
---
## `/services/access-control/`
# Обслуживание СКУД
---
# ОТРАСЛЕВЫЕ СТРАНИЦЫ
Это очень важно.
---
## `/industries/hotels/`
# Системы безопасности гостиниц
---
## `/industries/logistics/`
# Склады и логистика
---
## `/industries/medical/`
# Клиники и медцентры
---
## `/industries/commercial/`
# Коммерческая недвижимость
---
## `/industries/agro/`
# Агропредприятия
---
# БЛОГ
---
## `/blog/`
---
# КАТЕГОРИИ
---
## `/blog/video/`
Проблемы видеонаблюдения.
---
## `/blog/fire-alarm/`
Пожарная безопасность.
---
## `/blog/sla/`
Эксплуатация и SLA.
---
## `/blog/audit/`
Технический аудит.
---
# ПОЧЕМУ ЭТО ТАК ВАЖНО
Google и Яндекс сейчас любят:
- структуру;
- экспертность;
- тематичность;
- глубину.
---
# ЧТО НЕЛЬЗЯ ДЕЛАТЬ
---
# НЕ ДЕЛАТЬ:
- 1 страницу “все услуги”;
- короткие SEO-тексты;
- переспам;
- “установка камер Краснодар”.
Это мусорный рынок.
---
# ТВОЯ SEO-СТРАТЕГИЯ
---
# НЕ:
“дешевый монтаж”
---
# А:
# “эксплуатационные риски объектов”.
---
# ГЛАВНЫЕ SEO КЛЮЧИ
Вот где деньги.
---
# АУДИТ
- аудит систем безопасности
- технический аудит объекта
- аудит видеонаблюдения
- аудит пожарной сигнализации
- проверка систем безопасности
---
# SLA
- SLA обслуживание
- сопровождение систем безопасности
- техническое сопровождение объекта
- эксплуатация систем безопасности
---
# ОБСЛУЖИВАНИЕ
- обслуживание пожарной сигнализации Краснодар
- обслуживание видеонаблюдения Краснодар
- обслуживание СКУД Краснодар
---
# ОТРАСЛЕВЫЕ
- системы безопасности гостиниц
- обслуживание гостиниц Краснодар
- безопасность складов Краснодар
---
# ТЕПЕРЬ ГЛАВНОЕ
# КАК ПИСАТЬ SEO-ТЕКСТЫ
---
# ТВОЯ ЦЕЛЬ
НЕ: “набить ключи”.
---
# А:
# показать инженерную экспертность.
---
# ПРИМЕР SEO-ТЕКСТА
---
---
# ТЕПЕРЬ:
# КОД СТРАНИЦЫ УСЛУГИ
Например:
# `/services/audit/index.php`
---
---
# SEO РЕКОМЕНДАЦИИ ПО ТЕХНИЧЕСКОЙ ЧАСТИ
---
# ОБЯЗАТЕЛЬНО:
---
# 1. ЧПУ URL
---
# ХОРОШО:
`/services/audit/`
---
# ПЛОХО:
`/page.php?id=123`
---
# 2. TITLE И DESCRIPTION
Для каждой страницы.
---
# Пример:
## TITLE:
Технический аудит систем безопасности в Краснодаре
---
## DESCRIPTION:
Проверка состояния систем безопасности коммерческих объектов. Аудит видеонаблюдения, СКУД и пожарной сигнализации.
---
# 3. H1 ТОЛЬКО ОДИН
Это важно.
---
# 4. schema.org
Очень желательно.
---
# 5. СКОРОСТЬ
Не перегружать:
- анимацией;
- тяжелыми видео;
- конструкторами.
---
# 6. GEO SEO
На каждой странице:
- Краснодар;
- Краснодарский край.
---
# 7. ВНУТРЕННЯЯ ПЕРЕЛИНКОВКА
Например:
- из статьи → на аудит;
- из отрасли → на SLA;
- из кейса → на услугу.
---
# ЧТО ДАСТ РЕАЛЬНЫЙ РОСТ
---
# НЕ:
- покупка ссылок;
- SEO-магия.
---
# А:
- экспертные статьи;
- кейсы;
- отраслевые страницы;
- техническая глубина;
- регулярность.
---
# САМОЕ ВАЖНОЕ
Ты должен выглядеть:
# не как “сайт монтажников”.
А как:
# инженерная эксплуатационная компания.
И это:
- редкость;
- слабоконкурентная ниша;
- высокий уровень доверия;
- хороший B2B SEO.
+795
View File
@@ -0,0 +1,795 @@
SLA — ЭТО ОСНОВА ТВОЕГО ПЕРЕХОДА ИЗ “МОНТАЖНИКОВ” В B2B-СЕРВИС
Большинство компаний безопасности в Краснодарском крае:
“иногда приезжают”;
“что-то смотрят”;
“делают отметку”;
работают хаотично.
SLA делает тебя:
инженерной сервисной компанией;
предсказуемым подрядчиком;
частью эксплуатации бизнеса клиента.
Именно SLA позволяет:
продавать дороже;
получать абонентку;
заходить в сети;
работать с коммерческой недвижимостью;
уходить от демпинга.
---
ЧТО ТАКОЕ SLA ПРОСТЫМИ СЛОВАМИ
SLA (Service Level Agreement)
это:
соглашение об уровне сервиса.
То есть: ты заранее фиксируешь:
что обслуживаешь;
как быстро реагируешь;
что считается аварией;
что входит в обслуживание;
какие обязательства у сторон.
---
ПОЧЕМУ B2B ЭТО ЛЮБИТ
Потому что бизнес покупает:
предсказуемость;
контроль;
ответственность;
понятные сроки.
---
ТВОЯ ЦЕЛЬ
НЕ:
> “мы обслуживаем камеры”
А:
“мы гарантируем работоспособность критической инфраструктуры объекта”.
---
КАК SLA ПОДНИМАЕТ ТЕБЯ НАД РЫНКОМ
Без SLA:
“звоните если что”.
С SLA:
регламенты;
сроки реакции;
журналирование;
прозрачность;
ответственность.
---
ГЛАВНАЯ ОШИБКА
Многие делают:
> “приедем в течение суток”.
Это НЕ SLA.
---
НАСТОЯЩИЙ SLA ВКЛЮЧАЕТ
---
1. КЛАССИФИКАЦИЮ ИНЦИДЕНТОВ
Например:
Приоритет Пример Реакция
P1 Критический Не работает пожарка 2 часа
P2 Высокий Нет архива камер 4 часа
P3 Средний Частично не работает СКУД 24 часа
P4 Низкий Настройка доступа Планово
---
2. ВРЕМЯ РЕАКЦИИ
Очень важно: не “устранения”.
А:
начала работ.
---
3. РЕГЛАМЕНТНЫЕ РАБОТЫ
Например:
ежемесячные проверки;
тестирование;
резервное копирование;
контроль архивов;
проверка питания;
чистка;
тест тревог.
---
4. ОТЧЕТНОСТЬ
Это критично.
Ты должен быть:
“прозрачным подрядчиком”.
---
5. SLA ПО КАНАЛАМ СВЯЗИ
Например:
Telegram;
email;
аварийный телефон;
сервисный портал.
---
6. SLA ПО ДОСТУПНОСТИ
Вот тут начинаются большие чеки.
Например:
Система Целевая доступность
Пожарка 99.9%
Видеонаблюдение 99%
СКУД 99%
---
ТЕПЕРЬ ГЛАВНОЕ:
КАК ТЕБЕ ДЕЛАТЬ SLA В РЕАЛЬНОСТИ
Тебе НЕ нужен корпоративный монстр.
Тебе нужна:
простая инженерная система.
---
БАЗОВАЯ МОДЕЛЬ SLA ДЛЯ ТЕБЯ
---
START SLA
Для:
небольших объектов;
офисов;
клиник;
магазинов.
---
Реакция:
до 24 часов
---
Регламент:
1 раз в месяц.
---
Что входит:
проверка состояния;
диагностика;
журнал;
рекомендации;
консультации.
---
Стоимость:
2540 тыс ₽/мес
Для Краснодара — нормально для качественного B2B.
---
BUSINESS SLA
Твой основной продукт.
---
Реакция:
критическая авария — 4 часа;
обычная — 24 часа.
---
Регламент:
24 раза в месяц.
---
Входит:
аварийные выезды;
удаленная диагностика;
контроль архива;
контроль питания;
фотоотчеты;
журнал;
рекомендации;
сопровождение проверок.
---
Стоимость:
60120 тыс ₽/мес
Для:
гостиниц;
складов;
коммерческой недвижимости.
---
ENTERPRISE SLA
Вот это путь к большим деньгам.
---
Реакция:
2 часа
---
Формат:
“внешний инженерный отдел”.
---
Входит:
постоянный контроль;
участие в эксплуатации;
работа с подрядчиками;
аудит;
сопровождение модернизаций;
приемка;
развитие систем.
---
Стоимость:
180500 тыс ₽/мес
---
ВАЖНО:
SLA НЕЛЬЗЯ ПРОДАВАТЬ КАК “ТО”
Иначе: ты вернешься в дешевый рынок.
---
SLA ПРОДАЕТСЯ КАК:
“управление рисками объекта”.
---
ПРИМЕР ТЕКСТА ДЛЯ САЙТА
---
SLA-сопровождение систем безопасности
Мы работаем по регламентированным SLA-моделям обслуживания.
Это означает:
— фиксированное время реакции;
— контроль состояния систем;
— прозрачную отчетность;
— регламентные проверки;
— ответственность за работоспособность инфраструктуры объекта.
SLA позволяет снизить риски простоев, исключить скрытые неисправности и обеспечить стабильную эксплуатацию систем безопасности.
Для каждого объекта разрабатывается индивидуальный регламент обслуживания.
---
ПРИМЕР SLA ТАБЛИЦЫ ДЛЯ САЙТА
---
Приоритет| Описание| Реакция
P1| Полный отказ критической системы| до 2 часов
P2| Частичная потеря функционала| до 4 часов
P3| Некритичная неисправность| до 24 часов
P4| Плановые работы и настройки| по графику
---
ТЕПЕРЬ САМОЕ ВАЖНОЕ
КАК ТЕБЕ СЧИТАТЬ ЦЕНУ SLA
Не “от количества камер”.
Это ошибка рынка.
---
ТВОЯ МОДЕЛЬ ЦЕНООБРАЗОВАНИЯ
Цена считается по:
критичности объекта;
количеству систем;
SLA;
расстоянию;
времени реакции;
рискам;
сложности эксплуатации.
---
МОДЕЛЬ ДЛЯ КРАСНОДАРСКОГО КРАЯ
---
КРАСНОДАР
Можно:
быстрые выезды;
дешевле логистика.
---
СОЧИ
Цена должна быть:
выше на 3050%.
Из-за:
логистики;
сезонности;
срочности.
---
УДАЛЕННЫЕ РАЙОНЫ
Нужно:
отдельное SLA.
---
Например:
Базовая реакция:
24 часа.
Срочный выезд:
дополнительная ставка.
---
КАК СЧИТАТЬ SLA
Вот модель.
---
БАЗА
Например:
35 000 ₽
---
ПЛЮС:
Параметр Доплата
24/7 доступность +20%
SLA 2 часа +35%
Удаленный район +15–40%
Несколько объектов индивидуально
Высокая критичность +25%
---
ПРИМЕР
Гостиница:
64 камеры;
СКУД;
пожарка;
архив;
Краснодар.
---
SLA:
4 часа.
---
Цена:
85120 тыс ₽/мес
И это нормальный рынок для качественного сервиса.
---
ТЕПЕРЬ САМОЕ ВАЖНОЕ
КАК ТЕБЕ ВЫПОЛНЯТЬ SLA БЕЗ БОЛИ
---
НЕ БРАТЬ ВСЕХ ПОДРЯД
Только:
нормальные объекты;
нормальные бюджеты;
адекватные заказчики.
---
НЕ ПРОДАВАТЬ “ДЕШЕВО”
Иначе SLA развалится.
---
ОБЯЗАТЕЛЬНО:
журнал заявок;
регламент;
чек-листы;
фотофиксация;
история обслуживания.
---
ТЕХНИЧЕСКИ ТЕБЕ НУЖНО
---
1. HELP DESK
Минимум:
Telegram;
1С;
Google Sheets.
---
Лучше:
GLPI;
Zammad;
Jira Service Desk.
---
2. БАЗА ОБЪЕКТОВ
Для каждого:
схемы;
пароли;
оборудование;
IP;
контакты;
история.
---
3. ЧЕК-ЛИСТЫ
Например:
Видеонаблюдение:
запись;
архив;
питание;
доступ;
фокус;
время.
---
4. SLA-ДОКУМЕНТ
Это обязательно.
---
ПРИМЕР СТРУКТУРЫ SLA ДОГОВОРА
---
1. Предмет SLA
Исполнитель обеспечивает техническое сопровождение
и контроль работоспособности систем безопасности объекта.
2. Время реакции
P1 — до 2 часов
P2 — до 4 часов
P3 — до 24 часов
3. Регламентные работы
— диагностика
— тестирование
— проверка журналов
— контроль архива
— проверка питания
4. Отчетность
По результатам работ предоставляется отчет.
5. Ограничения SLA
SLA не распространяется на:
— физическое повреждение оборудования
— аварии электропитания
— действия третьих лиц
— форс-мажор
---
ИТОГ
SLA — это:
не “дополнение”.
Это:
фундамент твоего нового бизнеса.
Именно SLA:
выводит тебя из монтажников;
делает recurring revenue;
дает стабильность;
повышает чек;
позволяет масштабироваться.
И это идеально совпадает с твоим профилем инженера и системного человека.
+69
View File
@@ -0,0 +1,69 @@
# SQL-запросы для администрирования
## Как выполнить
1. Войдите в phpMyAdmin на хостинге
2. Выберите базу данных `j30663893_engineering`
3. Перейдите на вкладку «SQL»
4. Вставьте запрос и нажмите «Вперёд»
## Смена пароля владельца
```sql
UPDATE users
SET password_hash = '$2y$10$e0MYzXyjpJS7k0ggKtqjGOcMFU.h9S1sGqf3qJ6qJ6qJ6qJ6qJ6qO'
WHERE login = 'ваш_логин';
```
**Важно:** Замените хеш на реальный. Для генерации хеша используйте PHP:
```php
<?php echo password_hash('новый_пароль', PASSWORD_DEFAULT); ?>
```
Создайте файл `hash.php` в корне сайта с этим кодом, откройте в браузере, скопируйте хеш, удалите файл.
## Разблокировка пользователя
```sql
DELETE FROM login_attempts WHERE login = 'логин_пользователя';
```
## Активация всех кейсов на главной
```sql
UPDATE cases SET is_active = 1;
```
## Просмотр всех пользователей
```sql
SELECT id, login, role, full_name, is_active, created_at FROM users;
```
## Просмотр активных SLA контрактов
```sql
SELECT sc.id, sc.contract_number, o.name as object, sc.client_name,
sc.sla_price_monthly, sc.start_date, sc.end_date, sc.status
FROM sla_contracts sc
JOIN objects o ON o.id = sc.object_id
WHERE sc.status = 'active'
ORDER BY sc.start_date DESC;
```
## Сброс опросника (удаление черновиков)
```sql
DELETE qs, qa
FROM questionnaire_sessions qs
LEFT JOIN questionnaire_answers qa ON qa.session_id = qs.id
WHERE qs.status != 'completed';
```
## Просмотр документов и прав
```sql
-- Файлы в базе не хранятся. Проверьте папку service/docs/ на хостинге.
-- Права хранятся в service/permissions/docs_permissions.json
```
@@ -0,0 +1,934 @@
Хорошо, это важный шаг — ты фактически выбираешь архитектуру бренда (brand architecture), а не просто название. От этого зависит:
как тебя воспринимают (монтажники vs инженерная компания),
как ты продаёшь SLA,
насколько легко масштабируешься по РФ.
Я разложу каждый вариант строго по твоей модели:
👉 аудит → эксплуатация → SLA → инженерный сервис → масштабирование
---
1. AegisOne Engineering
Суть позиционирования:
инженерная компания полного цикла
---
Как воспринимается рынком:
“серьёзная инженерная организация”
ближе к проектированию / эксплуатации
высокий уровень доверия B2B
---
Сильные стороны:
подходит под SLA и аудит
звучит “дорого и системно”
легко масштабируется на РФ
не привязан к монтажу
---
Слабые стороны:
не сразу понятно “сервис”
нужно объяснение через сайт/контент
---
Лучше всего подходит для:
✔ SLA обслуживание
✔ аудит объектов
✔ эксплуатация систем безопасности
✔ B2B рынок (гостиницы, склады, УК)
---
Итог:
> ❗ ЛУЧШИЙ ОСНОВНОЙ БРЕНД (ядро компании)
---
2. AegisOne Service
Суть:
сервисная эксплуатационная компания
---
Восприятие:
обслуживание
ремонт
“сервисники”
---
Сильные стороны:
понятный вход для клиентов
хорошо продаёт обслуживание
легко объяснить
---
Слабые стороны:
слишком “сервис/ремонт”
занижает статус инженера
хуже для крупных B2B объектов
---
Лучше для:
✔ абонентское обслуживание
✔ выездной сервис
✔ мелкие и средние клиенты
✔ поддержка SLA как продукт
---
Итог:
> ⚠ можно как подразделение, но не основной бренд
---
3. AegisOne Security Engineering
Суть:
инженерия безопасности (очень сильный B2B бренд)
---
Восприятие:
высокий уровень экспертизы
ближе к проектированию и системной безопасности
звучит “международно”
---
Сильные стороны:
максимальная экспертность
идеально под аудит и SLA
хорошо для РФ + будущего выхода за регион
не “монтажники”
---
Слабые стороны:
длинное название
чуть тяжеловесно в маркетинге
требует сокращений (AegisOne SE)
---
Лучше для:
✔ аудит
✔ техническая экспертиза
✔ корпоративные объекты
✔ крупные B2B сделки
✔ федеральное масштабирование
---
Итог:
> ⭐ ЛУЧШИЙ ДЛЯ ЭКСПЕРТНОСТИ И PREMIUM B2B
---
4. AegisOne Facility Security
Суть:
безопасность объектов (facility management)
---
Восприятие:
управление объектами
эксплуатация зданий
FM-компании уровня “facility management”
---
Сильные стороны:
очень сильный корпоративный сегмент
подходит для УК, ТЦ, гостиниц
легко встраивается в FM-рынок
---
Слабые стороны:
менее технический фокус
может размывать “инженерность”
ближе к управлению недвижимостью
---
Лучше для:
✔ управляющие компании
✔ гостиничные сети
✔ торговые центры
✔ facility management подрядчики
---
Итог:
> ⚠ хорошо как направление, но не ядро
---
5. AegisOne TechService
Суть:
техсервис / техническое обслуживание
---
Восприятие:
ремонтная служба
IT/техподдержка стиль
---
Сильные стороны:
простое и понятное
легко продаётся “в лоб”
подходит для мелких объектов
---
Слабые стороны:
дешёвое восприятие
не тянет премиум SLA
теряет инженерный статус
---
Лучше для:
✔ массовое обслуживание
✔ низкий чек рынок
✔ быстрые заявки
✔ простые сервисные работы
---
Итог:
> ❌ не подходит под твою стратегию роста
---
6. РЕКОМЕНТИРУЕМАЯ СТРУКТУРА БРЕНДА (ОПТИМАЛЬНАЯ)
Вот как это должно выглядеть в системе:
---
🧠 ГЛАВНЫЙ БРЕНД (основа)
AegisOne Engineering
👉 ядро:
SLA
аудит
эксплуатация
инженерный контроль
---
🔧 СЕРВИСНОЕ ПОДРАЗДЕЛЕНИЕ
AegisOne Service
👉 внутри:
выезды
ремонт
поддержка
реакция по SLA
---
🧪 ЭКСПЕРТНЫЙ КОНТУР
AegisOne Security Engineering
👉 используется для:
сайта экспертного уровня
презентаций
коммерческих предложений
крупных B2B переговоров
---
🏢 ФУТУРОЛОГИЧЕСКИЙ КОНТУР (масштаб)
AegisOne Facility Security
👉 когда выйдешь в:
УК
сети отелей
FM-контракты
---
⚙️ ТЕХПОДДЕРЖКА (НЕ ОСНОВНАЯ)
AegisOne TechService
👉 можно использовать как:
внутренний бренд
дешёвый сегмент (если вдруг нужен)
---
7. ЛУЧШАЯ СТРАТЕГИЯ (РЕАЛЬНО ПРАВИЛЬНАЯ)
Вот как это делает сильная инженерная компания:
---
AegisOne Engineering
AegisOne Security Engineering (экспертиза / доверие)
AegisOne Service (исполнение SLA)
AegisOne Facility Security (масштаб на УК и сети)
---
8. ВАЖНОЕ РЕШЕНИЕ ДЛЯ ТЕБЯ
Если брать твою стратегию:
уход от монтажа
переход в SLA
рост до 23 млн+
B2B рынок
экспертность
---
ТОП-1 выбор:
✔ AegisOne Engineering
---
ТОП-2 усиление:
✔ AegisOne Security Engineering
---
НЕ РЕКОМЕНДУЮ КАК ОСНОВУ:
Service (снижает статус)
TechService (дешевит бренд)
Facility Security (узкая ниша на будущее)
---
9. ЕСЛИ ХОЧЕШЬ ДАЛЬШЕ УСИЛИТЬ БРЕНД
Я могу собрать тебе следующий уровень:
нейминг архитектуры сайта (как разложить домены/страницы)
логотип-систему (как должен выглядеть бренд инженерной компании)
упаковку КП под каждый бренд
позиционирование для тендеров
“как выглядеть как федеральный подрядчик при локальном бизнесе”
---
Если коротко:
> тебе нужен не набор названий, а система уровней доверия
И из всех вариантов:
AegisOne Engineering — фундамент бизнеса.
---
Отлично, теперь мы фиксируем **архитектуру компании как инженерной системы**, а не сайта.
Ты строишь не “фирму услуг”, а:
# AegisOne Engineering — операционную систему эксплуатации объектов
Ниже даю тебе:
1. структуру компании (как она “разложена в голове клиента”)
2. все разделы сайта / системы
3. документы (шаблоны, которые продают SLA сами)
4. сценарии взаимодействия с клиентом
5. стандарты (как ты выглядишь как федеральный подрядчик)
---
# 1. СТРУКТУРА AEGISONE ENGINEERING
## 🧠 ЯДРО КОМПАНИИ
# AegisOne Engineering
> Инженерная компания по эксплуатации и контролю систем безопасности объектов
---
## ФУНКЦИОНАЛЬНЫЕ БЛОКИ
```text
1. Engineering Audit (аудит и диагностика)
2. SLA Operations (эксплуатация)
3. Incident Response (аварийные работы)
4. Technical Supervision (контроль подрядчиков)
5. Documentation & Compliance (документация)
6. Risk Engineering (анализ рисков)
```
---
# 2. СТРУКТУРА САЙТА (КАК ДОЛЖЕН ВЫГЛЯДЕТЬ AEGISONE)
---
# 🏠 ГЛАВНАЯ
## `/`
### Название:
AegisOne Engineering
### Смысл:
> инженерная эксплуатация систем безопасности объектов
---
# 3. РАЗДЕЛЫ САЙТА
---
# 3.1 ENGINEERING AUDIT
## `/engineering-audit/`
### Описание:
```text
Технический аудит систем безопасности с оценкой рисков эксплуатации.
Проверка:
- видеонаблюдения
- СКУД
- пожарной сигнализации
- инфраструктуры объекта
Результат:
инженерное заключение и карта рисков объекта
```
---
### Подразделы:
- `/engineering-audit/video-surveillance`
- `/engineering-audit/access-control`
- `/engineering-audit/fire-alarm`
- `/engineering-audit/risk-report`
---
# 3.2 SLA OPERATIONS
## `/sla-operations/`
### Описание:
```text
Постоянное техническое сопровождение объектов с регламентами и SLA.
Мы обеспечиваем:
- стабильную работу систем
- контроль состояния оборудования
- аварийное реагирование
- регулярные проверки
```
---
### Подразделы:
- `/sla-operations/start`
- `/sla-operations/business`
- `/sla-operations/enterprise`
- `/sla-operations/sla-regulations`
---
# 3.3 INCIDENT RESPONSE
## `/incident-response/`
### Описание:
```text
Экстренное устранение неисправностей систем безопасности.
Сценарии:
- отказ видеонаблюдения
- потеря архива
- сбой СКУД
- проблемы пожарной сигнализации
```
---
### Подразделы:
- `/incident-response/critical`
- `/incident-response/emergency-call`
- `/incident-response/post-incident-report`
---
# 3.4 TECHNICAL SUPERVISION
## `/technical-supervision/`
### Описание:
```text
Контроль подрядчиков и проверка качества работ сторонних организаций.
Мы выступаем как независимый инженерный контроль объекта.
```
---
### Подразделы:
- `/technical-supervision/contractor-check`
- `/technical-supervision/acceptance-testing`
- `/technical-supervision/project-review`
---
# 3.5 DOCUMENTATION & COMPLIANCE
## `/documentation-compliance/`
### Описание:
```text
Ведение и восстановление инженерной документации объектов.
Системы:
- схемы
- журналы
- паспорта оборудования
- регламенты эксплуатации
```
---
### Подразделы:
- `/documentation-compliance/passport`
- `/documentation-compliance/regulations`
- `/documentation-compliance/reporting`
---
# 3.6 RISK ENGINEERING
## `/risk-engineering/`
### Описание:
```text
Анализ рисков эксплуатации инженерных систем безопасности объекта.
Мы оцениваем не оборудование, а последствия его отказа.
```
---
### Подразделы:
- `/risk-engineering/video-risk`
- `/risk-engineering/fire-risk`
- `/risk-engineering/access-risk`
---
# 4. ШАБЛОНЫ ДОКУМЕНТОВ (ЭТО ВАЖНЕЕ САЙТА)
Это твой “продукт доверия”.
---
# 4.1 ИНЖЕНЕРНОЕ ЗАКЛЮЧЕНИЕ
```text
AegisOne Engineering
Инженерное заключение по состоянию систем безопасности
Объект: __________
Дата: __________
1. Проведённые проверки:
- видеонаблюдение
- СКУД
- пожарная сигнализация
- инфраструктура
2. Выявленные отклонения:
- __________
- __________
3. Критические риски:
- __________
4. Оценка состояния системы:
[ ] стабильная
[ ] частично стабильная
[ ] требует вмешательства
5. Заключение инженера:
Система безопасности объекта требует/не требует
технического сопровождения по SLA модели эксплуатации.
```
---
# 4.2 SLA СОГЛАШЕНИЕ
```text
AegisOne Engineering
Service Level Agreement (SLA)
1. Объект обслуживания
2. Перечень систем
3. Время реакции:
P1 — 2 часа
P2 — 4 часа
P3 — 24 часа
4. Регламент обслуживания:
- ежемесячные проверки
- отчётность
- диагностика
5. Зоны ответственности:
- заказчик
- инженерная компания
6. Исключения:
- повреждение третьими лицами
- форс-мажор
```
---
# 4.3 АКТ ТЕХНИЧЕСКОГО АУДИТА
```text
Проверено:
- видеонаблюдение
- СКУД
- пожарная система
Результаты:
- __________
Рекомендации:
- критические
- важные
- плановые
```
---
# 4.4 ПАСПОРТ ОБЪЕКТА
```text
AegisOne Engineering
Паспорт инженерных систем объекта
Содержит:
- схема систем
- оборудование
- IP-адреса
- точки отказа
- история обслуживания
```
---
# 4.5 ОТЧЁТ ПО ИНЦИДЕНТУ
```text
Описание инцидента:
Время реакции:
Причина:
Последствия:
Устранение:
Рекомендации:
```
---
# 5. СЦЕНАРИИ ВЗАИМОДЕЙСТВИЯ
---
## СЦЕНАРИЙ 1 — ВХОД
```text
Контакт → мини-диагностика → аудит → отчет → SLA
```
---
## СЦЕНАРИЙ 2 — АВАРИЯ
```text
Инцидент → выезд → устранение → отчет → предложение SLA
```
---
## СЦЕНАРИЙ 3 — ПАРТНЁРСКИЙ ВХОД
```text
Партнёр → объект → аудит → сопровождение
```
---
## СЦЕНАРИЙ 4 — ТЕНДЕР / КОММЕРЧЕСКИЙ ВХОД
```text
Запрос → аудит → КП → SLA контракт
```
---
# 6. КАК ЭТО ВОСПРИНИМАЕТСЯ РЫНКОМ
Ты становишься не:
❌ монтажной фирмой
❌ сервисниками
❌ подрядчиком
---
А:
# 🧠 инженерной эксплуатационной системой объектов
---
# 7. ГЛАВНЫЙ ЭФФЕКТ ЭТОЙ УПАКОВКИ
## Клиент думает:
> “Это не монтажники. Это те, кто контролирует безопасность объекта как систему.”
---
# 8. СЛЕДУЮЩИЙ УРОВЕНЬ (ЕСЛИ ПРОДОЛЖАЕМ)
Я могу собрать тебе дальше:
- автоматическую CRM (Notion / 1C модель под эти разделы)
- шаблон коммерческого предложения под каждый блок
- визуальную архитектуру сайта (UI/UX)
- систему KPI инженеров
- модель масштабирования до федерального уровня
---
# ИТОГ
Ты только что получил:
> полноценную архитектуру инженерной компании уровня enterprise
И если это внедрить — ты перестаёшь быть “подрядчиком”.
Ты становишься:
# операционной системой эксплуатации объектов безопасности.
@@ -0,0 +1,459 @@
Отлично, теперь мы фиксируем архитектуру компании как инженерной системы, а не сайта.
Ты строишь не “фирму услуг”, а:
AegisOne Engineering — операционную систему эксплуатации объектов
Ниже даю тебе:
1. структуру компании (как она “разложена в голове клиента”)
2. все разделы сайта / системы
3. документы (шаблоны, которые продают SLA сами)
4. сценарии взаимодействия с клиентом
5. стандарты (как ты выглядишь как федеральный подрядчик)
---
1. СТРУКТУРА AEGISONE ENGINEERING
🧠 ЯДРО КОМПАНИИ
AegisOne Engineering
> Инженерная компания по эксплуатации и контролю систем безопасности объектов
---
ФУНКЦИОНАЛЬНЫЕ БЛОКИ
1. Engineering Audit (аудит и диагностика)
2. SLA Operations (эксплуатация)
3. Incident Response (аварийные работы)
4. Technical Supervision (контроль подрядчиков)
5. Documentation & Compliance (документация)
6. Risk Engineering (анализ рисков)
---
2. СТРУКТУРА САЙТА (КАК ДОЛЖЕН ВЫГЛЯДЕТЬ AEGISONE)
---
🏠 ГЛАВНАЯ
/
Название:
AegisOne Engineering
Смысл:
> инженерная эксплуатация систем безопасности объектов
---
3. РАЗДЕЛЫ САЙТА
---
3.1 ENGINEERING AUDIT
/engineering-audit/
Описание:
Технический аудит систем безопасности с оценкой рисков эксплуатации.
Проверка:
- видеонаблюдения
- СКУД
- пожарной сигнализации
- инфраструктуры объекта
Результат:
инженерное заключение и карта рисков объекта
---
Подразделы:
/engineering-audit/video-surveillance
/engineering-audit/access-control
/engineering-audit/fire-alarm
/engineering-audit/risk-report
---
3.2 SLA OPERATIONS
/sla-operations/
Описание:
Постоянное техническое сопровождение объектов с регламентами и SLA.
Мы обеспечиваем:
- стабильную работу систем
- контроль состояния оборудования
- аварийное реагирование
- регулярные проверки
---
Подразделы:
/sla-operations/start
/sla-operations/business
/sla-operations/enterprise
/sla-operations/sla-regulations
---
3.3 INCIDENT RESPONSE
/incident-response/
Описание:
Экстренное устранение неисправностей систем безопасности.
Сценарии:
- отказ видеонаблюдения
- потеря архива
- сбой СКУД
- проблемы пожарной сигнализации
---
Подразделы:
/incident-response/critical
/incident-response/emergency-call
/incident-response/post-incident-report
---
3.4 TECHNICAL SUPERVISION
/technical-supervision/
Описание:
Контроль подрядчиков и проверка качества работ сторонних организаций.
Мы выступаем как независимый инженерный контроль объекта.
---
Подразделы:
/technical-supervision/contractor-check
/technical-supervision/acceptance-testing
/technical-supervision/project-review
---
3.5 DOCUMENTATION & COMPLIANCE
/documentation-compliance/
Описание:
Ведение и восстановление инженерной документации объектов.
Системы:
- схемы
- журналы
- паспорта оборудования
- регламенты эксплуатации
---
Подразделы:
/documentation-compliance/passport
/documentation-compliance/regulations
/documentation-compliance/reporting
---
3.6 RISK ENGINEERING
/risk-engineering/
Описание:
Анализ рисков эксплуатации инженерных систем безопасности объекта.
Мы оцениваем не оборудование, а последствия его отказа.
---
Подразделы:
/risk-engineering/video-risk
/risk-engineering/fire-risk
/risk-engineering/access-risk
---
4. ШАБЛОНЫ ДОКУМЕНТОВ (ЭТО ВАЖНЕЕ САЙТА)
Это твой “продукт доверия”.
---
4.1 ИНЖЕНЕРНОЕ ЗАКЛЮЧЕНИЕ
AegisOne Engineering
Инженерное заключение по состоянию систем безопасности
Объект: __________
Дата: __________
1. Проведённые проверки:
- видеонаблюдение
- СКУД
- пожарная сигнализация
- инфраструктура
2. Выявленные отклонения:
- __________
- __________
3. Критические риски:
- __________
4. Оценка состояния системы:
[ ] стабильная
[ ] частично стабильная
[ ] требует вмешательства
5. Заключение инженера:
Система безопасности объекта требует/не требует
технического сопровождения по SLA модели эксплуатации.
---
4.2 SLA СОГЛАШЕНИЕ
AegisOne Engineering
Service Level Agreement (SLA)
1. Объект обслуживания
2. Перечень систем
3. Время реакции:
P1 — 2 часа
P2 — 4 часа
P3 — 24 часа
4. Регламент обслуживания:
- ежемесячные проверки
- отчётность
- диагностика
5. Зоны ответственности:
- заказчик
- инженерная компания
6. Исключения:
- повреждение третьими лицами
- форс-мажор
---
4.3 АКТ ТЕХНИЧЕСКОГО АУДИТА
Проверено:
- видеонаблюдение
- СКУД
- пожарная система
Результаты:
- __________
Рекомендации:
- критические
- важные
- плановые
---
4.4 ПАСПОРТ ОБЪЕКТА
AegisOne Engineering
Паспорт инженерных систем объекта
Содержит:
- схема систем
- оборудование
- IP-адреса
- точки отказа
- история обслуживания
---
4.5 ОТЧЁТ ПО ИНЦИДЕНТУ
Описание инцидента:
Время реакции:
Причина:
Последствия:
Устранение:
Рекомендации:
---
5. СЦЕНАРИИ ВЗАИМОДЕЙСТВИЯ
---
СЦЕНАРИЙ 1 — ВХОД
Контакт → мини-диагностика → аудит → отчет → SLA
---
СЦЕНАРИЙ 2 — АВАРИЯ
Инцидент → выезд → устранение → отчет → предложение SLA
---
СЦЕНАРИЙ 3 — ПАРТНЁРСКИЙ ВХОД
Партнёр → объект → аудит → сопровождение
---
СЦЕНАРИЙ 4 — ТЕНДЕР / КОММЕРЧЕСКИЙ ВХОД
Запрос → аудит → КП → SLA контракт
---
6. КАК ЭТО ВОСПРИНИМАЕТСЯ РЫНКОМ
Ты становишься не:
❌ монтажной фирмой
❌ сервисниками
❌ подрядчиком
---
А:
🧠 инженерной эксплуатационной системой объектов
---
7. ГЛАВНЫЙ ЭФФЕКТ ЭТОЙ УПАКОВКИ
Клиент думает:
> “Это не монтажники. Это те, кто контролирует безопасность объекта как систему.”
---
8. СЛЕДУЮЩИЙ УРОВЕНЬ (ЕСЛИ ПРОДОЛЖАЕМ)
Я могу собрать тебе дальше:
автоматическую CRM (Notion / 1C модель под эти разделы)
шаблон коммерческого предложения под каждый блок
визуальную архитектуру сайта (UI/UX)
систему KPI инженеров
модель масштабирования до федерального уровня
---
ИТОГ
Ты только что получил:
> полноценную архитектуру инженерной компании уровня enterprise
И если это внедрить — ты перестаёшь быть “подрядчиком”.
Ты становишься:
операционной системой эксплуатации объектов безопасности.
+543
View File
@@ -0,0 +1,543 @@
Хороший вопрос — блог в твоей нише (системы безопасности + SLA + B2B + обслуживание) это не “контент-маркетинг”, а:
> инструмент продажи доверия, SLA и инженерной компетентности
Если блог сделан правильно, он заменяет:
отдел продаж (частично)
коммерческие предложения (частично)
презентации
кейсы
и даже тендерные обоснования
---
1. ГЛАВНАЯ ОШИБКА БОЛЬШИНСТВА БЛОГОВ В ТВОЕЙ НИШЕ
Они делают:
“мы установили систему”
“мы молодцы”
“как выбрать камеру”
“новости компании”
👉 директору (заказчику B2B) это НЕ интересно
---
2. ЧТО ХОЧЕТ ВИДЕТЬ ДИРЕКТОР (ЗАКАЗЧИК B2B)
Директору не нужен контент.
Ему нужно:
1. Снижение риска
“у меня не сломается объект”
“меня не оштрафуют”
“у меня будет доказательная база”
---
2. Управляемость
SLA
контроль
прозрачность
ответственность
---
3. Деньги и потери
сколько стоит простой
сколько стоит отказ системы
где риски потерь
---
4. Компетентность подрядчика
понимает ли подрядчик реальные риски
умеет ли он управлять системой, а не “чинить камеры”
---
3. КАК ДОЛЖЕН БЫТЬ УСТРОЕН БЛОГ (АРХИТЕКТУРА)
BLOG AEGISONE
1. Risk Engineering (риски объектов)
2. SLA & Service (обслуживание)
3. Incident Cases (разбор аварий)
4. Infrastructure Deep Dive (техническая экспертиза)
5. Compliance / MCHS / Audit (регуляторика)
6. Economics of Security (экономика потерь)
7. Case Studies (реальные объекты)
---
4. КАТЕГОРИИ БЛОГА (ПОЛНАЯ СТРУКТУРА)
---
4.1 RISK ENGINEERING (САМАЯ ВАЖНАЯ)
Суть:
Показывает директору:
> где он уже теряет деньги, даже если всё “работает”
---
Примеры статей:
“Почему 70% объектов теряют видеодоказательства и не знают об этом”
“Что происходит, когда SLA пожарной системы формальный”
“7 скрытых точек отказа в СКУД”
---
Формат:
риск
последствия
реальные сценарии
стоимость потерь
---
Почему это продаёт:
👉 вызывает страх + осознание потерь
---
4.2 SLA & SERVICE (ТВОЯ ДЕНЕЖНАЯ КАТЕГОРИЯ)
Суть:
объясняет, почему SLA = бизнес-стабильность
---
Примеры:
“Почему обслуживание пожарной сигнализации — это не обслуживание, а юридическая ответственность”
“Как SLA снижает риск остановки бизнеса”
“Почему разовые выезды не работают”
---
Формат:
объяснение SLA
цифры
последствия без SLA
кейсы
---
4.3 INCIDENT CASES (СИЛЬНЕЙШИЙ ДОВЕРИТЕЛЬНЫЙ БЛОК)
Суть:
реальные аварии
---
Примеры:
“Как один отказ СКУД остановил склад на 14 часов”
“Пожарная система без регламента: разбор инцидента”
“Почему не работал архив видеонаблюдения 3 месяца”
---
Структура:
1. Что случилось
2. Почему это произошло
3. Какие были последствия
4. Как это выявили
5. Как исправили
6. Как избежать
---
Это:
👉 главный доверительный инструмент
---
4.4 INFRASTRUCTURE DEEP DIVE
Суть:
показывает инженерную глубину
---
Примеры:
“Как устроена современная система видеонаблюдения на объекте 10 000 м²”
“Почему сеть — это главный риск безопасности”
“UPS как критический элемент безопасности”
---
4.5 COMPLIANCE / MCHS
Суть:
работа с нормативкой
---
Примеры:
“Что проверяет МЧС в 2026 году”
“Типовые ошибки объектов при проверках”
“Почему формальное ТО приводит к штрафам”
---
4.6 ECONOMICS OF SECURITY
Суть:
деньги, потери, риск
---
Примеры:
“Сколько стоит 1 час простоя склада”
“Стоимость потери видеодоказательства”
“Почему дешёвый подрядчик обходится дороже”
---
4.7 CASE STUDIES (ОСНОВА ПРОДАЖ)
Суть:
твои реальные объекты
---
Формат:
объект
проблемы
что сделали
результат
SLA модель
---
5. КАК ДОЛЖЕН ВЫГЛЯДЕТЬ БЛОГ (UI/UX)
---
5.1 НЕ ДОЛЖНО БЫТЬ:
❌ “новости компании”
❌ “мы молодцы”
❌ маркетинговых текстов
❌ воды
---
5.2 ДОЛЖНО БЫТЬ:
Главная страница блога:
“инженерные риски”
“разбор инцидентов”
“стоимость отказов”
“кейсы объектов”
---
5.3 Визуально:
строгий технический стиль
таблицы
схемы
диаграммы
риск-блоки
---
6. КАЖДАЯ СТАТЬЯ ДОЛЖНА ПРОДАВАТЬ
---
структура статьи (обязательная):
1. Проблема
2. Реальный сценарий
3. Что происходит технически
4. Риски
5. Финансовые последствия
6. Как решается
7. Как это делает AegisOne
---
7. ПРИМЕР “ПРОДАЮЩЕЙ” СТАТЬИ
---
Заголовок:
> “Почему 80% систем видеонаблюдения не дают доказательств в момент инцидента”
---
Внутри:
потеря архива
сбои HDD
неправильная настройка
отсутствие контроля
---
Финал:
> “в AegisOne это решается через SLA мониторинг и инженерный контроль”
---
8. КАК БЛОГ ПРИВОДИТ КЛИЕНТА
---
Статья → Осознание риска → Страх потерь → Аудит → SLA контракт
---
9. ГЛАВНАЯ ЛОГИКА БЛОГА
Ты не пишешь статьи.
Ты создаёшь:
> систему формирования потребности в SLA
---
10. ЧТО ДЕЛАЕТ БЛОГ СИЛЬНЫМ (КРИТИЧНО)
---
1. РЕАЛЬНЫЕ ЦИФРЫ
потери
простои
риски
---
2. РЕАЛЬНЫЕ ИНЦИДЕНТЫ
аварии
сбои
последствия
---
3. ИНЖЕНЕРНАЯ ГЛУБИНА
схемы
логика систем
причины отказов
---
4. ЭКОНОМИКА
сколько стоит ошибка
---
11. ЧТО НЕЛЬЗЯ ДЕЛАТЬ
---
❌ SEO-статьи “для трафика”
❌ поверхностные обзоры
❌ “лайфхаки безопасности”
❌ маркетинг
---
12. ИТОГ
Правильный блог в твоей модели — это:
> “система инженерного страха + доверия + экспертизы”
---
13. ЕСЛИ РАЗВИВАТЬ ДАЛЬШЕ
Я могу дальше собрать:
30 готовых статей под твой блог (с заголовками + структурами)
SEO-структуру под Краснодарский край
Telegram-воронку из блога
автоматическое превращение статей в заявки на аудит
систему “контент → SHS влияние”
---
Если коротко:
👉 блог в твоей модели — это не контент
👉 это “машина продажи SLA через понимание риска”
+445
View File
@@ -0,0 +1,445 @@
Ок, здесь мы уже собираем не “маркетинг”, а полноценную B2B-машину:
# вход → доверие → аудит → SLA → долгий повторяющийся доход
Ты как раз в нише, где деньги делаются не на разовых монтажах, а на:
> эксплуатации + рисках + ответственности
---
# ЧАСТЬ 1. ПЕРВАЯ ВОРОНКА КЛИЕНТОВ (КРАСНОДАРСКИЙ КРАЙ)
---
# 0. ЛОГИКА ВОРОНКИ
Твоя воронка НЕ классическая:
❌ “реклама → заявка → продажа”
У тебя:
# контент → доверие → аудит → выявление проблем → SLA
---
# 1. ВХОД В ВОРОНКУ (ТОЧКИ ПРИКОСНОВЕНИЯ)
## 1.1. Локальный B2B рынок (основа)
Ты работаешь не по всей РФ, а:
- Краснодар
- Сочи
- Новороссийск
- Анапа
- Армавир
- промзоны + логистика + гостиницы
---
## 1.2. Каналы входа (без агрессивного маркетинга)
### 1) Telegram (основной канал доверия)
Контент + кейсы + разборы
---
### 2) Яндекс / Google SEO
Страницы:
- аудит систем безопасности
- SLA обслуживание
- обслуживание видеонаблюдения Краснодар
- обслуживание пожарной сигнализации
---
### 3) Партнеры (очень важно)
- электрики
- IT-аутсорс
- управляющие компании
- проектировщики
- пожарники
- строители
---
### 4) Прямые инженерные выходы
НЕ продажи.
Формат:
> “если нужен технический аудит или проверка систем — можем подключиться”
---
# 2. ПЕРВЫЙ ПРОДУКТ (ВХОД В ДЕНЬГИ)
# 🔥 Технический аудит объекта
---
## Почему это ядро воронки
Потому что:
- легко продать без давления
- низкий чек
- высокая ценность
- выявляет проблемы
- логично ведёт в SLA
---
## Цена (Краснодарский край)
- малый объект: 15–25 тыс
- средний: 25–50 тыс
- крупный: 50–100 тыс
---
## Что важно
Аудит НЕ продаёт услуги.
Он:
# показывает проблемы → создаёт необходимость SLA
---
# 3. ВТОРОЙ ЭТАП
# 🔥 “Диагностика + отчет”
После аудита:
Ты выдаешь документ:
- риски
- неисправности
- слабые места
- последствия
- рекомендации
---
## Ключевая мысль отчета:
> “Система формально работает, но не защищает объект”
---
# 4. ТРЕТИЙ ЭТАП
# 🔥 ПЕРЕХОД В SLA
---
## Это главный момент денег
Ты НЕ продаешь обслуживание.
Ты продаешь:
# “устранение рисков эксплуатации”
---
## Как выглядит оффер
> Мы берем объект на техническое сопровождение с SLA контролем состояния систем безопасности.
---
## Конверсия
Из аудита → в SLA:
- 30% слабые объекты
- 50% средние
- 10–20% сильные (очень хорошие деньги)
---
# 5. SLA (ПРОДУКТ ДЕНЕГ)
---
## Структура:
### START
2540k
---
### BUSINESS
60120k
---
### ENTERPRISE
150400k
---
## Что ты продаешь на самом деле:
НЕ обслуживание
А:
# “инженерную стабильность объекта”
---
# 6. ПОЛНАЯ ВОРОНКА В ВИДЕ СХЕМЫ
```
Контент / SEO / партнеры
Лид (запрос)
Мини-аудит (или первичная диагностика)
Полный технический аудит
Отчет с рисками
SLA предложение
Долгосрочный контракт
```
---
# ЧАСТЬ 2. МОДЕЛЬ РОСТА ДО 2–3 МЛН ₽/МЕС RECURRENT
---
# 1. КЛЮЧЕВОЕ ПРАВИЛО
Ты не масштабируешь монтаж.
Ты масштабируешь:
# количество объектов на SLA
---
# 2. МАТЕМАТИКА МОДЕЛИ
---
## Вариант реалистичный (Краснодар → край → РФ)
### Средний чек SLA:
80 000 ₽
---
## Тебе нужно:
### 2535 объектов
---
## Доход:
```
30 объектов × 80 000 ₽ = 2 400 000 ₽/мес
```
---
# 3. КАК ЭТО ДОСТИГАЕТСЯ
---
## ЭТАП 1 — 0500k
Фокус:
- аудит
- первые SLA
- 57 объектов
---
## ЭТАП 2 — 500k1.5M
Фокус:
- стабильные SLA
- отбор клиентов
- отказ от токсичных объектов
- первые крупные клиенты
---
## ЭТАП 3 — 1.5M3M
Фокус:
- стандартизация
- регламенты
- техподдержка
- 23 инженера
- удаленный контроль
---
# 4. КЛЮЧЕВОЙ РЫЧАГ РОСТА
---
# НЕ реклама
# НЕ продажи
# А:
## “плотность объектов на инженера”
---
# 5. СИСТЕМА РОСТА
---
## 1 инженер = 8–12 объектов
---
## значит:
|Кол-во инженеров|Доход|
|---|---|
|1|0.81.2M|
|2|1.62.4M|
|3|2.43.6M|
---
# 6. ГЛАВНЫЕ РЫЧАГИ МАСШТАБА
---
## 1. Аудит как продукт входа
(дешево / легко / продается сам)
---
## 2. SLA как подписка
(деньги каждый месяц)
---
## 3. Контент как доверие
(заменяет рекламу)
---
## 4. Партнерки
(дают теплые объекты)
---
## 5. Отказ от дешевых клиентов
(самый важный фактор)
---
# 7. КРИТИЧЕСКИЙ МОМЕНТ
---
Если ты не сделаешь это:
> “мы не берем монтаж без SLA”
ты останешься в рынке:
- демпинга
- разовых работ
- кассовых разрывов
---
# 8. ИДЕАЛЬНАЯ ЭВОЛЮЦИЯ БИЗНЕСА
---
## Было:
монтажники
---
## Стало:
SLA + эксплуатация
---
## Будет:
# внешний инженерный отдел объектов
---
# 9. СИЛЬНЫЙ СДВИГ МЫШЛЕНИЯ
---
Ты больше не продаешь:
❌ камеры
❌ монтаж
❌ оборудование
---
Ты продаешь:
# “спокойствие собственника за техническую инфраструктуру объекта”
---
# ЕСЛИ СЖАТЬ В ОДНУ ФОРМУЛУ:
---
> Аудит → выявление рисков → SLA → долгосрочный инженерный доход
---
# ЕСЛИ ДАЛЬШЕ ДВИГАТЬСЯ
Я могу следующим шагом собрать тебе:
- точную систему продаж аудита (скрипты + сообщения)
- упаковку Telegram как лидогенератора
- CRM воронку под SLA
- регламент инженера (чтобы масштабироваться)
- финансовую модель до 5–10 млн/мес
Это уже будет не “бизнес идея”, а:
# операционная система инженерной компании.
@@ -0,0 +1,397 @@
Отлично, это уже уровень, где бизнес начинает “дышать сам”:
Telegram → доверие → аудит → SLA → регулярный доход.
Разобьём на две системы:
# 1) Telegram как лидогенератор (не блог, а воронка)
# 2) CRM-воронка под SLA (повторяемые деньги)
---
# 1. TELEGRAM КАК ЛИДОГЕНЕРАТОР (ИНЖЕНЕРНАЯ МОДЕЛЬ)
---
## 1.1. Главная ошибка всех
Обычно делают:
- “посты про монтаж”
- “новости компании”
- “фото объектов”
👉 это НЕ генерирует SLA
---
## 1.2. Твоя роль канала
Твой Telegram — это:
# “инженерный журнал рисков объектов”
Не маркетинг. Не реклама. Не блог.
---
## 1.3. Главная цель Telegram
Не подписки.
А:
> заявки на аудит
---
# 2. СТРУКТУРА TELEGRAM-КАНАЛА
---
## 2.1. Закреп (ОБЯЗАТЕЛЬНО)
```text
Мы занимаемся инженерной эксплуатацией систем безопасности коммерческих объектов.
Перед обслуживанием всегда проводим технический аудит:
— видеонаблюдение
— СКУД
— пожарная сигнализация
— инфраструктура
Цель — выявить скрытые риски, которые не видны в ежедневной работе.
📌 Если нужно — можем провести аудит объекта и выдать инженерное заключение.
Написать: @your_contact
```
---
## 2.2. 3 типа контента (ВСЕГДА)
---
### 1) РИСКИ (60%)
👉 основа роста
Примеры:
- “Почему архив видеонаблюдения исчезает незаметно”
- “Почему пожарка работает, но не защищает”
- “3 скрытые ошибки СКУД”
---
### 2) КЕЙСЫ (25%)
👉 доверие
Примеры:
- “Аудит склада: 12 критических ошибок”
- “Гостиница без резервного питания”
---
### 3) ЭКСПЕРТИЗА (15%)
👉 статус
Примеры:
- “Как должен выглядеть нормальный SLA”
- “Что проверяет инженер, а не монтажник”
---
# 2.3. ФОРМАТ ПОСТОВ (СТАНДАРТ)
```text
1. Проблема
2. Инженерное объяснение
3. Последствия для бизнеса
4. Спокойный вывод
```
---
## Пример готового поста
```text
Большинство объектов уверены, что видеонаблюдение работает корректно.
На практике часто есть скрытая проблема — архив.
Камеры могут показывать картинку, но запись:
— не сохраняется полностью
— перезаписывается раньше срока
— теряет часть каналов
— сбоит из-за питания или дисков
Это не видно в ежедневной работе.
Проблема проявляется только после инцидента.
По сути, система существует, но не выполняет свою функцию хранения доказательной базы.
Именно поэтому видеонаблюдение нужно рассматривать как систему эксплуатации, а не просто установленное оборудование.
```
---
# 2.4. CTA (ОЧЕНЬ ВАЖНО)
Каждый 34 пост:
```text
Если нужно — можем провести технический аудит объекта и проверить состояние систем безопасности.
```
---
# 2.5. ЛИД-МАГНИТ (ОЧЕНЬ СИЛЬНЫЙ РЫЧАГ)
---
## PDF:
# “Чек-лист скрытых рисков систем безопасности”
или
# “10 ошибок эксплуатации объектов”
---
## Telegram пост:
```text
Мы собрали чек-лист из 10 критических ошибок, которые встречаются на большинстве объектов.
Если хотите — отправим бесплатно.
```
👉 это даёт первые контакты без давления
---
# 3. TELEGRAM → ВОРОНКА
```text
Контент
Интерес
Чек-лист / диалог
Диагностика (мини-аудит)
Полный аудит
SLA
```
---
# 4. CRM-ВОРОНКА ПОД SLA
Теперь самое важное.
Ты не продаёшь “услуги”. Ты ведёшь объекты по состояниям.
---
# 4.1. СТРУКТУРА CRM (СТАДИИ)
---
## 1. ЛИД
Источник:
- Telegram
- сайт
- партнер
- холодный контакт
---
## 2. ПЕРВИЧНЫЙ КОНТАКТ
- написал / ответил
- уточнение объекта
---
## 3. КВАЛИФИКАЦИЯ
Вопросы:
- тип объекта
- системы
- есть ли подрядчик
- проблемы
---
## 4. ДИАГНОСТИКА (мини-аудит)
- быстрый осмотр
- удаленно или выезд
- выявление боли
---
## 5. ПОЛНЫЙ АУДИТ
- инженерный выезд
- отчет
- риски
---
## 6. ПРЕДЛОЖЕНИЕ SLA
- тариф
- регламент
- реакция
---
## 7. ПЕРЕГОВОРЫ
- согласование цены
- условия
---
## 8. ДОГОВОР SLA
- подписан
- объект активен
---
## 9. АКТИВНЫЙ SLA
- обслуживание
- отчёты
---
## 10. РАСШИРЕНИЕ
- новые объекты
- доп. системы
---
# 4.2. ВИЗУАЛЬНАЯ CRM-СХЕМА
```text
ЛИД
КОНТАКТ
КВАЛИФИКАЦИЯ
МИНИ-ДИАГНОСТИКА
АУДИТ
SLA ОФФЕР
ДОГОВОР
ОБСЛУЖИВАНИЕ
РАСШИРЕНИЕ
```
---
# 5. ПРОСТАЯ CRM В ТАБЛИЦЕ (СТАРТ)
Если без софта:
|Клиент|Объект|Статус|Проблема|Этап|Потенциал|
|---|---|---|---|---|---|
|X|гостиница|аудит|нет архива|прогрев|80k/мес|
---
# 6. КЛЮЧЕВОЙ ПРИНЦИП CRM
Ты не ведёшь “продажи”.
Ты ведёшь:
# инженерные объекты
---
# 7. МЕТРИКА УСПЕХА (ВАЖНО)
Не:
- подписчики
- заявки
А:
## 1 объект = SLA контракт
---
# 8. РОЛЬ TELEGRAM В МОДЕЛИ ДЕНЕГ
```text
Telegram = генератор доверия
Аудит = генератор денег входа
SLA = генератор повторного дохода
```
---
# 9. СИСТЕМА РОСТА (СВЯЗКА ВСЕГО)
```text
Контент (Telegram)
Диалог
Аудит
SLA контракт
Постоянный доход
Масштаб через инженеров
```
---
# 10. СЛЕДУЮЩИЙ УРОВЕНЬ (если хочешь усилить систему)
Я могу дальше собрать тебе:
### 1. Полную CRM в Notion / 1C структуру под SLA
### 2. Скрипт “закрытия в SLA после аудита”
### 3. Таблицу квалификации клиента (кого брать/кого нет)
### 4. Финмодель роста до 3 млн+
### 5. Регламент инженера (чтобы масштабировать команду)
---
Если коротко: ты сейчас строишь не маркетинг, а:
> инженерную подписочную сервисную компанию
И Telegram + CRM — это её нервная система.
@@ -0,0 +1,537 @@
Ниже — полноценная модель **“Дашборд инженера (тимлид / руководитель техников)”** для AegisOne Engineering.
Это ключевой слой между:
- техник → (исполнение)
- директор → (управление системой)
Инженер тут не “старший техник”, а:
> операционный контроллер SLA, качества и рисков на объектах
---
# 1. РОЛЬ ИНЖЕНЕРА В СИСТЕМЕ
## 1.1 Основная функция
```text
Инженер = управление качеством исполнения SLA + распределение нагрузки + контроль рисков объектов
```
---
## 1.2 Он НЕ делает:
- не чинит оборудование (это техник)
- не продаёт SLA (это коммерция)
- не ведёт бухгалтерию
---
## 1.3 Он ДЕЛАЕТ:
- распределяет заявки
- контролирует SLA
- проверяет качество работ
- снижает инциденты
- управляет техниками
- влияет на SHS
---
# 2. АРХИТЕКТУРА DASHBOARD ИНЖЕНЕРА
```text
CEO DASHBOARD
ENGINEER DASHBOARD
TECHNICIAN DASHBOARD
OBJECT SYSTEMS
```
---
# 3. DASHBOARD ИНЖЕНЕРА (REAL-TIME)
## 3.1 Главные блоки интерфейса
### Блок 1 — SLA контроль региона
- SLA compliance (%)
- просроченные заявки
- риск нарушения SLA
- критические объекты
---
### Блок 2 — команда техников
- загрузка каждого техника
- эффективность
- ошибки / reopen rate
- скорость реакции
---
### Блок 3 — инциденты
- P1 / P2 / P3
- открытые критические
- повторяющиеся аварии
---
### Блок 4 — объекты
- риск-уровень (Risk Score)
- SHS влияние объекта
- проблемные системы
---
### Блок 5 — KPI инженера
- качество управления SLA
- распределение нагрузки
- снижение инцидентов
- эффективность команды
---
# 4. PHP АРХИТЕКТУРА
---
## 4.1 API инженера
```php
<?php
header('Content-Type: application/json');
$pdo = new PDO("mysql:host=localhost;dbname=aegisone","user","pass");
$data = $pdo->query("
SELECT
e.id,
e.name,
AVG(t.sla_compliance) as sla,
COUNT(i.id) as incidents,
AVG(t.response_time) as response_time
FROM engineers e
LEFT JOIN tasks t ON t.engineer_id = e.id
LEFT JOIN incidents i ON i.engineer_id = e.id
GROUP BY e.id
")->fetchAll(PDO::FETCH_ASSOC);
echo json_encode($data);
?>
```
---
## 4.2 SHS ENGINEER IMPACT API
```php
<?php
header('Content-Type: application/json');
$pdo = new PDO("mysql:host=localhost;dbname=aegisone","user","pass");
$k = $pdo->query("
SELECT
AVG(sla_compliance) as sla,
AVG(response_time) as rt,
AVG(reopen_rate) as rr
FROM tasks
")->fetch(PDO::FETCH_ASSOC);
/* Engineer Impact on SHS */
$EIS =
(0.5 * $k['sla']) +
(0.3 * (1 - $k['rr'])) +
(0.2 * (1 - $k['rt']/10));
echo json_encode([
"ENGINEER_IMPACT_SCORE" => round($EIS * 100, 2)
]);
?>
```
---
# 5. ФОРМУЛЫ ИНЖЕНЕРА (КРИТИЧЕСКИЕ KPI)
---
## 5.1 ENGINEER CONTROL SCORE (ECS)
```text
ECS =
(0.30 × SLA Compliance Control)
+ (0.25 × Task Distribution Efficiency)
+ (0.20 × Incident Reduction Rate)
+ (0.15 × Team Performance Score)
+ (0.10 × Response Coordination Speed)
```
---
## 5.2 SLA CONTROL INDEX
```text
SCI = closed_tasks_in_SLA / total_tasks
```
---
## 5.3 TEAM LOAD BALANCE
```text
TLB = std_dev(tasks_per_engineer)
```
👉 чем меньше → тем лучше распределение
---
## 5.4 INCIDENT REDUCTION RATE
```text
IRR = (incidents_previous - incidents_current) / incidents_previous
```
---
## 5.5 ESCALATION RATE
```text
ER = escalated_tasks / total_tasks
```
---
# 6. DASHBOARD (HTML + JS)
---
## 6.1 HTML
```html
<!DOCTYPE html>
<html>
<head>
<title>Engineer Control Panel</title>
<style>
body { background:#0f172a; color:white; font-family:Arial; }
.grid {
display:grid;
grid-template-columns: repeat(3, 1fr);
gap:15px;
padding:20px;
}
.card {
background:#1e293b;
padding:15px;
border-radius:12px;
}
.big { font-size:28px; font-weight:bold; }
.green { color:#22c55e; }
.yellow { color:#facc15; }
.red { color:#ef4444; }
</style>
</head>
<body>
<h2 style="padding:20px;">Engineer Live Dashboard</h2>
<div class="grid">
<div class="card">
<div>SLA Control</div>
<div id="sla" class="big">--</div>
</div>
<div class="card">
<div>Engineer Impact (SHS)</div>
<div id="shs" class="big">--</div>
</div>
<div class="card">
<div>Incidents</div>
<div id="inc" class="big">--</div>
</div>
</div>
<div id="team"></div>
<script src="engineer.js"></script>
</body>
</html>
```
---
## 6.2 JS realtime
```javascript
async function loadEngineer(){
const res = await fetch('/api/engineer.php');
const data = await res.json();
let sla = 0;
let inc = 0;
let html = "";
data.forEach(e => {
sla += parseFloat(e.sla);
inc += parseInt(e.incidents);
let color =
e.sla > 0.9 ? "green" :
e.sla > 0.75 ? "yellow" : "red";
html += `
<div class="card">
<h3>${e.name}</h3>
<p>SLA: <b class="${color}">
${(e.sla * 100).toFixed(1)}%
</b></p>
<p>Incidents: ${e.incidents}</p>
<p>Response: ${e.response_time}</p>
</div>
`;
});
document.getElementById("team").innerHTML = html;
document.getElementById("sla").innerText =
((sla / data.length) * 100).toFixed(1) + "%";
document.getElementById("inc").innerText = inc;
let shs = (sla / data.length) * 100 - inc * 0.5;
document.getElementById("shs").innerText =
shs.toFixed(1);
}
setInterval(loadEngineer, 15000);
loadEngineer();
```
---
# 7. ДОКУМЕНТЫ ИНЖЕНЕРА (ОБЯЗАТЕЛЬНЫЕ)
---
## 7.1 РЕГЛАМЕНТ ИНЖЕНЕРА
```text
AegisOne Engineering
Engineer Operational Standard
1. Назначение
Инженер отвечает за выполнение SLA и контроль качества работ техников.
---
2. Обязанности
- распределение заявок
- контроль SLA выполнения
- контроль качества диагностики
- управление инцидентами
- предотвращение повторных аварий
---
3. Запрещено
- закрывать задачи без проверки
- игнорировать SLA нарушения
- делегировать без фиксации
```
---
## 7.2 ESCALATION MATRIX
```text
P1 → инженер → директор (немедленно)
P2 → инженер → инженер контроль
P3 → техник
Если SLA риск > 80%:
→ обязательная эскалация
```
---
## 7.3 ЧЕК-ЛИСТ ИНЖЕНЕРА
```text
AegisOne Engineering
Engineer Control Checklist
[ ] Все заявки распределены
[ ] SLA риск оценён
[ ] Техники назначены
[ ] P1 инциденты закрыты
[ ] Повторные аварии проанализированы
[ ] Отчёты проверены
```
---
## 7.4 ОТЧЁТ ИНЖЕНЕРА
```text
Engineer Report
Период:
Инженер:
---
1. SLA состояние:
-
2. Проблемные объекты:
-
3. Инциденты:
-
4. Перегруз команды:
-
5. Принятые меры:
-
6. Риски:
-
```
---
# 8. ЧТО ИНЖЕНЕР ДОЛЖЕН ИМЕТЬ В СИСТЕМЕ
---
## ОБЯЗАТЕЛЬНО:
- SLA статус всех объектов
- список техников
- инциденты P1P3
- риск объектов
- SHS влияние
---
## РЕКОМЕНДУЕМО:
- история объектов
- схемы систем
- фото оборудования
- прошлые ремонты
- нагрузка по часам
---
# 9. ЧТО ЕМУ НЕЛЬЗЯ ДАВАТЬ
---
## КАТЕГОРИЧЕСКИ НЕ НУЖНО:
- прямые продажи
- финансовые данные прибыли
- доступ к CRM сделкам
- доступ к договорам без SLA слоя
- административные функции директора
---
# 10. РИСКИ СИСТЕМЫ
---
## РИСК 1 — инженер превращается в диспетчера
👉 решение:
- оставить KPI, а не “ручное управление”
---
## РИСК 2 — перегруз инженера
👉 решение:
- TLB (load balance)
---
## РИСК 3 — потеря SLA контроля
👉 решение:
- SHS alert system
---
# 11. ГЛАВНАЯ ЛОГИКА
```text
Техник → выполняет
Инженер → управляет качеством
Директор → управляет системой
```
---
# 12. ИТОГ
Ты получаешь:
> управляемый слой контроля SLA между исполнением и бизнесом
---
# 13. ЕСЛИ РАЗВИВАТЬ ДАЛЬШЕ
Я могу собрать:
- автораспределение заявок (AI dispatch)
- прогноз поломок объектов
- система “умного SLA риска”
- мобильный кабинет инженера
- интеграция с Telegram уведомлениями
---
Если коротко:
👉 это уже не “дашборд”
👉 это уровень операционной системы инженерного бизнеса (mini-SAP под твою нишу)
@@ -0,0 +1,424 @@
Ниже — полноценная система **“Дашборд техника в реальном времени” + пакет документов и инструкций**, который превращает техника не в “исполнителя заявок”, а в **узел управляемой SLA-системы**.
Я разделю на 3 уровня:
1. Дашборд техника (web / PHP система)
2. Документы и регламенты (полные тексты)
3. Шаблоны (акты, чек-листы, отчёты)
---
# 1. DASHBOARD ТЕХНИКА (REAL-TIME)
## 1.1 Логика системы
```text
Заявка → Назначение → Выезд → Диагностика → Исполнение → Закрытие → SLA фиксация → SHS влияние
```
---
## 1.2 Что должен видеть техник (главное)
### Блок 1 — текущая нагрузка
- активные заявки
- срочность (P1P3)
- дедлайн SLA
- расстояние до объекта
- статус выполнения
---
### Блок 2 — SLA контроль
- осталось времени до нарушения SLA
- критичность заявки
- штрафной риск
---
### Блок 3 — оборудование объекта
- камеры / СКУД / пожарка
- история неисправностей
- последние ремонты
---
### Блок 4 — чек-лист работ
- пошаговый регламент
- обязательные проверки
- фотофиксация
---
### Блок 5 — результат
- закрытие заявки
- причина
- подтверждение клиента
- влияние на KPI
---
# 2. PHP ДАШБОРД ТЕХНИКА (ГОТОВЫЙ ШАБЛОН)
---
## 2.1 API задач техника
```php
<?php
header('Content-Type: application/json');
$pdo = new PDO("mysql:host=localhost;dbname=aegisone","user","pass");
$stmt = $pdo->query("
SELECT
t.id,
t.title,
t.priority,
t.status,
t.deadline,
t.address,
t.system_type,
t.sla_remaining_hours
FROM tasks t
WHERE t.status != 'closed'
ORDER BY t.priority DESC, t.deadline ASC
");
echo json_encode($stmt->fetchAll(PDO::FETCH_ASSOC));
?>
```
---
## 2.2 FRONTEND (ТЕХНИК DASHBOARD)
```html
<!DOCTYPE html>
<html>
<head>
<title>Technician Panel - AegisOne</title>
<style>
body { background:#0f172a; color:white; font-family:Arial; }
.card { background:#1e293b; padding:15px; margin:10px; border-radius:10px; }
.grid { display:grid; grid-template-columns: repeat(2, 1fr); gap:10px; }
.red { color:#ef4444; }
.yellow { color:#facc15; }
.green { color:#22c55e; }
</style>
</head>
<body>
<h2 style="padding:10px;">Technician Live Dashboard</h2>
<div id="tasks"></div>
<script src="tech.js"></script>
</body>
</html>
```
---
## 2.3 JS realtime обновление
```javascript
async function loadTasks(){
const res = await fetch('/api/tech_tasks.php');
const data = await res.json();
let html = "";
data.forEach(t => {
let color =
t.sla_remaining_hours < 2 ? "red" :
t.sla_remaining_hours < 6 ? "yellow" : "green";
html += `
<div class="card">
<h3>${t.title}</h3>
<p><b>Priority:</b> ${t.priority}</p>
<p><b>Address:</b> ${t.address}</p>
<p><b>System:</b> ${t.system_type}</p>
<p><b>SLA:</b>
<span class="${color}">
${t.sla_remaining_hours}h left
</span>
</p>
<button onclick="startTask(${t.id})">Start</button>
<button onclick="closeTask(${t.id})">Close</button>
</div>
`;
});
document.getElementById("tasks").innerHTML = html;
}
setInterval(loadTasks, 10000);
loadTasks();
```
---
# 3. ДОКУМЕНТЫ ДЛЯ ТЕХНИКА (ОБЯЗАТЕЛЬНЫЕ)
---
# 3.1 РЕГЛАМЕНТ РАБОТЫ ТЕХНИКА
```text
AegisOne Engineering
Регламент работы инженера-техника
1. Общие положения
Техник обязан выполнять работы строго по SLA и чек-листу объекта.
Любое отклонение фиксируется в системе.
---
2. Прибытие на объект
- зарегистрировать прибытие в системе
- подтвердить время начала работ
- сделать фото объекта до начала работ
---
3. Диагностика
- проверить систему по чек-листу
- зафиксировать неисправности
- определить причину (если возможно)
---
4. Выполнение работ
- устранить неисправность
- не изменять конфигурацию без согласования
- использовать только разрешённые материалы
---
5. Завершение работ
- тестирование системы
- фото после выполнения
- подтверждение работоспособности
---
6. Закрытие заявки
- заполнить отчет
- указать причину неисправности
- получить подтверждение клиента (если возможно)
```
---
# 3.2 ЧЕК-ЛИСТ ВЫЕЗДА
```text
AegisOne Engineering
Checklist Technician Visit
[ ] Прибытие на объект зафиксировано
[ ] Фото “до”
[ ] Проверка питания системы
[ ] Проверка камер / датчиков
[ ] Проверка СКУД
[ ] Проверка пожарной панели
[ ] Локализация неисправности
[ ] Устранение проблемы
[ ] Тестирование системы
[ ] Фото “после”
[ ] Закрытие заявки
```
---
# 3.3 СТАНДАРТ ДИАГНОСТИКИ
```text
AegisOne Engineering
Diagnostic Standard
1. Неисправность фиксируется только после проверки:
- питания
- сети
- оборудования
- конфигурации
2. Причина должна быть классифицирована:
- Hardware
- Software
- Network
- Human error
- External factor
3. Запрещено:
- закрывать заявку без причины
- менять систему без фиксации
```
---
# 3.4 ОТЧЁТ ТЕХНИКА (ШАБЛОН)
```text
AegisOne Engineering
Technician Report
Объект:
Дата:
Техник:
---
1. Выполненные работы:
-
2. Выявленные проблемы:
-
3. Причина неисправности:
(Hardware / Software / Network / Other)
4. Что было сделано:
-
5. Результат:
[ ] Исправлено
[ ] Частично исправлено
[ ] Требует повторного выезда
6. Фото до/после:
(вложение)
7. Комментарий:
-
```
---
# 4. ОБЯЗАТЕЛЬНЫЕ ДАННЫЕ ТЕХНИКА В СИСТЕМЕ
---
## 4.1 Минимум данных
- текущие заявки
- SLA таймер
- адрес объекта
- тип системы
- приоритет
---
## 4.2 Рекомендуемые данные
- история объекта
- прошлые неисправности
- схема оборудования
- контакт ответственного
- тип неисправности по базе
---
# 5. КЛЮЧЕВАЯ ЛОГИКА СИСТЕМЫ
```text
Техник не “чинит оборудование”
Техник:
→ закрывает SLA
→ влияет на SHS
→ формирует retention
```
---
# 6. КОНТРОЛЬ РИСКОВ
---
## РИСК 1 — “закрытие без факта”
👉 решение:
- фотофиксация обязательна
---
## РИСК 2 — “ложное устранение”
👉 решение:
- reopen rate KPI
---
## РИСК 3 — перегруз техника
👉 решение:
- utilization tracking
---
## РИСК 4 — нарушение SLA
👉 решение:
- live SLA countdown
---
# 7. ИТОГОВАЯ МОДЕЛЬ
```text
CRM → SLA → TASK → TECH DASHBOARD → EXECUTION → KPI → SHS
```
---
# 8. СУТЬ СИСТЕМЫ
Ты создаёшь не “работу техника”:
> ты создаёшь управляемый процесс физической эксплуатации инфраструктуры бизнеса
---
# 9. ЕСЛИ ДАЛЬШЕ РАЗВИВАТЬ
Можно собрать:
- мобильное приложение техника (PWA)
- GPS контроль выездов
- автоакты выполненных работ
- голосовые отчёты
- AI диагностику неисправностей
- автоматическое распределение заявок
---
Если хочешь следующий шаг:
👉 я могу объединить дашборд техника + инженера + CEO в одну единую систему управления AegisOne (как ERP уровня SAP, но для твоей ниши)
@@ -0,0 +1,574 @@
Ниже — система, которая делает тебя не “компанией по безопасности”, а:
доказательным инженерным экспертом
Это ключевой переход.
Потому что рынок верит не словам, а:
> измерениям, проверкам и воспроизводимым результатам
---
0. ЧТО ТАКОЕ “ДОКАЗАТЕЛЬНАЯ ЭКСПЕРТИЗА”
Это подход, где каждое твоё утверждение:
проверяется тестом
фиксируется
документируется
повторяется на любом объекте
подтверждается результатом
---
1. ГЛАВНАЯ ИДЕЯ СИСТЕМЫ
Ты перестаёшь говорить:
❌ “у вас плохое обслуживание”
❌ “система работает неправильно”
Ты начинаешь говорить:
✔ “мы провели тест №X”
✔ “результат измерен”
✔ “отклонение от нормы”
✔ “риск подтверждён”
---
2. СТРУКТУРА СИСТЕМЫ ДОКАЗАТЕЛЬНОСТИ
3. Тест (что проверяем)
4. Методика (как проверяем)
5. Норма (как должно быть)
6. Фактический результат
7. Отклонение
8. Риск для бизнеса
9. Вывод инженера
---
3. БАЗОВЫЕ ТЕСТЫ (ЯДРО ТВОЕЙ ЭКСПЕРТИЗЫ)
---
ТЕСТ №1 — РЕАЛЬНОСТЬ АРХИВА ВИДЕОНАБЛЮДЕНИЯ
---
🔧 Методика
Выборочно проверяется запись с камер за последние:
- 1 день
- 7 дней
- 14 дней
- 30 дней
Проверяется:
- наличие записи
- непрерывность
- пропуски
- доступность воспроизведения
---
📏 Норма
запись 100% камер
непрерывность без пропусков
доступность архива согласно заявленному сроку
---
📉 Частая реальность
часть камер не пишет
архив “дыры”
перезапись раньше срока
сбои HDD
---
⚠️ Риск
> Потеря доказательной базы при инциденте
---
🧠 Заключение от тебя
> “На объекте система видеонаблюдения формально функционирует, но не гарантирует сохранность событийного архива.”
---
ТЕСТ №2 — СИНХРОНИЗАЦИЯ ВРЕМЕНИ
---
Методика
Сравнение времени:
- на камерах
- на регистраторе
- на сервере СКУД
- фактическое время события
---
Норма
отклонение ≤ 1–2 секунды
---
Часто
разрыв 215 минут
разные часовые зоны
нет NTP синхронизации
---
Риск
> невозможность юридически доказать момент события
---
Вывод
> “Система не обеспечивает юридически корректную фиксацию времени событий.”
---
ТЕСТ №3 — РЕЗЕРВНОЕ ПИТАНИЕ
---
Методика
Проверка:
- UPS
- время автономной работы
- отключение питания
- поведение систем
---
Норма
15–60 минут автономии минимум
---
Часто
UPS “для галочки”
не держит нагрузку
отсутствует тестирование
---
Риск
> полная остановка системы при отключении электричества
---
Вывод
> “Инфраструктура объекта не защищена от отключения электропитания.”
---
ТЕСТ №4 — ПОЛНОТА КАМЕРНОГО ПОКРЫТИЯ
---
Методика
Проверка зон:
- входы
- кассы
- периметр
- слепые зоны
---
Норма
отсутствие “мертвых зон”
---
Часто
перекрытия нет
камеры направлены неправильно
часть зон не контролируется
---
Риск
> невозможность фиксации инцидентов
---
Вывод
> “Фактическое покрытие объекта не соответствует заявленной системе безопасности.”
---
ТЕСТ №5 — ЖУРНАЛ СОБЫТИЙ СКУД
---
Методика
Проверка:
- записи проходов
- корректность пользователей
- история событий
---
Норма
полный лог всех проходов
---
Часто
потери логов
сбои базы
отключённый журнал
---
Риск
> невозможность отследить перемещения персонала
---
Вывод
> “СКУД не выполняет функцию контроля доступа в полном объёме.”
---
ТЕСТ №6 — СОСТОЯНИЕ ПИТАНИЯ СИСТЕМ
---
Методика
Проверка:
- напряжения
- нагрузки
- перегрева
- стабильности питания оборудования
---
Часто
перегрузка линий
дешёвые блоки питания
нестабильное напряжение
---
Риск
> деградация оборудования и внезапные отказы
---
Вывод
> “Система имеет скрытую деградацию по питанию.”
---
ТЕСТ №7 — СОСТОЯНИЕ ХРАНЕНИЯ ДАННЫХ
---
Методика
Проверка:
- HDD/SSD
- заполнение
- циклы перезаписи
- ошибки записи
---
Часто
диски в деградации
нет мониторинга
потеря данных без уведомлений
---
Риск
> потеря архива без внешних признаков
---
Вывод
> “Система хранения не контролируется и не мониторится.”
---
4. КАК ТЫ ПРЕЗЕНТУЕШЬ ЭТО КЛИЕНТУ
---
НЕ так:
❌ “у вас проблемы”
---
А так:
✔ “мы провели тест №3 и зафиксировали отклонение”
---
5. ШАБЛОН ТВОЕГО ЭКСПЕРТНОГО ЗАКЛЮЧЕНИЯ
ИНЖЕНЕРНОЕ ЗАКЛЮЧЕНИЕ
Объект: ________
Проведены тесты:
- архив видеонаблюдения
- синхронизация времени
- резервное питание
- СКУД
- хранение данных
Выявленные отклонения:
1. ________
2. ________
3. ________
Риски:
- ________
- ________
Заключение инженера:
Система безопасности объекта требует технического сопровождения
для обеспечения стабильной эксплуатации и снижения рисков отказов.
---
6. СИЛА ЭТОЙ СИСТЕМЫ
Ты становишься:
не монтажником
не продавцом услуг
не подрядчиком
---
А ЧЕМ?
инженером, который доказывает состояние объекта
---
7. ПОЧЕМУ ЭТО ПРОДАЁТ SLA
Потому что клиент видит:
факты
отклонения
риски
И сам приходит к выводу:
> “это нужно сопровождать постоянно”
---
8. КАК ЭТО МАСШТАБИРУЕТСЯ
Ты можешь:
обучить инженеров тестам
стандартизировать отчёты
превратить это в продукт аудита
автоматизировать CRM
---
9. СЛЕДУЮЩИЙ УРОВЕНЬ (если продолжим)
Я могу дальше собрать:
автоматический шаблон отчёта (PDF + PHP генерация)
систему балльной оценки объекта (risk scoring)
SLA-переход на основе результатов тестов
“паспорт объекта безопасности”
стандарты для инженеров в команде
---
Если коротко:
> ты превращаешь “услуги безопасности” в инженерную измеряемую систему контроля риска
И это уже не рынок монтажников.
Это рынок эксплуатационных инженеров уровня enterprise.
@@ -0,0 +1,85 @@
# Инструкция по админ-панели AegisOne
## Вход в систему
1. Перейдите на `https://aegisone.ru/service/`
2. Введите логин и пароль, выданные владельцем
3. При 3 неверных попытках — блокировка на 3 минуты
## Дашборд
Главная страница после входа. Карточки показывают ключевые метрики:
- **Активные объекты** — количество обслуживаемых объектов
- **Активные задачи** — открытые и в работе
- **Открытые инциденты** — текущие инциденты
- **SLA контракты** — активные договоры
- **SLA Compliance** — процент соблюдения SLA
- **SHS** — общий индекс здоровья системы
Нажмите на любую карточку для перехода в соответствующий раздел.
## Управление сотрудниками (только владелец)
- Создание учётных записей с ролями: Владелец / Инженер / Техник
- Блокировка/разблокировка сотрудников
- Сброс пароля
## Объекты
- Добавление объектов с параметрами: тип, площадь, регион
- Автоматическое создание из опросника
- Просмотр Risk Score, Object Index, SLA цены
## Назначения
Привязка сотрудников к объектам. Роль сотрудника определяется автоматически из его профиля.
## SLA контракты
- Создание контрактов с привязкой к объекту
- Номер договора (для синхронизации с 1С)
- Уровень обслуживания: Start / Business / Enterprise
- Гибкое время реакции (в часах)
## Опросник объекта
Пошаговое обследование объекта:
1. **Коммерческий** — название, тип, адрес, контакты
2. **Технический** — видеонаблюдение, СКУД, пожарная сигнализация, инфраструктура
3. **Эксплуатация** — текущее обслуживание, регламенты, проблемы
4. **Риски** — критические риски объекта
5. **Расчёт SLA** — уровень обслуживания, дополнительные требования
После завершения автоматически рассчитываются: Risk Score, Complexity Index, Object Index, SLA цена.
## Блог
- Создание/редактирование статей
- **Slug** — адрес статьи, генерируется автоматически из заголовка (можно изменить вручную)
- **Категории**: Аудит, SLA, Инциденты, Технадзор, Документация, Риск-инжиниринг, Кейсы
- **Содержание** — HTML-формат. Используйте тулбар над редактором для вставки форматирования
- Статусы: Черновик → Опубликовать → Архив
## Документация
- Файлы `.md` в папке `service/docs/` автоматически обнаруживаются
- Владелец управляет доступом инженеров к каждому документу
- Инженер видит только разрешённые документы
## Примеры из практики (карусель на главной)
- Добавление кейсов с заголовком, описанием и эффектом
- Поле «Порядок» определяет порядок в карусели
- Флажок «Активен» — показывает/скрывает кейс на главной
## CEO дашборд (только владелец)
Обзор бизнес-метрик: MRR, удержание клиентов, конверсия, производительность.
## Коэффициенты формул
Все расчётные коэффициенты можно редактировать. Изменения применяются ко всем новым расчётам.
## Паспорта объектов
Автоматически генерируются при завершении опросника. Содержат полный расчёт рисков и рекомендаций.
@@ -0,0 +1,589 @@
Ниже — уже не просто “текст КП”, а фундамент твоей новой модели бизнеса.
Я делаю это в формате:
инженерного B2B;
без “маркетингового мусора”;
с акцентом на доверие, SLA и эксплуатацию.
Это можно:
сразу использовать;
конвертировать в PDF;
вставить на сайт;
генерировать автоматически через CRM.
---
1. ПОЛНОЦЕННЫЙ ШАБЛОН КП (.md)
# КОММЕРЧЕСКОЕ ПРЕДЛОЖЕНИЕ
## Техническое сопровождение систем безопасности
---
# AEGISONE ENGINEERING
### Эксплуатация и сопровождение систем безопасности коммерческих объектов
---
## Контакты
Телефон: +7 (XXX) XXX-XX-XX
Email: info@aegisone.ru
Сайт: https://aegisone.ru
---
# О компании
AEGISONE ENGINEERING — инженерная сервисная компания,
специализирующаяся на техническом сопровождении
и эксплуатации систем безопасности коммерческих объектов.
Мы обеспечиваем:
- стабильную работу систем;
- контроль инфраструктуры;
- регламентное обслуживание;
- SLA и контроль сроков реакции;
- технический аудит;
- сопровождение эксплуатации объектов.
---
# Какие проблемы мы решаем
Большинство неисправностей систем безопасности
выявляются только после возникновения проблем:
- отсутствует архив видеонаблюдения;
- системы работают нестабильно;
- часть оборудования неисправна;
- отсутствует документация;
- неисправности копятся месяцами;
- подрядчики не несут ответственности;
- проверки выявляют критические нарушения.
Это приводит:
- к рискам простоев;
- потере контроля;
- проблемам при проверках;
- финансовым потерям.
---
# Решение
Мы берем на себя техническое сопровождение
и контроль работоспособности систем безопасности объекта.
В рамках сопровождения обеспечиваем:
- регламентное обслуживание;
- диагностику;
- контроль состояния оборудования;
- аварийное реагирование;
- сопровождение эксплуатации;
- фотоотчетность;
- рекомендации по модернизации;
- технический контроль подрядчиков.
---
# Состав услуг
| Услуга | Описание |
|---|---|
| Регламентные проверки | Контроль состояния систем |
| Диагностика | Поиск неисправностей |
| Контроль архива | Проверка записи и хранения |
| Проверка питания | Бесперебойность работы |
| Аварийные выезды | Реагирование по SLA |
| Фотоотчетность | Подтверждение работ |
| Ведение журналов | История обслуживания |
| Консультации | Поддержка эксплуатации |
---
# SLA
| Приоритет | Описание | Время реакции |
|---|---|---|
| P1 | Полный отказ критической системы | до 2 часов |
| P2 | Частичная потеря функционала | до 4 часов |
| P3 | Некритичная неисправность | до 24 часов |
| P4 | Плановые работы | по графику |
---
# Почему выбирают нас
- более 20 лет инженерного опыта;
- лицензия МЧС;
- работа по SLA и регламентам;
- опыт эксплуатации коммерческих объектов;
- прозрачная отчетность;
- несем ответственность за результат;
- не работаем по принципу «сделали и забыли».
---
# Тарифы
## START
Для небольших объектов.
Включает:
- ежемесячный регламент;
- диагностику;
- удаленную поддержку;
- отчетность.
Стоимость:
от 25 000 ₽ / месяц
---
## BUSINESS
Для коммерческих объектов.
Включает:
- SLA;
- аварийные выезды;
- контроль архива;
- сопровождение проверок;
- фотоотчетность.
Стоимость:
от 60 000 ₽ / месяц
---
## ENTERPRISE
Формат внешнего инженерного отдела.
Включает:
- постоянное сопровождение;
- участие в эксплуатации;
- контроль подрядчиков;
- развитие инфраструктуры;
- аудит объектов.
Стоимость:
индивидуально
---
# Этапы работы
1. Предварительная консультация
2. Технический аудит объекта
3. Подготовка отчета и рекомендаций
4. Формирование SLA и регламентов
5. Постановка объекта на сопровождение
6. Регулярное обслуживание и отчетность
---
# Технический аудит
Перед постановкой объекта на сопровождение
рекомендуем проведение технического аудита.
Аудит позволяет:
- выявить скрытые проблемы;
- оценить состояние систем;
- определить риски;
- подготовить рекомендации.
---
# Контакты
AEGISONE ENGINEERING
Телефон:
+7 (XXX) XXX-XX-XX
Email:
info@aegisone.ru
Сайт:
https://aegisone.ru
---
2. СТРУКТУРА ПЕРВОГО АУДИТА
Вот здесь начинается твоя реальная экспертная модель.
ЦЕЛЬ АУДИТА
НЕ:
“найти поломку”.
А:
показать уровень инженерной зрелости объекта.
---
ЧТО ДОЛЖЕН ДАВАТЬ АУДИТ
После него клиент должен понять:
где риски;
что не работает;
что может привести к проблемам;
насколько объект управляем;
почему нужен SLA.
---
ИДЕАЛЬНАЯ СТРУКТУРА АУДИТА
---
ЭТАП 1
ВВОДНАЯ ИНФОРМАЦИЯ
---
Собираем:
тип объекта;
площадь;
количество систем;
ответственные лица;
история проблем;
подрядчики;
наличие документации.
---
ЭТАП 2
ВИЗУАЛЬНЫЙ ОСМОТР
---
Проверяем:
шкафы;
коммутацию;
маркировку;
кабельные трассы;
питание;
серверные;
доступ к оборудованию.
---
ЭТАП 3
ПРОВЕРКА РАБОТОСПОСОБНОСТИ
---
Видеонаблюдение:
запись;
архив;
синхронизация времени;
качество изображения;
доступность камер;
питание.
---
СКУД:
проходы;
журналы;
права доступа;
аварийное открытие;
контроллеры.
---
Пожарка:
индикация;
ошибки;
оповещение;
связь;
резервное питание.
---
ЭТАП 4
ПРОВЕРКА ДОКУМЕНТАЦИИ
---
Проверяем:
схемы;
пароли;
IP;
журналы;
проекты;
исполнительную документацию.
---
ЭТАП 5
ОЦЕНКА РИСКОВ
Это важнейшая часть.
---
Например:
Риск Последствие
Не пишется архив Потеря доказательств
Нет резервного питания Полный отказ
Нет документации Долгое восстановление
Ошибки пожарки Риски проверок
---
ЭТАП 6
РЕКОМЕНДАЦИИ
---
Разделяем:
Критические
Исправить срочно.
---
Важные
В течение 30 дней.
---
Плановые
При модернизации.
---
ЭТАП 7
ПРЕДЛОЖЕНИЕ SLA
Вот тут ты переводишь аудит:
в абонентское сопровождение.
---
3. ЧЕК-ЛИСТ АУДИТА
Это уже реальный инструмент продаж.
---
# ЧЕК-ЛИСТ ТЕХНИЧЕСКОГО АУДИТА
## Общая информация
- [ ] Тип объекта
- [ ] Адрес
- [ ] Ответственное лицо
- [ ] Количество систем
- [ ] Наличие документации
- [ ] Наличие подрядчиков
---
# Видеонаблюдение
## Сервер / регистратор
- [ ] Проверка записи
- [ ] Проверка архива
- [ ] Проверка HDD
- [ ] Проверка времени
- [ ] Проверка сети
- [ ] Проверка питания
## Камеры
- [ ] Наличие сигнала
- [ ] Качество изображения
- [ ] Наличие загрязнений
- [ ] Корректность обзора
- [ ] Наличие ИК подсветки
---
# СКУД
- [ ] Проверка контроллеров
- [ ] Проверка считывателей
- [ ] Проверка журналов
- [ ] Проверка доступа
- [ ] Проверка аварийного открытия
- [ ] Проверка резервного питания
---
# Пожарная сигнализация
- [ ] Проверка панели
- [ ] Проверка ошибок
- [ ] Проверка шлейфов
- [ ] Проверка оповещения
- [ ] Проверка резервного питания
- [ ] Проверка журналов событий
---
# Сетевая инфраструктура
- [ ] Проверка коммутаторов
- [ ] Проверка UPS
- [ ] Проверка кабельной инфраструктуры
- [ ] Проверка интернет-каналов
- [ ] Проверка температурного режима
---
# Документация
- [ ] Схемы
- [ ] IP-адресация
- [ ] Пароли
- [ ] Журналы обслуживания
- [ ] Исполнительная документация
---
# Риски
- [ ] Отсутствие записи
- [ ] Отказ резервного питания
- [ ] Отсутствие документации
- [ ] Ошибки оборудования
- [ ] Нестабильность сети
---
# Рекомендации
## Критические:
_________________________________
## Важные:
_________________________________
## Плановые:
_________________________________
---
САМОЕ ВАЖНОЕ
Вот что тебе нужно понять стратегически.
АУДИТ — ЭТО НЕ “ДОП. УСЛУГА”.
Это:
твоя система продаж.
Потому что:
не надо “впаривать”;
ты показываешь реальность;
ты становишься экспертом;
клиент сам понимает необходимость SLA.
И это идеально подходит под твою модель инженерного B2B.
+503
View File
@@ -0,0 +1,503 @@
Хорошо. Здесь ты фактически строишь не “контент”, а:
инженерную медийную репутацию компании
Это важно различать.
Контент в твоей нише выполняет 3 функции:
1. формирует доверие у B2B (директора, УК, собственники)
2. объясняет, почему эксплуатация важнее монтажа
3. подводит к SLA и аудитам (деньгам)
---
0. СТРАТЕГИЯ ЭКСПЕРТНОСТИ (ОСНОВА)
Твоя роль в контенте:
> “Спокойный инженер, который объясняет, где у бизнеса скрытые риски в системах безопасности”
НЕ:
продавец
маркетолог
“лиды”
“акции”
А:
диагност
эксплуатационный инженер
человек, который видит риски до аварии
---
1. КОНТЕНТ-СТОЛПЫ (PILLARS)
У тебя должно быть 5 контент-направлений:
---
1. Эксплуатационные проблемы (самый важный)
👉 “Что ломается в реальности”
Примеры:
камеры не пишут архив
пожарка работает “на бумаге”
СКУД пропускает ошибки
нет резервного питания
подрядчики исчезают
---
2. Разбор реальных кейсов (без раскрытия клиента)
👉 “Что мы нашли на объектах”
---
3. Ошибки монтажа и эксплуатации
👉 “Почему дешево = дорого”
---
4. Инженерные разборы систем
👉 “как это должно работать правильно”
---
5. Управление рисками (B2B язык)
👉 “что теряет бизнес”
---
2. ЧАСТОТА ПУБЛИКАЦИЙ
Идеально:
2 поста в неделю (Telegram / VK / VC)
1 длинная статья в неделю
1 кейс в неделю (может совпадать)
Итого: 👉 8–10 единиц контента / месяц
---
3. КОНТЕНТ-ПЛАН НА 3 МЕСЯЦА
---
МЕСЯЦ 1 — “ПРОБЛЕМЫ РЫНКА”
Цель: 👉 показать, что рынок систем безопасности работает плохо
---
Неделя 1
Пост 1
Почему камеры видеонаблюдения не защищают бизнес
Пост 2
Что происходит, когда не ведется обслуживание систем безопасности
Статья:
ТОП-7 скрытых проблем систем безопасности на коммерческих объектах
---
Неделя 2
Пост:
Почему 80% объектов не имеют реального архива видеонаблюдения
Пост:
Как подрядчики “сдают” объект и исчезают
Кейс:
“Объект без архива 4 месяца — как это обнаруживается”
---
Неделя 3
Пост:
Почему пожарная сигнализация чаще всего существует “на бумаге”
Пост:
3 ошибки, которые делают монтажники при установке СКУД
Статья:
Почему системы безопасности не работают в момент инцидента
---
Неделя 4
Пост:
Что на самом деле означает “обслуживание систем безопасности”
Пост:
Почему дешёвый монтаж всегда превращается в дорогое обслуживание
Кейс:
“Склад с критическими ошибками в системе доступа”
---
МЕСЯЦ 2 — “ЭКСПЕРТНОСТЬ”
Цель: 👉 показать, что ты знаешь, как должно быть правильно
---
Неделя 1
Пост:
Как правильно проверять видеонаблюдение на объекте
Статья:
Чек-лист эксплуатации систем безопасности для бизнеса
Кейс:
“Восстановление системы видеонаблюдения после 2 лет хаоса”
---
Неделя 2
Пост:
Что должен контролировать собственник объекта
Пост:
Почему отсутствие SLA = потеря контроля
Статья:
Как устроена правильная эксплуатация систем безопасности
---
Неделя 3
Пост:
Как проверить, работает ли ваша система безопасности реально
Пост:
Почему регламент важнее оборудования
Кейс:
“Объект после смены подрядчика”
---
Неделя 4
Пост:
Что такое инженерная эксплуатация (а не монтаж)
Статья:
Почему рынок безопасности живет в иллюзии надежности
---
МЕСЯЦ 3 — “ДОВЕРИЕ И ПЕРЕХОД В ПРОДАЖИ”
Цель: 👉 мягко переводить в аудит и SLA
---
Неделя 1
Пост:
Что показывает технический аудит объекта
Статья:
Как мы выявляем скрытые проблемы систем безопасности
Кейс:
“Аудит коммерческого объекта: 12 критических ошибок”
---
Неделя 2
Пост:
Почему бизнесу нужен внешний инженер безопасности
Пост:
Что происходит, когда нет технического контроля
Статья:
SLA в системах безопасности: зачем он нужен
---
Неделя 3
Пост:
Как выглядит нормальное техническое сопровождение
Кейс:
“Перевод объекта на SLA обслуживание”
Пост:
Почему обслуживание = защита бизнеса, а не ремонт
---
Неделя 4
Пост:
Когда нужно делать аудит (и почему его откладывают)
Статья:
Как мы снижаем эксплуатационные риски объектов
---
4. ФОРМАТ КОНТЕНТА (ВАЖНО)
Каждый пост должен иметь структуру:
---
1. Проблема
2. Реальный инженерный разбор
3. Последствия для бизнеса
4. Вывод (очень спокойный)
---
5. ПРИМЕР ЭКСПЕРТНОГО ПОСТА (ГОТОВЫЙ)
---
Тема:
“Почему камеры видеонаблюдения не защищают бизнес”
---
Видеонаблюдение часто воспринимается как система безопасности.
На практике это не всегда так.
Основная проблема заключается не в оборудовании, а в эксплуатации.
На большинстве объектов мы наблюдаем типовые ситуации:
— часть камер не записывает архив;
— запись есть, но не сохраняется нужный период;
— часть каналов отключена;
— время на устройствах не синхронизировано;
— отсутствует резервирование питания;
— система не тестируется регулярно.
В результате при инциденте система формально есть, но фактически не выполняет свою функцию.
Бизнес узнаёт об этом только постфактум.
Правильная эксплуатация видеонаблюдения — это не установка оборудования, а регулярный контроль:
— состояния записи;
— целостности архива;
— доступности камер;
— корректности времени;
— работоспособности хранения.
Без этого система превращается в формальность.
Именно поэтому в инженерной эксплуатации видеонаблюдение рассматривается как сервис, а не как установленное оборудование.
---
6. КОД ДЛЯ КОНТЕНТ-СТРАНИЦЫ (PHP БЛОГ)
---
<?php include '../header.php'; ?>
<section class="blog-post">
<div class="container">
<h1>Почему камеры видеонаблюдения не защищают бизнес</h1>
<p class="meta">Инженерный разбор систем безопасности</p>
<p>
Видеонаблюдение часто воспринимается как система безопасности...
</p>
<h2>Типовые проблемы эксплуатации</h2>
<ul>
<li>нет записи архива</li>
<li>сбои хранения</li>
<li>отсутствие резервирования</li>
<li>рассинхронизация времени</li>
</ul>
<h2>Почему это критично</h2>
<p>
Система формально работает, но не выполняет функцию защиты бизнеса.
</p>
<h2>Вывод</h2>
<p>
Без регламентной эксплуатации система безопасности превращается в формальность.
</p>
</div>
</section>
<?php include '../footer.php'; ?>
---
7. ГЛАВНЫЙ ПРИНЦИП ВСЕГО КОНТЕНТА
Ты должен повторять одну мысль:
> “Без эксплуатации система безопасности не существует”
---
8. ЧТО ЭТО ДАСТ ЧЕРЕЗ 2–3 МЕСЯЦА
Если делать системно:
тебя начнут воспринимать как эксперта;
появятся входящие заявки;
снизится необходимость продавать;
аудит станет естественным входом;
SLA станет логичным продолжением;
монтаж уйдет в фон.
---
9. СЛЕДУЮЩИЙ ШАГ (если хочешь дальше усилить)
Я могу дальше собрать тебе:
контент-воронку (как пост → аудит → SLA)
шаблоны кейсов (очень мощно работает)
систему “доказательной экспертизы”
сценарий продаж через контент
упаковку Telegram-канала под лидогенерацию
Это уже уровень:
построения сервисной B2B-машины, а не просто сайта.
+673
View File
@@ -0,0 +1,673 @@
Вот здесь начинается настоящий B2B.
И это как раз твоя сильная сторона.
Потому что:
# тебе НЕ нужен агрессивный маркетинг.
Ты не:
- франшиза,
- массовый монтажник,
- “льем лиды”.
Твой рынок:
# доверительный инженерный B2B.
А там продают:
- компетенция;
- спокойствие;
- экспертность;
- репутация;
- предсказуемость.
---
# САМАЯ БОЛЬШАЯ ОШИБКА
Если ты сейчас:
- запустишь рекламу “монтаж камер”;
- начнешь конкурировать ценой;
- пойдешь в массовый трафик;
ты попадешь обратно:
# в болото монтажного рынка.
---
# ТВОЯ ИДЕАЛЬНАЯ СТРАТЕГИЯ
# “ИНЖЕНЕРНАЯ ЭКСПЕРТНОСТЬ + ЛОКАЛЬНОЕ ДОВЕРИЕ”
---
# ЧТО ТЕБЕ НУЖНО НА СТАРТЕ
НЕ:
- 100 клиентов.
А:
# 3–5 правильных объектов.
Это принципиально.
---
# ТВОЯ ЦЕЛЬ НА ПЕРВЫЕ 6 МЕСЯЦЕВ
Собрать:
- 5–10 объектов на SLA;
- с чеком 50–150 тыс.
Это уже:
# 500 тыс 1.5 млн recurring revenue.
И это достижимо без рекламы.
---
# СТРАТЕГИЯ ПЕРВЫХ ПРОДАЖ
---
# ЭТАП 1
# НЕ ПРОДАВАТЬ ОБСЛУЖИВАНИЕ
Это критично.
Потому что: “ТО” воспринимается как:
- обязаловка;
- минималка;
- формальность.
---
# ПРОДАВАТЬ НУЖНО:
# АУДИТ И СНИЖЕНИЕ РИСКОВ.
---
# ТВОЙ ИДЕАЛЬНЫЙ ВХОД
НЕ:
> “давайте мы вас обслужим”.
А:
# “давайте проверим текущее состояние систем”.
---
# ПОЧЕМУ ЭТО РАБОТАЕТ
Ты:
- не впариваешь;
- не навязываешься;
- не демпингуешь.
Ты:
# инженер-эксперт.
---
# ЧТО ПРОДАВАТЬ ПЕРВЫМ
---
# ПРОДУКТ №1
# “ТЕХНИЧЕСКИЙ АУДИТ ОБЪЕКТА”
---
## Стоимость:
### 1550 тыс ₽
Зависит:
- от объекта;
- площади;
- систем.
---
# ЧТО ВХОДИТ
- диагностика;
- проверка архива;
- проверка питания;
- тестирование;
- проверка документации;
- оценка рисков;
- рекомендации.
---
# РЕЗУЛЬТАТ:
PDF-отчет.
---
# ПОЧЕМУ ЭТО ГЕНИАЛЬНО ДЛЯ ТЕБЯ
Ты:
- умеешь находить проблемы;
- не любишь “продажи”;
- инженер.
То есть:
# это идеальная модель продаж под тебя.
---
# ЭТАП 2
# ПРОДАЖА SLA
После аудита.
---
# СХЕМА
## Ты показываешь:
### Сейчас:
- риски;
- неисправности;
- слабые места.
---
## Потом:
> “Чтобы это не накапливалось — нужен регламент эксплуатации.”
---
# И ТУТ ПОЯВЛЯЕТСЯ SLA
---
# КОМУ ИДТИ ПЕРВЫМИ
---
# НЕ:
- застройщики;
- тендеры;
- госка.
Это болото.
---
# ИДЕАЛЬНЫЕ ПЕРВЫЕ КЛИЕНТЫ
---
# 1. ГОСТИНИЦЫ / АПАРТЫ
(лучшая ниша для тебя)
---
## Почему:
- бардак;
- текучка;
- подрядчики пропадают;
- владельцы устали.
---
# 2. НЕБОЛЬШИЕ СКЛАДЫ
Очень хорошая ниша.
---
# 3. КОММЕРЧЕСКИЕ УК
Золото.
---
# 4. ЧАСТНЫЕ КЛИНИКИ
Отличная ниша.
---
# 5. СЕТЕВЫЕ КОМПАНИИ
Например:
- автомойки;
- рестораны;
- магазины;
- стоматологии.
---
# КАК ИСКАТЬ КЛИЕНТОВ БЕЗ РЕКЛАМЫ
Вот тут твой рынок.
---
# МЕТОД №1
# “ИНЖЕНЕРНЫЕ ЗНАКОМСТВА”
Ты недооцениваешь:
# насколько рынок маленький.
---
# ТЕБЕ НУЖНО:
- управляющие;
- инженеры эксплуатации;
- техдиры;
- собственники;
- УК.
---
# НЕ ПРОДАВАТЬ
А:
# знакомиться.
---
# ФОРМАТ
---
## Пример:
> “Мы занимаемся технической эксплуатацией систем безопасности коммерческих объектов. Если будет нужна диагностика или помощь — обращайтесь.”
---
# ВСЕ.
Никаких:
- впариваний;
- “акций”;
- скидок.
---
# МЕТОД №2
# АУДИТ КАК ВХОД
---
# ОЧЕНЬ МОЩНАЯ СХЕМА
## Предлагаешь:
# “экспресс-проверку объекта”.
---
## Например:
- проверка архива;
- отказоустойчивость;
- питание;
- документация.
---
# ПОЧЕМУ ЭТО РАБОТАЕТ
Большинство объектов:
# реально в плохом состоянии.
---
# МЕТОД №3
# LINKEDIN / TELEGRAM / VC / ДЗЕН
Это твоя золотая жила.
---
# ПОЧЕМУ
Рынок безопасности:
# чудовищно слаб по экспертности.
---
# А ТЫ:
- умеешь говорить по делу;
- инженер;
- системный.
---
# ТВОЙ КОНТЕНТ БУДЕТ ВЫДЕЛЯТЬСЯ
---
# ТЕПЕРЬ:
# КОНТЕНТ-ПЛАН НА 3 МЕСЯЦА
---
# ГЛАВНАЯ ЦЕЛЬ КОНТЕНТА
НЕ:
- “лайки”.
А:
# доверие B2B.
---
# ТВОЯ РОЛЬ В КОНТЕНТЕ
НЕ: “маркетолог”.
А:
# “спокойный инженер-эксперт”.
---
# СТИЛЬ КОНТЕНТА
---
# НЕ:
- хайп;
- кликбейт;
- “ТОП-5 камер”.
---
# А:
- реальные проблемы;
- эксплуатация;
- ошибки;
- риски;
- практика.
---
# КАНАЛЫ
---
# ОБЯЗАТЕЛЬНО:
- Telegram;
- сайт/блог;
- Яндекс Бизнес;
- VC.ru;
- Дзен.
---
# МОЖНО:
- YouTube Shorts;
- Rutube.
---
# ГЛАВНОЕ:
# не количество.
А:
# экспертность.
---
# КОНТЕНТ-ПЛАН
---
# МЕСЯЦ 1
# “ПОКАЗАТЬ ПРОБЛЕМЫ РЫНКА”
---
## Неделя 1
### Статья:
# Почему камеры не помогают в момент инцидента
---
## Пост:
5 причин потери архива видеонаблюдения.
---
## Короткое видео:
“Почему регистратор — не гарантия записи.”
---
## Кейс:
“На объекте архив не записывался 4 месяца.”
---
# Неделя 2
### Статья:
# Что реально проверяет МЧС
---
## Пост:
Почему ложные срабатывания — это не “нормально”.
---
## Видео:
3 ошибки обслуживания пожарки.
---
# Неделя 3
### Статья:
# Почему дешевый монтаж обходится дорого
---
## Пост:
Что чаще всего ломают монтажники.
---
## Фото-кейс:
“Как выглядит отсутствие эксплуатации через 2 года.”
---
# Неделя 4
### Статья:
# Что должно входить в нормальное SLA
---
## Пост:
Разница между “ТО для галочки” и эксплуатацией.
---
## Видео:
“Почему подрядчики исчезают после монтажа.”
---
# МЕСЯЦ 2
# “ПОКАЗАТЬ ЭКСПЕРТНОСТЬ”
---
# Темы:
- как проверить архив;
- как проверить подрядчика;
- как принимать объект;
- чек-лист эксплуатации;
- ошибки СКУД;
- проблемы гостиниц;
- проблемы складов.
---
# МЕСЯЦ 3
# “ФОРМИРОВАНИЕ ДОВЕРИЯ”
---
# Темы:
- реальные кейсы;
- аудиты;
- сравнения;
- “что нашли на объекте”;
- эксплуатационные риски;
- инженерные решения.
---
# КАК ЧАСТО ПУБЛИКОВАТЬ
---
# ИДЕАЛЬНО:
## 2–3 поста в неделю.
---
# НЕ НУЖНО:
- каждый день;
- “активность ради активности”.
---
# САМОЕ ВАЖНОЕ
Контент должен вызывать мысль:
> “Эти люди реально понимают эксплуатацию объектов.”
---
# ЧТО ДАСТ РЕЗУЛЬТАТ БЫСТРЕЕ ВСЕГО
---
# 1. АУДИТЫ
---
# 2. КЕЙСЫ
---
# 3. РАЗБОРЫ ОШИБОК
---
# 4. УЗКАЯ НИША
Например:
- гостиницы;
- склады.
---
# 5. ЛИЧНЫЙ БРЕНД ИНЖЕНЕРА
Вот это критично.
---
# ТЕБЕ НЕ НУЖНО БЫТЬ “ПРОДАВЦОМ”
Тебе нужно быть:
# человеком, которому доверяют сложный объект.
---
# ВАЖНОЕ ПОНИМАНИЕ
Твои клиенты:
- не ищут “самых дешевых”;
- не хотят “маркетинг”.
Они хотят:
# “нормального ответственного подрядчика”.
И рынок сейчас чудовищно голоден на таких людей.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,340 @@
Ниже — тебе не “скрипты продаж”, а инженерная система входа в аудит, адаптированная под Краснодарский край и B2B-реальность (УК, гостиницы, склады, собственники).
Главная идея:
> Ты не продаёшь аудит. Ты предлагаешь проверку рисков объекта.
---
1. ПСИХОЛОГИЯ ПРОДАЖ АУДИТА (ВАЖНО)
Что продаёт аудит на самом деле:
снижение рисков проверок (МЧС, внутренние, страховые)
уверенность, что система реально работает
снятие ответственности с менеджера/собственника
“независимое мнение инженера”
---
Что НЕ работает:
“проверим камеры”
“сделаем диагностику”
“обслуживание дешевле”
---
Рабочая позиция:
> “Мы делаем техническую проверку систем безопасности объекта с выдачей инженерного заключения по рискам эксплуатации.”
---
2. ВХОД В ПРОДАЖУ (3 КАНАЛА)
---
2.1 Холодный вход (самый важный в регионе)
Кому пишем:
гостиницы
апарт-отели
склады
УК
коммерческая недвижимость
---
2.2 Тёплый вход (через знакомства)
электрики
IT
строители
подрядчики
проектировщики
---
2.3 Вход через контент
Telegram / статьи / кейсы
---
3. СТРУКТУРА ПРОДАЖИ АУДИТА
---
1. Контакт
2. Короткое позиционирование
3. Выявление боли
4. Предложение аудита
5. Подтверждение логики
6. Назначение выезда
---
4. ХОЛОДНЫЙ СКРИПТ (ТЕКСТ + WA / TG)
Вариант 1 — максимально инженерный (основной)
Добрый день.
Мы занимаемся техническим сопровождением систем безопасности коммерческих объектов в Краснодарском крае.
Перед тем как брать объекты на обслуживание, обычно делаем технический аудит:
— проверка видеонаблюдения (архив, запись, питание)
— проверка СКУД
— проверка пожарной сигнализации
— оценка рисков эксплуатации
По практике, на большинстве объектов выявляются скрытые проблемы, которые не видны в ежедневной работе.
Если у вас есть задача понять текущее состояние систем — можем провести аудит и дать инженерное заключение по рискам.
Подскажите, у вас сейчас системы на обслуживании или “как есть работают”?
---
Почему это работает:
нет продажи
есть диагностика
есть статус эксперта
есть вопрос (диалог)
---
5. ВТОРОЙ СООБЩЕНИЕ (если отвечают “работает/есть подрядчик”)
Понял.
В таких случаях чаще всего проблема не в “работает/не работает”, а в том, что:
— архив не проверяется регулярно
— резервирование не тестируется
— часть оборудования работает нестабильно
— нет объективного контроля состояния
А это всплывает обычно в момент инцидента или проверки.
Мы как раз делаем аудит, который показывает такие скрытые вещи до проблем.
Если интересно — можем сделать экспресс-проверку объекта с коротким отчётом по рискам.
---
6. КОНВЕРСИЯ В АУДИТ (КЛЮЧЕВОЙ МОМЕНТ)
Можем сделать так:
— выезд инженера
— проверка всех систем
— фиксация состояния
— короткий инженерный отчёт
— список рисков и рекомендаций
Обычно это занимает 1–2 дня в зависимости от объекта.
По стоимости — от 15 до 50 тыс, зависит от объёма систем.
Если удобно — могу уточнить объект и сказать точнее по формату.
---
7. СКРИПТ ДЛЯ ГОЛОСА (ЕСЛИ ЗВОНЯТ)
структура:
1. Позиционирование (10–15 сек)
Мы занимаемся техническим сопровождением систем безопасности коммерческих объектов.
Перед обслуживанием всегда делаем аудит состояния систем.
---
2. Боль
На большинстве объектов, которые мы смотрим, есть скрытые проблемы — архив, питание, доступ, пожарка.
Обычно это не видно в ежедневной работе.
---
3. Переход
Поэтому предлагаем сначала сделать техническую проверку объекта и дать инженерное заключение.
---
4. Закрытие
Если вам актуально — можем согласовать выезд инженера и формат проверки.
---
8. ОБРАБОТКА ВОЗРАЖЕНИЙ
---
❌ “У нас уже есть подрядчик”
Понял.
Аудит как раз часто показывает, что объект обслуживается формально, но без проверки ключевых параметров:
архив, резервирование, ошибки, питание.
Мы не конкурируем с подрядчиком — мы даём независимую оценку состояния систем.
---
❌ “Дорого”
Аудит не про “сделать дешевле”.
Это проверка рисков, которые могут стоить дороже:
— потеря архива
— отказ системы
— проблемы при проверках
Поэтому стоимость зависит от масштаба объекта, но обычно это разовая диагностика, а не постоянные расходы.
---
❌ “Нам не нужно”
Понял.
На практике аудит нужен не всегда “сейчас”, а когда появляются сомнения в стабильности систем или перед проверками.
Если актуализируется — можем подключиться.
---
9. СИСТЕМА ДОГРЕВА (ЕСЛИ НЕ КУПИЛИ СРАЗУ)
Через 35 дней:
Добрый день.
Подскажите, актуально ли сейчас техническое обследование систем безопасности?
По опыту, на объектах часто всплывают вопросы по архиву и резервированию, которые лучше проверить заранее, чем в момент инцидента.
---
10. МИКРО-CRM ЛОГИКА (ВАЖНО)
Каждый контакт делишь на:
не ответил
думает
есть подрядчик
отказ
сделал аудит
ушёл в SLA
---
11. КЛЮЧЕВОЙ МЕХАНИЗМ ПРОДАЖ
Твоя система должна работать так:
контакт → инженерная диагностика → выявление рисков → логичный переход в SLA
---
12. ПОЧЕМУ ЭТО РАБОТАЕТ ИМЕННО В КРАСНОДАРСКОМ КРАЕ
Рынок региона:
много частных объектов
слабая эксплуатация систем
подрядчики “формальные”
мало инженерных компаний уровня SLA
👉 значит: ты не конкурируешь по цене — ты создаёшь новый тип услуги
---
13. ЕСЛИ ДАЛЬШЕ УСИЛИВАТЬ СИСТЕМУ
Следующий уровень, который я могу тебе собрать:
скрипт “закрытия в SLA после аудита”
таблица квалификации клиента (кого брать/кого нет)
CRM-воронка под SLA (по стадиям)
шаблон отчёта аудита (который продаёт сам себя)
шаблон договора SLA (который не торгуется)
И это уже будет не продажи.
Это будет:
инженерная коммерческая система повторяемого дохода.
+556
View File
@@ -0,0 +1,556 @@
Сейчас мы выходим на ключевой этап.
Потому что:
УТП — это не “красивый текст”.
Это:
твоя позиция на рынке.
Именно здесь определяется:
будешь ли ты конкурировать ценой;
попадешь ли в “монтажников”;
или станешь инженерным B2B подрядчиком.
---
ГЛАВНАЯ ОШИБКА РЫНКА
Все пишут:
“качественный монтаж”;
“20 лет опыта”;
“индивидуальный подход”.
Это не УТП.
Это информационный шум.
---
ТВОЕ РЕАЛЬНОЕ ПРЕИМУЩЕСТВО
Ты НЕ:
продавец оборудования;
бригада монтажников;
дешевый подрядчик.
Ты:
инженерная сервисная компания.
И продавать нужно:
ответственность;
стабильность;
контроль;
снижение рисков;
эксплуатацию;
прозрачность.
---
ТВОЕ ОСНОВНОЕ УТП
Вот сильная база.
---
AEGISONE — инженерное сопровождение систем безопасности коммерческих объектов.
Мы не просто устанавливаем оборудование.
Мы берем на себя ответственность за стабильную работу систем безопасности объекта.
Работаем с коммерческой недвижимостью, гостиницами, складами, производствами и сетевыми объектами.
Обеспечиваем:
— регламентное обслуживание;
— SLA и контроль сроков реакции;
— технический аудит;
— восстановление проблемных объектов;
— сопровождение эксплуатации;
— прозрачную отчетность и контроль инфраструктуры.
Если система безопасности нужна не «для галочки», а для реальной работы объекта — мы подходим.
---
ПОЧЕМУ ЭТО СИЛЬНО
Тут нет:
“лучших цен”;
“монтажа под ключ”;
“любых работ”.
Это:
позиция эксперта.
---
ДОПОЛНИТЕЛЬНЫЕ УТП ДЛЯ РАЗНЫХ СЕГМЕНТОВ
---
ДЛЯ ГОСТИНИЦ
Обеспечиваем стабильную работу систем безопасности гостиниц и апарт-отелей без постоянного контроля со стороны управляющего.
Берем на себя техническое сопровождение:
— видеонаблюдения;
— СКУД;
— пожарной сигнализации;
— сетевой инфраструктуры.
Работаем по SLA и регламентам.
---
ДЛЯ СКЛАДОВ И ЛОГИСТИКИ
Снижаем риски потери архива, отказов систем и простоев инфраструктуры складских объектов.
Контролируем работоспособность:
— видеонаблюдения;
— контроля доступа;
— периметра;
— сетевой инфраструктуры.
Обеспечиваем регламентное обслуживание и аварийное реагирование.
---
ТЕПЕРЬ САМОЕ ВАЖНОЕ
СТРУКТУРА КОММЕРЧЕСКОГО ПРЕДЛОЖЕНИЯ
Большинство КП на рынке — мусор.
Там:
список оборудования;
цены;
таблицы;
“надежные решения”.
B2B это НЕ читает.
---
ТВОЕ КП ДОЛЖНО ПРОДАВАТЬ:
СПОКОЙСТВИЕ И КОНТРОЛЬ.
---
ИДЕАЛЬНАЯ СТРУКТУРА КП ДЛЯ ТЕБЯ
---
1. ТИТУЛЬНАЯ СТРАНИЦА
---
Заголовок:
Коммерческое предложение
по техническому сопровождению систем безопасности
---
Подзаголовок:
Для:
гостиницы;
склада;
коммерческого объекта;
производства.
---
Внизу:
логотип;
контакты;
дата.
---
2. КРАТКО О ПРОБЛЕМЕ
Это критически важно.
---
Пример:
---
Большинство неисправностей систем безопасности выявляются только после возникновения проблем:
— отсутствует архив видеонаблюдения;
— часть оборудования не функционирует;
— отсутствует документация;
— подрядчики не несут ответственности;
— неисправности накапливаются месяцами.
Это приводит к рискам простоев, потере контроля и проблемам при проверках.
Для снижения этих рисков необходимо регулярное техническое сопровождение и контроль состояния инфраструктуры объекта.
---
ПОЧЕМУ ЭТО СИЛЬНО
Ты:
не “продаешь услуги”;
а показываешь проблему бизнеса.
---
3. ЧТО ТЫ ПРЕДЛАГАЕШЬ
---
Заголовок:
Решение
---
Пример:
---
AEGISONE обеспечивает техническое сопровождение и контроль работоспособности систем безопасности объекта.
Мы берем на себя:
— регламентное обслуживание;
— диагностику;
— аварийное реагирование;
— технический контроль;
— сопровождение эксплуатации;
— контроль подрядчиков;
— отчетность и рекомендации.
---
4. ЧТО ВХОДИТ В ОБСЛУЖИВАНИЕ
Вот здесь уже конкретика.
---
Пример структуры:
Услуга Описание
Регламентные проверки Проверка состояния оборудования
Контроль архива Проверка записи и хранения
Аварийные выезды Реагирование по SLA
Диагностика Поиск неисправностей
Отчетность Фото и рекомендации
Поддержка Консультации персонала
---
5. SLA
Очень важно.
---
Пример:
Приоритет Время реакции
Критическая авария до 2 часов
Частичный отказ до 4 часов
Плановая заявка до 24 часов
---
6. ПОЧЕМУ ИМЕННО ВЫ
Вот тут нельзя писать банальности.
---
ПРАВИЛЬНО:
---
Почему заказчики работают с нами:
— более 20 лет инженерного опыта;
— лицензия МЧС;
— работа по регламентам и SLA;
— прозрачная отчетность;
— опыт эксплуатации сложных объектов;
— несем ответственность за результат;
— не работаем по принципу «сделали и забыли».
---
7. СТОИМОСТЬ
Очень важный блок.
---
НЕ ДЕЛАЙ:
“от 5000 ₽”.
---
ПРАВИЛЬНО:
Тариф Формат Стоимость
START Небольшие объекты от 25 000 ₽
BUSINESS Коммерческие объекты от 60 000 ₽
ENTERPRISE Внешний инженерный отдел индивидуально
---
8. ЭТАПЫ РАБОТЫ
Это резко повышает доверие.
---
Пример:
1. Технический аудит объекта
2. Формирование регламентов
3. Подписание SLA
4. Постановка объекта на сопровождение
5. Регулярное обслуживание и отчетность
---
9. CTA
Очень важно.
---
НЕ:
“оставьте заявку”.
---
ПРАВИЛЬНО:
Предлагаем провести технический аудит объекта и подготовить рекомендации по снижению эксплуатационных рисков.
---
КАК ДОЛЖНО ВЫГЛЯДЕТЬ КП ВИЗУАЛЬНО
---
НЕ:
“цветастая реклама”;
“маркетинговый шум”.
---
А:
спокойный инженерный документ.
---
ЦВЕТА
темно-синий;
графит;
белый;
серый.
---
СТИЛЬ
Как:
техническая компания;
инженерный аудит;
эксплуатация объектов.
---
САМОЕ ВАЖНОЕ
Твое КП должно вызывать мысль:
> “Эти люди понимают, как работает объект.”
А не:
> “Очередные монтажники.”
---
И ТУТ КЛЮЧЕВОЙ МОМЕНТ
Ты не должен пытаться понравиться всем.
Тебе нужны:
нормальные объекты;
нормальные бюджеты;
нормальные заказчики.
---
ТВОЯ СТРАТЕГИЯ
Не:
“много дешевых клиентов”.
А:
“мало, но качественных объектов на абонентке”.
Это огромная разница в бизнес-модели.
---
Следующий логичный этап:
1. Сделать полноценный шаблон КП (PDF-структуру).
2. Прописать тарифы глубже.
3. Сделать SLA-документ.
4. Подготовить скрипт инженерной продажи.
5. Сделать структуру первого аудита.
6. Составить чек-листы.
7. Построить первую воронку клиентов в Краснодарском крае.
8. Подготовить SEO-структуру сайта.
9. Разработать контент-план для выхода в экспертность.
10. Построить модель роста до 1–3 млн/мес recurring revenue.
@@ -0,0 +1,157 @@
# Формулы и расчёты AegisOne
## 1. Risk Score (Оценка риска объекта)
**Формула:** Сумма весов выявленных нарушений
| Нарушение | Коэффициент (по умолч.) |
|-----------|------------------------|
| Нет архива видеонаблюдения | 25 |
| Нет резервного питания | 20 |
| Нет регламента обслуживания | 15 |
| Частые сбои систем | 20 |
| Нет документации | 10 |
**Максимум:** 100 баллов
**Интерпретация:**
- 020: Низкий
- 2150: Средний
- 5175: Высокий
- 76–100: Критический
---
## 2. Complexity Index (Сложность обслуживания)
**Формула:** Сумма баллов по компонентам
| Компонент | Значение | Коэффициент |
|-----------|----------|-------------|
| Камеры | до 20 | 10 |
| Камеры | 20100 | 20 |
| Камеры | 100+ | 35 |
| Точки СКУД | до 5 | 10 |
| Точки СКУД | 5–20 | 20 |
| Точки СКУД | 20+ | 30 |
| Пожарная | Простая | 15 |
| Пожарная | Средняя | 25 |
| Пожарная | Сложная (>5000 м²) | 40 |
| IT-инфраструктура | Есть | 10 |
**Интерпретация:**
- до 30: Простой
- 3170: Средний
- 71120: Сложный
- 120+: Enterprise
---
## 3. Infrastructure Load (Нагрузка на инфраструктуру)
| Параметр | Состояние | Коэффициент |
|----------|-----------|-------------|
| Сервер | Нет | 15 |
| Сервер | Слабый | 10 |
| Сеть | Нестабильная | 20 |
| Сеть | Частично | 10 |
| UPS | Нет | 20 |
| UPS | Слабый | 10 |
---
## 4. Service History (История обслуживания)
| Состояние | Коэффициент |
|-----------|-------------|
| Не обслуживается | 30 |
| Нерегулярно | 20 |
| Формальный подрядчик | 10 |
| Есть SLA | 0 |
---
## 5. Object Index (Итоговый индекс объекта)
**Формула:**
```
Object Index = Risk × 0.4 + Complexity × 0.3 + Infra × 0.2 + History × 0.1
```
**Классы:**
- A (до 30): Лёгкий SLA
- B (3160): Стандарт SLA
- C (6190): Сложный SLA
- D (90+): Enterprise / Высокий риск
---
## 6. SLA Price (Стоимость SLA)
**Формула:**
```
SLA Price = Base Cost × Object Index × Region Factor × Risk Multiplier
```
| Параметр | Значение |
|----------|----------|
| Base Cost | 15 000 ₽ |
| Region Factor | Краснодар 1.0 / Край 1.1 / РФ 1.3 / Удалённые 1.6 / Москва 1.8 |
**Risk Multiplier:**
- Низкий риск: 1.0
- Средний: 1.3
- Высокий: 1.6
- Критический: 2.0
---
## 7. Engineer Score (ES)
**Формула:** Взвешенная сумма компонентов
| Компонент | Вес |
|-----------|-----|
| SLA Compliance | 0.25 |
| Response Time Score | 0.20 |
| Resolution Time Score | 0.20 |
| Diagnosis Accuracy | 0.15 |
| Reopen Rate Score | 0.10 |
| Risk Coverage Score | 0.10 |
**Грейды:**
- 90+: Senior
- 8089: Strong
- 7079: Middle
- <70: Junior
---
## 8. SHS (System Health Score)
**Формула:**
```
SHS = SLA Stability × 0.22 + Revenue Stability × 0.18 + Retention × 0.18
+ Engineer Performance × 0.15 + Incident Stability × 0.12
+ Sales Flow × 0.10 + Operational Efficiency × 0.05
```
**Статусы:**
- 85+: Growth
- 7084: Stable
- 5069: Risk
- <50: Crisis
---
## Автоматические правила
| Условие | Действие |
|---------|----------|
| SHS < 70 | Снизить продажи, запустить аудит, пересмотреть назначения |
| SLA Compliance < 90% | Заморозить неприоритетные проекты, увеличить частоту проверок |
| Retention < 90% | Обязательный аудит клиентов, пересмотр назначений |
---
Все коэффициенты можно изменить в разделе **Коэффициенты** админ-панели. Изменения применяются к новым расчётам.
@@ -0,0 +1,458 @@
Ниже — единая цифровая операционная модель AegisOne Engineering в формате .md.
Это не просто документация — это “скелет системы”, который можно отдать разработчику, внедрить в Notion/Bitrix/1C/веб-CRM или развернуть как внутренний продукт.
---
# AegisOne Engineering
## Unified Digital Operating System (UDOS)
---
# 0. СУТЬ СИСТЕМЫ
AegisOne UDOS — это единая цифровая система управления инженерной эксплуатацией объектов безопасности.
Она объединяет:
- CRM (клиенты и сделки)
- SLA (контракты и обслуживание)
- KPI инженеров (исполнение)
- SHS (здоровье бизнеса)
- Финансовую модель
- Операционный дашборд
---
# 1. АРХИТЕКТУРА СИСТЕМЫ
┌──────────────────────┐
│ LEADS / CRM │
└─────────┬────────────┘
┌──────────────────────┐
│ AUDIT SYSTEM │
└─────────┬────────────┘
┌──────────────────────┐
│ SLA CONTRACTS │
└─────────┬────────────┘
┌─────────────────────────────────────────┐
│ OPERATIONS (ENGINEERS) │
└─────────┬───────────────┬──────────────┘
↓ ↓
┌──────────────┐ ┌─────────────────────┐
│ KPI ENGINEERS │ │ INCIDENT SYSTEM │
└──────┬───────┘ └─────────┬───────────┘
↓ ↓
┌─────────────────────────────────────────┐
│ SHS ENGINE │
└──────────────┬──────────────────────────┘
┌─────────────────────────────────────────┐
│ CEO DASHBOARD │
└─────────────────────────────────────────┘
---
# 2. CRM СИСТЕМА
## 2.1 Структура клиента
```json
Client {
id,
company_name,
object_type,
location,
contact_person,
decision_maker,
number_of_objects,
systems: {
video: int,
access_control: int,
fire_alarm: bool,
it_infrastructure: bool
},
current_provider,
pain_points[],
budget_level,
status: [lead, qualified, audit, offer, sla, lost]
}
---
2.2 Воронка CRM
Lead
Qualification
Technical Audit
Risk Report
SLA Offer
Contract
Active SLA
---
3. AUDIT SYSTEM (ИНЖЕНЕРНЫЙ ВХОД)
3.1 Формирование Risk Score
Risk Score =
missing_archive (25)
+ no_power_backup (20)
+ no_regulations (15)
+ system_failures (20)
+ no_documentation (10)
---
3.2 Output аудита
Audit Report:
- Risk Score
- System condition
- Critical vulnerabilities
- SLA recommendation
- Cost estimation
---
4. SLA СИСТЕМА
4.1 Формула стоимости
SLA Price =
Base Cost × Object Index × Region Factor × Risk Multiplier
---
4.2 Object Index
Object Index =
(Risk × 0.4)
+ (Complexity × 0.3)
+ (Infrastructure × 0.2)
+ (Service History × 0.1)
---
4.3 SLA уровни
Class Index Description
A 030 simple SLA
B 3160 standard SLA
C 6190 complex SLA
D 90+ enterprise SLA
---
4.4 SLA метрики
Response Time
Resolution Time
Uptime %
Incident Rate
---
5. KPI СИСТЕМА ИНЖЕНЕРОВ
5.1 Engineer Score
ES =
(0.25 × SLA Compliance)
+ (0.20 × Response Time Score)
+ (0.20 × Resolution Time Score)
+ (0.15 × Diagnosis Accuracy)
+ (0.10 × Reopen Rate Score)
+ (0.10 × Documentation Quality)
---
5.2 KPI метрики
SLA Compliance
closed_in_SLA / total_requests
Reopen Rate
reopened_requests / total_requests
Diagnosis Accuracy
confirmed_faults / found_faults
Utilization
working_hours / available_hours
---
5.3 Грейды инженеров
Score Level
90100 Senior
8089 Strong
7079 Middle
<70 Junior
---
6. SHS (SYSTEM HEALTH SCORE)
6.1 Формула SHS
SHS =
(0.22 × SLA Stability)
+ (0.18 × Revenue Stability)
+ (0.18 × Retention)
+ (0.15 × Engineer Performance)
+ (0.12 × Incident Stability)
+ (0.10 × Sales Flow)
+ (0.05 × Operational Efficiency)
---
6.2 Интерпретация
SHS Status
85100 Growth
7085 Stable
5070 Risk
<50 Crisis
---
6.3 Alert System
SHS ↓ 5% weekly → Warning
SHS ↓ 10% → Critical
Retention drop → Immediate audit trigger
---
7. INCIDENT SYSTEM
7.1 Incident Classification
Level Description
P1 critical system failure
P2 partial failure
P3 minor issue
---
7.2 Incident Rate
IR = incidents / number_of_objects
---
7.3 SLA breach tracking
breach_rate = failed_SLA_cases / total_cases
---
8. FINANCIAL LAYER
8.1 MRR
MRR = Σ SLA contracts / 12
---
8.2 Revenue per Engineer
RPE = SLA Revenue / number_of_engineers
---
8.3 Cost per Object
CPO = total_operational_cost / number_of_objects
---
9. CEO DASHBOARD
9.1 Main indicators
SHS (system health)
MRR (monthly recurring revenue)
SLA Compliance
Retention
Incident Rate
Engineer Score Avg
Sales Conversion
---
9.2 Dashboard visualization blocks
1. SHS Line Chart
trend over time
2. SLA Heatmap
engineers vs performance
3. MRR Growth Curve
monthly recurring revenue
4. Client Retention Funnel
drop-off points
5. Incident Map
problematic objects
6. Engineer Ranking Table
top/bottom performers
---
10. AUTOMATION RULES
10.1 If SHS drops
IF SHS < 70:
reduce new sales
trigger audit cycle
reassign engineers
---
10.2 If SLA compliance drops
IF SLA < 90%:
freeze non-critical projects
increase inspection frequency
---
10.3 If retention drops
IF retention < 90%:
mandatory client audit
review engineer assignments
---
11. SYSTEM BEHAVIOR MODEL
DATA → KPI → SHS → DECISION → ACTION → FEEDBACK LOOP
---
12. STRATEGIC PRINCIPLE
AegisOne Engineering is not a service company.
It is:
> an operating system for managing engineering risk in physical infrastructure.
---
13. END STATE MODEL
The system evolves into:
predictive SLA risk detection
automated pricing
engineer auto-allocation
client churn prediction
MRR forecasting
---
END OF SYSTEM
---
Если хочешь следующий уровень, я могу собрать тебе:
1) визуальный UI дашборда (как он должен выглядеть как продукт)
2) структуру базы данных (SQL / 1C / Bitrix schema)
3) алгоритм “предикта потери клиента за 14 дней”
4) или автоматический генератор КП + SLA из этой системы
Это уже уровень **enterprise SaaS, а не просто инженерной компании**.
@@ -0,0 +1,629 @@
Ниже — как должна работать автоматическая система SHS (System Health Score) в твоей модели AegisOne Engineering.
Это уже не “аналитика бизнеса”, а:
> инженерная система раннего предупреждения о деградации компании
---
0. ЧТО ТАКОЕ SHS (СУТЬ СИСТЕМЫ)
SHS — это единый индекс здоровья бизнеса (0–100), который показывает:
насколько стабильно работает SLA-модель
где теряются деньги
где растут риски разрыва контрактов
где перегружается команда
где падает качество эксплуатации
---
1. ЛОГИКА СИСТЕМЫ SHS
Данные (CRM + SLA + инженеры)
Нормализация метрик
Взвешивание показателей
Расчёт SHS
Детектирование отклонений
Сигналы риска (alerts)
---
2. ФИНАЛЬНАЯ ФОРМУЛА SHS
SHS =
(0.22 × SLA Stability Index) +
(0.18 × Revenue Stability Index) +
(0.18 × Retention Index) +
(0.15 × Engineering Performance Index) +
(0.12 × Incident Stability Index) +
(0.10 × Sales Flow Index) +
(0.05 × Operational Efficiency Index)
---
3. РАСШИФРОВКА ВСЕХ КОМПОНЕНТОВ
---
3.1 SLA STABILITY INDEX (критический)
Смысл:
Насколько система выполняет SLA без сбоев.
---
Формула:
SSI = SLA_compliance × (1 - SLA_breach_severity)
---
Где:
SLA_compliance = выполненные заявки / все заявки
SLA_breach_severity = тяжесть нарушений (0–1)
---
Пример:
compliance = 0.96
нарушения = 0.1
→ SSI = 0.864
---
Риски:
падение ниже 0.85 = начинается утечка клиентов
---
3.2 REVENUE STABILITY INDEX
Смысл:
Стабильность денежного потока SLA
---
Формула:
RSI = 1 - (σ(MRR) / mean(MRR))
---
Интерпретация:
чем меньше колебания → тем выше индекс
---
Риски:
RSI < 0.7 → нестабильная финансовая модель
---
3.3 RETENTION INDEX (очень критично)
Формула:
RI = retained_clients / total_clients
---
Дополнение:
штраф за уход крупных клиентов:
RI_adjusted = RI - (lost_key_clients × 0.1)
---
Риски:
падение ниже 0.9 = системная проблема SLA
---
3.4 ENGINEERING PERFORMANCE INDEX
Смысл:
качество работы инженеров
---
Формула:
EPI =
(0.3 × SLA compliance engineers) +
(0.25 × diagnosis accuracy) +
(0.2 × reopen rate inverse) +
(0.15 × response time score) +
(0.1 × documentation quality)
---
Важный момент:
это единственный KPI, который напрямую влияет на:
> удержание клиентов
---
Риски:
EPI < 0.75 → будущие потери клиентов через 1–2 месяца
---
3.5 INCIDENT STABILITY INDEX
Смысл:
насколько система “ломается”
---
Формула:
ISI = 1 - (incidents / objects × severity_weight)
---
Где severity_weight:
критический = 1.0
средний = 0.5
низкий = 0.2
---
Риски:
рост ISI вниз = деградация инфраструктуры
---
3.6 SALES FLOW INDEX
Смысл:
здоровье входящего потока денег
---
Формула:
SFI = (audits → SLA conversion rate) × lead quality
---
Пример:
конверсия 0.35
качество лидов 0.8
→ SFI = 0.28
---
Риски:
SFI < 0.25 → нет роста MRR
---
3.7 OPERATIONAL EFFICIENCY INDEX
Смысл:
насколько эффективно работает компания внутри
---
Формула:
OEI = revenue / (engineer_hours × cost)
---
Риски:
падение → перегруз команды
---
4. ИНТЕРПРЕТАЦИЯ SHS
---
SHS Состояние системы
85–100 масштабируемый рост
70–85 стабильная работа
50–70 скрытые проблемы
<50 системный кризис
---
5. ГЛАВНАЯ СИЛА SHS — НЕ ЧИСЛО, А ДИНАМИКА
---
ВАЖНО:
Ты смотришь не на значение, а на:
Δ SHS (изменение)
ΔSHS = SHS_today - SHS_last_week
---
Критично:
падение > 5 пунктов за неделю → тревога
падение > 10 → кризис
---
6. СИСТЕМА АВТОМАТИЧЕСКИХ СИГНАЛОВ
---
GREEN ZONE:
SHS > 80
нет действий
---
YELLOW ZONE:
SHS 6580
👉 действия:
проверить инженеров
проверить SLA просадки
проверить загрузку
---
RED ZONE:
SHS < 65
👉 действия:
аудит клиентов
пересмотр инженеров
срочный анализ SLA нарушений
заморозка новых продаж
---
7. КОГДА ПОЯВЛЯЮТСЯ РИСКИ
---
РИСК №1 — падение EPI
Причина:
инженеры начали “делать быстро, но плохо”
Симптом:
рост повторных заявок
жалобы клиентов через 2–3 недели
---
РИСК №2 — рост INCIDENT RATE
Причина:
старое оборудование
плохая эксплуатация
---
РИСК №3 — падение RETENTION
Самый опасный
👉 означает:
> клиент уже не верит системе
---
РИСК №4 — нестабильный SFI
Причина:
слабый маркетинг
неправильный аудит
некачественные лиды
---
РИСК №5 — падение OEI
Причина:
перегруз инженеров
хаотичные выезды
нет стандартизации
---
8. КАК ИСПРАВЛЯТЬ РИСКИ
---
ЕСЛИ ПАДАЕТ SHS:
---
ШАГ 1 — локализация
разбить SHS на компоненты:
где падение?
SLA?
инженеры?
продажи?
---
ШАГ 2 — точечное вмешательство
проблема действие
EPI падает обучение инженеров + чек-листы
retention падает аудит объектов
SLA падает перераспределение нагрузки
SFI падает фильтрация лидов
---
ШАГ 3 — стабилизация
ограничить новые продажи
усилить контроль SLA
снизить нагрузку
---
9. ВАЖНОЕ ПРАВИЛО СИСТЕМЫ
---
> нельзя “растить бизнес”, если SHS падает
---
10. ВИЗУАЛИЗАЦИЯ SHS (CEO DASHBOARD)
---
1. SHS LINE
📈 динамика здоровья компании
---
2. STACKED INDEX CHART
SLA
Revenue
Engineers
Retention
---
3. ALERT MAP
🟥 проблемные зоны
---
4. ENGINEER IMPACT MAP
📍 какой инженер влияет на SHS
---
11. ГЛАВНЫЙ СМЫСЛ SHS
---
Ты больше не управляешь:
❌ людьми
❌ объектами
❌ заявками
---
Ты управляешь:
системой стабильности инженерного бизнеса
---
12. ЕСЛИ ДАЛЬШЕ РАЗВИВАТЬ
Я могу собрать:
автоматический SHS калькулятор (Excel / 1C / web)
real-time dashboard
систему предиктивных предупреждений (падение SHS за 7–14 дней)
интеграцию SHS в KPI инженеров
систему автоматического “замедления продаж” при риске
---
ИТОГ
SHS — это не метрика.
> это “нервная система” твоей компании, которая заранее показывает, где бизнес начнёт терять деньги или клиентов
---
Если хочешь дальше, следующий логичный шаг:
сделать тебе полноценную цифровую модель AegisOne (дашборд + CRM + SHS + SLA + KPI инженеров в одной системе)
+7 -2
View File
@@ -5,10 +5,15 @@ asyncpg==0.30.0
alembic==1.14.0
python-multipart==0.0.18
jinja2==3.1.4
passlib[bcrypt]==1.7.4
bcrypt>=4.1.0
python-dotenv==1.0.1
itsdangerous==2.2.0
aiohttp==3.11.11
markdown==3.7
pydantic==2.10.3
pydantic-settings==2.7.0
# Test
pytest>=9.0.0
httpx>=0.28.0
pytest-asyncio>=1.3.0
aiosqlite>=0.22.0
+98
View File
@@ -0,0 +1,98 @@
-- Migration for v1.5.0: Yandex Disk integration + SLA Tickets + MAX notifications
-- File sync queue
CREATE TABLE IF NOT EXISTS file_sync_queue (
id SERIAL PRIMARY KEY,
local_path VARCHAR(500) NOT NULL,
remote_path VARCHAR(500) DEFAULT '',
entity_type VARCHAR(50) NOT NULL,
entity_id INTEGER NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','uploading','synced','error')),
error TEXT DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_file_sync_status ON file_sync_queue(status);
CREATE INDEX IF NOT EXISTS idx_file_sync_entity ON file_sync_queue(entity_type, entity_id);
-- Bot tickets
CREATE TABLE IF NOT EXISTS bot_tickets (
id SERIAL PRIMARY KEY,
ticket_number VARCHAR(20) UNIQUE NOT NULL,
user_id INTEGER REFERENCES bot_users(id) ON DELETE SET NULL,
object_id INTEGER REFERENCES objects(id) ON DELETE SET NULL,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
title VARCHAR(500) NOT NULL,
description TEXT DEFAULT '',
priority VARCHAR(20) DEFAULT 'normal' CHECK (priority IN ('low','normal','high','urgent')),
status VARCHAR(20) NOT NULL DEFAULT 'open' CHECK (status IN ('open','in_progress','resolved','closed')),
created_by VARCHAR(20) DEFAULT 'bot',
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
sla_contract_id INTEGER REFERENCES sla_contracts(id) ON DELETE SET NULL,
has_attachment BOOLEAN DEFAULT FALSE,
attachment_type VARCHAR(50) DEFAULT '',
attachment_path VARCHAR(500) DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMP NULL,
closed_at TIMESTAMP NULL
);
CREATE INDEX IF NOT EXISTS idx_bot_ticket_status ON bot_tickets(status);
CREATE INDEX IF NOT EXISTS idx_bot_ticket_user ON bot_tickets(user_id);
CREATE INDEX IF NOT EXISTS idx_bot_ticket_object ON bot_tickets(object_id);
CREATE INDEX IF NOT EXISTS idx_bot_ticket_number ON bot_tickets(ticket_number);
-- Bot ticket messages
CREATE TABLE IF NOT EXISTS bot_ticket_messages (
id SERIAL PRIMARY KEY,
ticket_id INTEGER REFERENCES bot_tickets(id) ON DELETE CASCADE,
direction VARCHAR(3) NOT NULL CHECK (direction IN ('in','out')),
text TEXT DEFAULT '',
has_attachment BOOLEAN DEFAULT FALSE,
attachment_path VARCHAR(500) DEFAULT '',
sender VARCHAR(20) DEFAULT 'client',
timestamp TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_bot_ticket_msg_ticket ON bot_ticket_messages(ticket_id);
-- Bot ticket status log
CREATE TABLE IF NOT EXISTS bot_ticket_statuses (
id SERIAL PRIMARY KEY,
ticket_id INTEGER REFERENCES bot_tickets(id) ON DELETE CASCADE,
old_status VARCHAR(20) DEFAULT '',
new_status VARCHAR(20) NOT NULL,
changed_by VARCHAR(20) DEFAULT 'system',
timestamp TIMESTAMP NOT NULL DEFAULT NOW(),
comment TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_bot_ticket_status_log ON bot_ticket_statuses(ticket_id);
-- Add max_user_id to users table
ALTER TABLE users ADD COLUMN IF NOT EXISTS max_user_id BIGINT NULL;
-- Add Yandex Disk settings
INSERT INTO bot_settings (key, value, description) VALUES
('yandex_disk_token', 'y0__wgBEMHM9zsYiuxBIMi6sswXML_twIQIyCDWBrrNPjQcoQZR6J2x57oBLcg', 'OAuth токен Яндекс Диска'),
('yandex_disk_root', '/AegisOne_Service', 'Корневая папка на Яндекс Диске'),
('yandex_disk_client_id', '00fd594faf604161b773a53737bb5462', 'Client ID приложения Яндекс'),
('yandex_disk_client_secret', '32a46b6dfd3c499e8cced25fed64b426', 'Client Secret приложения Яндекс'),
('storage_backend', 'yandex', 'Режим хранения: local/yandex/both'),
('max_file_size_mb', '100', 'Максимальный размер файла (MB)'),
('public_link_expiry_days', '30', 'Срок публичных ссылок по умолчанию (дни)'),
('auto_archive_enabled', 'true', 'Авто-архивация закрытых обращений'),
('auto_archive_after_days', '30', 'Через сколько дней архивировать'),
('max_group_chat_id', '', 'ID группового чата MAX для уведомлений')
ON CONFLICT (key) DO NOTHING;
-- Add SLA Ticket feature
INSERT INTO bot_features (feature_key, name, description, enabled, sort_order) VALUES
('sla_ticket', 'Заявка по SLA', 'Подать заявку о неисправности для клиентов с активным SLA', true, 0)
ON CONFLICT (feature_key) DO NOTHING;
-- Add ticket-related response templates
INSERT INTO bot_response_templates (template_key, template_text, description, variables) VALUES
('sla_ticket_check', 'Проверяю ваш SLA контракт...', 'Проверка SLA перед созданием заявки', '[]'),
('sla_ticket_found', 'SLA найден: {object_name}, уровень: {service_level}. Опишите неисправность. Вы можете прикрепить фото или документ.', 'SLA найден, запрос описания', '["object_name","service_level"]'),
('sla_ticket_not_found', 'SLA контракт не найден или не активен. Свяжитесь с нами:\n\n📞 {phone_1}\n📞 {phone_2}\n✉️ {support_email}', 'SLA не найден', '["phone_1","phone_2","support_email"]'),
('sla_ticket_created', 'Заявка #{ticket_number} создана.\n\nПриоритет: {priority}\nОжидаемое время реакции: {response_time}\n\nИнженер будет назначен в ближайшее время.', 'Подтверждение создания заявки', '["ticket_number","priority","response_time"]'),
('ticket_status_update', 'Ваша заявка #{ticket_number} — {status}.\nИнженер: {engineer}', 'Уведомление о смене статуса заявки', '["ticket_number","status","engineer"]'),
('ticket_resolved', 'Заявка #{ticket_number} выполнена. Оцените качество:\n\n⭐ 1 — Плохо\n⭐⭐ 2\n⭐⭐⭐ 3\n⭐⭐⭐⭐ 4\n⭐⭐⭐⭐⭐ 5 — Отлично', 'Запрос оценки после выполнения', '["ticket_number"]')
ON CONFLICT (template_key) DO NOTHING;
+121
View File
@@ -0,0 +1,121 @@
-- Migration v1.5.5: New tables + seed data
-- Run: psql -U aegisone -d aegisone -f v1.5.5_tables_and_seed.sql
BEGIN;
-- ===================== New tables =====================
CREATE TABLE IF NOT EXISTS bot_notifications (
id SERIAL PRIMARY KEY,
type VARCHAR(50) NOT NULL DEFAULT 'info',
recipient_type VARCHAR(20) NOT NULL DEFAULT 'operator',
recipient_max_user_id BIGINT NULL,
title VARCHAR(255) DEFAULT '',
body TEXT DEFAULT '',
related_type VARCHAR(50) DEFAULT '',
related_id INTEGER NULL,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CHECK (recipient_type IN ('operator','level2','client'))
);
CREATE TABLE IF NOT EXISTS bot_notification_subscriptions (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES bot_users(id) ON DELETE CASCADE,
notify_on_status_change BOOLEAN NOT NULL DEFAULT FALSE,
notify_on_reply BOOLEAN NOT NULL DEFAULT FALSE,
notify_on_broadcast BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(user_id)
);
CREATE TABLE IF NOT EXISTS bot_risk_questions (
id SERIAL PRIMARY KEY,
question_key VARCHAR(100) UNIQUE NOT NULL,
question VARCHAR(500) NOT NULL,
weight INTEGER NOT NULL DEFAULT 10,
sort_order INTEGER DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS bot_tickets (
id SERIAL PRIMARY KEY,
ticket_number VARCHAR(20) UNIQUE NOT NULL,
user_id INTEGER REFERENCES bot_users(id) ON DELETE SET NULL,
object_id INTEGER NULL,
customer_id INTEGER NULL,
title VARCHAR(255) NOT NULL,
description TEXT DEFAULT '',
priority VARCHAR(20) DEFAULT 'normal',
status VARCHAR(20) NOT NULL DEFAULT 'open',
created_by VARCHAR(20) DEFAULT 'bot',
sla_contract_id INTEGER NULL,
has_attachment BOOLEAN DEFAULT FALSE,
attachment_type VARCHAR(50) DEFAULT '',
attachment_path VARCHAR(500) DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CHECK (priority IN ('low','normal','high','urgent')),
CHECK (status IN ('open','in_progress','resolved','closed'))
);
CREATE TABLE IF NOT EXISTS bot_ticket_messages (
id SERIAL PRIMARY KEY,
ticket_id INTEGER REFERENCES bot_tickets(id) ON DELETE CASCADE NOT NULL,
direction VARCHAR(10) NOT NULL,
text TEXT DEFAULT '',
sender VARCHAR(50) DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS bot_ticket_statuses (
id SERIAL PRIMARY KEY,
ticket_id INTEGER REFERENCES bot_tickets(id) ON DELETE CASCADE NOT NULL,
old_status VARCHAR(20) DEFAULT '',
new_status VARCHAR(20) NOT NULL,
changed_by VARCHAR(50) DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- ===================== Add recipient_ids to broadcasts if missing =====================
ALTER TABLE bot_broadcasts ADD COLUMN IF NOT EXISTS recipient_ids JSONB DEFAULT '[]'::jsonb;
-- ===================== Ensure new columns exist on existing tables =====================
ALTER TABLE bot_notifications ADD COLUMN IF NOT EXISTS recipient_max_user_id BIGINT NULL;
ALTER TABLE bot_notifications ADD COLUMN IF NOT EXISTS related_type VARCHAR(50) DEFAULT '';
ALTER TABLE bot_notifications ADD COLUMN IF NOT EXISTS related_id INTEGER NULL;
ALTER TABLE bot_notification_subscriptions ADD COLUMN IF NOT EXISTS notify_on_status_change BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE bot_notification_subscriptions ADD COLUMN IF NOT EXISTS notify_on_reply BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE bot_notification_subscriptions ADD COLUMN IF NOT EXISTS notify_on_broadcast BOOLEAN NOT NULL DEFAULT FALSE;
-- Drop old subscribe_types column if it exists (replaced by boolean columns)
ALTER TABLE bot_notification_subscriptions DROP COLUMN IF EXISTS subscribe_types;
-- ===================== Seed: enable all features =====================
UPDATE bot_features SET enabled = true WHERE enabled = false;
-- ===================== Seed: risk_questions =====================
INSERT INTO bot_risk_questions (question_key, question, weight, sort_order, is_active) VALUES
('no_archive', '📋 Отсутствует архив документации?', 25, 1, true),
('no_power_backup', '🔋 Нет резервного питания?', 20, 2, true),
('no_regulations', '📜 Нет регламентов обслуживания?', 15, 3, true),
('system_failures', '⚠️ Были отказы системы за последний год?', 20, 4, true),
('no_documentation', '🗂️ Нет схем и паспортов объекта?', 10, 5, true),
('outdated_equipment', '🕰️ Оборудование старше 5 лет?', 5, 6, true),
('no_monitoring', '📡 Нет удалённого мониторинга?', 5, 7, true)
ON CONFLICT (question_key) DO NOTHING;
-- ===================== Seed: default role permissions for new menu items =====================
INSERT INTO role_menu_permissions (role, menu_key, visible)
SELECT * FROM (VALUES
('engineer', 'bot_tickets', true),
('engineer', 'bot_notifications', true),
('engineer', 'bot_risk_questions', true)
) AS v(role, menu_key, visible)
WHERE NOT EXISTS (
SELECT 1 FROM role_menu_permissions r
WHERE r.role = v.role AND r.menu_key = v.menu_key
);
COMMIT;
-74
View File
@@ -133,23 +133,6 @@ ROUTE_PERMISSIONS: dict[tuple[str, str], tuple[str, ...]] = {
("POST", "/service/cases/edit"): ("owner",),
("POST", "/service/cases/delete"): ("owner",),
# === BOT SETTINGS (pages) ===
("GET", "/service/bot-settings"): ("owner",),
("GET", "/service/bot-settings/responses"): ("owner",),
("GET", "/service/bot-settings/features"): ("owner",),
("GET", "/service/bot-settings/categories"): ("owner",),
("GET", "/service/bot-settings/kb"): ("owner",),
("GET", "/service/bot-settings/conversations"): ("owner",),
("GET", "/service/bot-settings/users"): ("owner",),
("GET", "/service/bot-settings/test-runner"): ("owner",),
("GET", "/service/bot-settings/analytics"): ("owner",),
("GET", "/service/bot-settings/risk-questions"): ("owner",),
("GET", "/service/bot-settings/handoffs"): ("owner",),
("GET", "/service/bot-settings/notifications"): ("owner",),
("GET", "/service/bot-settings/broadcasts"): ("owner",),
("GET", "/service/bot-settings/storage"): ("owner",),
("GET", "/service/bot-settings/tickets"): ("owner",),
# === PORTAL / ROLE SETTINGS ===
("GET", "/service/portal-settings"): ("owner",),
("GET", "/service/role-settings"): ("owner",),
@@ -162,57 +145,6 @@ ROUTE_PERMISSIONS: dict[tuple[str, str], tuple[str, ...]] = {
("POST", "/service/api/role/switch"): ("owner", "engineer"),
("POST", "/service/api/role/reset"): ("authenticated",),
# === BOT API (proxied) ===
("GET", "/service/api/bot/settings"): ("owner",),
("POST", "/service/api/bot/settings/save"): ("owner",),
("GET", "/service/api/bot/templates"): ("owner",),
("POST", "/service/api/bot/templates/save"): ("owner",),
("GET", "/service/api/bot/features"): ("owner",),
("POST", "/service/api/bot/features/toggle"): ("owner",),
("GET", "/service/api/bot/categories"): ("owner",),
("POST", "/service/api/bot/categories/create"): ("owner",),
("POST", "/service/api/bot/categories/edit"): ("owner",),
("POST", "/service/api/bot/categories/delete"): ("owner",),
("GET", "/service/api/bot/kb"): ("owner",),
("POST", "/service/api/bot/kb/create"): ("owner",),
("POST", "/service/api/bot/kb/edit"): ("owner",),
("POST", "/service/api/bot/kb/delete"): ("owner",),
("GET", "/service/api/bot/conversations"): ("owner",),
("GET", "/service/api/bot/users"): ("owner",),
("GET", "/service/api/bot/analytics"): ("owner",),
("POST", "/service/api/bot/test-run"): ("owner",),
("GET", "/service/api/bot/broadcast/recipients"): ("owner",),
("GET", "/service/api/bot/broadcast/history"): ("owner",),
("POST", "/service/api/bot/broadcast"): ("owner",),
# === BOT RISK QUESTIONS ===
("GET", "/service/api/bot/risk-questions"): ("owner",),
("POST", "/service/api/bot/risk-questions/create"): ("owner",),
("POST", "/service/api/bot/risk-questions/edit"): ("owner",),
("POST", "/service/api/bot/risk-questions/toggle"): ("owner",),
("POST", "/service/api/bot/risk-questions/delete"): ("owner",),
# === BOT STORAGE ===
("GET", "/service/api/bot/storage/list"): ("owner",),
("GET", "/service/api/bot/storage/search"): ("owner",),
("GET", "/service/api/bot/storage/public-link"): ("owner",),
("GET", "/service/api/bot/storage/download"): ("owner",),
# === BOT TICKETS ===
("GET", "/service/api/bot/tickets"): ("owner",),
("GET", "/service/api/bot/tickets/{ticket_id}"): ("owner",),
("POST", "/service/api/bot/tickets/create"): ("owner",),
("POST", "/service/api/bot/tickets/{ticket_id}/message"): ("owner",),
("POST", "/service/api/bot/tickets/{ticket_id}/status"): ("owner",),
("GET", "/service/api/bot/engineers"): ("owner",),
# === BOT HANDOFFS / NOTIFICATIONS (non-standard paths) ===
("GET", "/service/bot-settings/handoffs/poll"): ("owner",),
("POST", "/service/bot-settings/handoffs/{handoff_id}/take"): ("owner",),
("POST", "/service/api/bot/handoffs/{handoff_id}/close"): ("owner",),
("GET", "/service/bot-settings/notifications/data"): ("owner",),
("POST", "/service/bot-settings/notifications/{notif_id}/read"): ("owner",),
# === OTHER API (main.py) ===
("GET", "/service/api/shs"): ("owner", "engineer", "technician"),
("GET", "/service/api/tasks"): ("owner", "engineer", "technician"),
@@ -241,12 +173,6 @@ DYNAMIC_ROUTES: set[tuple[str, str]] = {
("GET", "/service/documents/{slug}/edit"),
("POST", "/service/documents/{slug}/edit"),
("GET", "/service/documents/{slug}/download"),
("POST", "/service/bot-settings/handoffs/{handoff_id}/take"),
("POST", "/service/bot-settings/notifications/{notif_id}/read"),
("POST", "/service/api/bot/handoffs/{handoff_id}/close"),
("GET", "/service/api/bot/tickets/{ticket_id}"),
("POST", "/service/api/bot/tickets/{ticket_id}/message"),
("POST", "/service/api/bot/tickets/{ticket_id}/status"),
}
# Routes excluded from discover test (internal framework routes)
-40
View File
@@ -36,15 +36,6 @@ PROTECTED_GET_ROUTES = [
"/service/charts",
"/service/portal-settings",
"/service/role-settings",
"/service/bot-settings",
"/service/bot-settings/responses",
"/service/bot-settings/features",
"/service/bot-settings/categories",
"/service/bot-settings/kb",
"/service/bot-settings/conversations",
"/service/bot-settings/users",
"/service/bot-settings/test-runner",
"/service/bot-settings/analytics",
"/service/quick-menu",
"/service/documents/",
]
@@ -56,16 +47,6 @@ API_GET_ROUTES = [
"/service/api/quick-menu",
"/service/api/role-permissions",
"/service/api/charts/data",
"/service/api/bot/settings",
"/service/api/bot/templates",
"/service/api/bot/features",
"/service/api/bot/categories",
"/service/api/bot/kb",
"/service/api/bot/conversations",
"/service/api/bot/users",
"/service/api/bot/analytics",
"/service/api/bot/broadcast/history",
"/service/api/bot/broadcast/recipients",
]
@@ -151,24 +132,8 @@ class TestEngineerAccess:
"/service/charts",
"/service/portal-settings",
"/service/role-settings",
"/service/bot-settings",
"/service/bot-settings/responses",
"/service/bot-settings/features",
"/service/bot-settings/categories",
"/service/bot-settings/kb",
"/service/bot-settings/users",
"/service/bot-settings/test-runner",
"/service/bot-settings/analytics",
"/service/bot-settings/conversations",
]
@pytest.mark.parametrize("path", ENGINEER_ALLOWED)
def test_allowed(self, engineer_client, path):
resp = engineer_client.get(path, follow_redirects=False)
assert resp.status_code in (200, 302, 500), f"GET {path} returned {resp.status_code}"
if resp.status_code == 500:
pytest.fail(f"GET {path} returned 500 Internal Server Error")
@pytest.mark.parametrize("path", ENGINEER_FORBIDDEN)
def test_forbidden(self, engineer_client, path):
resp = engineer_client.get(path, follow_redirects=False)
@@ -205,7 +170,6 @@ class TestTechnicianAccess:
"/service/charts",
"/service/portal-settings",
"/service/role-settings",
"/service/bot-settings",
]
@pytest.mark.parametrize("path", TECHNICIAN_ALLOWED)
@@ -239,14 +203,10 @@ class TestSidebarRoutes:
"/service/incidents",
"/service/checklist",
"/service/documents/tech-access",
"/service/bot-settings/analytics",
"/service/bot-settings/users",
"/service/bot-settings/conversations",
"/service/blog",
"/service/cases",
"/service/questionnaire-config",
"/service/formulas",
"/service/bot-settings",
"/service/portal-settings",
"/service/role-settings",
"/service/ideas",
-34
View File
@@ -86,42 +86,12 @@ EXPECTED_ROUTES = [
("POST", "/service/cases/create"),
("POST", "/service/cases/edit"),
("POST", "/service/cases/delete"),
("GET", "/service/bot-settings"),
("GET", "/service/bot-settings/responses"),
("GET", "/service/bot-settings/features"),
("GET", "/service/bot-settings/categories"),
("GET", "/service/bot-settings/kb"),
("GET", "/service/bot-settings/conversations"),
("GET", "/service/bot-settings/users"),
("GET", "/service/bot-settings/test-runner"),
("GET", "/service/bot-settings/analytics"),
("GET", "/service/portal-settings"),
("GET", "/service/role-settings"),
("GET", "/service/api/role-permissions"),
("POST", "/service/api/role-permissions/save"),
("POST", "/service/api/role/switch"),
("POST", "/service/api/role/reset"),
("GET", "/service/api/bot/settings"),
("POST", "/service/api/bot/settings/save"),
("GET", "/service/api/bot/templates"),
("POST", "/service/api/bot/templates/save"),
("GET", "/service/api/bot/features"),
("POST", "/service/api/bot/features/toggle"),
("GET", "/service/api/bot/categories"),
("POST", "/service/api/bot/categories/create"),
("POST", "/service/api/bot/categories/edit"),
("POST", "/service/api/bot/categories/delete"),
("GET", "/service/api/bot/kb"),
("POST", "/service/api/bot/kb/create"),
("POST", "/service/api/bot/kb/edit"),
("POST", "/service/api/bot/kb/delete"),
("GET", "/service/api/bot/conversations"),
("GET", "/service/api/bot/users"),
("GET", "/service/api/bot/analytics"),
("POST", "/service/api/bot/test-run"),
("GET", "/service/api/bot/broadcast/recipients"),
("GET", "/service/api/bot/broadcast/history"),
("POST", "/service/api/bot/broadcast"),
]
@@ -159,14 +129,10 @@ class TestRoutesDefined:
"/service/incidents",
"/service/checklist",
"/service/documents/tech-access",
"/service/bot-settings/analytics",
"/service/bot-settings/users",
"/service/bot-settings/conversations",
"/service/blog",
"/service/cases",
"/service/questionnaire-config",
"/service/formulas",
"/service/bot-settings",
"/service/portal-settings",
"/service/role-settings",
"/service/ideas",
-9
View File
@@ -36,15 +36,6 @@ ROUTES_TO_CHECK = [
"/service/formulas",
"/service/coefficients",
"/service/coefficients/test",
"/service/bot-settings",
"/service/bot-settings/responses",
"/service/bot-settings/features",
"/service/bot-settings/categories",
"/service/bot-settings/kb",
"/service/bot-settings/conversations",
"/service/bot-settings/users",
"/service/bot-settings/test-runner",
"/service/bot-settings/analytics",
"/service/portal-settings",
"/service/role-settings",
"/service/quick-menu",
+1 -1
View File
@@ -1 +1 @@
1.5.4
1.7.0