Files
site_aegisone/py_service/app/routers/service_pages.py
T
angel 4c3026a80f
Tests / test (push) Has been cancelled
Tests / test-max-bot (push) Has been cancelled
v1.8.2: AGENTS.md, consent revocation, delete bot users, dialogues fix, intent improvements, CI for max_bot
2026-06-02 18:22:45 +03:00

1442 lines
77 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import math
from datetime import datetime
from collections import defaultdict
from fastapi import APIRouter, Request, Depends, Form, Query
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
from app.models.models import (
User, Customer, Object, ObjectAssignment, SLAContract,
Task, Incident, Report, SHSRecord, EngineerKPI,
FormulaCoefficient, BlogPost, Case, QuestionnaireItem,
QuestionnaireSession, QuestionnaireAnswer, ObjectPassport,
Idea, QuickMenuItem,
)
from app.calculations import engine as calc_engine
FORMULA_GROUPS = {
"risk_score": {"name": "Оценка рисков", "formula": "Риск = no_archive×25 + no_power_backup×20 + no_regulations×15 + system_failures×20 + no_documentation×10", "result": "0100 баллов"},
"risk_multiplier": {"name": "Множитель риска", "formula": "Множитель = f(уровень риска): низкий→1.0, средний→1.3, высокий→1.6, критический→2.0", "result": "1.02.0 ×"},
"complexity": {"name": "Сложность объекта", "formula": "Сложность = video(based on cameras) + access(based on APs) + fire(type) + IT(bool)", "result": "Простой(<30) / Средний(3070) / Сложный(70120) / Enterprise(>120)"},
"infra": {"name": "Нагрузка на инфраструктуру", "formula": "Нагрузка = server + network + power (по состояниям)", "result": "050 баллов"},
"history": {"name": "История обслуживания", "formula": "Присваивается вес: none→max, sla→min", "result": "Зависит от типа"},
"object_index": {"name": "Индекс объекта", "formula": "I = risk×0.4 + complexity×0.3 + infra×0.2 + history×0.1", "result": "0100 → Класс A/B/C/D"},
"engineer": {"name": "Оценка инженера", "formula": "ES = SLA×0.25 + отклик×0.20 + решение×0.20 + диагноз×0.15 + переоткрытия×0.10 + риски×0.10 → grade", "result": "0100 → Junior/Middle/Strong/Senior"},
"ecs": {"name": "Контроль инженера (ECS)", "formula": "ECS = SLA×0.30 + задачи×0.25 + инциденты×0.20 + команда×0.15 + координация×0.10", "result": "0100"},
"shs": {"name": "SHS (Service Health Score)", "formula": "SHS = SLA×0.22 + выручка×0.18 + удержание×0.18 + инженеры×0.15 + инциденты×0.12 + продажи×0.10 + эффективность×0.05", "result": "0100 → Crisis/Risk/Stable/Growth"},
"ceo_shs": {"name": "CEO SHS", "formula": "CEO SHS = MRR×0.25 + SLA×0.20 + удержание×0.20 + продукт-ть×0.15 + конверсия×0.10 + инциденты×0.10", "result": "0100"},
"ssi": {"name": "SLA Stability Index", "formula": "SSI = SLA_compliance × (1 - Breach_Severity)", "result": "0100"},
"isi": {"name": "Incident Stability Index", "formula": "ISI = max(0, min(100, 100 - weighted_rate×20))", "result": "0100"},
"bonus": {"name": "Бонус инженера", "formula": "% премии = f(оценка): ≥90→20%, 8089→10%, <80→0%", "result": "0%20%"},
"retention": {"name": "Удержание клиентов", "formula": "Удержание = base - lost_key×10% (per key client)", "result": "0100%"},
"rules": {"name": "Правила автоматизации", "formula": "Пороги: SHS<70→Crisis, SLA<90%→Warning, Retention<90%→Critical", "result": "Триггеры алертов"},
}
router = APIRouter()
from app.templating import templates
def now_str():
return datetime.now().strftime("%d.%m.%Y %H:%M")
def 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 [],
"page_name": kw.get("active_page", "")}
c.update(kw)
return c
async def load_quick_menu(db: AsyncSession, role: str) -> list:
result = await db.execute(
select(QuickMenuItem).where(QuickMenuItem.role == role).order_by(QuickMenuItem.sort_order, QuickMenuItem.id)
)
return result.scalars().all()
# ===================== USERS =====================
@router.get("/service/users")
async def users_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
result = await db.execute(select(User).order_by(User.role, User.full_name))
return templates.TemplateResponse("pages/users.html", ctx(request, user=user, users=result.scalars().all(), active_page="users", title="Сотрудники"))
@router.post("/service/users/create")
async def users_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
login = form.get("login")
password = form.get("password")
if not login or not password:
return RedirectResponse(url="/service/users", status_code=302)
from app.auth import hash_password
max_id_val = form.get("max_user_id")
new_user = User(
login=login,
password_hash=hash_password(password),
full_name=form.get("full_name"), role=form.get("role", "technician"),
phone=form.get("phone", ""), email=form.get("email", ""),
max_user_id=int(max_id_val) if max_id_val and max_id_val.strip() else None,
is_active=form.get("is_active", "1") == "1",
)
db.add(new_user); await db.flush()
await audit_log(db, user["id"], "user_create", f"Создан пользователь {login}", request.client.host or "")
return RedirectResponse(url="/service/users", status_code=302)
@router.post("/service/users/edit")
async def users_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
user_id = form.get("id")
if not user_id:
return RedirectResponse(url="/service/users", status_code=302)
try:
u = await db.get(User, int(user_id))
except (TypeError, ValueError):
return RedirectResponse(url="/service/users", status_code=302)
if not u: return RedirectResponse(url="/service/users", status_code=302)
u.full_name = form.get("full_name", u.full_name)
u.role = form.get("role", u.role)
u.phone = form.get("phone", u.phone)
u.email = form.get("email", u.email)
max_id_val = form.get("max_user_id")
u.max_user_id = int(max_id_val) if max_id_val and max_id_val.strip() else None
if "is_active" in form:
u.is_active = form["is_active"] == "1"
from app.auth import hash_password
pwd = form.get("password", "")
if pwd: u.password_hash = hash_password(pwd)
await db.flush()
await audit_log(db, user["id"], "user_edit", f"Изменён пользователь {u.login}", request.client.host or "")
return RedirectResponse(url="/service/users", status_code=302)
@router.post("/service/users/delete")
async def users_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
user_id = form.get("id")
if not user_id:
return RedirectResponse(url="/service/users", status_code=302)
try:
u = await db.get(User, int(user_id))
except (TypeError, ValueError):
return RedirectResponse(url="/service/users", status_code=302)
if u and u.role != "owner": await db.delete(u); await db.flush()
return RedirectResponse(url="/service/users", status_code=302)
# ===================== CUSTOMERS =====================
@router.get("/service/customers")
async def customers_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer")), search: str = Query(""), error: str = Query("")):
sql = select(Customer)
if search: sql = sql.where(or_(Customer.name.ilike(f"%{search}%"), Customer.inn.ilike(f"%{search}%")))
result = await db.execute(sql.order_by(Customer.name))
customers = result.scalars().all()
data = []
for c in customers:
oc = (await db.execute(select(func.count(Object.id)).where(Object.customer_id == c.id))).scalar() or 0
sc = (await db.execute(select(func.count(SLAContract.id)).where(and_(SLAContract.customer_id == c.id, SLAContract.status == "active")))).scalar() or 0
data.append({"c": c, "objects_count": oc, "active_contracts": sc})
err_msg = {"inn_invalid": "ИНН должен содержать 10 или 12 цифр"}.get(error, "")
return templates.TemplateResponse("pages/customers.html", ctx(request, user=user, customers=data, search=search, active_page="customers", title="Клиенты", error=err_msg))
@router.post("/service/customers/create")
async def customers_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
form = await request.form()
inn = form.get("inn", "").strip()
if not inn or not inn.isdigit() or len(inn) not in (10, 12):
return RedirectResponse(url="/service/customers?error=inn_invalid", status_code=302)
c = Customer(name=form.get("name"), inn=inn, kpp=form.get("kpp",""), legal_address=form.get("legal_address",""),
contact_person=form.get("contact_person",""), contact_phone=form.get("contact_phone",""), contact_email=form.get("contact_email",""),
status=form.get("status","active"), notes=form.get("notes",""), created_by=user["id"])
db.add(c); await db.flush()
return RedirectResponse(url="/service/customers", status_code=302)
@router.post("/service/customers/edit")
async def customers_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
form = await request.form()
c = await db.get(Customer, int(form.get("id")))
if not c: return RedirectResponse(url="/service/customers", status_code=302)
for f in ("name","inn","kpp","legal_address","contact_person","contact_phone","contact_email","status","notes"):
if form.get(f) is not None: setattr(c, f, form.get(f))
await db.flush()
return RedirectResponse(url="/service/customers", status_code=302)
@router.post("/service/customers/delete")
async def customers_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
c = await db.get(Customer, int((await request.form()).get("id")))
if c: await db.delete(c); await db.flush()
return RedirectResponse(url="/service/customers", status_code=302)
# ===================== OBJECTS =====================
@router.get("/service/objects")
async def objects_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer")), search: str = Query(""), customer_id: int = Query(None)):
sql = select(Object).options(joinedload(Object.customer))
if search: sql = sql.where(or_(Object.name.ilike(f"%{search}%"), Object.address.ilike(f"%{search}%")))
if customer_id: sql = sql.where(Object.customer_id == customer_id)
result = await db.execute(sql.order_by(Object.name))
objects = result.unique().scalars().all()
cust_res = await db.execute(select(Customer).where(Customer.status == "active").order_by(Customer.name))
customers = cust_res.scalars().all()
return templates.TemplateResponse("pages/objects.html", ctx(request, user=user, objects=objects, customers_list=customers, search=search, customer_id=customer_id, active_page="objects", title="Объекты"))
@router.post("/service/objects/create")
async def objects_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
form = await request.form()
o = Object(name=form.get("name"), customer_id=int(form.get("customer_id")) if form.get("customer_id") else None,
object_type=form.get("object_type",""), address=form.get("address",""), contact_person=form.get("contact_person",""),
contact_phone=form.get("contact_phone",""), status=form.get("status","active"), notes=form.get("notes",""), created_by=user["id"])
db.add(o); await db.flush()
return RedirectResponse(url="/service/objects", status_code=302)
@router.post("/service/objects/edit")
async def objects_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
form = await request.form()
try:
oid = int(form.get("id"))
except (ValueError, TypeError):
return RedirectResponse(url="/service/objects", status_code=302)
o = await db.get(Object, oid)
if not o: return RedirectResponse(url="/service/objects", status_code=302)
for f in ("name","customer_id","object_type","address","contact_person","contact_phone","status","notes"):
val = form.get(f)
if val is not None:
if f == "customer_id":
try:
val = int(val) if val else None
except (ValueError, TypeError):
val = None
setattr(o, f, val)
await db.flush()
return RedirectResponse(url="/service/objects", status_code=302)
@router.post("/service/objects/delete")
async def objects_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
o = await db.get(Object, int((await request.form()).get("id")))
if o: await db.delete(o); await db.flush()
return RedirectResponse(url="/service/objects", status_code=302)
# ===================== ASSIGNMENTS =====================
@router.get("/service/assignments")
async def assignments_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
objects = (await db.execute(select(Object).where(Object.status == "active").order_by(Object.name))).scalars().all()
users = (await db.execute(select(User).where(and_(User.is_active, User.role.in_(["engineer","technician"]))).order_by(User.full_name))).scalars().all()
result = await db.execute(
select(ObjectAssignment).options(
joinedload(ObjectAssignment.object),
joinedload(ObjectAssignment.user)
).where(ObjectAssignment.unassigned_at.is_(None)).order_by(ObjectAssignment.assigned_at.desc())
)
assignments = result.unique().scalars().all()
return templates.TemplateResponse("pages/assignments.html", ctx(request, user=user, objects=objects, users=users, assignments=assignments, active_page="assignments", title="Назначение сотрудников"))
@router.post("/service/assignments/create")
async def assignments_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
u = await db.get(User, int(form.get("user_id")))
a = ObjectAssignment(object_id=int(form.get("object_id")), user_id=int(form.get("user_id")), role=u.role if u else "technician")
db.add(a); await db.flush()
return RedirectResponse(url="/service/assignments", status_code=302)
@router.post("/service/assignments/unassign")
async def assignments_unassign(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
a = await db.get(ObjectAssignment, int((await request.form()).get("id")))
if a: a.unassigned_at = datetime.now(); await db.flush()
return RedirectResponse(url="/service/assignments", status_code=302)
# ===================== SLA =====================
@router.get("/service/sla")
async def sla_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer")), object_id: int = Query(None)):
sql = select(SLAContract).options(joinedload(SLAContract.object), joinedload(SLAContract.customer))
if object_id: sql = sql.where(SLAContract.object_id == object_id)
result = await db.execute(sql.order_by(SLAContract.created_at.desc()))
contracts = result.unique().scalars().all()
objects = (await db.execute(select(Object).where(Object.status.in_(["active","audit"])).order_by(Object.name))).scalars().all()
return templates.TemplateResponse("pages/sla.html", ctx(request, user=user, contracts=contracts, objects=objects, sel_object_id=object_id, active_page="sla", title="SLA контракты"))
@router.post("/service/sla/create")
async def sla_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
form = await request.form()
try:
oid = int(form.get("object_id")) if form.get("object_id") else None
except (ValueError, TypeError):
oid = None
if not oid:
return RedirectResponse(url="/service/sla", status_code=302)
try:
price = float(form.get("sla_price_monthly", 0)) if form.get("sla_price_monthly") else 0
except (ValueError, TypeError):
price = 0
try:
start = datetime.strptime(form.get("start_date"), "%Y-%m-%d").date() if form.get("start_date") else None
except (ValueError, TypeError):
start = None
try:
end = datetime.strptime(form.get("end_date"), "%Y-%m-%d").date() if form.get("end_date") else None
except (ValueError, TypeError):
end = None
sc = SLAContract(object_id=oid, service_level=form.get("service_level", "start"),
sla_price_monthly=price, start_date=start, end_date=end, status=form.get("status", "negotiation"))
db.add(sc); await db.flush()
return RedirectResponse(url="/service/sla", status_code=302)
@router.post("/service/sla/edit")
async def sla_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
form = await request.form()
try:
sc_id = int(form.get("id"))
except (ValueError, TypeError):
return RedirectResponse(url="/service/sla", status_code=302)
sc = await db.get(SLAContract, sc_id)
if not sc: return RedirectResponse(url="/service/sla", status_code=302)
for f in ("object_id","service_level","sla_price_monthly","start_date","end_date","status"):
val = form.get(f)
if val is not None:
if f == "object_id":
try:
val = int(val) if val else None
except (ValueError, TypeError):
val = None
elif f == "sla_price_monthly":
try:
val = float(val) if val else 0
except (ValueError, TypeError):
val = 0
elif f in ("start_date","end_date"):
try:
val = datetime.strptime(val,"%Y-%m-%d").date() if val else None
except (ValueError, TypeError):
val = None
setattr(sc, f, val)
await db.flush()
return RedirectResponse(url="/service/sla", status_code=302)
# ===================== CEO Dashboard =====================
@router.get("/service/ceo")
async def ceo_dashboard(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
shs_history = (await db.execute(select(SHSRecord).order_by(SHSRecord.id.desc()).limit(12))).scalars().all()
latest_shs = shs_history[0] if shs_history else None
mrr = float((await db.execute(select(func.sum(SLAContract.sla_price_monthly)).where(SLAContract.status == "active"))).scalar() or 0)
active_objects = (await db.execute(select(func.count(Object.id)).where(Object.status == "active"))).scalar() or 0
active_contracts = (await db.execute(select(func.count(SLAContract.id)).where(SLAContract.status == "active"))).scalar() or 0
active_engineers = (await db.execute(select(func.count(User.id)).where(and_(User.is_active, User.role.in_(["engineer","technician"]))))).scalar() or 0
compl_res = await db.execute(select(func.count(Task.id), func.sum(Task.sla_compliant.cast(Integer))).where(Task.closed_at != None))
total_c, compliant_c = compl_res.first() or (0,0)
sla_comp = round((compliant_c / total_c) * 100, 1) if total_c > 0 else 100
incidents_30 = (await db.execute(select(func.count(Incident.id)).where(Incident.created_at > datetime.now().replace(day=1)))).scalar() or 0
kpi_data = (await db.execute(
select(EngineerKPI, User.full_name).join(User, EngineerKPI.engineer_id == User.id).order_by(EngineerKPI.period_end.desc()).limit(10)
)).all()
avg_es = (await db.execute(select(func.avg(EngineerKPI.engineer_score)).where(EngineerKPI.period_end > datetime.now().replace(day=1)))).scalar() or 0
return templates.TemplateResponse("pages/ceo.html", ctx(request, user=user, shs_history=shs_history, latest_shs=latest_shs, mrr=mrr,
active_objects=active_objects, active_contracts=active_contracts, active_engineers=active_engineers,
sla_compliance=sla_comp, incidents_30=incidents_30, kpi_data=kpi_data, avg_es=round(float(avg_es),2),
active_page="ceo", title="CEO дашборд"))
# ===================== COEFFICIENTS =====================
@router.get("/service/coefficients")
async def coefficients_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
coeffs = (await db.execute(select(FormulaCoefficient).order_by(FormulaCoefficient.key))).scalars().all()
groups = defaultdict(list)
for c in coeffs:
prefix = c.formula_ref.split(".")[0] if c.formula_ref else "_other"
groups[prefix].append(c)
grouped = {}
for prefix in sorted(groups.keys()):
info = FORMULA_GROUPS.get(prefix, {"name": prefix, "formula": "", "result": ""})
grouped[prefix] = {"info": info, "coeffs": groups[prefix]}
return templates.TemplateResponse("pages/coefficients.html", ctx(request, user=user, groups=grouped, active_page="coefficients", title="Коэффициенты"))
@router.post("/service/coefficients/edit")
async def coefficients_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
c = await db.get(FormulaCoefficient, int(form.get("id")))
if c:
try: c.value = float(form.get("value"))
except: c.value = form.get("value")
c.updated_by = user["id"]; await db.flush()
return RedirectResponse(url="/service/coefficients", status_code=302)
@router.post("/service/coefficients/toggle")
async def coefficients_toggle(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
c = await db.get(FormulaCoefficient, int((await request.form()).get("id")))
if c: c.is_active = not c.is_active; c.updated_by = user["id"]; await db.flush()
return RedirectResponse(url="/service/coefficients", status_code=302)
@router.post("/service/coefficients/create")
async def coefficients_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
db.add(FormulaCoefficient(key=form.get("key"), value=float(form.get("value")), description=form.get("description",""),
formula_ref=form.get("formula_ref",""), updated_by=user["id"]))
await db.flush()
return RedirectResponse(url="/service/coefficients", status_code=302)
@router.post("/service/coefficients/delete")
async def coefficients_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
c = await db.get(FormulaCoefficient, int((await request.form()).get("id")))
if c: await db.delete(c); await db.flush()
return RedirectResponse(url="/service/coefficients", status_code=302)
# ===================== FORMULA TEST =====================
FORMULA_SANDBOX = {
"risk_score": {
"name": "Оценка рисков",
"inputs": [
{"key": "no_archive", "label": "Нет архивации", "type": "checkbox"},
{"key": "no_power_backup", "label": "Нет резервного питания", "type": "checkbox"},
{"key": "no_regulations", "label": "Нет регламентов", "type": "checkbox"},
{"key": "system_failures", "label": "Отказы системы", "type": "checkbox"},
{"key": "no_documentation", "label": "Нет документации", "type": "checkbox"},
],
},
"complexity": {
"name": "Сложность объекта",
"inputs": [
{"key": "cameras", "label": "Кол-во камер", "type": "number", "default": 20},
{"key": "access_points", "label": "Кол-во СКУД точек", "type": "number", "default": 5},
{"key": "fire_type", "label": "Тип пожарной системы", "type": "select", "options": {"none": "Нет", "simple": "Простая", "medium": "Средняя", "complex": "Комплексная"}},
{"key": "has_it", "label": "Есть IT-инфраструктура", "type": "checkbox"},
],
},
"object_index": {
"name": "Индекс объекта",
"inputs": [
{"key": "risk_score", "label": "Риск (0100)", "type": "number", "default": 50},
{"key": "complexity_score", "label": "Сложность (0150)", "type": "number", "default": 50},
{"key": "infra_score", "label": "Инфраструктура (0–50)", "type": "number", "default": 10},
{"key": "history_score", "label": "История (030)", "type": "number", "default": 15},
],
},
"engineer": {
"name": "Оценка инженера (Engineer Score)",
"inputs": [
{"key": "sla_compliance", "label": "SLA Compliance", "type": "number", "default": 90},
{"key": "response_time_score", "label": "Response Time Score", "type": "number", "default": 80},
{"key": "resolution_time_score", "label": "Resolution Time Score", "type": "number", "default": 80},
{"key": "diagnosis_accuracy", "label": "Diagnosis Accuracy", "type": "number", "default": 85},
{"key": "reopen_rate_score", "label": "Reopen Rate Score", "type": "number", "default": 90},
{"key": "risk_coverage_score", "label": "Risk Coverage Score", "type": "number", "default": 75},
],
},
"ecs": {
"name": "Контроль инженера (ECS)",
"inputs": [
{"key": "sla_control", "label": "SLA Control", "type": "number", "default": 85},
{"key": "task_dist", "label": "Task Distribution", "type": "number", "default": 80},
{"key": "incident_red", "label": "Incident Reduction", "type": "number", "default": 70},
{"key": "team_perf", "label": "Team Performance", "type": "number", "default": 75},
{"key": "response_coord", "label": "Response Coordination", "type": "number", "default": 80},
],
},
"shs": {
"name": "Service Health Score (SHS)",
"inputs": [
{"key": "sla_stability", "label": "SLA Stability", "type": "number", "default": 85},
{"key": "revenue_stability", "label": "Revenue Stability", "type": "number", "default": 80},
{"key": "retention", "label": "Retention", "type": "number", "default": 90},
{"key": "engineer_performance", "label": "Engineer Performance", "type": "number", "default": 75},
{"key": "incident_stability", "label": "Incident Stability", "type": "number", "default": 80},
{"key": "sales_flow", "label": "Sales Flow", "type": "number", "default": 70},
{"key": "operational_efficiency", "label": "Operational Efficiency", "type": "number", "default": 75},
],
},
"ceo_shs": {
"name": "CEO SHS",
"inputs": [
{"key": "mrr_growth", "label": "MRR Growth", "type": "number", "default": 80},
{"key": "sla_compliance", "label": "SLA Compliance", "type": "number", "default": 90},
{"key": "retention", "label": "Retention", "type": "number", "default": 85},
{"key": "productivity", "label": "Productivity", "type": "number", "default": 75},
{"key": "conversion", "label": "Conversion", "type": "number", "default": 70},
{"key": "incident_stability", "label": "Incident Stability", "type": "number", "default": 80},
],
},
}
@router.get("/service/coefficients/test")
async def formula_test_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")), group: str = Query("")):
coeffs = (await db.execute(select(FormulaCoefficient).order_by(FormulaCoefficient.key))).scalars().all()
groups = defaultdict(list)
for c in coeffs:
prefix = c.formula_ref.split(".")[0] if c.formula_ref else "_other"
groups[prefix].append(c)
formula_list = []
for prefix in sorted(groups.keys()):
info = FORMULA_GROUPS.get(prefix, {"name": prefix, "formula": "", "result": ""})
sb = FORMULA_SANDBOX.get(prefix)
formula_list.append({
"key": prefix,
"name": info["name"],
"formula": info["formula"],
"result": info["result"],
"sandbox": sb,
"coeffs": groups[prefix],
})
selected = group if group else (formula_list[0]["key"] if formula_list else "")
return templates.TemplateResponse("pages/formula_test.html", ctx(request, user=user, formulas=formula_list, selected=selected, active_page="coefficients", title="Тест формул"))
@router.get("/service/formulas")
async def formulas_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
coeffs = (await db.execute(select(FormulaCoefficient).order_by(FormulaCoefficient.key))).scalars().all()
groups = defaultdict(list)
for c in coeffs:
prefix = c.formula_ref.split(".")[0] if c.formula_ref else "_other"
groups[prefix].append(c)
grouped = {}
for prefix in sorted(groups.keys()):
info = FORMULA_GROUPS.get(prefix, {"name": prefix, "formula": "", "result": ""})
grouped[prefix] = {"info": info, "coeffs": groups[prefix]}
formula_list = []
for prefix in sorted(groups.keys()):
info = FORMULA_GROUPS.get(prefix, {"name": prefix, "formula": "", "result": ""})
sb = FORMULA_SANDBOX.get(prefix)
formula_list.append({
"key": prefix,
"name": info["name"],
"formula": info["formula"],
"result": info["result"],
"sandbox": sb,
"coeffs": groups[prefix],
})
selected = formula_list[0]["key"] if formula_list else ""
return templates.TemplateResponse("pages/formulas.html", ctx(request, user=user, formulas=formula_list, selected=selected, groups=grouped, active_page="formulas", title="Формулы"))
@router.post("/service/coefficients/test/{formula_name}")
async def formula_test_calc(request: Request, formula_name: str, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
try:
if formula_name == "risk_score":
result = await calc_engine.calc_risk_score(
db,
no_archive=form.get("no_archive") == "1",
no_power_backup=form.get("no_power_backup") == "1",
no_regulations=form.get("no_regulations") == "1",
system_failures=form.get("system_failures") == "1",
no_documentation=form.get("no_documentation") == "1",
)
label = await calc_engine.risk_label(db, result)
return JSONResponse({"result": f"{result:.1f} баллов — {label}"})
elif formula_name == "complexity":
result = await calc_engine.calc_complexity(
db,
cameras=int(form.get("cameras", 0)),
access_points=int(form.get("access_points", 0)),
fire_type=form.get("fire_type", "none"),
has_it=form.get("has_it") == "1",
)
label = calc_engine.complexity_label(result)
return JSONResponse({"result": f"{result:.1f} баллов — {label}"})
elif formula_name == "object_index":
# These are pre-calculated scores, not the same as coefficient-based.
# We use the weighted formula directly.
risk = float(form.get("risk_score", 50))
complexity = float(form.get("complexity_score", 50))
infra = float(form.get("infra_score", 10))
history = float(form.get("history_score", 15))
result = await calc_engine.calc_object_index(db, risk, complexity, infra, history)
cls = calc_engine.object_class(result)
label = calc_engine.object_class_label(result)
return JSONResponse({"result": f"Индекс: {result} — Класс {cls} ({label})"})
elif formula_name == "engineer":
result = await calc_engine.calc_engineer_score(
db,
sla_compliance=float(form.get("sla_compliance", 90)),
response_time_score=float(form.get("response_time_score", 80)),
resolution_time_score=float(form.get("resolution_time_score", 80)),
diagnosis_accuracy=float(form.get("diagnosis_accuracy", 85)),
reopen_rate_score=float(form.get("reopen_rate_score", 90)),
risk_coverage_score=float(form.get("risk_coverage_score", 75)),
)
grade = await calc_engine.engineer_grade(db, result)
return JSONResponse({"result": f"Engineer Score: {result}{grade}"})
elif formula_name == "ecs":
result = await calc_engine.calc_engineer_control_score(
db,
sla_control=float(form.get("sla_control", 85)),
task_dist=float(form.get("task_dist", 80)),
incident_red=float(form.get("incident_red", 70)),
team_perf=float(form.get("team_perf", 75)),
response_coord=float(form.get("response_coord", 80)),
)
return JSONResponse({"result": f"ECS: {result} баллов"})
elif formula_name == "shs":
result = await calc_engine.calc_shs(
db,
sla_stability=float(form.get("sla_stability", 85)),
revenue_stability=float(form.get("revenue_stability", 80)),
retention=float(form.get("retention", 90)),
engineer_performance=float(form.get("engineer_performance", 75)),
incident_stability=float(form.get("incident_stability", 80)),
sales_flow=float(form.get("sales_flow", 70)),
operational_efficiency=float(form.get("operational_efficiency", 75)),
)
zone = await calc_engine.shs_zone(db, result)
return JSONResponse({"result": f"SHS: {result}{zone['label']} ({zone['zone']})"})
elif formula_name == "ceo_shs":
result = await calc_engine.calc_ceo_shs(
db,
mrr_growth=float(form.get("mrr_growth", 80)),
sla_compliance=float(form.get("sla_compliance", 90)),
retention=float(form.get("retention", 85)),
productivity=float(form.get("productivity", 75)),
conversion=float(form.get("conversion", 70)),
incident_stability=float(form.get("incident_stability", 80)),
)
return JSONResponse({"result": f"CEO SHS: {result} баллов"})
return JSONResponse({"result": "Формула не поддерживается в песочнице"})
except Exception as e:
return JSONResponse({"result": f"Ошибка: {str(e)}"})
# ===================== TASKS =====================
@router.get("/service/tasks")
async def tasks_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer", "technician")), status_filter: str = Query(""), search: str = Query("")):
sql = select(Task).options(joinedload(Task.object), joinedload(Task.assignee))
if user["role"] == "technician": sql = sql.where(Task.assigned_to == user["id"])
if status_filter: sql = sql.where(Task.status == status_filter)
if search: sql = sql.where(or_(Task.title.ilike(f"%{search}%")))
result = await db.execute(sql.order_by(Task.created_at.desc()))
tasks = result.unique().scalars().all()
objects = (await db.execute(select(Object).where(Object.status == "active").order_by(Object.name))).scalars().all()
users = (await db.execute(select(User).where(and_(User.is_active, User.role.in_(["engineer","technician"]))).order_by(User.full_name))).scalars().all()
return templates.TemplateResponse("pages/tasks.html", ctx(request, user=user, tasks=tasks, objects=objects, users=users, status_filter=status_filter, search=search, active_page="tasks", title="Задачи"))
@router.post("/service/tasks/create")
async def tasks_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
form = await request.form()
db.add(Task(title=form.get("title"), description=form.get("description",""), object_id=int(form.get("object_id")),
assigned_to=int(form.get("assigned_to")) if form.get("assigned_to") else None,
priority=form.get("priority","P3"),
deadline=datetime.strptime(form.get("deadline"),"%Y-%m-%d").date() if form.get("deadline") else None,
created_by=user["id"]))
await db.flush()
return RedirectResponse(url="/service/tasks", status_code=302)
@router.post("/service/tasks/close")
async def tasks_close(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer", "technician"))):
t = await db.get(Task, int((await request.form()).get("id")))
if t and (user["role"] in ("owner","engineer") or t.assigned_to == user["id"]):
t.status = "completed"; t.closed_at = datetime.now(); await db.flush()
return RedirectResponse(url="/service/tasks", status_code=302)
# ===================== REPORTS =====================
@router.get("/service/reports")
async def reports_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer", "technician")), type_filter: str = Query("")):
sql = select(Report).options(joinedload(Report.object))
if user["role"] in ("engineer","technician"): sql = sql.where(Report.created_by == user["id"])
if type_filter: sql = sql.where(Report.report_type == type_filter)
result = await db.execute(sql.order_by(Report.created_at.desc()))
reports = result.unique().scalars().all()
objects = (await db.execute(select(Object).where(Object.status.in_(["active","audit"])).order_by(Object.name))).scalars().all()
return templates.TemplateResponse("pages/reports.html", ctx(request, user=user, reports=reports, objects=objects, type_filter=type_filter, active_page="reports", title="Отчёты"))
@router.post("/service/reports/create")
async def reports_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer", "technician"))):
form = await request.form()
db.add(Report(object_id=int(form.get("object_id")), report_type=form.get("report_type","inspection"), title=form.get("title"),
description=form.get("description",""), findings=form.get("findings",""), recommendations=form.get("recommendations",""),
work_done=form.get("work_done",""), cause_classification=form.get("cause_classification",""),
status=form.get("status","draft"), result_status=form.get("result_status","ok"), created_by=user["id"]))
await db.flush()
return RedirectResponse(url="/service/reports", status_code=302)
# ===================== INCIDENTS =====================
@router.get("/service/incidents")
async def incidents_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer", "technician")), severity: str = Query(""), status_filter: str = Query("")):
sql = select(Incident).options(joinedload(Incident.object), joinedload(Incident.assignee))
if user["role"] == "technician": sql = sql.where(Incident.assigned_to == user["id"])
if severity: sql = sql.where(Incident.severity == severity)
if status_filter: sql = sql.where(Incident.status == status_filter)
result = await db.execute(sql.order_by(Incident.created_at.desc()))
incidents = result.unique().scalars().all()
objects = (await db.execute(select(Object).where(Object.status == "active").order_by(Object.name))).scalars().all()
users = (await db.execute(select(User).where(User.is_active).order_by(User.full_name))).scalars().all()
return templates.TemplateResponse("pages/incidents.html", ctx(request, user=user, incidents=incidents, objects=objects, users=users, severity=severity, status_filter=status_filter, active_page="incidents", title="Инциденты"))
@router.post("/service/incidents/create")
async def incidents_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
form = await request.form()
db.add(Incident(title=form.get("title"), description=form.get("description",""), object_id=int(form.get("object_id")),
assigned_to=int(form.get("assigned_to")) if form.get("assigned_to") else None,
severity=form.get("severity","P3"), reported_by=user["id"]))
await db.flush()
return RedirectResponse(url="/service/incidents", status_code=302)
@router.post("/service/incidents/resolve")
async def incidents_resolve(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer", "technician"))):
form = await request.form()
i = await db.get(Incident, int(form.get("id")))
if i: i.status = "resolved"; i.resolution = form.get("resolution",""); i.resolved_at = datetime.now(); await db.flush()
return RedirectResponse(url="/service/incidents", status_code=302)
# ===================== QUESTIONNAIRE =====================
@router.get("/service/questionnaire")
async def questionnaire_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
result = await db.execute(
select(QuestionnaireSession, User.full_name).join(User, QuestionnaireSession.created_by == User.id).order_by(QuestionnaireSession.created_at.desc()).limit(20)
)
return templates.TemplateResponse("pages/questionnaire.html", ctx(request, user=user, sessions=result.all(), active_page="questionnaire", title="Опросник"))
@router.get("/service/questionnaire/step/{step}")
async def questionnaire_step(request: Request, step: int, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer")), session_id: int = Query(None)):
items = (await db.execute(
select(QuestionnaireItem).where(and_(QuestionnaireItem.is_active, QuestionnaireItem.step == step)).order_by(QuestionnaireItem.sort_order, QuestionnaireItem.id)
)).scalars().all()
return templates.TemplateResponse("pages/questionnaire_step.html", ctx(request, user=user, items=items, step=step, session_id=session_id, active_page="questionnaire", title=f"Опросник — шаг {step}"))
@router.post("/service/questionnaire/save/{step}")
async def questionnaire_save(step: int, request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
form = await request.form()
session_id = form.get("session_id")
if session_id:
sess = await db.get(QuestionnaireSession, int(session_id))
else:
sess = QuestionnaireSession(created_by=user["id"], status="draft", current_step=step)
db.add(sess); await db.flush()
sess.current_step = step
items = (await db.execute(
select(QuestionnaireItem).where(and_(QuestionnaireItem.is_active, QuestionnaireItem.step == step))
)).scalars().all()
for item in items:
val = form.get(item.question_key)
if val is not None:
existing = (await db.execute(
select(QuestionnaireAnswer).where(and_(
QuestionnaireAnswer.session_id == sess.id,
QuestionnaireAnswer.question_key == item.question_key
))
)).scalar_one_or_none()
if existing:
existing.answer_value = val
else:
db.add(QuestionnaireAnswer(session_id=sess.id, step=step, question_key=item.question_key, answer_value=val))
await db.flush()
next_step = step + 1
if step >= 5 or form.get("action") == "complete":
sess.status = "completed"
await db.flush()
return RedirectResponse(url="/service/questionnaire", status_code=302)
return RedirectResponse(url=f"/service/questionnaire/step/{next_step}", status_code=302)
# ===================== QUESTIONNAIRE CONFIG =====================
@router.get("/service/questionnaire-config")
async def questionnaire_config(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")), step: int = Query(None)):
sql = select(QuestionnaireItem)
if step: sql = sql.where(QuestionnaireItem.step == step)
items = (await db.execute(sql.order_by(QuestionnaireItem.step, QuestionnaireItem.sort_order, QuestionnaireItem.id))).scalars().all()
return templates.TemplateResponse("pages/questionnaire_config.html", ctx(request, user=user, items=items, sel_step=step, active_page="questionnaire-config", title="Настройка опросника"))
@router.post("/service/questionnaire-config/create")
async def questionnaire_config_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
db.add(QuestionnaireItem(step=int(form.get("step",1)), section=form.get("section",""), question_key=form.get("question_key"),
label=form.get("label"), type=form.get("type","text"), options="{}", required=form.get("required","0")=="1",
sort_order=int(form.get("sort_order",0)), help_text=form.get("help_text","")))
await db.flush()
return RedirectResponse(url="/service/questionnaire-config", status_code=302)
@router.post("/service/questionnaire-config/edit")
async def questionnaire_config_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
qi = await db.get(QuestionnaireItem, int(form.get("id")))
if qi:
for f in ("step","section","question_key","label","type","options","required","sort_order","help_text"):
val = form.get(f)
if val is not None:
if f in ("step","sort_order"): val = int(val)
elif f == "required": val = val == "1"
setattr(qi, f, val)
await db.flush()
return RedirectResponse(url="/service/questionnaire-config", status_code=302)
@router.post("/service/questionnaire-config/toggle")
async def questionnaire_config_toggle(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
qi = await db.get(QuestionnaireItem, int((await request.form()).get("id")))
if qi: qi.is_active = not qi.is_active; await db.flush()
return RedirectResponse(url="/service/questionnaire-config", status_code=302)
@router.post("/service/questionnaire-config/delete")
async def questionnaire_config_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
qi = await db.get(QuestionnaireItem, int((await request.form()).get("id")))
if qi: await db.delete(qi); await db.flush()
return RedirectResponse(url="/service/questionnaire-config", status_code=302)
# ===================== PASSPORTS =====================
@router.get("/service/passports")
async def passports_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner", "engineer"))):
result = await db.execute(
select(ObjectPassport).options(
joinedload(ObjectPassport.object),
joinedload(ObjectPassport.creator)
).order_by(ObjectPassport.created_at.desc())
)
return templates.TemplateResponse("pages/passports.html", ctx(request, user=user, passports=result.unique().scalars().all(), active_page="passports", title="Паспорта объектов"))
# ===================== CHECKLIST =====================
@router.get("/service/checklist")
async def checklist_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("technician", "owner"))):
result = await db.execute(
select(Task).options(joinedload(Task.object)).where(
and_(Task.assigned_to == user["id"], Task.status.in_(["open","in_progress"]))
).order_by(Task.created_at.desc())
)
return templates.TemplateResponse("pages/checklist.html", ctx(request, user=user, tasks=result.unique().scalars().all(), active_page="checklist", title="Чек-лист"))
@router.post("/service/checklist/complete")
async def checklist_complete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("technician", "owner"))):
form = await request.form()
t = await db.get(Task, int(form.get("task_id")))
if t and t.assigned_to == user["id"]:
checks = {k: form.get(k) == "1" for k in ("arrival_fixed","photo_before","power_check","cameras_check","acs_check","fire_check","fault_found","fault_fixed","system_test","photo_after")}
t.status = "completed"; t.closed_at = datetime.now(); t.checklist_data = checks; t.result_notes = form.get("notes","")
await db.flush()
return RedirectResponse(url="/service/checklist", status_code=302)
# ===================== BLOG =====================
@router.get("/service/blog")
async def blog_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
result = await db.execute(select(BlogPost, User.full_name).join(User, BlogPost.author_id == User.id).order_by(BlogPost.created_at.desc()))
return templates.TemplateResponse("pages/blog.html", ctx(request, user=user, posts=result.all(), active_page="blog", title="Управление блогом"))
@router.post("/service/blog/create")
async def blog_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
db.add(BlogPost(title=form.get("title"), slug=form.get("slug"), category=form.get("category",""),
status=form.get("status","draft"), excerpt=form.get("excerpt",""), content=form.get("content",""), author_id=user["id"]))
await db.flush()
return RedirectResponse(url="/service/blog", status_code=302)
@router.post("/service/blog/edit")
async def blog_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
bp = await db.get(BlogPost, int(form.get("id")))
if bp:
for f in ("title","slug","category","status","excerpt","content"):
if form.get(f) is not None: setattr(bp, f, form.get(f))
if form.get("status") == "published" and not bp.published_at: bp.published_at = datetime.now()
await db.flush()
return RedirectResponse(url="/service/blog", status_code=302)
@router.post("/service/blog/delete")
async def blog_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
bp = await db.get(BlogPost, int((await request.form()).get("id")))
if bp: await db.delete(bp); await db.flush()
return RedirectResponse(url="/service/blog", status_code=302)
# ===================== QUICK MENU =====================
@router.get("/service/api/quick-menu")
async def quick_menu_api(request: Request, db: AsyncSession = Depends(get_db)):
user = request.session.get("user")
if not user:
return JSONResponse([])
items = await load_quick_menu(db, user["role"])
return JSONResponse([{"id": i.id, "label": i.label, "url": i.url} for i in items])
@router.get("/service/quick-menu")
async def quick_menu_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
if user["role"] not in ("owner", "engineer"):
return HTMLResponse("Доступ запрещён", status_code=403)
items = await load_quick_menu(db, user["role"])
return templates.TemplateResponse("pages/quickmenu.html", ctx(request, user=user, items=items, active_page="quickmenu", title="Быстрое меню"))
@router.post("/service/quick-menu/save")
async def quick_menu_save(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
if user["role"] not in ("owner", "engineer"):
return HTMLResponse("Доступ запрещён", status_code=403)
form = await request.form()
await db.execute(delete(QuickMenuItem).where(QuickMenuItem.role == user["role"], QuickMenuItem.created_by == user["id"]))
labels = form.getlist("label[]")
urls = form.getlist("url[]")
for i, (label, url) in enumerate(zip(labels, urls)):
if label.strip() and url.strip():
db.add(QuickMenuItem(role=user["role"], label=label.strip(), url=url.strip(), sort_order=i, created_by=user["id"]))
await db.flush()
return RedirectResponse(url="/service/quick-menu", status_code=302)
@router.get("/service/quick-menu/tech")
async def quick_menu_tech_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("engineer"))):
items = await load_quick_menu(db, "technician")
return templates.TemplateResponse("pages/quickmenu.html", ctx(request, user=user, items=items, editing_tech=True, active_page="quickmenu-tech", title="Меню для техников"))
@router.post("/service/quick-menu/tech-save")
async def quick_menu_tech_save(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("engineer"))):
form = await request.form()
await db.execute(delete(QuickMenuItem).where(QuickMenuItem.role == "technician"))
labels = form.getlist("label[]")
urls = form.getlist("url[]")
for i, (label, url) in enumerate(zip(labels, urls)):
if label.strip() and url.strip():
db.add(QuickMenuItem(role="technician", label=label.strip(), url=url.strip(), sort_order=i, created_by=user["id"]))
await db.flush()
return RedirectResponse(url="/service/quick-menu/tech", status_code=302)
# ===================== IDEAS =====================
@router.get("/service/ideas")
async def ideas_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
result = await db.execute(select(Idea).order_by(Idea.status, Idea.updated_at.desc()))
return templates.TemplateResponse("pages/ideas.html", ctx(request, user=user, ideas=result.scalars().all(), active_page="ideas", title="Идеи"))
@router.post("/service/ideas/create")
async def ideas_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
db.add(Idea(title=form.get("title"), description=form.get("description",""), created_by=user["id"]))
await db.flush()
return RedirectResponse(url="/service/ideas", status_code=302)
@router.post("/service/ideas/edit")
async def ideas_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
try:
idea_id = int(form.get("id"))
except (ValueError, TypeError):
return RedirectResponse(url="/service/ideas", status_code=302)
idea = await db.get(Idea, idea_id)
if idea:
idea.title = form.get("title", idea.title)
idea.description = form.get("description", idea.description)
await db.flush()
return RedirectResponse(url="/service/ideas", status_code=302)
@router.post("/service/ideas/status")
async def ideas_status(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
try:
idea_id = int(form.get("id"))
except (TypeError, ValueError):
return RedirectResponse(url="/service/ideas", status_code=302)
idea = await db.get(Idea, idea_id)
if idea:
statuses = {"normal": "in_progress", "in_progress": "completed", "completed": "normal"}
idea.status = statuses.get(idea.status, "normal")
await db.flush()
return RedirectResponse(url="/service/ideas", status_code=302)
@router.post("/service/ideas/delete")
async def ideas_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
idea = await db.get(Idea, int((await request.form()).get("id")))
if idea:
await db.delete(idea)
await db.flush()
return RedirectResponse(url="/service/ideas", status_code=302)
# ===================== CEO CALC STUBS =====================
@router.post("/service/ceo/calc")
async def ceo_calc(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
# SHS stub — until the calculation pipeline is designed
return JSONResponse({"status": "stub", "message": "Расчёт SHS будет доработан после согласования формул с заказчиком"})
@router.post("/service/ceo/kpi")
async def ceo_kpi(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
# KPI stub — until the calculation pipeline is designed
return JSONResponse({"status": "stub", "message": "Расчёт KPI будет доработан после согласования формул с заказчиком"})
# ===================== CHARTS =====================
import calendar
from collections import defaultdict
@router.get("/service/charts")
async def charts_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/charts.html", ctx(request, user=user, active_page="charts", title="Графики и диаграммы"))
@router.get("/service/api/charts/data")
async def charts_data(
metric: str = Query("tasks"),
group: str = Query("month"),
db: AsyncSession = Depends(get_db),
user: dict = Depends(require_role("owner")),
):
from app.models.models import Task, Incident, SHSRecord, EngineerKPI, SLAContract, Object
import datetime
labels = []
values = []
datasets = []
if metric == "tasks":
result = await db.execute(
select(func.date_trunc(group, Task.created_at).label("period"), func.count(Task.id))
.where(Task.created_at != None)
.group_by(text("period"))
.order_by(text("period"))
)
rows = result.all()
labels = [r[0].strftime("%Y-%m") if r[0] else "" for r in rows]
values = [r[1] for r in rows]
datasets = [
{"label": "Все задачи", "data": values},
]
# Also get by status
for status in ("open", "in_progress", "completed"):
r2 = await db.execute(
select(func.date_trunc(group, Task.created_at).label("period"), func.count(Task.id))
.where(Task.status == status, Task.created_at != None)
.group_by(text("period"))
.order_by(text("period"))
)
rows2 = r2.all()
if rows2:
datasets.append({"label": status, "data": [r[1] for r in rows2]})
elif metric == "incidents":
for sev in ("P1", "P2", "P3"):
result = await db.execute(
select(func.date_trunc(group, Incident.created_at).label("period"), func.count(Incident.id))
.where(Incident.severity == sev, Incident.created_at != None)
.group_by(text("period"))
.order_by(text("period"))
)
rows = result.all()
if rows:
if not labels:
labels = [r[0].strftime("%Y-%m") if r[0] else "" for r in rows]
datasets.append({"label": sev, "data": [r[1] for r in rows]})
elif metric == "shs":
result = await db.execute(
select(SHSRecord).order_by(SHSRecord.id.asc())
)
records = result.scalars().all()
labels = [r.period_end.strftime("%Y-%m") if r.period_end else "" for r in records]
datasets = [
{"label": "SHS Score", "data": [float(r.shs_score) for r in records]},
{"label": "SLA Stability", "data": [float(r.sla_stability) for r in records]},
{"label": "Revenue Stability", "data": [float(r.revenue_stability) for r in records]},
]
elif metric == "kpi":
result = await db.execute(
select(EngineerKPI).order_by(EngineerKPI.period_end.asc()).limit(50)
)
kpis = result.scalars().all()
labels = [r.period_end.strftime("%Y-%m") for r in kpis]
datasets = [
{"label": "Engineer Score", "data": [float(r.engineer_score or 0) for r in kpis]},
{"label": "SLA Compliance", "data": [float(r.sla_compliance or 0) for r in kpis]},
]
elif metric == "mrr":
result = await db.execute(
select(SLAContract.status, func.sum(SLAContract.sla_price_monthly))
.group_by(SLAContract.status)
)
rows = result.all()
labels = [r[0] for r in rows]
values = [float(r[1] or 0) for r in rows]
datasets = [{"label": "MRR", "data": values}]
elif metric == "objects":
result = await db.execute(
select(Object.status, func.count(Object.id)).group_by(Object.status)
)
rows = result.all()
labels = [r[0] for r in rows]
values = [r[1] for r in rows]
datasets = [{"label": "Объекты", "data": values}]
return JSONResponse({"labels": labels, "datasets": datasets})
# ===================== CASES =====================
@router.get("/service/cases")
async def cases_list(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
cases = (await db.execute(select(Case).order_by(Case.sort_order, Case.id))).scalars().all()
return templates.TemplateResponse("pages/cases.html", ctx(request, user=user, cases=cases, active_page="cases", title="Примеры из практики"))
@router.post("/service/cases/create")
async def cases_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
db.add(Case(title=form.get("title"), text=form.get("text"), effect=form.get("effect",""), sort_order=int(form.get("sort_order",0)), is_active=form.get("is_active","1")=="1"))
await db.flush()
return RedirectResponse(url="/service/cases", status_code=302)
@router.post("/service/cases/edit")
async def cases_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
form = await request.form()
c = await db.get(Case, int(form.get("id")))
if c:
for f in ("title","text","effect","sort_order","is_active"):
val = form.get(f)
if val is not None:
if f == "sort_order": val = int(val)
elif f == "is_active": val = val == "1"
setattr(c, f, val)
await db.flush()
return RedirectResponse(url="/service/cases", status_code=302)
@router.post("/service/cases/delete")
async def cases_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
c = await db.get(Case, int((await request.form()).get("id")))
if c: await db.delete(c); await db.flush()
return RedirectResponse(url="/service/cases", status_code=302)
@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="Настройка портала"))
@router.get("/service/role-settings")
async def role_settings_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
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-unknown")
async def bot_unknown_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
return templates.TemplateResponse("pages/bot_unknown.html", ctx(request, user=user, active_page="bot-unknown", 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="Заявки от чат-бота"))
@router.get("/service/bot-dialogues")
async def bot_dialogues_page(request: Request, user: dict = Depends(get_current_user)):
if not user:
return RedirectResponse(url="/service/login", status_code=302)
return templates.TemplateResponse("pages/bot_dialogues.html", ctx(request, user=user, active_page="bot-dialogues", 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"))):
from app.models.models import RoleMenuPermission
from sqlalchemy import select
result = {"engineer": [], "technician": []}
rows = (await db.execute(select(RoleMenuPermission))).scalars().all()
for row in rows:
result.setdefault(row.role, []).append({"menu_key": row.menu_key, "visible": row.visible})
return JSONResponse(result)
@router.post("/service/api/role-permissions/save")
async def api_role_permissions_save(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
from app.models.models import RoleMenuPermission
from sqlalchemy import delete
body = await request.json()
role = body.get("role")
permissions = body.get("permissions", [])
if role not in ("engineer", "technician"):
return JSONResponse({"ok": False, "error": "Invalid role"}, status_code=400)
await db.execute(delete(RoleMenuPermission).where(RoleMenuPermission.role == role))
for p in permissions:
db.add(RoleMenuPermission(role=role, menu_key=p["menu_key"], visible=p["visible"]))
await db.commit()
return JSONResponse({"ok": True})
import re
import os
def parse_sidebar_menu():
sidebar_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates", "_sidebar.html")
try:
with open(sidebar_path, "r", encoding="utf-8") as f:
html = f.read()
except FileNotFoundError:
return []
items = []
current_section = ""
for line in html.split("\n"):
sec = re.search(r'sidebar-section[^>]*>([^<]+)', line)
if sec:
current_section = sec.group(1).strip()
perm = re.search(r"role_permissions\.get\('(\w+)'", line)
label = re.search(r'>([^<]+)</a>', line)
if perm and label:
items.append({"key": perm.group(1), "label": label.group(1).strip(), "section": current_section})
return items
@router.get("/service/api/menu-items")
async def api_menu_items(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
from app.models.models import RoleMenuPermission
from sqlalchemy import select as sa_select
items = parse_sidebar_menu()
existing = await db.execute(sa_select(RoleMenuPermission))
existing_keys = {(r.role, r.menu_key) for r in existing.scalars().all()}
for item in items:
for role in ("engineer", "technician"):
if (role, item["key"]) not in existing_keys:
db.add(RoleMenuPermission(role=role, menu_key=item["key"], visible=False))
await db.commit()
return JSONResponse(items)
# ===================== ROLE SWITCH =====================
@router.post("/service/api/role/switch")
async def api_role_switch(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()
target_role = body.get("role")
current_role = user["role"]
allowed_roles = {"owner": ["owner", "engineer", "technician"], "engineer": ["engineer", "technician"]}
if current_role not in allowed_roles or target_role not in allowed_roles.get(current_role, []):
return JSONResponse({"ok": False, "error": "Forbidden"}, status_code=403)
if "original_user" not in request.session:
request.session["original_user"] = dict(user)
request.session["user"] = {**user, "role": target_role}
return JSONResponse({"ok": True, "role": target_role, "original_role": request.session["original_user"]["role"]})
@router.post("/service/api/role/reset")
async def api_role_reset(request: Request, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
if "original_user" in request.session:
request.session["user"] = request.session["original_user"]
del request.session["original_user"]
return JSONResponse({"ok": True, "role": request.session["user"]["role"]})
# ===================== BOT API PROXY =====================
BOT_SERVICE_URL = "http://127.0.0.1:8002"
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 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 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("/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("/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", body)
@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("PATCH", f"/api/bot/tickets/{ticket_id}", body)
@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("PUT", f"/api/bot/kb/{card_id}", body)
@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.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.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/broadcast/preview", body)
@router.post("/api/bot/test/clear")
async def proxy_bot_test_clear(user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
return await _proxy("POST", "/api/bot/test/clear")
@router.post("/api/bot/webhook")
async def proxy_bot_webhook(request: Request):
body = await request.json()
headers = {"Content-Type": "application/json"}
secret = request.headers.get("X-Max-Bot-Api-Secret")
if secret:
headers["X-Max-Bot-Api-Secret"] = secret
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(f"{BOT_SERVICE_URL}/webhook", json=body, headers=headers)
return JSONResponse(resp.json(), status_code=resp.status_code)
except httpx.TimeoutException:
return JSONResponse({"ok": False, "error": "Max bot timeout"}, status_code=504)
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=502)
@router.get("/api/bot/conversations")
async def proxy_bot_conversations(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/conversations")
@router.get("/api/bot/conversations/{conv_id}/messages")
async def proxy_bot_conversation_messages(conv_id: int, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
return await _proxy("GET", f"/api/bot/conversations/{conv_id}/messages")
@router.get("/api/bot/conversations/{conv_id}/logs")
async def proxy_bot_conversation_logs(conv_id: int, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
return await _proxy("GET", f"/api/bot/conversations/{conv_id}/logs")
@router.delete("/api/bot/conversations/{conv_id}")
async def proxy_delete_conversation(conv_id: int, request: Request, user: dict = Depends(require_role("owner"))):
return await _proxy("DELETE", f"/api/bot/conversations/{conv_id}")
@router.delete("/api/bot/users/{user_id}")
async def proxy_delete_bot_user(user_id: int, request: Request, user: dict = Depends(require_role("owner"))):
return await _proxy("DELETE", f"/api/bot/users/{user_id}")