Initial commit
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../config.php';
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
/**
|
||||
* ---------- SESSION ----------
|
||||
*/
|
||||
function auth_session_start(): void {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_set_cookie_params([
|
||||
'lifetime' => SERVICE_SESSION_TTL,
|
||||
'path' => '/service/',
|
||||
'secure' => !empty($_SERVER['HTTPS']),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
session_name('AEGIS_SRV');
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ---------- LOGIN ----------
|
||||
*/
|
||||
define('BRUTE_FORCE_WINDOW', 180);
|
||||
define('BRUTE_FORCE_MAX_ATTEMPTS', 3);
|
||||
|
||||
function auth_brute_force_check(string $login): bool {
|
||||
$pdo = getDB();
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM login_attempts WHERE (login = ? OR ip_address = ?) AND attempt_time > DATE_SUB(NOW(), INTERVAL ? SECOND)");
|
||||
$stmt->execute([$login, $_SERVER['REMOTE_ADDR'] ?? '', BRUTE_FORCE_WINDOW]);
|
||||
return $stmt->fetchColumn() >= BRUTE_FORCE_MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
function auth_record_attempt(string $login): void {
|
||||
$pdo = getDB();
|
||||
$stmt = $pdo->prepare("INSERT INTO login_attempts (ip_address, login) VALUES (?, ?)");
|
||||
$stmt->execute([$_SERVER['REMOTE_ADDR'] ?? '', $login]);
|
||||
}
|
||||
|
||||
function auth_clear_attempts(string $login): void {
|
||||
$pdo = getDB();
|
||||
$stmt = $pdo->prepare("DELETE FROM login_attempts WHERE login = ? OR ip_address = ?");
|
||||
$stmt->execute([$login, $_SERVER['REMOTE_ADDR'] ?? '']);
|
||||
}
|
||||
|
||||
function auth_login(string $login, string $password): array {
|
||||
$pdo = getDB();
|
||||
|
||||
if (auth_brute_force_check($login)) {
|
||||
return ['success' => false, 'error' => 'Превышен лимит попыток. Подождите 3 минуты.'];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id, login, password_hash, role, full_name, is_active FROM users WHERE login = ? LIMIT 1");
|
||||
$stmt->execute([$login]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||
auth_record_attempt($login);
|
||||
return ['success' => false, 'error' => 'Неверный логин или пароль.'];
|
||||
}
|
||||
|
||||
if (!$user['is_active']) {
|
||||
return ['success' => false, 'error' => 'Учётная запись заблокирована.'];
|
||||
}
|
||||
|
||||
auth_clear_attempts($login);
|
||||
|
||||
$_SESSION['user_id'] = (int)$user['id'];
|
||||
$_SESSION['user_login'] = $user['login'];
|
||||
$_SESSION['user_role'] = $user['role'];
|
||||
$_SESSION['user_name'] = $user['full_name'];
|
||||
$_SESSION['auth_time'] = time();
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
|
||||
auth_log($user['id'], 'login', 'Вход в систему');
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* ---------- LOGOUT ----------
|
||||
*/
|
||||
function auth_logout(): void {
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
auth_log($_SESSION['user_id'], 'logout', 'Выход из системы');
|
||||
}
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
setcookie(session_name(), '', time() - 42000, '/service/');
|
||||
}
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* ---------- GUARD ----------
|
||||
*/
|
||||
function auth_require(): array {
|
||||
auth_session_start();
|
||||
$allowed_roles = func_get_args();
|
||||
if (empty($_SESSION['user_id'])) {
|
||||
header('Location: /service/index.php?redirect=' . urlencode($_SERVER['REQUEST_URI']));
|
||||
exit;
|
||||
}
|
||||
if (!empty($allowed_roles) && !in_array($_SESSION['user_role'], $allowed_roles, true)) {
|
||||
http_response_code(403);
|
||||
require __DIR__ . '/../pages/shared/403.php';
|
||||
exit;
|
||||
}
|
||||
return $_SESSION;
|
||||
}
|
||||
|
||||
function auth_has_role(string $role): bool {
|
||||
return isset($_SESSION['user_role']) && $_SESSION['user_role'] === $role;
|
||||
}
|
||||
|
||||
/**
|
||||
* ---------- CSRF ----------
|
||||
*/
|
||||
function auth_csrf_token(): string {
|
||||
return $_SESSION['csrf_token'] ?? '';
|
||||
}
|
||||
|
||||
function auth_csrf_verify(string $token): bool {
|
||||
return hash_equals($_SESSION['csrf_token'] ?? '', $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* ---------- AUDIT LOG ----------
|
||||
*/
|
||||
function auth_log(?int $userId, string $action, string $details = ''): void {
|
||||
try {
|
||||
$pdo = getDB();
|
||||
$stmt = $pdo->prepare("INSERT INTO audit_log (user_id, action, details, ip_address) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$userId, $action, $details, $_SERVER['REMOTE_ADDR'] ?? '']);
|
||||
} catch (\Throwable $e) {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../data/db-config.php';
|
||||
|
||||
function getDB(): PDO {
|
||||
static $pdo = null;
|
||||
if ($pdo === null) {
|
||||
if (DB_TYPE === 'pgsql') {
|
||||
$dsn = 'pgsql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME;
|
||||
} else {
|
||||
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET;
|
||||
}
|
||||
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
}
|
||||
return $pdo;
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
<?php
|
||||
/**
|
||||
* AegisOne Engineering — Функции управления документацией в Markdown
|
||||
*
|
||||
* Обеспечивает:
|
||||
* - Автоматическое обнаружение .md-файлов в service/docs/
|
||||
* - Разграничение доступа по ролям (owner — всё, engineer — только разрешённое)
|
||||
* - Хранение прав в JSON-файле service/permissions/docs_permissions.json
|
||||
*/
|
||||
|
||||
define('DOCS_DIR', __DIR__ . '/../docs');
|
||||
define('DOCS_PERMISSIONS_FILE', __DIR__ . '/../permissions/docs_permissions.json');
|
||||
|
||||
/**
|
||||
* Сканирует папку docs/ и возвращает массив документов.
|
||||
* Автоматически добавляет новые файлы в файл прав (если их там нет).
|
||||
*
|
||||
* @return array Массив документов: [['slug'=>'...', 'title'=>'...', 'filename'=>'...'], ...]
|
||||
*/
|
||||
function get_docs_list(): array
|
||||
{
|
||||
if (!is_dir(DOCS_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = glob(DOCS_DIR . '/*.md');
|
||||
if ($files === false || empty($files)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$docs = [];
|
||||
$permissions = load_permissions();
|
||||
|
||||
foreach ($files as $path) {
|
||||
$filename = basename($path);
|
||||
$slug = doc_slug($filename);
|
||||
$title = doc_title($slug, $filename);
|
||||
|
||||
// Автоматически добавляем новый документ в права, если его ещё нет
|
||||
if (!isset($permissions[$slug])) {
|
||||
$permissions[$slug] = [
|
||||
'title' => $title,
|
||||
'filename' => $filename,
|
||||
'sort_order' => count($permissions),
|
||||
'engineer' => false,
|
||||
];
|
||||
}
|
||||
|
||||
// Миграция: старый boolean → новый array permissions
|
||||
if (isset($permissions[$slug]['engineer']) && !isset($permissions[$slug]['permissions'])) {
|
||||
$permissions[$slug]['permissions'] = [
|
||||
'engineer' => [
|
||||
'view' => !empty($permissions[$slug]['engineer']),
|
||||
'edit' => false,
|
||||
'cancel' => false,
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// Если файл на диске отличается от сохранённого — обновляем
|
||||
if ($permissions[$slug]['filename'] !== $filename) {
|
||||
$permissions[$slug]['filename'] = $filename;
|
||||
}
|
||||
|
||||
$docs[] = [
|
||||
'slug' => $slug,
|
||||
'title' => $permissions[$slug]['title'],
|
||||
'filename' => $filename,
|
||||
'sort_order' => $permissions[$slug]['sort_order'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Сохраняем, если были добавлены новые
|
||||
save_permissions($permissions);
|
||||
|
||||
// Сортировка по sort_order (ручная), затем по title
|
||||
usort($docs, function($a, $b) {
|
||||
$soA = $a['sort_order'] ?? 0;
|
||||
$soB = $b['sort_order'] ?? 0;
|
||||
if ($soA !== $soB) return $soA - $soB;
|
||||
return strcmp($a['title'], $b['title']);
|
||||
});
|
||||
|
||||
return $docs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает содержимое .md-файла по слагу.
|
||||
* Ищет файл по slug, сканируя директорию (не полагается на сохранённое имя).
|
||||
*
|
||||
* @param string $slug Слаг документа
|
||||
* @return string|null Содержимое файла или null, если не найден
|
||||
*/
|
||||
function get_doc_content(string $slug): ?string
|
||||
{
|
||||
$permissions = load_permissions();
|
||||
if (!isset($permissions[$slug])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Сначала пробуем сохранённое имя файла
|
||||
$savedFilename = $permissions[$slug]['filename'];
|
||||
$path = DOCS_DIR . '/' . $savedFilename;
|
||||
if (file_exists($path)) {
|
||||
$content = file_get_contents($path);
|
||||
return $content !== false ? $content : null;
|
||||
}
|
||||
|
||||
// Если не нашли — ищем по slug среди всех .md файлов
|
||||
$files = glob(DOCS_DIR . '/*.md');
|
||||
if ($files === false) return null;
|
||||
|
||||
foreach ($files as $filePath) {
|
||||
$fn = basename($filePath);
|
||||
$fSlug = doc_slug($fn);
|
||||
if ($fSlug === $slug) {
|
||||
// Обновляем сохранённое имя
|
||||
$permissions[$slug]['filename'] = $fn;
|
||||
save_permissions($permissions);
|
||||
$content = file_get_contents($filePath);
|
||||
return $content !== false ? $content : null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует md-заголовки и ссылки в HTML.
|
||||
* Простейший парсер для базовой читаемости.
|
||||
*
|
||||
* @param string $markdown Исходный Markdown
|
||||
* @return string HTML
|
||||
*/
|
||||
function render_markdown_simple(string $markdown): string
|
||||
{
|
||||
$html = htmlspecialchars($markdown, ENT_NOQUOTES, 'UTF-8');
|
||||
|
||||
// Заголовки h1-h3
|
||||
$html = preg_replace('/^### (.+)$/m', '<h3>$1</h3>', $html);
|
||||
$html = preg_replace('/^## (.+)$/m', '<h2>$1</h2>', $html);
|
||||
$html = preg_replace('/^# (.+)$/m', '<h1>$1</h1>', $html);
|
||||
|
||||
// Жирный и курсив
|
||||
$html = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $html);
|
||||
$html = preg_replace('/\*(.+?)\*/', '<em>$1</em>', $html);
|
||||
|
||||
// Списки
|
||||
$html = preg_replace('/^- (.+)$/m', '<li>$1</li>', $html);
|
||||
$html = preg_replace('/(<li>.*<\/li>)/s', '<ul>$1</ul>', $html);
|
||||
|
||||
// Таблицы — группируем строки с | в блоки <table>
|
||||
$lines = explode("\n", $html);
|
||||
$in_table = false;
|
||||
$sep_skipped = false;
|
||||
$table_lines = [];
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
if (preg_match('/^\|.+\|$/', $trimmed)) {
|
||||
if (!$in_table) {
|
||||
$table_lines[] = '<table>';
|
||||
$in_table = true;
|
||||
$sep_skipped = false;
|
||||
}
|
||||
if (preg_match('/^\|[-:\s]+\|$/', $trimmed)) {
|
||||
$sep_skipped = true;
|
||||
continue;
|
||||
}
|
||||
$inner = substr($trimmed, 1, -1);
|
||||
$cells = array_map('trim', explode('|', $inner));
|
||||
$tag = $sep_skipped ? 'th' : 'td';
|
||||
$sep_skipped = false;
|
||||
$table_lines[] = "<tr><$tag>" . implode("</$tag><$tag>", $cells) . "</$tag></tr>";
|
||||
} else {
|
||||
if ($in_table) {
|
||||
$table_lines[] = '</table>';
|
||||
$in_table = false;
|
||||
}
|
||||
$sep_skipped = false;
|
||||
$table_lines[] = $line;
|
||||
}
|
||||
}
|
||||
if ($in_table) {
|
||||
$table_lines[] = '</table>';
|
||||
}
|
||||
$html = implode("\n", $table_lines);
|
||||
|
||||
// Ссылки
|
||||
$html = preg_replace('/\[(.+?)\]\((.+?)\)/', '<a href="$2" target="_blank">$1</a>', $html);
|
||||
|
||||
// Абзацы
|
||||
$paragraphs = explode("\n\n", $html);
|
||||
$html = '';
|
||||
foreach ($paragraphs as $p) {
|
||||
$p = trim($p);
|
||||
if ($p === '') continue;
|
||||
if (strpos($p, '<h') === 0 || strpos($p, '<ul') === 0 || strpos($p, '<tr') === 0 || strpos($p, '<th') === 0 || strpos($p, '<table') === 0) {
|
||||
$html .= $p . "\n";
|
||||
} elseif (strpos($p, '<li') === 0) {
|
||||
$html .= $p . "\n";
|
||||
} else {
|
||||
$html .= '<p>' . nl2br($p) . "</p>\n";
|
||||
}
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает slug из имени файла (архитектура.md → arkhitektura).
|
||||
*/
|
||||
function doc_slug(string $filename): string
|
||||
{
|
||||
$slug = pathinfo($filename, PATHINFO_FILENAME);
|
||||
// Транслитерация кириллицы в латиницу для URL
|
||||
$converted = transliterate($slug);
|
||||
return preg_replace('/[^a-z0-9_-]/', '', strtolower($converted));
|
||||
}
|
||||
|
||||
/**
|
||||
* Транслитерация кириллицы → латиница.
|
||||
*/
|
||||
function transliterate(string $text): string
|
||||
{
|
||||
$map = [
|
||||
'а'=>'a','б'=>'b','в'=>'v','г'=>'g','д'=>'d','е'=>'e','ё'=>'yo','ж'=>'zh',
|
||||
'з'=>'z','и'=>'i','й'=>'y','к'=>'k','л'=>'l','м'=>'m','н'=>'n','о'=>'o',
|
||||
'п'=>'p','р'=>'r','с'=>'s','т'=>'t','у'=>'u','ф'=>'f','х'=>'kh','ц'=>'ts',
|
||||
'ч'=>'ch','ш'=>'sh','щ'=>'shch','ъ'=>'','ы'=>'y','ь'=>'','э'=>'e','ю'=>'yu',
|
||||
'я'=>'ya','А'=>'A','Б'=>'B','В'=>'V','Г'=>'G','Д'=>'D','Е'=>'E','Ё'=>'Yo',
|
||||
'Ж'=>'Zh','З'=>'Z','И'=>'I','Й'=>'Y','К'=>'K','Л'=>'L','М'=>'M','Н'=>'N',
|
||||
'О'=>'O','П'=>'P','Р'=>'R','С'=>'S','Т'=>'T','У'=>'U','Ф'=>'F','Х'=>'Kh',
|
||||
'Ц'=>'Ts','Ч'=>'Ch','Ш'=>'Sh','Щ'=>'Shch','Ъ'=>'','Ы'=>'Y','Ь'=>'','Э'=>'E',
|
||||
'Ю'=>'Yu','Я'=>'Ya',' '=>'-','_'=>'-',
|
||||
];
|
||||
return strtr($text, $map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Формирует человекочитаемый заголовок из слага.
|
||||
* architecture → Архитектура
|
||||
*/
|
||||
function doc_title(string $slug, string $filename = ''): string
|
||||
{
|
||||
// обратная транслитерация для некоторых известных слагов
|
||||
$known = [
|
||||
'owner' => 'Owner',
|
||||
'seo' => 'SEO',
|
||||
'sla' => 'SLA',
|
||||
'kpi-inzhenerov' => 'KPI инженеров',
|
||||
'avtomaticheskaya-sistema-shs' => 'Автоматическая система SHS',
|
||||
'oprosnik-raschet' => 'Опросник-расчёт',
|
||||
'arkhitektura-kompanii' => 'Архитектура компании',
|
||||
'kontent-plan' => 'Контент план',
|
||||
'kp-i-struktura-audita' => 'КП и структура аудита',
|
||||
'utp-i-kp' => 'УТП и КП',
|
||||
'arkhetiktura-brenda' => 'Архетиктура бренда',
|
||||
'marketing' => 'Маркеттинг',
|
||||
'skripty-prodazh' => 'Скрипты продаж',
|
||||
'osnova' => 'Основа',
|
||||
'dokazatelnaya-ekspertiza' => 'Доказательная экспертиза',
|
||||
'voronki-telegram-i-crm' => 'Воронки телеграм и CRM',
|
||||
'voronka' => 'Воронка',
|
||||
'sayt-i-upakovka' => 'Сайт и упаковка',
|
||||
'dashboard-tekhnika' => 'Дашборд техника',
|
||||
'dashboard-inzhenera' => 'Дашборд инженера',
|
||||
'blog' => 'Блог',
|
||||
'tsifrovaya-model-obshchaya' => 'Цифровая модель общая',
|
||||
];
|
||||
|
||||
if (isset($known[$slug])) {
|
||||
return $known[$slug];
|
||||
}
|
||||
|
||||
// Если есть имя файла — используем его без .md
|
||||
if ($filename !== '') {
|
||||
return pathinfo($filename, PATHINFO_FILENAME);
|
||||
}
|
||||
|
||||
return strtr($slug, ['-' => ' ', '_' => ' ']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает JSON-файл прав доступа.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
function load_permissions(): array
|
||||
{
|
||||
if (!file_exists(DOCS_PERMISSIONS_FILE)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$json = file_get_contents(DOCS_PERMISSIONS_FILE);
|
||||
$data = json_decode($json, true);
|
||||
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет JSON-файл прав доступа.
|
||||
*
|
||||
* @param array $permissions
|
||||
* @return bool
|
||||
*/
|
||||
function save_permissions(array $permissions): bool
|
||||
{
|
||||
$dir = dirname(DOCS_PERMISSIONS_FILE);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
return file_put_contents(
|
||||
DOCS_PERMISSIONS_FILE,
|
||||
json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||
) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, разрешён ли документ для роли engineer.
|
||||
*
|
||||
* @param string $slug Слаг документа
|
||||
* @return bool
|
||||
*/
|
||||
function is_doc_allowed_for_engineer(string $slug): bool
|
||||
{
|
||||
$permissions = load_permissions();
|
||||
if (!isset($permissions[$slug])) return false;
|
||||
$p = $permissions[$slug];
|
||||
// Поддержка старого формата (boolean) и нового (array)
|
||||
if (is_array($p) && isset($p['permissions'])) {
|
||||
return !empty($p['permissions']['engineer']['view']);
|
||||
}
|
||||
return !empty($p['engineer']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет конкретное право инженера для документа.
|
||||
*
|
||||
* @param string $slug Слаг документа
|
||||
* @param string $action Действие: view, edit, cancel
|
||||
* @return bool
|
||||
*/
|
||||
function is_engineer_allowed(string $slug, string $action): bool
|
||||
{
|
||||
$permissions = load_permissions();
|
||||
if (!isset($permissions[$slug])) return false;
|
||||
$p = $permissions[$slug];
|
||||
if (is_array($p) && isset($p['permissions']['engineer'])) {
|
||||
return !empty($p['permissions']['engineer'][$action]);
|
||||
}
|
||||
// Backward compat
|
||||
if ($action === 'view') return !empty($p['engineer']);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает список документов, доступных для указанной роли.
|
||||
*
|
||||
* @param string $role Роль пользователя (owner|engineer|technician)
|
||||
* @return array
|
||||
*/
|
||||
function get_allowed_docs(string $role): array
|
||||
{
|
||||
$all = get_docs_list();
|
||||
|
||||
if ($role === 'owner') {
|
||||
return $all;
|
||||
}
|
||||
|
||||
if ($role === 'engineer') {
|
||||
return array_values(array_filter($all, function($d) {
|
||||
return is_doc_allowed_for_engineer($d['slug']);
|
||||
}));
|
||||
}
|
||||
|
||||
// technician — нет доступа к документам
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php if (empty($_SESSION['user_id'])) return; ?>
|
||||
</main>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var sb = document.querySelector('.sidebar-nav');
|
||||
if (!sb) return;
|
||||
var key = 'sidebar_scroll';
|
||||
var saved = localStorage.getItem(key);
|
||||
if (saved) { sb.scrollTop = parseInt(saved, 10); }
|
||||
window.addEventListener('beforeunload', function(){
|
||||
localStorage.setItem(key, sb.scrollTop);
|
||||
});
|
||||
var scrollTimer;
|
||||
sb.addEventListener('scroll', function(){
|
||||
clearTimeout(scrollTimer);
|
||||
scrollTimer = setTimeout(function(){
|
||||
localStorage.setItem(key, sb.scrollTop);
|
||||
}, 100);
|
||||
});
|
||||
})();
|
||||
document.addEventListener('click', function(e) {
|
||||
var sb = document.querySelector('.sidebar');
|
||||
var btn = document.querySelector('.sidebar-toggle');
|
||||
if (sb && sb.classList.contains('open') && !sb.contains(e.target) && btn && !btn.contains(e.target)) {
|
||||
sb.classList.remove('open');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,678 @@
|
||||
<?php
|
||||
/**
|
||||
* ============================================
|
||||
* AegisOne Engineering — Расчётные формулы
|
||||
* ============================================
|
||||
*
|
||||
* Все коэффициенты вынесены в БД (таблица formula_coefficients)
|
||||
* и редактируются владельцем через /service/pages/owner/coefficients.php.
|
||||
*
|
||||
* Хардкоды ЗАПРЕЩЕНЫ — используй get_coefficient('key').
|
||||
*
|
||||
* Формат ключа: <формула>.<параметр>, например:
|
||||
* risk_score.no_archive → 25
|
||||
* object_index.risk_weight → 0.4
|
||||
* sla_price.base_cost → 15000
|
||||
*/
|
||||
|
||||
// ========================================================================
|
||||
// ВСПОМОГАТЕЛЬНЫЕ
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Получить значение коэффициента из БД.
|
||||
* Результат кэшируется в статической переменной (один SQL-запрос за вызов).
|
||||
*
|
||||
* @param string $key Ключ коэффициента (напр. 'risk_score.no_archive')
|
||||
* @param float $default Значение по умолчанию, если ключ не найден
|
||||
* @return float
|
||||
*/
|
||||
function get_coefficient(string $key, float $default = 0): float {
|
||||
static $cache = null;
|
||||
if ($cache === null) {
|
||||
try {
|
||||
$pdo = getDB();
|
||||
$stmt = $pdo->query("SELECT `key`, `value` FROM formula_coefficients WHERE is_active=1");
|
||||
$cache = [];
|
||||
while ($row = $stmt->fetch()) {
|
||||
$cache[$row['key']] = (float)$row['value'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$cache = [];
|
||||
}
|
||||
}
|
||||
return $cache[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирование: 1 234 567 ₽
|
||||
*/
|
||||
function fmt_money(float $amount): string {
|
||||
return number_format($amount, 0, '.', ' ') . ' ₽';
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирование: 95.3%
|
||||
*/
|
||||
function fmt_percent(float $value): string {
|
||||
return number_format($value, 1, '.', '') . '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирование: 3.5 ч
|
||||
*/
|
||||
function fmt_hours(float $hours): string {
|
||||
return number_format($hours, 1, '.', '') . ' ч';
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS-класс для статуса / приоритета
|
||||
*/
|
||||
function badge_class(string $status, string $type = 'default'): string {
|
||||
$map = [
|
||||
'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 $map[$status] ?? 'badge-default';
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 1. RISK SCORE (ОЦЕНКА РИСКА ОБЪЕКТА)
|
||||
// ========================================================================
|
||||
// Формула: сумма весов нарушений (каждое — 0 или 1).
|
||||
// Веса хранятся в БД:
|
||||
// risk_score.no_archive — нет архива видеонаблюдения (умолч. 25)
|
||||
// risk_score.no_power_backup — нет резервного питания (умолч. 20)
|
||||
// risk_score.no_regulations — нет регламента обслуживания (умолч. 15)
|
||||
// risk_score.system_failures — частые сбои систем (умолч. 20)
|
||||
// risk_score.no_documentation — нет документации (умолч. 10)
|
||||
|
||||
function calc_risk_score(bool $noArchive, bool $noPowerBackup, bool $noRegulations, bool $systemFailures, bool $noDocumentation): float {
|
||||
return ($noArchive ? get_coefficient('risk_score.no_archive', 25) : 0)
|
||||
+ ($noPowerBackup ? get_coefficient('risk_score.no_power_backup', 20) : 0)
|
||||
+ ($noRegulations ? get_coefficient('risk_score.no_regulations', 15) : 0)
|
||||
+ ($systemFailures ? get_coefficient('risk_score.system_failures', 20) : 0)
|
||||
+ ($noDocumentation ? get_coefficient('risk_score.no_documentation', 10) : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерпретация Risk Score
|
||||
* risk_score.bound_low — граница низкого риска (умолч. 20)
|
||||
* risk_score.bound_medium — граница среднего (умолч. 50)
|
||||
* risk_score.bound_high — граница высокого (умолч. 75)
|
||||
*/
|
||||
function risk_label(float $score): string {
|
||||
if ($score <= get_coefficient('risk_score.bound_low', 20)) return 'Низкий';
|
||||
if ($score <= get_coefficient('risk_score.bound_medium', 50)) return 'Средний';
|
||||
if ($score <= get_coefficient('risk_score.bound_high', 75)) return 'Высокий';
|
||||
return 'Критический';
|
||||
}
|
||||
|
||||
/**
|
||||
* Множитель риска для расчёта SLA Price
|
||||
* risk_multiplier.low — (0-20) умолч. 1.0
|
||||
* risk_multiplier.medium — (21-50) умолч. 1.3
|
||||
* risk_multiplier.high — (51-75) умолч. 1.6
|
||||
* risk_multiplier.critical— (76-100) умолч. 2.0
|
||||
*/
|
||||
function risk_multiplier(float $score): float {
|
||||
if ($score <= get_coefficient('risk_score.bound_low', 20)) return get_coefficient('risk_multiplier.low', 1.0);
|
||||
if ($score <= get_coefficient('risk_score.bound_medium', 50)) return get_coefficient('risk_multiplier.medium', 1.3);
|
||||
if ($score <= get_coefficient('risk_score.bound_high', 75)) return get_coefficient('risk_multiplier.high', 1.6);
|
||||
return get_coefficient('risk_multiplier.critical', 2.0);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 2. SLA COMPLEXITY INDEX (СЛОЖНОСТЬ ОБСЛУЖИВАНИЯ)
|
||||
// ========================================================================
|
||||
// Параметры:
|
||||
// complexity.video_0_20 — до 20 камер (умолч. 10)
|
||||
// complexity.video_20_100 — 20-100 камер (умолч. 20)
|
||||
// complexity.video_100plus — 100+ камер (умолч. 35)
|
||||
// complexity.access_0_5 — до 5 точек СКУД (умолч. 10)
|
||||
// complexity.access_5_20 — 5-20 точек (умолч. 20)
|
||||
// complexity.access_20plus — 20+ точек (умолч. 30)
|
||||
// complexity.fire_simple — простая пожарка (умолч. 15)
|
||||
// complexity.fire_medium — средняя (умолч. 25)
|
||||
// complexity.fire_complex — сложная (>5000 м²) (умолч. 40)
|
||||
// complexity.it — IT-инфраструктура (умолч. 10)
|
||||
|
||||
function calc_complexity(int $cameras, int $accessPoints, string $fireType, bool $hasIT): float {
|
||||
$video = $cameras <= 20 ? get_coefficient('complexity.video_0_20', 10)
|
||||
: ($cameras <= 100 ? get_coefficient('complexity.video_20_100', 20)
|
||||
: get_coefficient('complexity.video_100plus', 35));
|
||||
$access = $accessPoints <= 5 ? get_coefficient('complexity.access_0_5', 10)
|
||||
: ($accessPoints <= 20 ? get_coefficient('complexity.access_5_20', 20)
|
||||
: get_coefficient('complexity.access_20plus', 30));
|
||||
$fire = $fireType === 'simple' ? get_coefficient('complexity.fire_simple', 15)
|
||||
: ($fireType === 'medium' ? get_coefficient('complexity.fire_medium', 25)
|
||||
: ($fireType === 'complex' ? get_coefficient('complexity.fire_complex', 40)
|
||||
: 0));
|
||||
$it = $hasIT ? get_coefficient('complexity.it', 10) : 0;
|
||||
return $video + $access + $fire + $it;
|
||||
}
|
||||
|
||||
function complexity_label(float $score): string {
|
||||
if ($score <= 30) return 'Простой';
|
||||
if ($score <= 70) return 'Средний';
|
||||
if ($score <= 120) return 'Сложный';
|
||||
return 'Enterprise';
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 3. INFRASTRUCTURE LOAD (НАГРУЗКА НА ИНФРАСТРУКТУРУ)
|
||||
// ========================================================================
|
||||
// infra.server_none — нет сервера (умолч. 15)
|
||||
// infra.server_weak — слабый сервер (умолч. 10)
|
||||
// infra.network_unstable — нестабильная сеть (умолч. 20)
|
||||
// infra.network_partial — частично (умолч. 10)
|
||||
// infra.power_none — нет UPS (умолч. 20)
|
||||
// infra.power_weak — слабый UPS (умолч. 10)
|
||||
|
||||
function calc_infrastructure_load(string $serverState, string $networkState, string $powerState): float {
|
||||
$server = $serverState === 'none' ? get_coefficient('infra.server_none', 15) : ($serverState === 'weak' ? get_coefficient('infra.server_weak', 10) : 0);
|
||||
$network = $networkState === 'unstable' ? get_coefficient('infra.network_unstable', 20) : ($networkState === 'partial' ? get_coefficient('infra.network_partial', 10) : 0);
|
||||
$power = $powerState === 'none' ? get_coefficient('infra.power_none', 20) : ($powerState === 'weak' ? get_coefficient('infra.power_weak', 10) : 0);
|
||||
return $server + $network + $power;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 4. SERVICE HISTORY INDEX (ИСТОРИЯ ОБСЛУЖИВАНИЯ)
|
||||
// ========================================================================
|
||||
// history.none — нет обслуживания (умолч. 30)
|
||||
// history.irregular — нерегулярное (умолч. 20)
|
||||
// history.formal — формальный подрядчик (умолч. 10)
|
||||
// history.sla — есть SLA (умолч. 0)
|
||||
|
||||
function calc_service_history(string $serviceState): float {
|
||||
if ($serviceState === 'none') return get_coefficient('history.none', 30);
|
||||
if ($serviceState === 'irregular') return get_coefficient('history.irregular', 20);
|
||||
if ($serviceState === 'formal') return get_coefficient('history.formal', 10);
|
||||
if ($serviceState === 'sla') return get_coefficient('history.sla', 0);
|
||||
return get_coefficient('history.none', 30);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 5. OBJECT INDEX (ИТОГОВЫЙ ИНДЕКС ОБЪЕКТА)
|
||||
// ========================================================================
|
||||
// object_index.risk_weight — вес Risk Score (умолч. 0.4)
|
||||
// object_index.complexity_weight — вес Complexity (умолч. 0.3)
|
||||
// object_index.infra_weight — вес Infra Load (умолч. 0.2)
|
||||
// object_index.history_weight — вес Service History (умолч. 0.1)
|
||||
|
||||
function calc_object_index(float $risk, float $complexity, float $infra, float $history): float {
|
||||
return round(
|
||||
$risk * get_coefficient('object_index.risk_weight', 0.4)
|
||||
+ $complexity * get_coefficient('object_index.complexity_weight', 0.3)
|
||||
+ $infra * get_coefficient('object_index.infra_weight', 0.2)
|
||||
+ $history * get_coefficient('object_index.history_weight', 0.1)
|
||||
, 2);
|
||||
}
|
||||
|
||||
function object_class(float $index): string {
|
||||
if ($index <= 30) return 'A';
|
||||
if ($index <= 60) return 'B';
|
||||
if ($index <= 90) return 'C';
|
||||
return 'D';
|
||||
}
|
||||
|
||||
function object_class_label(float $index): string {
|
||||
$cls = object_class($index);
|
||||
if ($cls === 'A') return 'Лёгкий SLA';
|
||||
if ($cls === 'B') return 'Стандарт SLA';
|
||||
if ($cls === 'C') return 'Сложный SLA';
|
||||
if ($cls === 'D') return 'Enterprise / Высокий риск';
|
||||
return '';
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 6. SLA PRICE (СТОИМОСТЬ SLA)
|
||||
// ========================================================================
|
||||
// sla_price.base_cost — базовая стоимость инженера (умолч. 15000)
|
||||
// sla_price.region_* — региональные коэффициенты
|
||||
// sla_price.risk_* — мультипликаторы риска (через risk_multiplier)
|
||||
|
||||
function calc_sla_price(float $baseCost, float $objectIndex, float $regionFactor, float $riskMult): float {
|
||||
return round($baseCost * $objectIndex * $regionFactor * $riskMult, 2);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 7. ENGINEER SCORE (ES) — ИТОГОВЫЙ ИНДЕКС ИНЖЕНЕРА
|
||||
// ========================================================================
|
||||
// engineer.score.sla_weight — 0.25
|
||||
// engineer.score.response_weight — 0.20
|
||||
// engineer.score.resolution_weight — 0.20
|
||||
// engineer.score.diagnosis_weight — 0.15
|
||||
// engineer.score.reopen_weight — 0.10
|
||||
// engineer.score.risk_coverage_weight — 0.10
|
||||
|
||||
function calc_engineer_score(float $slaCompliance, float $responseTimeScore, float $resolutionTimeScore, float $diagnosisAccuracy, float $reopenRateScore, float $riskCoverageScore): float {
|
||||
return round(
|
||||
(get_coefficient('engineer.score.sla_weight', 0.25) * $slaCompliance)
|
||||
+ (get_coefficient('engineer.score.response_weight', 0.20) * $responseTimeScore)
|
||||
+ (get_coefficient('engineer.score.resolution_weight', 0.20) * $resolutionTimeScore)
|
||||
+ (get_coefficient('engineer.score.diagnosis_weight', 0.15) * $diagnosisAccuracy)
|
||||
+ (get_coefficient('engineer.score.reopen_weight', 0.10) * $reopenRateScore)
|
||||
+ (get_coefficient('engineer.score.risk_coverage_weight', 0.10) * $riskCoverageScore)
|
||||
, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Грейд инженера по итоговому Score
|
||||
* engineer.grade.senior — 90
|
||||
* engineer.grade.strong — 80
|
||||
* engineer.grade.middle — 70
|
||||
*/
|
||||
function engineer_grade(float $score): string {
|
||||
if ($score >= get_coefficient('engineer.grade.senior', 90)) return 'Senior';
|
||||
if ($score >= get_coefficient('engineer.grade.strong', 80)) return 'Strong';
|
||||
if ($score >= get_coefficient('engineer.grade.middle', 70)) return 'Middle';
|
||||
return 'Junior';
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 8. ENGINEER CONTROL SCORE (ECS) — ИНДЕКС УПРАВЛЕНИЯ ИНЖЕНЕРА
|
||||
// ========================================================================
|
||||
// ecs.sla_weight — 0.30
|
||||
// ecs.task_dist_weight — 0.25
|
||||
// ecs.incident_red_weight — 0.20
|
||||
// ecs.team_perf_weight — 0.15
|
||||
// ecs.response_coord_weight — 0.10
|
||||
|
||||
function calc_engineer_control_score(float $slaControl, float $taskDist, float $incidentRed, float $teamPerf, float $responseCoord): float {
|
||||
return round(
|
||||
(get_coefficient('ecs.sla_weight', 0.30) * $slaControl)
|
||||
+ (get_coefficient('ecs.task_dist_weight', 0.25) * $taskDist)
|
||||
+ (get_coefficient('ecs.incident_red_weight', 0.20) * $incidentRed)
|
||||
+ (get_coefficient('ecs.team_perf_weight', 0.15) * $teamPerf)
|
||||
+ (get_coefficient('ecs.response_coord_weight', 0.10) * $responseCoord)
|
||||
, 2);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 9. SHS (SYSTEM HEALTH SCORE) — ЗДОРОВЬЕ СИСТЕМЫ
|
||||
// ========================================================================
|
||||
// shs.sla_stability_weight — 0.22
|
||||
// shs.revenue_stability_weight — 0.18
|
||||
// shs.retention_weight — 0.18
|
||||
// shs.engineer_perf_weight — 0.15
|
||||
// shs.incident_stability_weight — 0.12
|
||||
// shs.sales_flow_weight — 0.10
|
||||
// shs.operational_eff_weight — 0.05
|
||||
|
||||
function calc_shs(float $slaStability, float $revenueStability, float $retention, float $engineerPerformance, float $incidentStability, float $salesFlow, float $operationalEfficiency): float {
|
||||
return round(
|
||||
(get_coefficient('shs.sla_stability_weight', 0.22) * $slaStability)
|
||||
+ (get_coefficient('shs.revenue_stability_weight', 0.18) * $revenueStability)
|
||||
+ (get_coefficient('shs.retention_weight', 0.18) * $retention)
|
||||
+ (get_coefficient('shs.engineer_perf_weight', 0.15) * $engineerPerformance)
|
||||
+ (get_coefficient('shs.incident_stability_weight', 0.12) * $incidentStability)
|
||||
+ (get_coefficient('shs.sales_flow_weight', 0.10) * $salesFlow)
|
||||
+ (get_coefficient('shs.operational_eff_weight', 0.05) * $operationalEfficiency)
|
||||
, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* CEO SHS — отдельные веса для CEO-дашборда
|
||||
* ceo_shs.mrr_weight — 0.25
|
||||
* ceo_shs.sla_weight — 0.20
|
||||
* ceo_shs.retention_weight — 0.20
|
||||
* ceo_shs.productivity_weight — 0.15
|
||||
* ceo_shs.conversion_weight — 0.10
|
||||
* ceo_shs.incident_weight — 0.10
|
||||
*/
|
||||
function calc_ceo_shs(float $mrrGrowth, float $slaCompliance, float $retention, float $productivity, float $conversion, float $incidentStability): float {
|
||||
return round(
|
||||
(get_coefficient('ceo_shs.mrr_weight', 0.25) * $mrrGrowth)
|
||||
+ (get_coefficient('ceo_shs.sla_weight', 0.20) * $slaCompliance)
|
||||
+ (get_coefficient('ceo_shs.retention_weight', 0.20) * $retention)
|
||||
+ (get_coefficient('ceo_shs.productivity_weight', 0.15) * $productivity)
|
||||
+ (get_coefficient('ceo_shs.conversion_weight', 0.10) * $conversion)
|
||||
+ (get_coefficient('ceo_shs.incident_weight', 0.10) * $incidentStability)
|
||||
, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Статус SHS
|
||||
* shs.zone_growth — 85
|
||||
* shs.zone_stable — 70
|
||||
* shs.zone_risk — 50
|
||||
*/
|
||||
function shs_status(float $score): string {
|
||||
if ($score >= get_coefficient('shs.zone_growth', 85)) return 'Growth';
|
||||
if ($score >= get_coefficient('shs.zone_stable', 70)) return 'Stable';
|
||||
if ($score >= get_coefficient('shs.zone_risk', 50)) return 'Risk';
|
||||
return 'Crisis';
|
||||
}
|
||||
|
||||
/**
|
||||
* Зона SHS с предупреждением
|
||||
*/
|
||||
function shs_zone(float $score): array {
|
||||
if ($score >= get_coefficient('shs.zone_growth', 85))
|
||||
return ['zone' => 'green', 'label' => 'Growth', 'action' => ''];
|
||||
if ($score >= get_coefficient('shs.zone_stable', 70))
|
||||
return ['zone' => 'yellow', 'label' => 'Stable', 'action' => 'Мониторинг'];
|
||||
if ($score >= get_coefficient('shs.zone_risk', 50))
|
||||
return ['zone' => 'yellow', 'label' => 'Risk', 'action' => 'Снизить продажи, запустить аудит цикла, пересмотреть назначения'];
|
||||
return ['zone' => 'red', 'label' => 'Crisis', 'action' => 'Немедленный аудит, приостановка новых продаж, пересмотр команды'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Дельта SHS (изменение относительно предыдущей записи)
|
||||
* Если падение > 5 — warning, > 10 — critical
|
||||
* shs.delta_warning — 5
|
||||
* shs.delta_critical — 10
|
||||
*/
|
||||
function shs_delta(float $currentScore, ?float $previousScore): ?array {
|
||||
if ($previousScore === null) return null;
|
||||
$delta = $currentScore - $previousScore;
|
||||
$thresholdWarn = get_coefficient('shs.delta_warning', 5);
|
||||
$thresholdCrit = get_coefficient('shs.delta_critical', 10);
|
||||
if ($delta <= -$thresholdCrit)
|
||||
return ['delta' => round($delta, 1), 'level' => 'critical', 'message' => 'SHS упал более чем на ' . $thresholdCrit . ' пунктов — кризис'];
|
||||
if ($delta <= -$thresholdWarn)
|
||||
return ['delta' => round($delta, 1), 'level' => 'warning', 'message' => 'SHS упал более чем на ' . $thresholdWarn . ' пунктов — требуется внимание'];
|
||||
return ['delta' => round($delta, 1), 'level' => 'ok', 'message' => ''];
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 10. SLA STABILITY INDEX (SSI)
|
||||
// ========================================================================
|
||||
// Учитывает не только compliance, но и severity нарушений
|
||||
// SSI = SLA_compliance × (1 - breach_severity)
|
||||
// ssi.breach_severity_p1 — 1.0
|
||||
// ssi.breach_severity_p2 — 0.5
|
||||
// ssi.breach_severity_p3 — 0.2
|
||||
|
||||
function calc_sla_stability_index(float $slaCompliance, float $breachSeverityWeighted): float {
|
||||
$ssi = $slaCompliance * (1 - $breachSeverityWeighted);
|
||||
return round(max(0, min(100, $ssi)), 2);
|
||||
}
|
||||
|
||||
function calc_breach_severity(int $breachesP1, int $breachesP2, int $breachesP3): float {
|
||||
$total = $breachesP1 + $breachesP2 + $breachesP3;
|
||||
if ($total === 0) return 0;
|
||||
$weighted = $breachesP1 * get_coefficient('ssi.breach_severity_p1', 1.0)
|
||||
+ $breachesP2 * get_coefficient('ssi.breach_severity_p2', 0.5)
|
||||
+ $breachesP3 * get_coefficient('ssi.breach_severity_p3', 0.2);
|
||||
return min(1, $weighted / $total);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 11. REVENUE STABILITY INDEX (RSI) через коэффициент вариации
|
||||
// ========================================================================
|
||||
// RSI = max(0, 100 × (1 - σ(MRR) / mean(MRR)))
|
||||
// Основан на исторических значениях MRR за N периодов
|
||||
|
||||
function calc_revenue_stability_index(array $mrrHistory): float {
|
||||
$count = count($mrrHistory);
|
||||
if ($count < 2) return 100;
|
||||
$mean = array_sum($mrrHistory) / $count;
|
||||
if ($mean <= 0) return 100;
|
||||
$variance = 0;
|
||||
foreach ($mrrHistory as $mrr) {
|
||||
$variance += ($mrr - $mean) ** 2;
|
||||
}
|
||||
$stdDev = sqrt($variance / $count);
|
||||
$cv = $stdDev / $mean;
|
||||
return round(max(0, min(100, 100 * (1 - $cv))), 2);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 12. SALES FLOW INDEX (SFI) — ВОРОНКА ПРОДАЖ
|
||||
// ========================================================================
|
||||
// SFI = (аудиты → SLA конверсия) × качество лидов
|
||||
|
||||
function calc_sales_flow_index(int $audits, int $slaContracts, float $leadQuality = 1.0): float {
|
||||
if ($audits <= 0) return 0;
|
||||
$conversion = $slaContracts / $audits;
|
||||
return round(min(100, $conversion * 100 * $leadQuality), 2);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 13. OPERATIONAL EFFICIENCY INDEX (OEI)
|
||||
// ========================================================================
|
||||
// OEI = revenue / (engineer_hours × cost_per_hour) × 100
|
||||
// Нормируется к 100
|
||||
|
||||
function calc_operational_efficiency_index(float $revenue, float $engineerHours, float $costPerHour): float {
|
||||
$total = $engineerHours * $costPerHour;
|
||||
if ($total <= 0) return 100;
|
||||
return round(min(100, ($revenue / $total) * 100), 2);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 14. INCIDENT STABILITY INDEX (ISI) с весами severity
|
||||
// ========================================================================
|
||||
// ISI = max(0, 100 - (incidents / objects × severity_weight))
|
||||
// isi.severity_p1 — 1.0
|
||||
// isi.severity_p2 — 0.5
|
||||
// isi.severity_p3 — 0.2
|
||||
|
||||
function calc_incident_stability_index(int $incidentsP1, int $incidentsP2, int $incidentsP3, int $objectCount): float {
|
||||
if ($objectCount <= 0) return 100;
|
||||
$weighted = $incidentsP1 * get_coefficient('isi.severity_p1', 1.0)
|
||||
+ $incidentsP2 * get_coefficient('isi.severity_p2', 0.5)
|
||||
+ $incidentsP3 * get_coefficient('isi.severity_p3', 0.2);
|
||||
$rate = $weighted / $objectCount;
|
||||
return round(max(0, min(100, 100 - ($rate * 20))), 2);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 15. AUTOMATION RULES (АВТОМАТИЧЕСКИЕ ПРАВИЛА)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Проверить все правила автоматизации.
|
||||
* Возвращает массив предупреждений.
|
||||
*
|
||||
* Правила из docs/Цифровая модель общая.md:
|
||||
* 1. IF SHS < 70 → снизить продажи, запустить аудит, пересмотреть назначения
|
||||
* 2. IF SLA < 90% → заморозить неприоритетные проекты, увеличить частоту проверок
|
||||
* 3. IF retention < 90% → обязательный аудит клиентов, пересмотр назначений
|
||||
*
|
||||
* rules.shs_threshold — 70
|
||||
* rules.sla_threshold — 90
|
||||
* rules.retention_threshold — 90
|
||||
*/
|
||||
function check_automation_rules(?float $shs, ?float $slaCompliance, ?float $retention): array {
|
||||
$alerts = [];
|
||||
|
||||
if ($shs !== null && $shs < get_coefficient('rules.shs_threshold', 70)) {
|
||||
$alerts[] = [
|
||||
'rule' => 'SHS',
|
||||
'severity' => 'critical',
|
||||
'message' => 'SHS ниже ' . get_coefficient('rules.shs_threshold', 70) . ' — снизить продажи, запустить аудит, пересмотреть назначения инженеров.',
|
||||
];
|
||||
}
|
||||
if ($slaCompliance !== null && $slaCompliance < get_coefficient('rules.sla_threshold', 90)) {
|
||||
$alerts[] = [
|
||||
'rule' => 'SLA',
|
||||
'severity' => 'warning',
|
||||
'message' => 'SLA Compliance ниже ' . get_coefficient('rules.sla_threshold', 90) . '% — заморозить неприоритетные проекты, увеличить частоту проверок.',
|
||||
];
|
||||
}
|
||||
if ($retention !== null && $retention < get_coefficient('rules.retention_threshold', 90)) {
|
||||
$alerts[] = [
|
||||
'rule' => 'Retention',
|
||||
'severity' => 'critical',
|
||||
'message' => 'Удержание клиентов ниже ' . get_coefficient('rules.retention_threshold', 90) . '% — обязательный аудит клиентов, пересмотр назначений инженеров.',
|
||||
];
|
||||
}
|
||||
return $alerts;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 16. БАЗОВЫЕ KPI
|
||||
// ========================================================================
|
||||
|
||||
/** SLA Compliance: процент задач, закрытых в срок */
|
||||
function calc_sla_compliance(int $closedInSla, int $total): float {
|
||||
if ($total <= 0) return 100;
|
||||
return round(($closedInSla / $total) * 100, 2);
|
||||
}
|
||||
|
||||
/** Reopen Rate: процент повторных обращений */
|
||||
function calc_reopen_rate(int $reopened, int $total): float {
|
||||
if ($total <= 0) return 0;
|
||||
return round(($reopened / $total) * 100, 2);
|
||||
}
|
||||
|
||||
/** Diagnosis Accuracy: точность диагностики */
|
||||
function calc_diagnosis_accuracy(int $confirmed, int $found): float {
|
||||
if ($found <= 0) return 100;
|
||||
return round(($confirmed / $found) * 100, 2);
|
||||
}
|
||||
|
||||
/** Response Time Score: оценка времени реакции */
|
||||
function calc_response_time_score(float $actualHours, float $slaHours): float {
|
||||
if ($slaHours <= 0) return 100;
|
||||
$ratio = $actualHours / $slaHours;
|
||||
if ($ratio <= 1.0) return 100;
|
||||
if ($ratio <= 1.2) return 80;
|
||||
return max(0, 100 - ($ratio - 1.2) * 50);
|
||||
}
|
||||
|
||||
/** Resolution Time Score: оценка времени устранения */
|
||||
function calc_resolution_time_score(float $actualHours, float $normHours): float {
|
||||
if ($normHours <= 0) return 100;
|
||||
$ratio = $actualHours / $normHours;
|
||||
if ($ratio <= 1.0) return 100;
|
||||
if ($ratio <= 1.5) return 70;
|
||||
return max(0, 100 - ($ratio - 1.5) * 40);
|
||||
}
|
||||
|
||||
/** Risk Coverage Score: покрытие зон проверки */
|
||||
function calc_risk_coverage(int $checkedZones, int $totalZones = 5): float {
|
||||
if ($totalZones <= 0) return 0;
|
||||
return round(($checkedZones / $totalZones) * 100, 2);
|
||||
}
|
||||
|
||||
/** Documentation Quality: полнота отчётов */
|
||||
function calc_documentation_quality(int $fullReports, int $totalVisits): float {
|
||||
if ($totalVisits <= 0) return 100;
|
||||
return round(($fullReports / $totalVisits) * 100, 2);
|
||||
}
|
||||
|
||||
/** Team Load Balance: равномерность загрузки (stdDev) — чем меньше, тем лучше */
|
||||
function calc_tlb(array $tasksPerEngineer): float {
|
||||
$count = count($tasksPerEngineer);
|
||||
if ($count < 2) return 0;
|
||||
$mean = array_sum($tasksPerEngineer) / $count;
|
||||
$variance = 0;
|
||||
foreach ($tasksPerEngineer as $t) {
|
||||
$variance += ($t - $mean) ** 2;
|
||||
}
|
||||
return round(sqrt($variance / $count), 2);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 17. ФИНАНСОВЫЕ ПОКАЗАТЕЛИ
|
||||
// ========================================================================
|
||||
|
||||
/** MRR: месячный повторяющийся доход */
|
||||
function calc_mrr(float $totalSlaAnnual): float {
|
||||
return round($totalSlaAnnual / 12, 2);
|
||||
}
|
||||
|
||||
/** Revenue per Engineer */
|
||||
function calc_rpe(float $slaRevenue, int $engineerCount): float {
|
||||
if ($engineerCount <= 0) return 0;
|
||||
return round($slaRevenue / $engineerCount, 2);
|
||||
}
|
||||
|
||||
/** Cost per SLA Object (CPO) */
|
||||
function calc_cpo(float $totalCost, int $objectCount): float {
|
||||
if ($objectCount <= 0) return 0;
|
||||
return round($totalCost / $objectCount, 2);
|
||||
}
|
||||
|
||||
/** Average Revenue Per Client (ARPC) */
|
||||
function calc_arpc(float $totalRevenue, int $clientCount): float {
|
||||
if ($clientCount <= 0) return 0;
|
||||
return round($totalRevenue / $clientCount, 2);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 18. ОПЕРАЦИОННЫЕ KPI
|
||||
// ========================================================================
|
||||
|
||||
/** Utilization: загрузка сотрудника */
|
||||
function calc_utilization(int $workingHours, int $availableHours): float {
|
||||
if ($availableHours <= 0) return 0;
|
||||
return round(($workingHours / $availableHours) * 100, 2);
|
||||
}
|
||||
|
||||
/** Autonomy: самостоятельность */
|
||||
function calc_autonomy(int $independent, int $total): float {
|
||||
if ($total <= 0) return 100;
|
||||
return round(($independent / $total) * 100, 2);
|
||||
}
|
||||
|
||||
/** Escalation Rate */
|
||||
function calc_escalation_rate(int $escalated, int $total): float {
|
||||
if ($total <= 0) return 0;
|
||||
return round(($escalated / $total) * 100, 2);
|
||||
}
|
||||
|
||||
/** System Uptime: доступность системы */
|
||||
function calc_uptime(float $uptimeHours, float $totalHours): float {
|
||||
if ($totalHours <= 0) return 100;
|
||||
return round(($uptimeHours / $totalHours) * 100, 2);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 19. КЛИЕНТСКИЕ МЕТРИКИ
|
||||
// ========================================================================
|
||||
|
||||
/** Retention: удержание клиентов */
|
||||
function calc_retention(int $activeClients, int $totalClients): float {
|
||||
if ($totalClients <= 0) return 100;
|
||||
return round(($activeClients / $totalClients) * 100, 2);
|
||||
}
|
||||
|
||||
/** Retention с штрафом за потерю ключевых клиентов */
|
||||
function calc_retention_key_client(float $baseRetention, int $lostKeyClients): float {
|
||||
$penalty = $lostKeyClients * get_coefficient('retention.key_client_penalty', 0.1);
|
||||
return round(max(0, $baseRetention - $penalty * 100), 2);
|
||||
}
|
||||
|
||||
/** CSAT (удовлетворённость клиентов) */
|
||||
function calc_csat(int $positive, int $total): float {
|
||||
if ($total <= 0) return 100;
|
||||
return round(($positive / $total) * 100, 2);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 20. ИНЦИДЕНТЫ
|
||||
// ========================================================================
|
||||
|
||||
/** Incident Rate: количество инцидентов на объект */
|
||||
function calc_incident_rate(int $incidents, int $objectCount): float {
|
||||
if ($objectCount <= 0) return 0;
|
||||
return round($incidents / $objectCount, 2);
|
||||
}
|
||||
|
||||
/** Incident Reduction Rate: снижение инцидентов */
|
||||
|
||||
/** Incident Reduction Rate */
|
||||
function calc_incident_reduction(int $previous, int $current): float {
|
||||
if ($previous <= 0) return 0;
|
||||
return round((($previous - $current) / $previous) * 100, 2);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 21. БОНУСНАЯ СИСТЕМА
|
||||
// ========================================================================
|
||||
// bonus.score_90plus — +20%
|
||||
// bonus.score_80_89 — +10%
|
||||
// bonus.score_below_80 — 0%
|
||||
|
||||
function calc_bonus_percent(float $engineerScore): float {
|
||||
if ($engineerScore >= get_coefficient('bonus.score_90plus', 90)) return get_coefficient('bonus.premium_90plus', 20);
|
||||
if ($engineerScore >= get_coefficient('bonus.score_80_89', 80)) return get_coefficient('bonus.premium_80_89', 10);
|
||||
return get_coefficient('bonus.premium_below_80', 0);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
/**
|
||||
* ============================================
|
||||
* AegisOne Engineering Service — Шапка дашборда
|
||||
* ============================================
|
||||
*
|
||||
* Выводит <head> (с запретом индексации), открывает <body>,
|
||||
* сайдбар с навигацией по ролям и <main>.
|
||||
* Если пользователь не авторизован — редирект на логин.
|
||||
*/
|
||||
|
||||
if (empty($_SESSION['user_id'])) return;
|
||||
|
||||
$current_uri = $_SERVER['REQUEST_URI'];
|
||||
$role = $_SESSION['user_role'];
|
||||
$name = $_SESSION['user_name'];
|
||||
|
||||
function nav_link(string $href, string $label, string $tooltip = '', array $roles = []): string {
|
||||
global $current_uri, $role;
|
||||
if (!empty($roles) && !in_array($role, $roles, true)) return '';
|
||||
$active = strpos($current_uri, $href) === 0 ? ' active' : '';
|
||||
$tip = $tooltip !== '' ? ' data-tooltip="' . htmlspecialchars($tooltip) . '"' : '';
|
||||
return '<a href="' . htmlspecialchars($href) . '" class="' . $active . '"' . $tip . '>' . htmlspecialchars($label) . '</a>';
|
||||
}
|
||||
|
||||
$roleLabel = $role === 'owner' ? 'Владелец' : ($role === 'engineer' ? 'Инженер' : 'Техник');
|
||||
?><!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>Service Portal — AegisOne Engineering</title>
|
||||
<link rel="stylesheet" href="/service-style.php">
|
||||
<link rel="icon" type="image/x-icon" href="/assets/img/favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<?= nav_link('/service/dashboard.php', 'Дашборд', 'Главная панель', ['owner','engineer','technician']) ?>
|
||||
<div class="sidebar-section">Управление</div>
|
||||
<?= nav_link('/service/pages/owner/users.php', 'Сотрудники', 'Управление сотрудниками', ['owner']) ?>
|
||||
<?= nav_link('/service/pages/owner/customers.php', 'Клиенты', 'Управление клиентами', ['owner','engineer']) ?>
|
||||
<?= nav_link('/service/pages/owner/objects.php', 'Объекты', 'Управление объектами', ['owner','engineer']) ?>
|
||||
<?= nav_link('/service/pages/owner/assignments.php', 'Назначения', 'Назначения на объекты', ['owner']) ?>
|
||||
<?= nav_link('/service/pages/owner/sla.php', 'SLA контракты', 'Управление SLA', ['owner','engineer']) ?>
|
||||
<?= nav_link('/service/pages/owner/ceo.php', 'CEO дашборд', 'Бизнес-метрики', ['owner']) ?>
|
||||
<?= nav_link('/service/pages/owner/coefficients.php', 'Коэффициенты', 'Расчётные коэффициенты', ['owner']) ?>
|
||||
<div class="sidebar-section">Работа</div>
|
||||
<?= nav_link('/service/pages/engineer/tasks.php', 'Задачи', 'Мои задачи', ['engineer','technician']) ?>
|
||||
<?= nav_link('/service/pages/engineer/reports.php', 'Отчёты', 'Отчёты по объектам', ['engineer','technician']) ?>
|
||||
<?= nav_link('/service/pages/engineer/incidents.php', 'Инциденты', 'Журнал инцидентов', ['engineer','technician']) ?>
|
||||
<?= nav_link('/service/pages/shared/questionnaire.php', 'Опросник', 'Обследование объекта', ['owner','engineer']) ?>
|
||||
<?= nav_link('/service/pages/owner/questionnaire_config.php', 'Настройка опросника', 'Конфигурация вопросов', ['owner']) ?>
|
||||
<?= nav_link('/service/pages/shared/passports.php', 'Паспорта объектов', 'Паспорта и расчёты', ['owner','engineer']) ?>
|
||||
<?php if ($role === 'technician'): ?>
|
||||
<?= nav_link('/service/pages/technician/checklist.php', 'Чек-лист', 'Чек-лист техника', ['technician']) ?>
|
||||
<?php endif; ?>
|
||||
<div class="sidebar-section">Блог</div>
|
||||
<?= nav_link('/service/pages/owner/blog.php', 'Управление блогом', 'Создание и редактирование статей', ['owner']) ?>
|
||||
<?= nav_link('/blog/', 'Публичный блог', 'Открытый блог на сайте', ['owner','engineer','technician']) ?>
|
||||
<div class="sidebar-section">Контент</div>
|
||||
<?= nav_link('/service/pages/owner/cases.php', 'Примеры из практики', 'Карусель на главной', ['owner']) ?>
|
||||
<div class="sidebar-section">Документация</div>
|
||||
<?= nav_link('/service/pages/shared/docs.php', 'Просмотр документации', 'Читать документы', ['owner','engineer']) ?>
|
||||
<?= nav_link('/service/pages/owner/docs_section.php', 'Управление документами', 'Настройка доступа к документам', ['owner']) ?>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-name"><?= htmlspecialchars($name) ?></div>
|
||||
<div class="user-role"><?= $roleLabel ?></div>
|
||||
<a href="/service/logout.php" style="display:inline-block;margin-top:6px;font-size:12px;">Выйти</a>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
Reference in New Issue
Block a user