Initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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()")
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user