Initial commit

This commit is contained in:
2026-05-17 05:22:06 +03:00
commit ca4d00c895
155 changed files with 45216 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
DATABASE_URL=postgresql+asyncpg://aegisone:aegisone_pass@localhost:5432/aegisone
SECRET_KEY=change-me-to-random-string
SESSION_TTL=3600
APP_ENV=development
LOG_LEVEL=debug
+19
View File
@@ -0,0 +1,19 @@
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
COPY . .
RUN mkdir -p /app/docs /app/permissions /app/uploads
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
View File
+92
View File
@@ -0,0 +1,92 @@
import time
import secrets
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from passlib.context import CryptContext
from app.models.models import User, LoginAttempt, AuditLog
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
BRUTE_FORCE_WINDOW = 180
BRUTE_FORCE_MAX_ATTEMPTS = 3
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(password: str, hashed: str) -> bool:
return pwd_context.verify(password, hashed)
def generate_csrf_token() -> str:
return secrets.token_hex(32)
async def brute_force_check(db: AsyncSession, login: str, ip_address: str) -> bool:
cutoff = datetime.utcnow().timestamp() - BRUTE_FORCE_WINDOW
result = await db.execute(
select(func.count(LoginAttempt.id))
.where(
(LoginAttempt.login == login) | (LoginAttempt.ip_address == ip_address),
LoginAttempt.attempt_time > datetime.fromtimestamp(cutoff),
)
)
count = result.scalar() or 0
return count >= BRUTE_FORCE_MAX_ATTEMPTS
async def record_attempt(db: AsyncSession, login: str, ip_address: str):
attempt = LoginAttempt(login=login, ip_address=ip_address)
db.add(attempt)
await db.flush()
async def clear_attempts(db: AsyncSession, login: str, ip_address: str):
from sqlalchemy import delete
await db.execute(
delete(LoginAttempt).where(
(LoginAttempt.login == login) | (LoginAttempt.ip_address == ip_address)
)
)
await db.flush()
async def authenticate(db: AsyncSession, login: str, password: str, ip_address: str) -> dict:
if await brute_force_check(db, login, ip_address):
return {"success": False, "error": "Превышен лимит попыток. Подождите 3 минуты."}
result = await db.execute(
select(User).where(User.login == login).limit(1)
)
user = result.scalar_one_or_none()
if not user or not verify_password(password, user.password_hash):
await record_attempt(db, login, ip_address)
return {"success": False, "error": "Неверный логин или пароль."}
if not user.is_active:
return {"success": False, "error": "Учётная запись заблокирована."}
await clear_attempts(db, login, ip_address)
await audit_log(db, user.id, "login", "Вход в систему", ip_address)
return {
"success": True,
"user": {
"id": user.id,
"login": user.login,
"role": user.role,
"full_name": user.full_name,
},
}
async def audit_log(db: AsyncSession, user_id: int | None, action: str, details: str, ip_address: str):
try:
log = AuditLog(user_id=user_id, action=action, details=details, ip_address=ip_address)
db.add(log)
await db.flush()
except Exception:
pass
+499
View File
@@ -0,0 +1,499 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.models import FormulaCoefficient
from functools import lru_cache
import math
_coeff_cache: dict[str, float] = {}
async def get_coefficient(db: AsyncSession, key: str, default: float = 0.0) -> float:
global _coeff_cache
if not _coeff_cache:
result = await db.execute(
select(FormulaCoefficient.key, FormulaCoefficient.value)
.where(FormulaCoefficient.is_active == True)
)
_coeff_cache = {row.key: float(row.value) for row in result.all()}
return _coeff_cache.get(key, default)
async def reload_coefficients(db: AsyncSession):
global _coeff_cache
_coeff_cache = {}
await get_coefficient(db, "dummy")
def fmt_money(amount: float) -> str:
return f"{amount:,.0f}".replace(",", " ") + ""
def fmt_percent(value: float) -> str:
return f"{value:.1f}%"
def fmt_hours(hours: float) -> str:
return f"{hours:.1f} ч"
def badge_class(status: str, badge_type: str = "default") -> str:
mapping = {
"P1": "badge-critical", "P2": "badge-high", "P3": "badge-medium", "P4": "badge-low",
"open": "badge-open", "in_progress": "badge-progress", "completed": "badge-done", "cancelled": "badge-cancelled",
"active": "badge-active", "inactive": "badge-inactive", "draft": "badge-draft", "final": "badge-done",
"Risk": "badge-risk", "Crisis": "badge-crisis", "Stable": "badge-stable", "Growth": "badge-growth",
}
return mapping.get(status, "badge-default")
async def calc_risk_score(db: AsyncSession, no_archive: bool, no_power_backup: bool, no_regulations: bool, system_failures: bool, no_documentation: bool) -> float:
return (
(await get_coefficient(db, "risk_score.no_archive", 25) if no_archive else 0)
+ (await get_coefficient(db, "risk_score.no_power_backup", 20) if no_power_backup else 0)
+ (await get_coefficient(db, "risk_score.no_regulations", 15) if no_regulations else 0)
+ (await get_coefficient(db, "risk_score.system_failures", 20) if system_failures else 0)
+ (await get_coefficient(db, "risk_score.no_documentation", 10) if no_documentation else 0)
)
async def risk_label(db: AsyncSession, score: float) -> str:
if score <= await get_coefficient(db, "risk_score.bound_low", 20):
return "Низкий"
if score <= await get_coefficient(db, "risk_score.bound_medium", 50):
return "Средний"
if score <= await get_coefficient(db, "risk_score.bound_high", 75):
return "Высокий"
return "Критический"
async def risk_multiplier(db: AsyncSession, score: float) -> float:
if score <= await get_coefficient(db, "risk_score.bound_low", 20):
return await get_coefficient(db, "risk_multiplier.low", 1.0)
if score <= await get_coefficient(db, "risk_score.bound_medium", 50):
return await get_coefficient(db, "risk_multiplier.medium", 1.3)
if score <= await get_coefficient(db, "risk_score.bound_high", 75):
return await get_coefficient(db, "risk_multiplier.high", 1.6)
return await get_coefficient(db, "risk_multiplier.critical", 2.0)
async def calc_complexity(db: AsyncSession, cameras: int, access_points: int, fire_type: str, has_it: bool) -> float:
if cameras <= 20:
video = await get_coefficient(db, "complexity.video_0_20", 10)
elif cameras <= 100:
video = await get_coefficient(db, "complexity.video_20_100", 20)
else:
video = await get_coefficient(db, "complexity.video_100plus", 35)
if access_points <= 5:
access = await get_coefficient(db, "complexity.access_0_5", 10)
elif access_points <= 20:
access = await get_coefficient(db, "complexity.access_5_20", 20)
else:
access = await get_coefficient(db, "complexity.access_20plus", 30)
if fire_type == "simple":
fire = await get_coefficient(db, "complexity.fire_simple", 15)
elif fire_type == "medium":
fire = await get_coefficient(db, "complexity.fire_medium", 25)
elif fire_type == "complex":
fire = await get_coefficient(db, "complexity.fire_complex", 40)
else:
fire = 0
it = await get_coefficient(db, "complexity.it", 10) if has_it else 0
return video + access + fire + it
def complexity_label(score: float) -> str:
if score <= 30:
return "Простой"
if score <= 70:
return "Средний"
if score <= 120:
return "Сложный"
return "Enterprise"
async def calc_infrastructure_load(db: AsyncSession, server_state: str, network_state: str, power_state: str) -> float:
if server_state == "none":
server = await get_coefficient(db, "infra.server_none", 15)
elif server_state == "weak":
server = await get_coefficient(db, "infra.server_weak", 10)
else:
server = 0
if network_state == "unstable":
network = await get_coefficient(db, "infra.network_unstable", 20)
elif network_state == "partial":
network = await get_coefficient(db, "infra.network_partial", 10)
else:
network = 0
if power_state == "none":
power = await get_coefficient(db, "infra.power_none", 20)
elif power_state == "weak":
power = await get_coefficient(db, "infra.power_weak", 10)
else:
power = 0
return server + network + power
async def calc_service_history(db: AsyncSession, service_state: str) -> float:
mapping = {
"none": "history.none",
"irregular": "history.irregular",
"formal": "history.formal",
"sla": "history.sla",
}
key = mapping.get(service_state, "history.none")
return await get_coefficient(db, key, 30)
async def calc_object_index(db: AsyncSession, risk: float, complexity: float, infra: float, history: float) -> float:
return round(
risk * await get_coefficient(db, "object_index.risk_weight", 0.4)
+ complexity * await get_coefficient(db, "object_index.complexity_weight", 0.3)
+ infra * await get_coefficient(db, "object_index.infra_weight", 0.2)
+ history * await get_coefficient(db, "object_index.history_weight", 0.1),
2,
)
def object_class(index: float) -> str:
if index <= 30:
return "A"
if index <= 60:
return "B"
if index <= 90:
return "C"
return "D"
def object_class_label(index: float) -> str:
cls = object_class(index)
labels = {
"A": "Лёгкий SLA",
"B": "Стандарт SLA",
"C": "Сложный SLA",
"D": "Enterprise / Высокий риск",
}
return labels.get(cls, "")
def calc_sla_price(base_cost: float, object_index: float, region_factor: float, risk_mult: float) -> float:
return round(base_cost * object_index * region_factor * risk_mult, 2)
async def calc_engineer_score(db: AsyncSession, sla_compliance: float, response_time_score: float, resolution_time_score: float, diagnosis_accuracy: float, reopen_rate_score: float, risk_coverage_score: float) -> float:
return round(
(await get_coefficient(db, "engineer.score.sla_weight", 0.25) * sla_compliance)
+ (await get_coefficient(db, "engineer.score.response_weight", 0.20) * response_time_score)
+ (await get_coefficient(db, "engineer.score.resolution_weight", 0.20) * resolution_time_score)
+ (await get_coefficient(db, "engineer.score.diagnosis_weight", 0.15) * diagnosis_accuracy)
+ (await get_coefficient(db, "engineer.score.reopen_weight", 0.10) * reopen_rate_score)
+ (await get_coefficient(db, "engineer.score.risk_coverage_weight", 0.10) * risk_coverage_score),
2,
)
async def engineer_grade(db: AsyncSession, score: float) -> str:
if score >= await get_coefficient(db, "engineer.grade.senior", 90):
return "Senior"
if score >= await get_coefficient(db, "engineer.grade.strong", 80):
return "Strong"
if score >= await get_coefficient(db, "engineer.grade.middle", 70):
return "Middle"
return "Junior"
async def calc_engineer_control_score(db: AsyncSession, sla_control: float, task_dist: float, incident_red: float, team_perf: float, response_coord: float) -> float:
return round(
(await get_coefficient(db, "ecs.sla_weight", 0.30) * sla_control)
+ (await get_coefficient(db, "ecs.task_dist_weight", 0.25) * task_dist)
+ (await get_coefficient(db, "ecs.incident_red_weight", 0.20) * incident_red)
+ (await get_coefficient(db, "ecs.team_perf_weight", 0.15) * team_perf)
+ (await get_coefficient(db, "ecs.response_coord_weight", 0.10) * response_coord),
2,
)
async def calc_shs(db: AsyncSession, sla_stability: float, revenue_stability: float, retention: float, engineer_performance: float, incident_stability: float, sales_flow: float, operational_efficiency: float) -> float:
return round(
(await get_coefficient(db, "shs.sla_stability_weight", 0.22) * sla_stability)
+ (await get_coefficient(db, "shs.revenue_stability_weight", 0.18) * revenue_stability)
+ (await get_coefficient(db, "shs.retention_weight", 0.18) * retention)
+ (await get_coefficient(db, "shs.engineer_perf_weight", 0.15) * engineer_performance)
+ (await get_coefficient(db, "shs.incident_stability_weight", 0.12) * incident_stability)
+ (await get_coefficient(db, "shs.sales_flow_weight", 0.10) * sales_flow)
+ (await get_coefficient(db, "shs.operational_eff_weight", 0.05) * operational_efficiency),
2,
)
async def calc_ceo_shs(db: AsyncSession, mrr_growth: float, sla_compliance: float, retention: float, productivity: float, conversion: float, incident_stability: float) -> float:
return round(
(await get_coefficient(db, "ceo_shs.mrr_weight", 0.25) * mrr_growth)
+ (await get_coefficient(db, "ceo_shs.sla_weight", 0.20) * sla_compliance)
+ (await get_coefficient(db, "ceo_shs.retention_weight", 0.20) * retention)
+ (await get_coefficient(db, "ceo_shs.productivity_weight", 0.15) * productivity)
+ (await get_coefficient(db, "ceo_shs.conversion_weight", 0.10) * conversion)
+ (await get_coefficient(db, "ceo_shs.incident_weight", 0.10) * incident_stability),
2,
)
async def shs_status(db: AsyncSession, score: float) -> str:
if score >= await get_coefficient(db, "shs.zone_growth", 85):
return "Growth"
if score >= await get_coefficient(db, "shs.zone_stable", 70):
return "Stable"
if score >= await get_coefficient(db, "shs.zone_risk", 50):
return "Risk"
return "Crisis"
async def shs_zone(db: AsyncSession, score: float) -> dict:
if score >= await get_coefficient(db, "shs.zone_growth", 85):
return {"zone": "green", "label": "Growth", "action": ""}
if score >= await get_coefficient(db, "shs.zone_stable", 70):
return {"zone": "yellow", "label": "Stable", "action": "Мониторинг"}
if score >= await get_coefficient(db, "shs.zone_risk", 50):
return {"zone": "yellow", "label": "Risk", "action": "Снизить продажи, запустить аудит цикла, пересмотреть назначения"}
return {"zone": "red", "label": "Crisis", "action": "Немедленный аудит, приостановка новых продаж, пересмотр команды"}
async def shs_delta(db: AsyncSession, current_score: float, previous_score: float | None) -> dict | None:
if previous_score is None:
return None
delta = current_score - previous_score
threshold_warn = await get_coefficient(db, "shs.delta_warning", 5)
threshold_crit = await get_coefficient(db, "shs.delta_critical", 10)
if delta <= -threshold_crit:
return {"delta": round(delta, 1), "level": "critical", "message": f"SHS упал более чем на {threshold_crit} пунктов — кризис"}
if delta <= -threshold_warn:
return {"delta": round(delta, 1), "level": "warning", "message": f"SHS упал более чем на {threshold_warn} пунктов — требуется внимание"}
return {"delta": round(delta, 1), "level": "ok", "message": ""}
async def calc_sla_stability_index(db: AsyncSession, sla_compliance: float, breach_severity_weighted: float) -> float:
ssi = sla_compliance * (1 - breach_severity_weighted)
return round(max(0, min(100, ssi)), 2)
async def calc_breach_severity(db: AsyncSession, breaches_p1: int, breaches_p2: int, breaches_p3: int) -> float:
total = breaches_p1 + breaches_p2 + breaches_p3
if total == 0:
return 0
weighted = (
breaches_p1 * await get_coefficient(db, "ssi.breach_severity_p1", 1.0)
+ breaches_p2 * await get_coefficient(db, "ssi.breach_severity_p2", 0.5)
+ breaches_p3 * await get_coefficient(db, "ssi.breach_severity_p3", 0.2)
)
return min(1, weighted / total)
def calc_revenue_stability_index(mrr_history: list[float]) -> float:
count = len(mrr_history)
if count < 2:
return 100
mean = sum(mrr_history) / count
if mean <= 0:
return 100
variance = sum((mrr - mean) ** 2 for mrr in mrr_history) / count
std_dev = math.sqrt(variance)
cv = std_dev / mean
return round(max(0, min(100, 100 * (1 - cv))), 2)
def calc_sales_flow_index(audits: int, sla_contracts: int, lead_quality: float = 1.0) -> float:
if audits <= 0:
return 0
conversion = sla_contracts / audits
return round(min(100, conversion * 100 * lead_quality), 2)
def calc_operational_efficiency_index(revenue: float, engineer_hours: float, cost_per_hour: float) -> float:
total = engineer_hours * cost_per_hour
if total <= 0:
return 100
return round(min(100, (revenue / total) * 100), 2)
async def calc_incident_stability_index(db: AsyncSession, incidents_p1: int, incidents_p2: int, incidents_p3: int, object_count: int) -> float:
if object_count <= 0:
return 100
weighted = (
incidents_p1 * await get_coefficient(db, "isi.severity_p1", 1.0)
+ incidents_p2 * await get_coefficient(db, "isi.severity_p2", 0.5)
+ incidents_p3 * await get_coefficient(db, "isi.severity_p3", 0.2)
)
rate = weighted / object_count
return round(max(0, min(100, 100 - (rate * 20))), 2)
async def check_automation_rules(db: AsyncSession, shs: float | None, sla_compliance: float | None, retention: float | None) -> list[dict]:
alerts = []
if shs is not None and shs < await get_coefficient(db, "rules.shs_threshold", 70):
alerts.append({
"rule": "SHS",
"severity": "critical",
"message": f"SHS ниже {await get_coefficient(db, 'rules.shs_threshold', 70)} — снизить продажи, запустить аудит, пересмотреть назначения инженеров.",
})
if sla_compliance is not None and sla_compliance < await get_coefficient(db, "rules.sla_threshold", 90):
alerts.append({
"rule": "SLA",
"severity": "warning",
"message": f"SLA Compliance ниже {await get_coefficient(db, 'rules.sla_threshold', 90)}% — заморозить неприоритетные проекты, увеличить частоту проверок.",
})
if retention is not None and retention < await get_coefficient(db, "rules.retention_threshold", 90):
alerts.append({
"rule": "Retention",
"severity": "critical",
"message": f"Удержание клиентов ниже {await get_coefficient(db, 'rules.retention_threshold', 90)}% — обязательный аудит клиентов, пересмотр назначений инженеров.",
})
return alerts
def calc_sla_compliance(closed_in_sla: int, total: int) -> float:
if total <= 0:
return 100
return round((closed_in_sla / total) * 100, 2)
def calc_reopen_rate(reopened: int, total: int) -> float:
if total <= 0:
return 0
return round((reopened / total) * 100, 2)
def calc_diagnosis_accuracy(confirmed: int, found: int) -> float:
if found <= 0:
return 100
return round((confirmed / found) * 100, 2)
def calc_response_time_score(actual_hours: float, sla_hours: float) -> float:
if sla_hours <= 0:
return 100
ratio = actual_hours / sla_hours
if ratio <= 1.0:
return 100
if ratio <= 1.2:
return 80
return max(0, 100 - (ratio - 1.2) * 50)
def calc_resolution_time_score(actual_hours: float, norm_hours: float) -> float:
if norm_hours <= 0:
return 100
ratio = actual_hours / norm_hours
if ratio <= 1.0:
return 100
if ratio <= 1.5:
return 70
return max(0, 100 - (ratio - 1.5) * 40)
def calc_risk_coverage(checked_zones: int, total_zones: int = 5) -> float:
if total_zones <= 0:
return 0
return round((checked_zones / total_zones) * 100, 2)
def calc_documentation_quality(full_reports: int, total_visits: int) -> float:
if total_visits <= 0:
return 100
return round((full_reports / total_visits) * 100, 2)
def calc_tlb(tasks_per_engineer: list[float]) -> float:
count = len(tasks_per_engineer)
if count < 2:
return 0
mean = sum(tasks_per_engineer) / count
variance = sum((t - mean) ** 2 for t in tasks_per_engineer) / count
return round(math.sqrt(variance), 2)
def calc_mrr(total_sla_annual: float) -> float:
return round(total_sla_annual / 12, 2)
def calc_rpe(sla_revenue: float, engineer_count: int) -> float:
if engineer_count <= 0:
return 0
return round(sla_revenue / engineer_count, 2)
def calc_cpo(total_cost: float, object_count: int) -> float:
if object_count <= 0:
return 0
return round(total_cost / object_count, 2)
def calc_arpc(total_revenue: float, client_count: int) -> float:
if client_count <= 0:
return 0
return round(total_revenue / client_count, 2)
def calc_utilization(working_hours: int, available_hours: int) -> float:
if available_hours <= 0:
return 0
return round((working_hours / available_hours) * 100, 2)
def calc_autonomy(independent: int, total: int) -> float:
if total <= 0:
return 100
return round((independent / total) * 100, 2)
def calc_escalation_rate(escalated: int, total: int) -> float:
if total <= 0:
return 0
return round((escalated / total) * 100, 2)
def calc_uptime(uptime_hours: float, total_hours: float) -> float:
if total_hours <= 0:
return 100
return round((uptime_hours / total_hours) * 100, 2)
def calc_retention(active_clients: int, total_clients: int) -> float:
if total_clients <= 0:
return 100
return round((active_clients / total_clients) * 100, 2)
async def calc_retention_key_client(db: AsyncSession, base_retention: float, lost_key_clients: int) -> float:
penalty = lost_key_clients * await get_coefficient(db, "retention.key_client_penalty", 0.1)
return round(max(0, base_retention - penalty * 100), 2)
def calc_csat(positive: int, total: int) -> float:
if total <= 0:
return 100
return round((positive / total) * 100, 2)
def calc_incident_rate(incidents: int, object_count: int) -> float:
if object_count <= 0:
return 0
return round(incidents / object_count, 2)
def calc_incident_reduction(previous: int, current: int) -> float:
if previous <= 0:
return 0
return round(((previous - current) / previous) * 100, 2)
async def calc_bonus_percent(db: AsyncSession, engineer_score: float) -> float:
if engineer_score >= await get_coefficient(db, "bonus.score_90plus", 90):
return await get_coefficient(db, "bonus.premium_90plus", 20)
if engineer_score >= await get_coefficient(db, "bonus.score_80_89", 80):
return await get_coefficient(db, "bonus.premium_80_89", 10)
return await get_coefficient(db, "bonus.premium_below_80", 0)
+24
View File
@@ -0,0 +1,24 @@
import os
from pathlib import Path
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://aegisone:aegisone_pass@localhost:5432/aegisone"
SECRET_KEY: str = "change-me-to-random-string"
SESSION_TTL: int = 3600
APP_ENV: str = "development"
LOG_LEVEL: str = "debug"
DOCS_DIR: str = str(Path(__file__).parent.parent.parent / "docs")
PERMISSIONS_DIR: str = str(Path(__file__).parent.parent.parent / "permissions")
UPLOADS_DIR: str = str(Path(__file__).parent.parent.parent / "uploads")
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
@lru_cache()
def get_settings() -> Settings:
return Settings()
+24
View File
@@ -0,0 +1,24 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.config import get_settings
settings = get_settings()
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.APP_ENV == "development",
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db() -> AsyncSession:
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
+187
View File
@@ -0,0 +1,187 @@
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Depends
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.database import get_db, async_session
from app.auth import audit_log
from app.services.docs_permissions import discover_docs
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
async with async_session() as db:
from app.calculations.engine import reload_coefficients
await reload_coefficients(db)
discover_docs()
yield
app = FastAPI(title="AegisOne Service Portal", lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY, max_age=settings.SESSION_TTL)
templates = Jinja2Templates(directory="app/templates")
templates.env.globals["SITE_NAME"] = "AegisOne Engineering"
class RoleGuardMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
path = request.url.path
if path.startswith("/service/") and not any(
path.startswith(p) for p in ["/service/login", "/service/static", "/service/api/"]
):
user = request.session.get("user")
if not user:
return RedirectResponse(url="/service/login", status_code=302)
response = await call_next(request)
return response
app.add_middleware(RoleGuardMiddleware)
def get_current_user(request: Request) -> dict | None:
return request.session.get("user")
def require_role(*roles: str):
def dependency(request: Request, user: dict = Depends(get_current_user)):
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)
return user
return dependency
@app.get("/service/login")
async def login_page(request: Request, db: AsyncSession = Depends(get_db)):
if request.session.get("user"):
return RedirectResponse(url="/service/dashboard", status_code=302)
return templates.TemplateResponse("login.html", {"request": request, "error": ""})
@app.post("/service/login")
async def login_submit(request: Request, db: AsyncSession = Depends(get_db)):
form = await request.form()
login = form.get("login", "").strip()
password = form.get("password", "")
if not login or not password:
return templates.TemplateResponse("login.html", {"request": request, "error": "Заполните все поля."})
from app.auth import authenticate
ip = request.client.host if request.client else ""
result = await authenticate(db, login, password, ip)
if result["success"]:
request.session["user"] = result["user"]
request.session["csrf_token"] = __import__("secrets").token_hex(32)
redirect = request.query_params.get("redirect", "/service/dashboard")
return RedirectResponse(url=redirect, status_code=302)
return templates.TemplateResponse("login.html", {"request": request, "error": result["error"]})
@app.get("/service/logout")
async def logout(request: Request, db: AsyncSession = Depends(get_db)):
user = request.session.get("user")
if user:
await audit_log(db, user["id"], "logout", "Выход из системы", request.client.host if request.client else "")
request.session.clear()
return RedirectResponse(url="/service/login", status_code=302)
@app.get("/service/dashboard")
async def dashboard(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
from sqlalchemy import select, func
from app.models.models import Object, Task, Incident, SLAContract, SHSRecord
stats = {}
result = await db.execute(select(func.count(Object.id)).where(Object.status == "active"))
stats["objects"] = result.scalar() or 0
result = await db.execute(select(func.count(Task.id)).where(Task.status.in_(["open", "in_progress"])))
stats["tasks"] = result.scalar() or 0
result = await db.execute(select(func.count(Incident.id)).where(Incident.status.in_(["open", "in_progress"])))
stats["incidents"] = result.scalar() or 0
result = await db.execute(select(func.count(SLAContract.id)).where(SLAContract.status == "active"))
stats["sla_count"] = result.scalar() or 0
result = await db.execute(select(SHSRecord.shs_score, SHSRecord.shs_status).order_by(SHSRecord.id.desc()).limit(1))
shs_row = result.first()
stats["shs"] = {"shs_score": shs_row[0], "shs_status": shs_row[1]} if shs_row else None
if user["role"] in ("engineer", "technician"):
result = await db.execute(
select(func.count(Task.id)).where(
Task.assigned_to == user["id"],
Task.status.in_(["open", "in_progress"]),
)
)
stats["my_tasks"] = result.scalar() or 0
result = await db.execute(
select(func.count(Task.id), func.sum(Task.sla_compliant.cast(int)))
.where(Task.closed_at.isnot(None))
)
row = result.first()
total = row[0] or 0
compliant = row[1] or 0
stats["sla_compliance"] = round((compliant / total) * 100, 1) if total > 0 else 0
shs_alert = None
if stats["shs"] and stats["shs"]["shs_score"] < 70:
shs_alert = f"SHS ниже 70 ({stats['shs']['shs_score']}) — {stats['shs']['shs_status']}"
now = __import__("datetime").datetime.now().strftime("%d.%m.%Y %H:%M")
return templates.TemplateResponse("dashboard.html", {
"request": request, "stats": stats, "shs_alert": shs_alert, "now": now, "user": user,
})
@app.get("/service/api/shs")
async def api_shs(db: AsyncSession = Depends(get_db)):
from sqlalchemy import select
from app.models.models import SHSRecord
result = await db.execute(select(SHSRecord).order_by(SHSRecord.id.desc()).limit(1))
record = result.scalar_one_or_none()
if not record:
return JSONResponse({"error": "No data"})
return JSONResponse({
"shs_score": float(record.shs_score),
"shs_status": record.shs_status,
"period_start": record.period_start.isoformat(),
"period_end": record.period_end.isoformat(),
})
@app.get("/service/api/tasks")
async def api_tasks(db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
from sqlalchemy import select
from app.models.models import Task
query = select(Task).where(Task.status.in_(["open", "in_progress"]))
if user["role"] == "technician":
query = query.where(Task.assigned_to == user["id"])
result = await db.execute(query.order_by(Task.created_at.desc()))
tasks = result.scalars().all()
return JSONResponse([
{
"id": t.id, "title": t.title, "priority": t.priority,
"status": t.status, "object_id": t.object_id,
}
for t in tasks
])
@app.get("/health")
async def health():
return {"status": "ok"}
View File
+465
View File
@@ -0,0 +1,465 @@
from datetime import datetime
from sqlalchemy import (
Column, Integer, String, Boolean, Text, DateTime, Numeric, Date, BigInteger, SmallInteger, JSON, ForeignKey, CheckConstraint
)
from sqlalchemy.orm import relationship, declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
login = Column(String(50), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
role = Column(String(20), nullable=False, default="technician")
full_name = Column(String(255), nullable=False)
phone = Column(String(50), default="")
email = Column(String(255), default="")
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()")
__table_args__ = (
CheckConstraint("role IN ('owner','engineer','technician')", name="ck_users_role"),
)
objects_created = relationship("Object", foreign_keys="Object.created_by", back_populates="creator")
tasks_created = relationship("Task", foreign_keys="Task.created_by", back_populates="creator")
tasks_assigned = relationship("Task", foreign_keys="Task.assigned_to", back_populates="assignee")
class LoginAttempt(Base):
__tablename__ = "login_attempts"
id = Column(Integer, primary_key=True, autoincrement=True)
ip_address = Column(String(45), nullable=False)
login = Column(String(50), nullable=False, default="")
attempt_time = Column(DateTime, nullable=False, server_default="NOW()")
class AuditLog(Base):
__tablename__ = "audit_log"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
action = Column(String(255), nullable=False)
details = Column(Text)
ip_address = Column(String(45), default="")
created_at = Column(DateTime, nullable=False, server_default="NOW()")
class Customer(Base):
__tablename__ = "customers"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False)
inn = Column(String(12), default="")
kpp = Column(String(9), default="")
legal_address = Column(String(500), default="")
contact_person = Column(String(255), default="")
contact_phone = Column(String(50), default="")
contact_email = Column(String(255), default="")
status = Column(String(20), nullable=False, default="active")
notes = Column(Text)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("status IN ('active','inactive','prospect')", name="ck_customers_status"),
)
objects = relationship("Object", back_populates="customer")
sla_contracts = relationship("SLAContract", back_populates="customer")
class Object(Base):
__tablename__ = "objects"
id = Column(Integer, primary_key=True, autoincrement=True)
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"))
name = Column(String(255), nullable=False)
address = Column(String(500), default="")
object_type = Column(String(100), default="")
area_sqm = Column(Numeric(10, 2), default=0)
employees_count = Column(Integer, default=0)
contact_person = Column(String(255), default="")
contact_phone = Column(String(50), default="")
status = Column(String(20), nullable=False, default="active")
notes = Column(Text)
risk_score = Column(Numeric(5, 2), default=0)
complexity_index = Column(Numeric(5, 2), default=0)
infrastructure_load = Column(Numeric(5, 2), default=0)
service_history = Column(Numeric(5, 2), default=0)
object_index = Column(Numeric(5, 2), default=0)
sla_price_monthly = Column(Numeric(12, 2), default=0)
region_factor = Column(Numeric(3, 2), default=1.00)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("status IN ('active','inactive','audit','prospective')", name="ck_objects_status"),
)
customer = relationship("Customer", back_populates="objects")
creator = relationship("User", foreign_keys=[created_by], back_populates="objects_created")
assignments = relationship("ObjectAssignment", back_populates="object")
tasks = relationship("Task", back_populates="object")
reports = relationship("Report", back_populates="object")
incidents = relationship("Incident", back_populates="object")
sla_contracts = relationship("SLAContract", back_populates="object")
passports = relationship("ObjectPassport", back_populates="object")
class ObjectAssignment(Base):
__tablename__ = "object_assignments"
id = Column(Integer, primary_key=True, autoincrement=True)
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
role = Column(String(20), nullable=False)
assigned_at = Column(DateTime, nullable=False, server_default="NOW()")
unassigned_at = Column(DateTime)
__table_args__ = (
CheckConstraint("role IN ('engineer','technician')", name="ck_assignments_role"),
)
object = relationship("Object", back_populates="assignments")
user = relationship("User")
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, autoincrement=True)
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
assigned_to = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
title = Column(String(500), nullable=False)
description = Column(Text)
priority = Column(String(4), nullable=False, default="P3")
status = Column(String(20), nullable=False, default="open")
deadline = Column(DateTime)
sla_remaining_hours = Column(Numeric(6, 1), default=0)
response_time_minutes = Column(Integer, default=0)
resolution_time_minutes = Column(Integer, default=0)
sla_compliant = Column(Boolean)
checklist_data = Column(JSON)
result_notes = Column(Text)
closed_at = Column(DateTime)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("priority IN ('P1','P2','P3','P4')", name="ck_tasks_priority"),
CheckConstraint("status IN ('open','in_progress','completed','cancelled')", name="ck_tasks_status"),
)
object = relationship("Object", back_populates="tasks")
creator = relationship("User", foreign_keys=[created_by], back_populates="tasks_created")
assignee = relationship("User", foreign_keys=[assigned_to], back_populates="tasks_assigned")
comments = relationship("TaskComment", back_populates="task")
photos = relationship("TaskPhoto", back_populates="task")
class TaskComment(Base):
__tablename__ = "task_comments"
id = Column(Integer, primary_key=True, autoincrement=True)
task_id = Column(Integer, ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
comment = Column(Text, nullable=False)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
task = relationship("Task", back_populates="comments")
user = relationship("User")
class TaskPhoto(Base):
__tablename__ = "task_photos"
id = Column(Integer, primary_key=True, autoincrement=True)
task_id = Column(Integer, ForeignKey("tasks.id", ondelete="SET NULL"))
report_id = Column(Integer, ForeignKey("reports.id", ondelete="SET NULL"))
file_path = Column(String(500), nullable=False)
description = Column(String(500), default="")
type = Column(String(20), default="other")
uploaded_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_at = Column(DateTime, nullable=False, server_default="NOW()")
__table_args__ = (
CheckConstraint("type IN ('before','after','evidence','other')", name="ck_photos_type"),
)
task = relationship("Task", back_populates="photos")
uploader = relationship("User")
class Report(Base):
__tablename__ = "reports"
id = Column(Integer, primary_key=True, autoincrement=True)
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
report_type = Column(String(20), nullable=False, default="service")
title = Column(String(500), default="")
description = Column(Text)
findings = Column(Text)
recommendations = Column(Text)
work_done = Column(Text)
cause_classification = Column(String(100))
result_status = Column(String(20), default="ok")
status = Column(String(20), nullable=False, default="draft")
client_confirmed = Column(Boolean, default=False)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("report_type IN ('inspection','service','emergency','audit','monthly')", name="ck_reports_type"),
CheckConstraint("result_status IN ('fixed','partial','revisit','ok')", name="ck_reports_result"),
CheckConstraint("status IN ('draft','final','cancelled')", name="ck_reports_status"),
)
object = relationship("Object", back_populates="reports")
creator = relationship("User")
class QuestionnaireSession(Base):
__tablename__ = "questionnaire_sessions"
id = Column(Integer, primary_key=True, autoincrement=True)
object_id = Column(Integer, ForeignKey("objects.id", ondelete="SET NULL"))
client_name = Column(String(255), default="")
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
status = Column(String(20), nullable=False, default="draft")
current_step = Column(SmallInteger, nullable=False, default=1)
risk_score = Column(Numeric(5, 2), default=0)
complexity_index = Column(Numeric(5, 2), default=0)
infrastructure_load = Column(Numeric(5, 2), default=0)
service_history = Column(Numeric(5, 2), default=0)
object_index = Column(Numeric(5, 2), default=0)
sla_price_monthly = Column(Numeric(12, 2), default=0)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("status IN ('draft','completed','cancelled')", name="ck_qsessions_status"),
)
answers = relationship("QuestionnaireAnswer", back_populates="session")
passport = relationship("ObjectPassport", back_populates="session")
class QuestionnaireAnswer(Base):
__tablename__ = "questionnaire_answers"
id = Column(Integer, primary_key=True, autoincrement=True)
session_id = Column(Integer, ForeignKey("questionnaire_sessions.id", ondelete="CASCADE"), nullable=False)
step = Column(SmallInteger, nullable=False)
question_key = Column(String(100), nullable=False)
answer_value = Column(Text)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
session = relationship("QuestionnaireSession", back_populates="answers")
class QuestionnaireItem(Base):
__tablename__ = "questionnaire_items"
id = Column(Integer, primary_key=True, autoincrement=True)
step = Column(Integer, nullable=False, default=1)
section = Column(String(100), nullable=False, default="")
question_key = Column(String(100), unique=True, nullable=False)
label = Column(String(255), nullable=False)
type = Column(String(20), nullable=False, default="text")
options = Column(JSON)
required = Column(Boolean, nullable=False, default=False)
sort_order = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, nullable=False, default=True)
help_text = Column(String(500), default="")
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("type IN ('text','number','select','radio','checkbox','textarea')", name="ck_qitems_type"),
)
class ObjectPassport(Base):
__tablename__ = "object_passports"
id = Column(Integer, primary_key=True, autoincrement=True)
session_id = Column(Integer, ForeignKey("questionnaire_sessions.id", ondelete="SET NULL"))
object_id = Column(Integer, ForeignKey("objects.id", ondelete="SET NULL"))
object_index = Column(Numeric(5, 2), default=0)
risk_score = Column(Numeric(5, 2), default=0)
complexity_index = Column(Numeric(5, 2), default=0)
sla_price_monthly = Column(Numeric(12, 2), default=0)
passport_data = Column(JSON)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_at = Column(DateTime, nullable=False, server_default="NOW()")
session = relationship("QuestionnaireSession", back_populates="passport")
object = relationship("Object", back_populates="passports")
creator = relationship("User")
class SLAContract(Base):
__tablename__ = "sla_contracts"
id = Column(Integer, primary_key=True, autoincrement=True)
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"))
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
contract_number = Column(String(50), default="")
client_name = Column(String(255), default="")
description = Column(Text)
start_date = Column(Date, nullable=False)
end_date = Column(Date)
sla_price_monthly = Column(Numeric(12, 2), nullable=False, default=0)
penalty_rate = Column(Numeric(5, 2), default=0)
response_time_p1 = Column(Integer, default=4)
response_time_p2 = Column(Integer, default=8)
response_time_p3 = Column(Integer, default=24)
response_time_hours = Column(Numeric(5, 1))
service_level = Column(String(20), nullable=False, default="business")
status = Column(String(20), nullable=False, default="negotiation")
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("service_level IN ('start','business','enterprise')", name="ck_sla_level"),
CheckConstraint("status IN ('active','expired','cancelled','negotiation')", name="ck_sla_status"),
)
customer = relationship("Customer", back_populates="sla_contracts")
object = relationship("Object", back_populates="sla_contracts")
creator = relationship("User")
class Incident(Base):
__tablename__ = "incidents"
id = Column(Integer, primary_key=True, autoincrement=True)
object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False)
reported_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
assigned_to = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
title = Column(String(500), nullable=False)
description = Column(Text)
severity = Column(String(4), nullable=False, default="P3")
status = Column(String(20), nullable=False, default="open")
resolution = Column(Text)
cause = Column(String(500), default="")
resolution_time_minutes = Column(Integer, default=0)
sla_breached = Column(Boolean, default=False)
resolved_at = Column(DateTime)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("severity IN ('P1','P2','P3')", name="ck_incidents_severity"),
CheckConstraint("status IN ('open','in_progress','resolved','closed')", name="ck_incidents_status"),
)
object = relationship("Object", back_populates="incidents")
reporter = relationship("User", foreign_keys=[reported_by])
assignee = relationship("User", foreign_keys=[assigned_to])
class EngineerKPI(Base):
__tablename__ = "engineer_kpi"
id = Column(Integer, primary_key=True, autoincrement=True)
engineer_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
period_start = Column(Date, nullable=False)
period_end = Column(Date, nullable=False)
sla_compliance = Column(Numeric(5, 2), default=0)
response_time_score = Column(Numeric(5, 2), default=0)
resolution_time_score = Column(Numeric(5, 2), default=0)
diagnosis_accuracy = Column(Numeric(5, 2), default=0)
reopen_rate = Column(Numeric(5, 2), default=0)
risk_coverage_score = Column(Numeric(5, 2), default=0)
documentation_quality = Column(Numeric(5, 2), default=0)
engineer_score = Column(Numeric(5, 2), default=0)
grade = Column(String(20), default="")
components_json = Column(JSON)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
engineer = relationship("User")
class SHSRecord(Base):
__tablename__ = "shs_records"
id = Column(Integer, primary_key=True, autoincrement=True)
recorded_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
period_start = Column(Date, nullable=False)
period_end = Column(Date, nullable=False)
sla_stability = Column(Numeric(5, 2), default=0)
revenue_stability = Column(Numeric(5, 2), default=0)
retention = Column(Numeric(5, 2), default=0)
engineer_performance = Column(Numeric(5, 2), default=0)
incident_stability = Column(Numeric(5, 2), default=0)
sales_flow = Column(Numeric(5, 2), default=0)
operational_efficiency = Column(Numeric(5, 2), default=0)
shs_score = Column(Numeric(5, 2), default=0)
shs_status = Column(String(20), default="")
components_json = Column(JSON)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
recorder = relationship("User")
class BlogPost(Base):
__tablename__ = "blog_posts"
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(500), nullable=False)
slug = Column(String(500), unique=True, nullable=False)
content = Column(Text, nullable=False)
excerpt = Column(String(1000), default="")
category = Column(String(50), nullable=False, default="audit")
author_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
status = Column(String(20), nullable=False, default="draft")
published_at = Column(DateTime)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
__table_args__ = (
CheckConstraint("category IN ('audit','sla','incident','supervision','documentation','risk','cases')", name="ck_blog_category"),
CheckConstraint("status IN ('draft','published','archived')", name="ck_blog_status"),
)
author = relationship("User")
class FormulaCoefficient(Base):
__tablename__ = "formula_coefficients"
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(100), unique=True, nullable=False)
value = Column(Numeric(10, 4), nullable=False, default=0)
description = Column(String(500), default="")
formula_ref = Column(String(255), default="")
is_active = Column(Boolean, nullable=False, default=True)
updated_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
updater = relationship("User")
class Case(Base):
__tablename__ = "cases"
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(500), nullable=False)
text = Column(Text, nullable=False)
effect = Column(Text, nullable=False)
sort_order = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
View File
View File
View File
@@ -0,0 +1,65 @@
import os
import json
from pathlib import Path
from app.config import get_settings
settings = get_settings()
PERMISSIONS_FILE = os.path.join(settings.PERMISSIONS_DIR, "docs_permissions.json")
def load_permissions() -> dict:
if os.path.exists(PERMISSIONS_FILE):
with open(PERMISSIONS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
return {}
def save_permissions(perms: dict):
os.makedirs(settings.PERMISSIONS_DIR, exist_ok=True)
with open(PERMISSIONS_FILE, "w", encoding="utf-8") as f:
json.dump(perms, f, indent=2, ensure_ascii=False)
def discover_docs() -> dict:
docs_dir = settings.DOCS_DIR
if not os.path.exists(docs_dir):
return {}
perms = load_permissions()
existing_slugs = set(perms.keys())
for filename in os.listdir(docs_dir):
if filename.endswith(".md"):
slug = Path(filename).stem
if slug not in existing_slugs:
title = slug.replace("-", " ").title()
perms[slug] = {
"title": title,
"filename": filename,
"sort_order": len(perms),
"permissions": {
"engineer": {"view": False, "edit": False, "cancel": False}
},
}
save_permissions(perms)
return perms
def get_allowed_docs(role: str) -> list[dict]:
perms = discover_docs()
if role == "owner":
return sorted(perms.values(), key=lambda x: x.get("sort_order", 0))
if role == "engineer":
return [
doc for doc in perms.values()
if doc.get("permissions", {}).get("engineer", {}).get("view", False)
]
return []
def can_access_doc(role: str, slug: str, action: str = "view") -> bool:
if role == "owner":
return True
if role == "engineer":
perms = load_permissions()
doc = perms.get(slug, {})
return doc.get("permissions", {}).get("engineer", {}).get(action, False)
return False
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}Service Portal — AegisOne Engineering{% endblock %}</title>
<link rel="stylesheet" href="/service-style.php">
<link rel="icon" type="image/x-icon" href="/assets/img/favicon.ico">
{% block extra_head %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
{% block content %}{% endblock %}
{% block extra_scripts %}{% endblock %}
</body>
</html>
+126
View File
@@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block body_class %}dashboard-page{% endblock %}
{% 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">Aegis<span>One</span></div>
<div style="font-size:10px;color:var(--text-muted);">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>
{% 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>
{% 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 %}
<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/docs">Просмотр документации</a>
{% endif %}
{% if user.role == 'owner' %}
<a href="/service/docs-manage">Управление документами</a>
{% endif %}
</nav>
<div class="sidebar-footer">
<div class="user-name">{{ user.full_name }}</div>
<div class="user-role">{{ {'owner': 'Владелец', 'engineer': 'Инженер', 'technician': 'Техник'}[user.role] }}</div>
<a href="/service/logout" style="display:inline-block;margin-top:6px;font-size:12px;">Выйти</a>
</div>
</aside>
<main class="main">
<div class="main-header">
<div>
<h1>Дашборд</h1>
<div class="breadcrumb"><a href="/service/dashboard">Главная</a></div>
</div>
<div class="actions">
<span style="font-size:12px;color:var(--text-muted);">{{ now }}</span>
</div>
</div>
{% if shs_alert %}
<div class="shs-alert critical">⚠ {{ shs_alert }}</div>
{% endif %}
<div class="card-grid">
<a href="/service/objects" class="card card-link">
<div class="card-label">Активные объекты</div>
<div class="card-value value-accent">{{ stats.objects }}</div>
</a>
<a href="/service/tasks" class="card card-link">
<div class="card-label">Активные задачи</div>
<div class="card-value {% if stats.tasks > 0 %}value-yellow{% else %}value-green{% endif %}">{{ stats.tasks }}</div>
</a>
<a href="/service/incidents" class="card card-link">
<div class="card-label">Открытые инциденты</div>
<div class="card-value {% if stats.incidents > 0 %}value-red{% else %}value-green{% endif %}">{{ stats.incidents }}</div>
</a>
<a href="/service/sla" class="card card-link">
<div class="card-label">SLA контракты</div>
<div class="card-value value-info">{{ stats.sla_count }}</div>
</a>
<a href="/service/sla" class="card card-link">
<div class="card-label">SLA Compliance</div>
<div class="card-value {% if stats.sla_compliance >= 90 %}value-green{% elif stats.sla_compliance >= 70 %}value-yellow{% else %}value-red{% endif %}">{{ stats.sla_compliance }}%</div>
</a>
{% if stats.shs %}
<a href="/service/ceo" class="card card-link">
<div class="card-label">SHS (System Health)</div>
<div class="card-value {% if stats.shs.shs_status in ['Growth', 'Stable'] %}value-green{% else %}value-red{% endif %}">{{ stats.shs.shs_score }}</div>
<div class="card-label"><span class="badge badge-{{ stats.shs.shs_status|lower }}">{{ stats.shs.shs_status }}</span></div>
</a>
{% endif %}
{% if stats.my_tasks is defined %}
<a href="/service/tasks" class="card card-link">
<div class="card-label">Мои задачи</div>
<div class="card-value">{{ stats.my_tasks }}</div>
</a>
{% endif %}
</div>
</main>
</div>
<style>
.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>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block body_class %}login-page{% endblock %}
{% block content %}
<div class="login-box">
<div class="login-brand">
<img src="/logo.php" alt="AegisOne" class="login-brand__img">
<div class="login-brand__title">AegisOne Engineering</div>
<div class="login-brand__subtitle">Service Portal</div>
</div>
<div class="login-divider"></div>
<h1>Вход в сервисный портал</h1>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post">
<div class="form-group">
<label for="login">Логин</label>
<input type="text" id="login" name="login" placeholder="Введите логин" required autocomplete="username" autofocus>
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input type="password" id="password" name="password" placeholder="Введите пароль" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary btn-block">Войти</button>
</form>
<p class="login-box__footnote">* Сервисный портал для сотрудников AegisOne Engineering</p>
</div>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
version: "3.8"
services:
postgres:
image: postgres:16-alpine
container_name: aegisone-postgres
environment:
POSTGRES_DB: aegisone
POSTGRES_USER: aegisone
POSTGRES_PASSWORD: aegisone_pass
volumes:
- 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"]
interval: 10s
timeout: 5s
retries: 5
app:
build: .
container_name: aegisone-app
environment:
DATABASE_URL: postgresql+asyncpg://aegisone:aegisone_pass@postgres:5432/aegisone
SECRET_KEY: ${SECRET_KEY:-change-me-to-random-string}
SESSION_TTL: 3600
APP_ENV: production
LOG_LEVEL: info
depends_on:
postgres:
condition: service_healthy
ports:
- "8000:8000"
volumes:
- ./docs:/app/docs
- ./permissions:/app/permissions
- ./uploads:/app/uploads
networks:
- aegisone-net
restart: unless-stopped
volumes:
pgdata:
networks:
aegisone-net:
driver: bridge
+14
View File
@@ -0,0 +1,14 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.0
python-multipart==0.0.18
jinja2==3.1.4
passlib[bcrypt]==1.7.4
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
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""
MySQL → PostgreSQL Data Migration Script
Reads from MySQL, transforms data, writes to PostgreSQL.
Handles ENUM→VARCHAR, TINYINT→BOOLEAN, JSON→JSONB, AUTO_INCREMENT→SERIAL.
"""
import mysql.connector
import asyncpg
import asyncio
import json
import os
from dotenv import load_dotenv
load_dotenv()
MYSQL_CONFIG = {
"host": os.getenv("MYSQL_HOST", "localhost"),
"port": int(os.getenv("MYSQL_PORT", "3306")),
"user": os.getenv("MYSQL_USER", "root"),
"password": os.getenv("MYSQL_PASSWORD", ""),
"database": os.getenv("MYSQL_DATABASE", "aegisone"),
}
PG_DSN = os.getenv("PG_DSN", "postgresql://aegisone:aegisone_pass@localhost:5432/aegisone")
TABLES = [
"users", "customers", "objects", "object_assignments",
"tasks", "task_comments", "task_photos", "reports",
"questionnaire_sessions", "questionnaire_answers", "questionnaire_items",
"object_passports", "sla_contracts", "incidents",
"engineer_kpi", "shs_records", "blog_posts",
"formula_coefficients", "cases", "login_attempts", "audit_log",
]
ENUM_MAP = {
"users.role": {"owner": "owner", "engineer": "engineer", "technician": "technician"},
"objects.status": {"active": "active", "inactive": "inactive", "audit": "audit", "prospective": "prospective"},
"tasks.priority": {"P1": "P1", "P2": "P2", "P3": "P3", "P4": "P4"},
"tasks.status": {"open": "open", "in_progress": "in_progress", "completed": "completed", "cancelled": "cancelled"},
"reports.report_type": {"inspection": "inspection", "service": "service", "emergency": "emergency", "audit": "audit", "monthly": "monthly"},
"reports.result_status": {"fixed": "fixed", "partial": "partial", "revisit": "revisit", "ok": "ok"},
"reports.status": {"draft": "draft", "final": "final", "cancelled": "cancelled"},
"questionnaire_sessions.status": {"draft": "draft", "completed": "completed", "cancelled": "cancelled"},
"questionnaire_items.type": {"text": "text", "number": "number", "select": "select", "radio": "radio", "checkbox": "checkbox", "textarea": "textarea"},
"sla_contracts.service_level": {"start": "start", "business": "business", "enterprise": "enterprise"},
"sla_contracts.status": {"active": "active", "expired": "expired", "cancelled": "cancelled", "negotiation": "negotiation"},
"incidents.severity": {"P1": "P1", "P2": "P2", "P3": "P3"},
"incidents.status": {"open": "open", "in_progress": "in_progress", "resolved": "resolved", "closed": "closed"},
"blog_posts.category": {"audit": "audit", "sla": "sla", "incident": "incident", "supervision": "supervision", "documentation": "documentation", "risk": "risk", "cases": "cases"},
"blog_posts.status": {"draft": "draft", "published": "published", "archived": "archived"},
"object_assignments.role": {"engineer": "engineer", "technician": "technician"},
"customers.status": {"active": "active", "inactive": "inactive", "prospect": "prospect"},
"task_photos.type": {"before": "before", "after": "after", "evidence": "evidence", "other": "other"},
}
BOOL_COLUMNS = {
"users": ["is_active"],
"tasks": ["sla_compliant"],
"reports": ["client_confirmed"],
"questionnaire_items": ["required", "is_active"],
"object_passports": [],
"sla_contracts": [],
"incidents": ["sla_breached"],
"engineer_kpi": [],
"shs_records": [],
"blog_posts": [],
"formula_coefficients": ["is_active"],
"cases": ["is_active"],
"customers": [],
"objects": [],
"object_assignments": [],
"task_comments": [],
"task_photos": [],
"questionnaire_sessions": [],
"questionnaire_answers": [],
"login_attempts": [],
"audit_log": [],
}
SKIP_COLUMNS = {
"users": [],
"customers": [],
"objects": [],
"object_assignments": [],
"tasks": [],
"task_comments": [],
"task_photos": [],
"reports": [],
"questionnaire_sessions": [],
"questionnaire_answers": [],
"questionnaire_items": [],
"object_passports": [],
"sla_contracts": [],
"incidents": [],
"engineer_kpi": [],
"shs_records": [],
"blog_posts": [],
"formula_coefficients": [],
"cases": [],
"login_attempts": [],
"audit_log": [],
}
def transform_row(table: str, row: dict) -> dict:
result = {}
for key, value in row.items():
if key in SKIP_COLUMNS.get(table, []):
continue
if value is None:
result[key] = None
continue
enum_key = f"{table}.{key}"
if enum_key in ENUM_MAP:
result[key] = ENUM_MAP[enum_key].get(value, value)
elif table in BOOL_COLUMNS and key in BOOL_COLUMNS[table]:
result[key] = bool(value)
elif isinstance(value, (bytes, bytearray)):
result[key] = value.decode("utf-8")
elif isinstance(value, dict) or isinstance(value, list):
result[key] = json.dumps(value, ensure_ascii=False)
else:
result[key] = value
return result
async def migrate_table(mysql_conn, pg_conn, table: str):
cursor = mysql_conn.cursor(dictionary=True)
cursor.execute(f"SELECT * FROM {table}")
rows = cursor.fetchall()
if not rows:
print(f" {table}: нет данных")
return
columns = list(rows[0].keys())
placeholders = ", ".join([f"${i+1}" for i in range(len(columns))])
col_names = ", ".join(columns)
inserted = 0
errors = 0
for row in rows:
try:
transformed = transform_row(table, row)
values = [transformed.get(c) for c in columns]
await pg_conn.execute(
f"INSERT INTO {table} ({col_names}) VALUES ({placeholders}) ON CONFLICT DO NOTHING",
*values,
)
inserted += 1
except Exception as e:
errors += 1
print(f" Ошибка в {table}: {e}")
print(f" {table}: {inserted} записей, {errors} ошибок")
async def main():
print("Подключение к MySQL...")
mysql_conn = mysql.connector.connect(**MYSQL_CONFIG)
print("Подключение к PostgreSQL...")
pg_conn = await asyncpg.connect(PG_DSN)
total_tables = len(TABLES)
for i, table in enumerate(TABLES, 1):
print(f"[{i}/{total_tables}] Миграция {table}...")
try:
await migrate_table(mysql_conn, pg_conn, table)
except Exception as e:
print(f" Ошибка миграции {table}: {e}")
await pg_conn.close()
mysql_conn.close()
print("\nМиграция завершена.")
if __name__ == "__main__":
asyncio.run(main())
+401
View File
@@ -0,0 +1,401 @@
-- AegisOne Engineering — PostgreSQL Schema
-- Adapted from MySQL for Python/FastAPI migration
-- Users
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
login VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'technician' CHECK (role IN ('owner','engineer','technician')),
full_name VARCHAR(255) NOT NULL,
phone VARCHAR(50) DEFAULT '',
email VARCHAR(255) DEFAULT '',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_role ON users(role);
CREATE INDEX idx_users_active ON users(is_active);
-- Login attempts
CREATE TABLE IF NOT EXISTS login_attempts (
id SERIAL PRIMARY KEY,
ip_address VARCHAR(45) NOT NULL,
login VARCHAR(50) NOT NULL DEFAULT '',
attempt_time TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_login_attempts_ip ON login_attempts(ip_address);
CREATE INDEX idx_login_attempts_time ON login_attempts(attempt_time);
-- Audit log
CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(255) NOT NULL,
details TEXT,
ip_address VARCHAR(45) DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_user ON audit_log(user_id);
CREATE INDEX idx_audit_action ON audit_log(action);
CREATE INDEX idx_audit_created ON audit_log(created_at);
-- Customers
CREATE TABLE IF NOT EXISTS customers (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
inn VARCHAR(12) DEFAULT '',
kpp VARCHAR(9) DEFAULT '',
legal_address VARCHAR(500) DEFAULT '',
contact_person VARCHAR(255) DEFAULT '',
contact_phone VARCHAR(50) DEFAULT '',
contact_email VARCHAR(255) DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active','inactive','prospect')),
notes TEXT,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_customers_status ON customers(status);
CREATE INDEX idx_customers_inn ON customers(inn);
-- Objects
CREATE TABLE IF NOT EXISTS objects (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
name VARCHAR(255) NOT NULL,
address VARCHAR(500) DEFAULT '',
object_type VARCHAR(100) DEFAULT '',
area_sqm NUMERIC(10,2) DEFAULT 0,
employees_count INTEGER DEFAULT 0,
contact_person VARCHAR(255) DEFAULT '',
contact_phone VARCHAR(50) DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active','inactive','audit','prospective')),
notes TEXT,
risk_score NUMERIC(5,2) DEFAULT 0,
complexity_index NUMERIC(5,2) DEFAULT 0,
infrastructure_load NUMERIC(5,2) DEFAULT 0,
service_history NUMERIC(5,2) DEFAULT 0,
object_index NUMERIC(5,2) DEFAULT 0,
sla_price_monthly NUMERIC(12,2) DEFAULT 0,
region_factor NUMERIC(3,2) DEFAULT 1.00,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_objects_status ON objects(status);
CREATE INDEX idx_objects_customer ON objects(customer_id);
-- Object assignments
CREATE TABLE IF NOT EXISTS object_assignments (
id SERIAL PRIMARY KEY,
object_id INTEGER NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL CHECK (role IN ('engineer','technician')),
assigned_at TIMESTAMP NOT NULL DEFAULT NOW(),
unassigned_at TIMESTAMP DEFAULT NULL
);
CREATE INDEX idx_assignments_object ON object_assignments(object_id);
CREATE INDEX idx_assignments_user ON object_assignments(user_id);
-- Tasks
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
object_id INTEGER NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
priority VARCHAR(4) NOT NULL DEFAULT 'P3' CHECK (priority IN ('P1','P2','P3','P4')),
status VARCHAR(20) NOT NULL DEFAULT 'open' CHECK (status IN ('open','in_progress','completed','cancelled')),
deadline TIMESTAMP,
sla_remaining_hours NUMERIC(6,1) DEFAULT 0,
response_time_minutes INTEGER DEFAULT 0,
resolution_time_minutes INTEGER DEFAULT 0,
sla_compliant BOOLEAN,
checklist_data JSONB,
result_notes TEXT,
closed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tasks_object ON tasks(object_id);
CREATE INDEX idx_tasks_assigned ON tasks(assigned_to);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_priority ON tasks(priority);
-- Task comments
CREATE TABLE IF NOT EXISTS task_comments (
id SERIAL PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
comment TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_comments_task ON task_comments(task_id);
-- Task photos
CREATE TABLE IF NOT EXISTS task_photos (
id SERIAL PRIMARY KEY,
task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL,
report_id INTEGER REFERENCES reports(id) ON DELETE SET NULL,
file_path VARCHAR(500) NOT NULL,
description VARCHAR(500) DEFAULT '',
type VARCHAR(20) DEFAULT 'other' CHECK (type IN ('before','after','evidence','other')),
uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_photos_task ON task_photos(task_id);
-- Reports (created after task_photos for FK reference)
CREATE TABLE IF NOT EXISTS reports (
id SERIAL PRIMARY KEY,
object_id INTEGER NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
report_type VARCHAR(20) NOT NULL DEFAULT 'service' CHECK (report_type IN ('inspection','service','emergency','audit','monthly')),
title VARCHAR(500) DEFAULT '',
description TEXT,
findings TEXT,
recommendations TEXT,
work_done TEXT,
cause_classification VARCHAR(100),
result_status VARCHAR(20) DEFAULT 'ok' CHECK (result_status IN ('fixed','partial','revisit','ok')),
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','final','cancelled')),
client_confirmed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_reports_object ON reports(object_id);
CREATE INDEX idx_reports_author ON reports(created_by);
-- Add FK for task_photos.report_id after reports table exists
-- (handled in application-level migration)
-- Questionnaire sessions
CREATE TABLE IF NOT EXISTS questionnaire_sessions (
id SERIAL PRIMARY KEY,
object_id INTEGER REFERENCES objects(id) ON DELETE SET NULL,
client_name VARCHAR(255) DEFAULT '',
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','completed','cancelled')),
current_step SMALLINT NOT NULL DEFAULT 1,
risk_score NUMERIC(5,2) DEFAULT 0,
complexity_index NUMERIC(5,2) DEFAULT 0,
infrastructure_load NUMERIC(5,2) DEFAULT 0,
service_history NUMERIC(5,2) DEFAULT 0,
object_index NUMERIC(5,2) DEFAULT 0,
sla_price_monthly NUMERIC(12,2) DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_qsessions_object ON questionnaire_sessions(object_id);
CREATE INDEX idx_qsessions_author ON questionnaire_sessions(created_by);
-- Questionnaire answers
CREATE TABLE IF NOT EXISTS questionnaire_answers (
id SERIAL PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES questionnaire_sessions(id) ON DELETE CASCADE,
step SMALLINT NOT NULL,
question_key VARCHAR(100) NOT NULL,
answer_value TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_qanswers_session ON questionnaire_answers(session_id);
CREATE INDEX idx_qanswers_step ON questionnaire_answers(step);
-- Questionnaire items
CREATE TABLE IF NOT EXISTS questionnaire_items (
id SERIAL PRIMARY KEY,
step INTEGER NOT NULL DEFAULT 1,
section VARCHAR(100) NOT NULL DEFAULT '',
question_key VARCHAR(100) NOT NULL UNIQUE,
label VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'text' CHECK (type IN ('text','number','select','radio','checkbox','textarea')),
options JSONB,
required BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
help_text VARCHAR(500) DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_qitems_step ON questionnaire_items(step);
CREATE INDEX idx_qitems_key ON questionnaire_items(question_key);
CREATE INDEX idx_qitems_sort ON questionnaire_items(sort_order);
-- Object passports
CREATE TABLE IF NOT EXISTS object_passports (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES questionnaire_sessions(id) ON DELETE SET NULL,
object_id INTEGER REFERENCES objects(id) ON DELETE SET NULL,
object_index NUMERIC(5,2) DEFAULT 0,
risk_score NUMERIC(5,2) DEFAULT 0,
complexity_index NUMERIC(5,2) DEFAULT 0,
sla_price_monthly NUMERIC(12,2) DEFAULT 0,
passport_data JSONB,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_passports_object ON object_passports(object_id);
CREATE INDEX idx_passports_session ON object_passports(session_id);
-- SLA contracts
CREATE TABLE IF NOT EXISTS sla_contracts (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
object_id INTEGER NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
contract_number VARCHAR(50) DEFAULT '',
client_name VARCHAR(255) DEFAULT '',
description TEXT,
start_date DATE NOT NULL,
end_date DATE,
sla_price_monthly NUMERIC(12,2) NOT NULL DEFAULT 0,
penalty_rate NUMERIC(5,2) DEFAULT 0,
response_time_p1 INTEGER DEFAULT 4,
response_time_p2 INTEGER DEFAULT 8,
response_time_p3 INTEGER DEFAULT 24,
response_time_hours NUMERIC(5,1),
service_level VARCHAR(20) NOT NULL DEFAULT 'business' CHECK (service_level IN ('start','business','enterprise')),
status VARCHAR(20) NOT NULL DEFAULT 'negotiation' CHECK (status IN ('active','expired','cancelled','negotiation')),
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_sla_object ON sla_contracts(object_id);
CREATE INDEX idx_sla_status ON sla_contracts(status);
CREATE INDEX idx_sla_customer ON sla_contracts(customer_id);
-- Incidents
CREATE TABLE IF NOT EXISTS incidents (
id SERIAL PRIMARY KEY,
object_id INTEGER NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
reported_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
severity VARCHAR(4) NOT NULL DEFAULT 'P3' CHECK (severity IN ('P1','P2','P3')),
status VARCHAR(20) NOT NULL DEFAULT 'open' CHECK (status IN ('open','in_progress','resolved','closed')),
resolution TEXT,
cause VARCHAR(500) DEFAULT '',
resolution_time_minutes INTEGER DEFAULT 0,
sla_breached BOOLEAN DEFAULT FALSE,
resolved_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_incidents_object ON incidents(object_id);
CREATE INDEX idx_incidents_severity ON incidents(severity);
CREATE INDEX idx_incidents_status ON incidents(status);
CREATE INDEX idx_incidents_assigned ON incidents(assigned_to);
-- Engineer KPI
CREATE TABLE IF NOT EXISTS engineer_kpi (
id SERIAL PRIMARY KEY,
engineer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
sla_compliance NUMERIC(5,2) DEFAULT 0,
response_time_score NUMERIC(5,2) DEFAULT 0,
resolution_time_score NUMERIC(5,2) DEFAULT 0,
diagnosis_accuracy NUMERIC(5,2) DEFAULT 0,
reopen_rate NUMERIC(5,2) DEFAULT 0,
risk_coverage_score NUMERIC(5,2) DEFAULT 0,
documentation_quality NUMERIC(5,2) DEFAULT 0,
engineer_score NUMERIC(5,2) DEFAULT 0,
grade VARCHAR(20) DEFAULT '',
components_json JSONB,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_kpi_engineer ON engineer_kpi(engineer_id);
CREATE INDEX idx_kpi_period ON engineer_kpi(period_start, period_end);
-- SHS records
CREATE TABLE IF NOT EXISTS shs_records (
id SERIAL PRIMARY KEY,
recorded_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
sla_stability NUMERIC(5,2) DEFAULT 0,
revenue_stability NUMERIC(5,2) DEFAULT 0,
retention NUMERIC(5,2) DEFAULT 0,
engineer_performance NUMERIC(5,2) DEFAULT 0,
incident_stability NUMERIC(5,2) DEFAULT 0,
sales_flow NUMERIC(5,2) DEFAULT 0,
operational_efficiency NUMERIC(5,2) DEFAULT 0,
shs_score NUMERIC(5,2) DEFAULT 0,
shs_status VARCHAR(20) DEFAULT '',
components_json JSONB,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_shs_period ON shs_records(period_start, period_end);
-- Blog posts
CREATE TABLE IF NOT EXISTS blog_posts (
id SERIAL PRIMARY KEY,
title VARCHAR(500) NOT NULL,
slug VARCHAR(500) NOT NULL UNIQUE,
content TEXT NOT NULL,
excerpt VARCHAR(1000) DEFAULT '',
category VARCHAR(50) NOT NULL DEFAULT 'audit' CHECK (category IN ('audit','sla','incident','supervision','documentation','risk','cases')),
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','archived')),
published_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_blog_slug ON blog_posts(slug);
CREATE INDEX idx_blog_category ON blog_posts(category);
CREATE INDEX idx_blog_status ON blog_posts(status);
CREATE INDEX idx_blog_published ON blog_posts(published_at);
-- Formula coefficients
CREATE TABLE IF NOT EXISTS formula_coefficients (
id SERIAL PRIMARY KEY,
key VARCHAR(100) NOT NULL UNIQUE,
value NUMERIC(10,4) NOT NULL DEFAULT 0,
description VARCHAR(500) DEFAULT '',
formula_ref VARCHAR(255) DEFAULT '',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_coeff_key ON formula_coefficients(key);
CREATE INDEX idx_coeff_active ON formula_coefficients(is_active);
-- Cases
CREATE TABLE IF NOT EXISTS cases (
id SERIAL PRIMARY KEY,
title VARCHAR(500) NOT NULL,
text TEXT NOT NULL,
effect TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_cases_sort ON cases(sort_order);
CREATE INDEX idx_cases_active ON cases(is_active);
-- Updated_at trigger function
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply triggers
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_customers_updated_at BEFORE UPDATE ON customers FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_objects_updated_at BEFORE UPDATE ON objects FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_tasks_updated_at BEFORE UPDATE ON tasks FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_reports_updated_at BEFORE UPDATE ON reports FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_qsessions_updated_at BEFORE UPDATE ON questionnaire_sessions FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_qitems_updated_at BEFORE UPDATE ON questionnaire_items FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_sla_updated_at BEFORE UPDATE ON sla_contracts FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_incidents_updated_at BEFORE UPDATE ON incidents FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_blog_updated_at BEFORE UPDATE ON blog_posts FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_cases_updated_at BEFORE UPDATE ON cases FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_coeff_updated_at BEFORE UPDATE ON formula_coefficients FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+149
View File
@@ -0,0 +1,149 @@
-- Seed data for PostgreSQL
-- Default owner (password: AegisOne2024!)
INSERT INTO users (login, password_hash, role, full_name, is_active)
VALUES ('owner', '$2b$12$LJ3m4ys3Lk8KqJqKqKqKqO5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'owner', 'System Owner', TRUE)
ON CONFLICT (login) DO NOTHING;
-- Formula coefficients
INSERT INTO formula_coefficients (key, value, description, formula_ref) VALUES
('risk_score.no_archive', 25, 'Нет архива видеонаблюдения', 'calc_risk_score()'),
('risk_score.no_power_backup', 20, 'Нет резервного питания', 'calc_risk_score()'),
('risk_score.no_regulations', 15, 'Нет регламента обслуживания', 'calc_risk_score()'),
('risk_score.system_failures', 20, 'Частые сбои систем', 'calc_risk_score()'),
('risk_score.no_documentation', 10, 'Нет документации', 'calc_risk_score()'),
('risk_score.bound_low', 20, 'Граница низкого риска', 'risk_label()'),
('risk_score.bound_medium', 50, 'Граница среднего риска', 'risk_label()'),
('risk_score.bound_high', 75, 'Граница высокого риска', 'risk_label()'),
('risk_multiplier.low', 1.0, 'Множитель низкого риска (0-20)', 'risk_multiplier()'),
('risk_multiplier.medium', 1.3, 'Множитель среднего риска (21-50)', 'risk_multiplier()'),
('risk_multiplier.high', 1.6, 'Множитель высокого риска (51-75)', 'risk_multiplier()'),
('risk_multiplier.critical', 2.0, 'Множитель критического риска (76-100)', 'risk_multiplier()'),
('complexity.video_0_20', 10, 'Видеонаблюдение до 20 камер', 'calc_complexity()'),
('complexity.video_20_100', 20, 'Видеонаблюдение 20-100 камер', 'calc_complexity()'),
('complexity.video_100plus', 35, 'Видеонаблюдение 100+ камер', 'calc_complexity()'),
('complexity.access_0_5', 10, 'СКУД до 5 точек', 'calc_complexity()'),
('complexity.access_5_20', 20, 'СКУД 5-20 точек', 'calc_complexity()'),
('complexity.access_20plus', 30, 'СКУД 20+ точек', 'calc_complexity()'),
('complexity.fire_simple', 15, 'Пожарная сигнализация простая', 'calc_complexity()'),
('complexity.fire_medium', 25, 'Пожарная сигнализация средняя', 'calc_complexity()'),
('complexity.fire_complex', 40, 'Пожарная сигнализация сложная (>5000 м²)', 'calc_complexity()'),
('complexity.it', 10, 'IT-инфраструктура', 'calc_complexity()'),
('infra.server_none', 15, 'Нет сервера', 'calc_infrastructure_load()'),
('infra.server_weak', 10, 'Слабый сервер', 'calc_infrastructure_load()'),
('infra.network_unstable', 20, 'Нестабильная сеть', 'calc_infrastructure_load()'),
('infra.network_partial', 10, 'Частичная сеть', 'calc_infrastructure_load()'),
('infra.power_none', 20, 'Нет UPS', 'calc_infrastructure_load()'),
('infra.power_weak', 10, 'Слабый UPS', 'calc_infrastructure_load()'),
('history.none', 30, 'Нет обслуживания', 'calc_service_history()'),
('history.irregular', 20, 'Нерегулярное обслуживание', 'calc_service_history()'),
('history.formal', 10, 'Формальный подрядчик', 'calc_service_history()'),
('history.sla', 0, 'Есть SLA', 'calc_service_history()'),
('object_index.risk_weight', 0.4, 'Вес Risk Score', 'calc_object_index()'),
('object_index.complexity_weight', 0.3, 'Вес Complexity', 'calc_object_index()'),
('object_index.infra_weight', 0.2, 'Вес Infra Load', 'calc_object_index()'),
('object_index.history_weight', 0.1, 'Вес Service History', 'calc_object_index()'),
('sla_price.base_cost', 15000, 'Базовая стоимость инженера', 'calc_sla_price()'),
('sla_price.region_default', 1.0, 'Региональный коэффициент по умолчанию', 'calc_sla_price()'),
('engineer.score.sla_weight', 0.25, 'Вес SLA Compliance', 'calc_engineer_score()'),
('engineer.score.response_weight', 0.20, 'Вес Response Time', 'calc_engineer_score()'),
('engineer.score.resolution_weight', 0.20, 'Вес Resolution Time', 'calc_engineer_score()'),
('engineer.score.diagnosis_weight', 0.15, 'Вес Diagnosis Accuracy', 'calc_engineer_score()'),
('engineer.score.reopen_weight', 0.10, 'Вес Reopen Rate', 'calc_engineer_score()'),
('engineer.score.risk_coverage_weight', 0.10, 'Вес Risk Coverage', 'calc_engineer_score()'),
('engineer.grade.senior', 90, 'Граница Senior', 'engineer_grade()'),
('engineer.grade.strong', 80, 'Граница Strong', 'engineer_grade()'),
('engineer.grade.middle', 70, 'Граница Middle', 'engineer_grade()'),
('ecs.sla_weight', 0.30, 'Вес SLA Control', 'calc_engineer_control_score()'),
('ecs.task_dist_weight', 0.25, 'Вес Task Distribution', 'calc_engineer_control_score()'),
('ecs.incident_red_weight', 0.20, 'Вес Incident Reduction', 'calc_engineer_control_score()'),
('ecs.team_perf_weight', 0.15, 'Вес Team Performance', 'calc_engineer_control_score()'),
('ecs.response_coord_weight', 0.10, 'Вес Response Coordination', 'calc_engineer_control_score()'),
('shs.sla_stability_weight', 0.22, 'Вес SLA Stability', 'calc_shs()'),
('shs.revenue_stability_weight', 0.18, 'Вес Revenue Stability', 'calc_shs()'),
('shs.retention_weight', 0.18, 'Вес Retention', 'calc_shs()'),
('shs.engineer_perf_weight', 0.15, 'Вес Engineer Performance', 'calc_shs()'),
('shs.incident_stability_weight', 0.12, 'Вес Incident Stability', 'calc_shs()'),
('shs.sales_flow_weight', 0.10, 'Вес Sales Flow', 'calc_shs()'),
('shs.operational_eff_weight', 0.05, 'Вес Operational Efficiency', 'calc_shs()'),
('shs.zone_growth', 85, 'Граница зоны Growth', 'shs_status(), shs_zone()'),
('shs.zone_stable', 70, 'Граница зоны Stable', 'shs_status(), shs_zone()'),
('shs.zone_risk', 50, 'Граница зоны Risk', 'shs_status(), shs_zone()'),
('shs.delta_warning', 5, 'Порог предупреждения ΔSHS', 'shs_delta()'),
('shs.delta_critical', 10, 'Порог критического ΔSHS', 'shs_delta()'),
('ceo_shs.mrr_weight', 0.25, 'Вес MRR Growth', 'calc_ceo_shs()'),
('ceo_shs.sla_weight', 0.20, 'Вес SLA Compliance', 'calc_ceo_shs()'),
('ceo_shs.retention_weight', 0.20, 'Вес Retention', 'calc_ceo_shs()'),
('ceo_shs.productivity_weight', 0.15, 'Вес Productivity', 'calc_ceo_shs()'),
('ceo_shs.conversion_weight', 0.10, 'Вес Conversion', 'calc_ceo_shs()'),
('ceo_shs.incident_weight', 0.10, 'Вес Incident Stability', 'calc_ceo_shs()'),
('ssi.breach_severity_p1', 1.0, 'Severity P1', 'calc_breach_severity()'),
('ssi.breach_severity_p2', 0.5, 'Severity P2', 'calc_breach_severity()'),
('ssi.breach_severity_p3', 0.2, 'Severity P3', 'calc_breach_severity()'),
('isi.severity_p1', 1.0, 'Severity P1', 'calc_incident_stability_index()'),
('isi.severity_p2', 0.5, 'Severity P2', 'calc_incident_stability_index()'),
('isi.severity_p3', 0.2, 'Severity P3', 'calc_incident_stability_index()'),
('rules.shs_threshold', 70, 'Порог SHS', 'check_automation_rules()'),
('rules.sla_threshold', 90, 'Порог SLA Compliance', 'check_automation_rules()'),
('rules.retention_threshold', 90, 'Порог Retention', 'check_automation_rules()'),
('bonus.score_90plus', 90, 'Граница премии 90+', 'calc_bonus_percent()'),
('bonus.score_80_89', 80, 'Граница премии 80-89', 'calc_bonus_percent()'),
('bonus.premium_90plus', 20, 'Премия при Score >= 90 (%)', 'calc_bonus_percent()'),
('bonus.premium_80_89', 10, 'Премия при Score 80-89 (%)', 'calc_bonus_percent()'),
('bonus.premium_below_80', 0, 'Премия при Score < 80 (%)', 'calc_bonus_percent()'),
('retention.key_client_penalty', 0.1, 'Штраф за потерю ключевого клиента', 'calc_retention_key_client()')
ON CONFLICT (key) DO NOTHING;
-- Cases
INSERT INTO cases (title, text, effect, sort_order) VALUES
('Склад крупной торговой сети', 'Выявлено: слепые зоны в зонах разгрузки, недостаточное разрешение камер в зонах погрузки/разгрузки. Одна камера не исправна, у двух не настроена запись.', 'предотвращение убытков на сумму более 5 млн рублей в год.', 1),
('Производство пищевых продуктов', 'Неправильная настройка СКУД позволяла несанкционированный доступ в производственные помещения, создавая риск нарушения норм СанПин.', 'снижение риска порчи продукции и утечки конфиденциальной информации.', 2),
('Банковский офис', 'Недостатки в системе ОПС приводили к невозможности срабатывания или задержкам в реагировании при ЧП.', 'сокращение рисков утраты имущества и ценностей, повышение уровня безопасности.', 3),
('Торгово-развлекательный центр', 'Метод «Тайный покупатель» выявил: охрана не реагирует на срабатывание металлодетекторов, оставляет посты без присмотра. Камеры на парковке не охватывают часть машиномест.', 'снижение риска краж, повышение дисциплины службы охраны.', 4),
('Гостиница', 'СКУД не синхронизирована с системой бронирования — доступ в занятые номера. ОПС в части номеров отключена. Видеокамеры имеют мёртвые зоны.', 'устранение репутационных рисков и риска хищения личных вещей гостей.', 5),
('Логистический центр', 'Отсутствие видеоконтроля на участках приёмки и отгрузки приводило к систематическим хищениям. СКУД на въезде не фиксирует данные транспортных средств.', 'сокращение потерь товара на 15%.', 6)
ON CONFLICT DO NOTHING;
-- Questionnaire items
INSERT INTO questionnaire_items (step, section, question_key, label, type, options, required, sort_order, help_text) VALUES
(1, 'Коммерческий профиль объекта', 'object_name', 'Название организации / объекта', 'text', NULL, TRUE, 1, ''),
(1, 'Коммерческий профиль объекта', 'object_type', 'Тип объекта', 'select', '["","Гостиница","Склад","Производство","ТЦ","Офис","БЦ","Медицина","Образование","Другое"]', FALSE, 2, ''),
(1, 'Коммерческий профиль объекта', 'address', 'Адрес объекта', 'text', NULL, FALSE, 3, ''),
(1, 'Коммерческий профиль объекта', 'area', 'Площадь (м²)', 'number', NULL, FALSE, 4, ''),
(1, 'Коммерческий профиль объекта', 'employees', 'Количество сотрудников', 'number', NULL, FALSE, 5, ''),
(1, 'Коммерческий профиль объекта', 'contact_person', 'Контактное лицо', 'text', NULL, FALSE, 6, ''),
(1, 'Коммерческий профиль объекта', 'contact_phone', 'Телефон', 'text', NULL, FALSE, 7, ''),
(1, 'Коммерческий профиль объекта', 'region_factor', 'Региональный коэффициент', 'select', '{"1.0":"Краснодар (1.0)","1.1":"Краснодарский край (1.1)","1.3":"РФ (1.3)","1.6":"РФ удалённые (1.6)","1.8":"Москва (1.8)"}', FALSE, 8, ''),
(2, 'Видеонаблюдение', 'has_video', 'Есть система видеонаблюдения?', 'radio', '{"1":"Да","0":"Нет"}', FALSE, 1, ''),
(2, 'Видеонаблюдение', 'camera_count', 'Количество камер', 'number', NULL, FALSE, 2, ''),
(2, 'Видеонаблюдение', 'video_type', 'Тип системы', 'select', '["","ip","analog","mixed"]', FALSE, 3, ''),
(2, 'Видеонаблюдение', 'archive_depth', 'Глубина архива', 'select', '{"7":"7 дней","14":"14 дней","30":"30+ дней"}', FALSE, 4, ''),
(2, 'СКУД', 'has_acs', 'Есть система СКУД?', 'radio', '{"1":"Да","0":"Нет"}', FALSE, 5, ''),
(2, 'СКУД', 'access_points', 'Точек доступа', 'number', NULL, FALSE, 6, ''),
(2, 'СКУД', 'acs_vendor', 'Производитель СКУД', 'text', NULL, FALSE, 7, ''),
(2, 'Пожарная сигнализация', 'has_fire', 'Есть пожарная сигнализация?', 'radio', '{"1":"Да","0":"Нет"}', FALSE, 8, ''),
(2, 'Пожарная сигнализация', 'fire_type', 'Сложность системы', 'select', '{"simple":"Простая","medium":"Средняя","complex":"Сложная (>5000 м²)"}', FALSE, 9, ''),
(2, 'Инфраструктура', 'server_state', 'Состояние сервера', 'select', '{"ok":"Нормальный","weak":"Слабый","none":"Нет"}', FALSE, 10, ''),
(2, 'Инфраструктура', 'network_state', 'Состояние сети', 'select', '{"stable":"Стабильная","partial":"Частично","unstable":"Нестабильная"}', FALSE, 11, ''),
(2, 'Инфраструктура', 'power_state', 'Электропитание (UPS)', 'select', '{"ok":"Нормальное","weak":"Слабый UPS","none":"Нет UPS"}', FALSE, 12, ''),
(3, 'Эксплуатационная модель', 'service_state', 'Как обслуживается объект сейчас?', 'select', '{"none":"Не обслуживается","irregular":"Нерегулярно","formal":"Формальный подрядчик","sla":"Есть SLA"}', FALSE, 1, ''),
(3, 'Эксплуатационная модель', 'has_regulations', 'Есть ли регламент?', 'checkbox', NULL, FALSE, 2, ''),
(3, 'Эксплуатационная модель', 'problems', 'Отметьте частые проблемы', 'checkbox', '["Не работают камеры","Пропадает архив","Зависает СКУД","Ошибки пожарки","Нет реакции подрядчика","Нет понимания состояния систем"]', FALSE, 3, ''),
(3, 'Эксплуатационная модель', 'resolution_time', 'Среднее время устранения', 'select', '{"2h":"До 2 часов","24h":"До суток","days":"Несколько дней"}', FALSE, 4, ''),
(3, 'Эксплуатационная модель', 'controller', 'Кто контролирует систему?', 'select', '{"engineer":"Штатный инженер","contractor":"Подрядчик","nobody":"Никто"}', FALSE, 5, ''),
(4, 'Risk Assessment', 'no_archive', 'Нет архива видеонаблюдения (+25)', 'checkbox', NULL, FALSE, 1, ''),
(4, 'Risk Assessment', 'no_power', 'Нет резервного питания (+20)', 'checkbox', NULL, FALSE, 2, ''),
(4, 'Risk Assessment', 'no_regulations', 'Нет регламента обслуживания (+15)', 'checkbox', NULL, FALSE, 3, ''),
(4, 'Risk Assessment', 'frequent_failures', 'Частые сбои систем (+20)', 'checkbox', NULL, FALSE, 4, ''),
(4, 'Risk Assessment', 'no_documentation', 'Нет документации (+10)', 'checkbox', NULL, FALSE, 5, ''),
(4, 'Risk Assessment', 'consequences', 'Потенциальные последствия', 'checkbox', '["loss_of_evidence","shutdown","fines","no_investigation","access_fail"]', FALSE, 6, ''),
(4, 'Risk Assessment', 'confidence', 'Уровень уверенности в системе', 'select', '{"high":"Высокая","medium":"Средняя","low":"Низкая"}', FALSE, 7, ''),
(5, 'Расчёт SLA', 'sla_level', 'Предпочтительный уровень обслуживания', 'select', '{"start":"Базовый (Start SLA)","business":"Стандарт (Business SLA)","enterprise":"Расширенный (Enterprise SLA)"}', FALSE, 1, ''),
(5, 'Расчёт SLA', 'visit_frequency', 'Интенсивность обслуживания', 'select', '{"1":"1 выезд / мес","2":"2 выезда / мес","incident":"По инцидентам"}', FALSE, 2, ''),
(5, 'Расчёт SLA', 'reaction_time', 'Требуемое время реакции', 'select', '{"24":"24 часа","4":"4 часа","2":"2 часа"}', FALSE, 3, ''),
(5, 'Расчёт SLA', 'extra_247', '24/7 поддержка', 'checkbox', NULL, FALSE, 4, ''),
(5, 'Расчёт SLA', 'extra_emergency', 'Аварийные выезды', 'checkbox', NULL, FALSE, 5, ''),
(5, 'Расчёт SLA', 'extra_audit', 'Аудит', 'checkbox', NULL, FALSE, 6, ''),
(5, 'Расчёт SLA', 'extra_docs', 'Документация', 'checkbox', NULL, FALSE, 7, ''),
(5, 'Расчёт SLA', 'extra_contractors', 'Контроль подрядчиков', 'checkbox', NULL, FALSE, 8, '')
ON CONFLICT (question_key) DO NOTHING;