feat: add Метрики МП page + app-metrics updater

- new page app_stats/index.html (login-gated, same style/nav)
- app_stats/app_metrics.json data (year-over-year comparison, NEW badges)
- updater/update_app_metrics.py: adaptive SQL (Jan 1 -> yesterday vs prev year)
- run both updaters from run_update.bat; refactor shared git push
This commit is contained in:
Iliyas 2026-06-16 17:34:19 +05:00
parent 26390f6239
commit ccf82b026f
7 changed files with 1057 additions and 10 deletions

236
app_stats/app_metrics.json Normal file
View File

@ -0,0 +1,236 @@
{
"generated_at": "2026-06-16T17:30:19",
"cur_year": 2026,
"prev_year": 2025,
"period_label": "с 1 января по 15 июня",
"range": {
"start": "2026-01-01",
"end": "2026-06-15"
},
"metrics": [
{
"key": "my_services",
"label": "Мои услуги",
"prev": 1459571,
"cur": 1769470,
"growth": 0.21232197680003234,
"is_new": false
},
{
"key": "traffic",
"label": "Детализация трафика",
"prev": 1079736,
"cur": 1271563,
"growth": 0.17766102084213178,
"is_new": false
},
{
"key": "payments",
"label": "Платежи",
"prev": 553808,
"cur": 730185,
"growth": 0.3184804119839367,
"is_new": false
},
{
"key": "orders",
"label": "Заявки",
"prev": 635621,
"cur": 826255,
"growth": 0.2999177182629271,
"is_new": false
},
{
"key": "loyalty",
"label": "Лояльность",
"prev": 464365,
"cur": 470969,
"growth": 0.014221571393192856,
"is_new": false
},
{
"key": "pay",
"label": "Оплата",
"prev": 302510,
"cur": 337103,
"growth": 0.11435324452084229,
"is_new": false
},
{
"key": "billing_detail",
"label": "Детали счета",
"prev": 358290,
"cur": 496915,
"growth": 0.3869072539004717,
"is_new": false
},
{
"key": "viktorina",
"label": "Викторина KT Club",
"prev": 298475,
"cur": 213879,
"growth": -0.28342742273222216,
"is_new": false
},
{
"key": "partners",
"label": "Акции партнеров",
"prev": 94639,
"cur": 197009,
"growth": 1.08168936696288,
"is_new": false
},
{
"key": "tv_plus",
"label": "TV+",
"prev": 95647,
"cur": 64104,
"growth": -0.32978556567377965,
"is_new": false
},
{
"key": "boosters",
"label": "Бустеры",
"prev": 53649,
"cur": 121065,
"growth": 1.2566124252082984,
"is_new": false
},
{
"key": "roaming",
"label": "Роуминг",
"prev": 39200,
"cur": 22160,
"growth": -0.4346938775510204,
"is_new": false
},
{
"key": "pereoform",
"label": "Переоформление",
"prev": 23537,
"cur": 34570,
"growth": 0.46875132769681777,
"is_new": false
},
{
"key": "aitu_music",
"label": "Aitu Music",
"prev": 0,
"cur": 8651,
"growth": null,
"is_new": true
},
{
"key": "online_booking",
"label": "Онлайн очередь",
"prev": 5421,
"cur": 22144,
"growth": 3.084855192768862,
"is_new": false
},
{
"key": "my_docs",
"label": "Мои документы",
"prev": 0,
"cur": 53376,
"growth": null,
"is_new": true
},
{
"key": "dz_statement",
"label": "Справка о ДЗ",
"prev": 0,
"cur": 132795,
"growth": null,
"is_new": true
},
{
"key": "new_boosters_roaming_kcell",
"label": "Новая линейка бустеров и роумингов Кселл",
"prev": 0,
"cur": 28626,
"growth": null,
"is_new": true
},
{
"key": "adsl",
"label": "ADSL отключение услуги",
"prev": 0,
"cur": 69,
"growth": null,
"is_new": true
},
{
"key": "law_and_order",
"label": "Закон и порядок",
"prev": 0,
"cur": 1555,
"growth": null,
"is_new": true
},
{
"key": "acs",
"label": "ACS",
"prev": 0,
"cur": 9154,
"growth": null,
"is_new": true
},
{
"key": "kaspi_freedom_pay",
"label": "Прием платежей через Freedom и Kaspi",
"prev": 0,
"cur": 61481,
"growth": null,
"is_new": true
},
{
"key": "csat",
"label": "CSAT",
"prev": 0,
"cur": 2486,
"growth": null,
"is_new": true
},
{
"key": "multicustomer",
"label": "Мультикастомер",
"prev": 0,
"cur": 164,
"growth": null,
"is_new": true
},
{
"key": "tv_plus_setup",
"label": "Настройка TV+",
"prev": 0,
"cur": 4545,
"growth": null,
"is_new": true
},
{
"key": "static_ip",
"label": "Статический IP",
"prev": 0,
"cur": 108,
"growth": null,
"is_new": true
},
{
"key": "turbo_button",
"label": "Turbo кнопка",
"prev": 0,
"cur": 4312,
"growth": null,
"is_new": true
},
{
"key": "real_estate_docs",
"label": "Справка о недвижимости",
"prev": 0,
"cur": 78,
"growth": null,
"is_new": true
}
]
}

560
app_stats/index.html Normal file
View File

@ -0,0 +1,560 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Метрики МП — Казахтелеком 2026</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--color-bg: #F4F6F9;
--color-surface: #FFFFFF;
--color-border: #E5E7EB;
--color-text-primary: #111827;
--color-text-secondary: #6B7280;
--color-brand: #0052CC;
--color-brand-light: #EFF6FF;
--color-green: #10B981;
--color-green-bg: #ECFDF5;
--color-yellow: #F59E0B;
--color-yellow-bg: #FFFBEB;
--color-red: #EF4444;
--color-red-bg: #FEF2F2;
--color-purple: #8B5CF6;
--color-purple-bg: #F5F3FF;
--radius-card: 12px;
--radius-btn: 8px;
--shadow-card: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
--shadow-card-hover: 0 4px 12px rgba(0,0,0,0.1);
--font-base: 'Inter', system-ui, sans-serif;
--font-mono: 'Roboto Mono', monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-base); background: var(--color-bg); color: var(--color-text-primary); font-size: 14px; line-height: 1.5; }
@keyframes spin { to { transform: rotate(360deg); } }
/* LOGIN */
#login-screen { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #0052CC 0%, #0070F3 100%); }
.login-box { background: #fff; border-radius: 20px; padding: 48px 56px; text-align: center; max-width: 420px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
.login-logo { display: inline-flex; align-items: center; justify-content: center; width: 64px; height: 64px; background: var(--color-brand); color: #fff; font-size: 24px; font-weight: 700; border-radius: 16px; margin-bottom: 20px; }
.login-box h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
.login-box > p { color: var(--color-text-secondary); margin-bottom: 28px; font-size: 14px; }
.txt-input { width: 100%; padding: 11px 14px; border: 1px solid var(--color-border); border-radius: var(--radius-btn); font-size: 15px; font-family: var(--font-base); outline: none; transition: border-color 0.15s; }
.txt-input:focus { border-color: var(--color-brand); }
.btn-primary { padding: 11px 40px; background: var(--color-brand); color: #fff; border: none; border-radius: var(--radius-btn); font-size: 14px; font-weight: 600; cursor: pointer; font-family: var(--font-base); transition: background 0.15s; }
.btn-primary:hover { background: #003fa3; }
#login-error { color: var(--color-red); font-size: 13px; margin-top: 10px; min-height: 18px; }
/* HEADER */
#app { display: none; }
.header { background: var(--color-brand); color: #fff; padding: 0 24px; height: 64px; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 8px rgba(0,82,204,0.3); }
.header-left { display: flex; align-items: center; gap: 14px; }
.header-logo { width: 40px; height: 40px; background: rgba(255,255,255,0.2); border-radius: 10px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 14px; flex-shrink: 0; }
.header-left h1 { font-size: 16px; font-weight: 700; line-height: 1.2; }
.header-left p { font-size: 12px; opacity: 0.75; }
.header-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
#generated-at { font-size: 12px; opacity: 0.85; }
.btn { padding: 7px 14px; border-radius: var(--radius-btn); font-size: 13px; font-weight: 500; cursor: pointer; border: none; font-family: var(--font-base); transition: all 0.15s; text-decoration: none; display: inline-block; }
.btn-ghost { background: rgba(255,255,255,0.15); color: #fff; }
.btn-ghost:hover { background: rgba(255,255,255,0.25); }
/* SUMMARY */
.wrap { padding: 20px 24px 40px; max-width: 1400px; margin: 0 auto; }
.summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 22px; }
.sum-card { background: var(--color-surface); border-radius: var(--radius-card); padding: 16px 18px; box-shadow: var(--shadow-card); border-top: 4px solid var(--color-brand); }
.sum-card.accent-green { border-top-color: var(--color-green); }
.sum-card.accent-purple { border-top-color: var(--color-purple); }
.sum-card.accent-yellow { border-top-color: var(--color-yellow); }
.sum-label { font-size: 11px; font-weight: 600; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 8px; }
.sum-value { font-size: 26px; font-weight: 700; font-family: var(--font-mono); line-height: 1.1; }
.sum-sub { font-size: 12px; color: var(--color-text-secondary); margin-top: 4px; }
/* CONTROLS */
.controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; }
.controls .grow { flex: 1 1 220px; }
.controls label { font-size: 13px; color: var(--color-text-secondary); display: flex; align-items: center; gap: 6px; cursor: pointer; }
select.txt-input, input.txt-input { font-size: 13px; padding: 9px 12px; }
select.txt-input { cursor: pointer; background: #fff; }
/* METRIC CARDS */
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.card { background: var(--color-surface); border-radius: var(--radius-card); padding: 16px 18px; box-shadow: var(--shadow-card); border-left: 4px solid var(--color-border); transition: box-shadow 0.2s, transform 0.2s; }
.card:hover { box-shadow: var(--shadow-card-hover); transform: translateY(-2px); }
.card.up { border-left-color: var(--color-green); }
.card.down { border-left-color: var(--color-red); }
.card.flat { border-left-color: var(--color-yellow); }
.card.new { border-left-color: var(--color-purple); }
.card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; margin-bottom: 12px; }
.card-title { font-size: 13px; font-weight: 600; line-height: 1.35; }
.badge { font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 20px; white-space: nowrap; flex-shrink: 0; }
.badge.up { background: var(--color-green-bg); color: var(--color-green); }
.badge.down { background: var(--color-red-bg); color: var(--color-red); }
.badge.flat { background: var(--color-yellow-bg); color: #92400E; }
.badge.new { background: var(--color-purple-bg); color: var(--color-purple); }
.card-value { font-size: 30px; font-weight: 700; font-family: var(--font-mono); line-height: 1; }
.card-value-year { font-size: 11px; color: var(--color-text-secondary); font-weight: 600; margin-left: 2px; }
.cmp { margin-top: 14px; display: flex; flex-direction: column; gap: 7px; }
.cmp-row { display: grid; grid-template-columns: 38px 1fr auto; align-items: center; gap: 8px; }
.cmp-year { font-size: 11px; color: var(--color-text-secondary); font-weight: 600; font-family: var(--font-mono); }
.cmp-track { height: 8px; background: var(--color-bg); border-radius: 8px; overflow: hidden; }
.cmp-fill { height: 100%; border-radius: 8px; transition: width 0.6s ease; min-width: 2px; }
.cmp-fill.cur { background: var(--color-brand); }
.cmp-fill.prev { background: #C7D2E8; }
.cmp-num { font-size: 11px; font-family: var(--font-mono); color: var(--color-text-secondary); white-space: nowrap; }
.empty-note { text-align: center; color: var(--color-text-secondary); padding: 40px; grid-column: 1 / -1; }
/* loading / error */
#status-msg { text-align:center; padding: 60px 20px; color: var(--color-text-secondary); }
@media (max-width: 900px) { .summary-grid { grid-template-columns: repeat(2,1fr); } }
@media (max-width: 560px) { .summary-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<!-- LOGIN -->
<div id="login-screen">
<div class="login-box">
<div class="login-logo">KT</div>
<h1>Метрики МП</h1>
<p>Казахтелеком · 2026</p>
<input type="password" id="login-password" class="txt-input" placeholder="Введите пароль" style="text-align:center;margin-bottom:12px;">
<button class="btn-primary" id="btn-login">Войти</button>
<p id="login-error"></p>
</div>
</div>
<!-- APP -->
<div id="app">
<header class="header">
<div class="header-left">
<div class="header-logo">KT</div>
<div>
<h1>📱 Метрики МП</h1>
<p>Использование функций мобильного приложения</p>
</div>
</div>
<div class="header-right">
<span id="generated-at"></span>
<a class="btn btn-ghost" href="../index.html">← KPI Dashboard</a>
</div>
</header>
<div class="wrap">
<div id="status-msg">Загрузка данных…</div>
<div id="content" style="display:none">
<div class="summary-grid" id="summary-grid"></div>
<div class="controls">
<input type="text" id="search" class="txt-input grow" placeholder="🔍 Поиск метрики…">
<select id="sort" class="txt-input">
<option value="cur">Сортировка: по использованию ↓</option>
<option value="growth">по приросту ↓</option>
<option value="label">по названию (А→Я)</option>
</select>
<label><input type="checkbox" id="only-new"> только новые</label>
</div>
<div class="cards" id="cards"></div>
</div>
</div>
</div>
<script>
const LOGIN_PASSWORD = 'KTdash1';
const DATA_URL = 'app_metrics.json';
// Резервные данные (на случай file:// или недоступности файла). Обновляются скриптом updater.
const EMBEDDED_METRICS = /*__DATA__*/{
"generated_at": "2026-06-16T17:30:19",
"cur_year": 2026,
"prev_year": 2025,
"period_label": "с 1 января по 15 июня",
"range": {
"start": "2026-01-01",
"end": "2026-06-15"
},
"metrics": [
{
"key": "my_services",
"label": "Мои услуги",
"prev": 1459571,
"cur": 1769470,
"growth": 0.21232197680003234,
"is_new": false
},
{
"key": "traffic",
"label": "Детализация трафика",
"prev": 1079736,
"cur": 1271563,
"growth": 0.17766102084213178,
"is_new": false
},
{
"key": "payments",
"label": "Платежи",
"prev": 553808,
"cur": 730185,
"growth": 0.3184804119839367,
"is_new": false
},
{
"key": "orders",
"label": "Заявки",
"prev": 635621,
"cur": 826255,
"growth": 0.2999177182629271,
"is_new": false
},
{
"key": "loyalty",
"label": "Лояльность",
"prev": 464365,
"cur": 470969,
"growth": 0.014221571393192856,
"is_new": false
},
{
"key": "pay",
"label": "Оплата",
"prev": 302510,
"cur": 337103,
"growth": 0.11435324452084229,
"is_new": false
},
{
"key": "billing_detail",
"label": "Детали счета",
"prev": 358290,
"cur": 496915,
"growth": 0.3869072539004717,
"is_new": false
},
{
"key": "viktorina",
"label": "Викторина KT Club",
"prev": 298475,
"cur": 213879,
"growth": -0.28342742273222216,
"is_new": false
},
{
"key": "partners",
"label": "Акции партнеров",
"prev": 94639,
"cur": 197009,
"growth": 1.08168936696288,
"is_new": false
},
{
"key": "tv_plus",
"label": "TV+",
"prev": 95647,
"cur": 64104,
"growth": -0.32978556567377965,
"is_new": false
},
{
"key": "boosters",
"label": "Бустеры",
"prev": 53649,
"cur": 121065,
"growth": 1.2566124252082984,
"is_new": false
},
{
"key": "roaming",
"label": "Роуминг",
"prev": 39200,
"cur": 22160,
"growth": -0.4346938775510204,
"is_new": false
},
{
"key": "pereoform",
"label": "Переоформление",
"prev": 23537,
"cur": 34570,
"growth": 0.46875132769681777,
"is_new": false
},
{
"key": "aitu_music",
"label": "Aitu Music",
"prev": 0,
"cur": 8651,
"growth": null,
"is_new": true
},
{
"key": "online_booking",
"label": "Онлайн очередь",
"prev": 5421,
"cur": 22144,
"growth": 3.084855192768862,
"is_new": false
},
{
"key": "my_docs",
"label": "Мои документы",
"prev": 0,
"cur": 53376,
"growth": null,
"is_new": true
},
{
"key": "dz_statement",
"label": "Справка о ДЗ",
"prev": 0,
"cur": 132795,
"growth": null,
"is_new": true
},
{
"key": "new_boosters_roaming_kcell",
"label": "Новая линейка бустеров и роумингов Кселл",
"prev": 0,
"cur": 28626,
"growth": null,
"is_new": true
},
{
"key": "adsl",
"label": "ADSL отключение услуги",
"prev": 0,
"cur": 69,
"growth": null,
"is_new": true
},
{
"key": "law_and_order",
"label": "Закон и порядок",
"prev": 0,
"cur": 1555,
"growth": null,
"is_new": true
},
{
"key": "acs",
"label": "ACS",
"prev": 0,
"cur": 9154,
"growth": null,
"is_new": true
},
{
"key": "kaspi_freedom_pay",
"label": "Прием платежей через Freedom и Kaspi",
"prev": 0,
"cur": 61481,
"growth": null,
"is_new": true
},
{
"key": "csat",
"label": "CSAT",
"prev": 0,
"cur": 2486,
"growth": null,
"is_new": true
},
{
"key": "multicustomer",
"label": "Мультикастомер",
"prev": 0,
"cur": 164,
"growth": null,
"is_new": true
},
{
"key": "tv_plus_setup",
"label": "Настройка TV+",
"prev": 0,
"cur": 4545,
"growth": null,
"is_new": true
},
{
"key": "static_ip",
"label": "Статический IP",
"prev": 0,
"cur": 108,
"growth": null,
"is_new": true
},
{
"key": "turbo_button",
"label": "Turbo кнопка",
"prev": 0,
"cur": 4312,
"growth": null,
"is_new": true
},
{
"key": "real_estate_docs",
"label": "Справка о недвижимости",
"prev": 0,
"cur": 78,
"growth": null,
"is_new": true
}
]
};
const App = { data: null };
// ───────── helpers ─────────
const fmtInt = n => (n || 0).toLocaleString('ru-RU');
function fmtPct(g) {
if (g === null || g === undefined) return '';
const s = (g * 100);
return (s >= 0 ? '+' : '') + s.toFixed(1) + '%';
}
function classify(m) {
if (m.is_new) return 'new';
if (m.growth === null) return 'flat';
if (m.growth > 0.0005) return 'up';
if (m.growth < -0.0005) return 'down';
return 'flat';
}
function badgeText(m, cls) {
if (cls === 'new') return 'NEW';
if (cls === 'flat') return '≈ 0%';
return fmtPct(m.growth);
}
// ───────── render ─────────
function renderSummary() {
const d = App.data;
const totCur = d.metrics.reduce((s, m) => s + m.cur, 0);
const totPrev = d.metrics.reduce((s, m) => s + m.prev, 0);
const overall = totPrev > 0 ? (totCur - totPrev) / totPrev : null;
const nNew = d.metrics.filter(m => m.is_new).length;
const nUp = d.metrics.filter(m => !m.is_new && m.growth > 0).length;
const nDown = d.metrics.filter(m => !m.is_new && m.growth < 0).length;
const cards = [
{ cls: '', label: 'Период', value: d.cur_year + ' / ' + d.prev_year, sub: d.period_label },
{ cls: 'accent-green', label: 'Обращений · ' + d.cur_year, value: fmtInt(totCur),
sub: 'против ' + fmtInt(totPrev) + ' в ' + d.prev_year + (overall !== null ? ' · ' + fmtPct(overall) : '') },
{ cls: 'accent-yellow', label: 'Динамика', value: '▲ ' + nUp + ' ▼ ' + nDown,
sub: nUp + ' выросли, ' + nDown + ' снизились' },
{ cls: 'accent-purple', label: 'Новых функций', value: String(nNew),
sub: 'появились в ' + d.cur_year + ' году' },
];
document.getElementById('summary-grid').innerHTML = cards.map(c => `
<div class="sum-card ${c.cls}">
<div class="sum-label">${c.label}</div>
<div class="sum-value">${c.value}</div>
<div class="sum-sub">${c.sub}</div>
</div>`).join('');
}
function renderCards() {
const d = App.data;
const q = document.getElementById('search').value.trim().toLowerCase();
const sort = document.getElementById('sort').value;
const onlyNew = document.getElementById('only-new').checked;
let list = d.metrics.slice();
if (onlyNew) list = list.filter(m => m.is_new);
if (q) list = list.filter(m => m.label.toLowerCase().includes(q));
list.sort((a, b) => {
if (sort === 'label') return a.label.localeCompare(b.label, 'ru');
if (sort === 'growth') {
// новые — в конце при сортировке по приросту (у них нет базы сравнения)
const ga = a.is_new ? -Infinity : (a.growth ?? -Infinity);
const gb = b.is_new ? -Infinity : (b.growth ?? -Infinity);
return gb - ga;
}
return b.cur - a.cur; // по использованию
});
const el = document.getElementById('cards');
if (!list.length) { el.innerHTML = '<div class="empty-note">Ничего не найдено</div>'; return; }
el.innerHTML = list.map(m => {
const cls = classify(m);
const max = Math.max(m.cur, m.prev, 1);
const curW = (m.cur / max * 100).toFixed(1);
const prevW = (m.prev / max * 100).toFixed(1);
return `
<div class="card ${cls}">
<div class="card-head">
<div class="card-title">${m.label}</div>
<span class="badge ${cls}">${badgeText(m, cls)}</span>
</div>
<div class="card-value">${fmtInt(m.cur)}<span class="card-value-year"> · ${d.cur_year}</span></div>
<div class="cmp">
<div class="cmp-row">
<span class="cmp-year">${d.cur_year}</span>
<span class="cmp-track"><span class="cmp-fill cur" style="width:${curW}%"></span></span>
<span class="cmp-num">${fmtInt(m.cur)}</span>
</div>
<div class="cmp-row">
<span class="cmp-year">${d.prev_year}</span>
<span class="cmp-track"><span class="cmp-fill prev" style="width:${prevW}%"></span></span>
<span class="cmp-num">${fmtInt(m.prev)}</span>
</div>
</div>
</div>`;
}).join('');
}
function renderAll() {
document.getElementById('status-msg').style.display = 'none';
document.getElementById('content').style.display = 'block';
const gen = App.data.generated_at ? new Date(App.data.generated_at) : null;
document.getElementById('generated-at').textContent =
'Обновлено: ' + (gen ? gen.toLocaleDateString('ru-RU') : '—');
renderSummary();
renderCards();
}
// ───────── load ─────────
async function loadData() {
if (window.location.protocol !== 'file:') {
try {
const r = await fetch(DATA_URL, { cache: 'no-cache' });
if (r.ok) { App.data = await r.json(); renderAll(); return; }
} catch (e) { /* fall through */ }
}
if (EMBEDDED_METRICS && EMBEDDED_METRICS.metrics) {
App.data = EMBEDDED_METRICS; renderAll(); return;
}
document.getElementById('status-msg').textContent =
'Не удалось загрузить данные (app_metrics.json).';
}
['search', 'sort', 'only-new'].forEach(id =>
document.addEventListener('input', e => { if (e.target.id === id) renderCards(); }));
// ───────── login ─────────
function showApp() {
document.getElementById('login-screen').style.display = 'none';
document.getElementById('app').style.display = 'block';
loadData();
}
function handleLogin() {
const inp = document.getElementById('login-password');
if (inp.value === LOGIN_PASSWORD) {
if (window.sessionStorage) sessionStorage.setItem('kpi_auth', '1');
showApp();
} else {
document.getElementById('login-error').textContent = 'Неверный пароль';
inp.value = '';
}
}
document.getElementById('btn-login').addEventListener('click', handleLogin);
document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') handleLogin(); });
document.addEventListener('DOMContentLoaded', () => {
if (window.sessionStorage && sessionStorage.getItem('kpi_auth') === '1') showApp();
});
</script>
</body>
</html>

View File

@ -280,6 +280,7 @@ body { font-family: var(--font-base); background: var(--color-bg); color: var(--
</div> </div>
<div class="header-right"> <div class="header-right">
<span id="last-updated"></span> <span id="last-updated"></span>
<a class="btn btn-ghost" href="app_stats/index.html" style="text-decoration:none">📱 Метрики МП</a>
<button class="btn btn-ghost" id="btn-export-csv">↓ Скачать CSV</button> <button class="btn btn-ghost" id="btn-export-csv">↓ Скачать CSV</button>
<button class="btn btn-outline" id="btn-reload">Обновить данные</button> <button class="btn btn-outline" id="btn-reload">Обновить данные</button>
</div> </div>

View File

@ -13,8 +13,16 @@ if exist "venv\Scripts\python.exe" (
set "PYEXE=python" set "PYEXE=python"
) )
REM 1) KPI dashboard (drb_iliyas_kpi_2026.csv)
"%PYEXE%" "%~dp0update_kpi.py" "%PYEXE%" "%~dp0update_kpi.py"
set "RC=%ERRORLEVEL%" set "RC_KPI=%ERRORLEVEL%"
echo Exit code: %RC% REM 2) Метрики МП (app_stats/app_metrics.json)
"%PYEXE%" "%~dp0update_app_metrics.py"
set "RC_APP=%ERRORLEVEL%"
echo KPI exit code: %RC_KPI% App-metrics exit code: %RC_APP%
REM Ненулевой код, если упал хотя бы один
set /a RC=%RC_KPI%+%RC_APP%
exit /b %RC% exit /b %RC%

View File

@ -0,0 +1,236 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Ежедневное обновление статистики «Метрики МП» из Impala.
Сравнивает использование функций мобильного приложения за текущий год
(с 1 января по ВЧЕРАШНИЙ день) с аналогичным периодом прошлого года.
Результат пишется в ../app_stats/app_metrics.json и пушится в ветку pages
его читает страница app_stats/index.html.
Подключение к Impala, конфиг и git-push переиспользуются из update_kpi.py.
"""
import sys
import json
import datetime as dt
from pathlib import Path
import update_kpi as base # общие функции: load_config, connect_impala, git_commit_push, ...
log = base.log
REPO_DIR = base.REPO_DIR
OUT_PATH = REPO_DIR / "app_stats" / "app_metrics.json"
OUT_REL = "app_stats/app_metrics.json"
# ─── Метрики: ключ в SQL → человекочитаемое название (в порядке SELECT) ───
METRICS = [
("my_services", "Мои услуги"),
("traffic", "Детализация трафика"),
("payments", "Платежи"),
("orders", "Заявки"),
("loyalty", "Лояльность"),
("pay", "Оплата"),
("billing_detail", "Детали счета"),
("viktorina", "Викторина KT Club"),
("partners", "Акции партнеров"),
("tv_plus", "TV+"),
("boosters", "Бустеры"),
("roaming", "Роуминг"),
("pereoform", "Переоформление"),
("aitu_music", "Aitu Music"),
("online_booking", "Онлайн очередь"),
("my_docs", "Мои документы"),
("dz_statement", "Справка о ДЗ"),
("new_boosters_roaming_kcell", "Новая линейка бустеров и роумингов Кселл"),
("adsl", "ADSL отключение услуги"),
("law_and_order", "Закон и порядок"),
("acs", "ACS"),
("kaspi_freedom_pay", "Прием платежей через Freedom и Kaspi"),
("csat", "CSAT"),
("multicustomer", "Мультикастомер"),
("tv_plus_setup", "Настройка TV+"),
("static_ip", "Статический IP"),
("turbo_button", "Turbo кнопка"),
("real_estate_docs", "Справка о недвижимости"),
]
_MONTHS_RU = ["", "января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря"]
def date_range(today: dt.date | None = None):
"""Возвращает (cur_year, prev_year, start_cur, end_cur, start_prev, end_prev) как date."""
today = today or dt.date.today()
end_cur = today - dt.timedelta(days=1) # вчера
cur_year = today.year
prev_year = cur_year - 1
start_cur = dt.date(cur_year, 1, 1)
start_prev = dt.date(prev_year, 1, 1)
# та же дата прошлого года; подстраховка на 29 февраля
try:
end_prev = dt.date(prev_year, end_cur.month, end_cur.day)
except ValueError:
end_prev = dt.date(prev_year, end_cur.month, 28)
return cur_year, prev_year, start_cur, end_cur, start_prev, end_prev
def build_sql(start_cur, end_cur, start_prev, end_prev) -> str:
return f"""
with t as (
select round(report_period_id/100) as report_year,
count(case when event_type = 'OPENWSCREENMYSERVICES' then 1 end) as my_services,
count(case when event_type = 'OPENWINDOWDETALIZTION' then 1 end) as traffic,
count(case when event_type = 'OPENWINDOWPAYMENT' then 1 end) as payments,
count(case when event_type = 'OPENSCREENAPPEALS' then 1 end) as orders,
count(case when event_type in ('banner_auth', 'banner_unauth', 'loyalty_banner_slider_auth', 'loyalty_banner_slider_unauth', 'get_bonus_opened', 'bonus_opened','promo_partners_opened','company_promo_opened') then 1 end) as loyalty,
count(case when event_type = 'WINDOWPAYMENT' then 1 end) as pay,
count(case when event_type = 'OPENWINDOWBILLING' then 1 end) as billing_detail,
count(case when event_type = 'game_page' then 1 end) as viktorina,
count(case when event_type = 'promo_partners_opened' then 1 end) as partners,
count(case when event_type = 'OPENWINDOWTVPLUS' then 1 end) as tv_plus,
count(case when event_type = 'MOBCONNECTIONOPENWINDOWADDITIONALTRAFFIC' then 1 end) as boosters,
count(case when event_type = 'OPENWINDOWROAMING' then 1 end) as roaming,
count(case when event_type = 'reregistration_comm_start' then 1 end) as pereoform,
count(case when event_type = 'aitu_music_banner_clicked' then 1 end) as aitu_music,
count(case when event_type = 'ONLINE_BOOKING_SERVICES' then 1 end) as online_booking,
count(case when event_type in ('EMPTYLISTDOCS', 'HASLISTDOCS') then 1 end) as my_docs,
count(case when event_type = 'PDFSTATEMENT' then 1 end) as dz_statement,
count(case when event_type in ('booster_success_screen_kcell', 'ROAMINGPACKAGEMOBILEKCELL') then 1 end) as new_boosters_roaming_kcell,
count(case when event_type = 'law_and_order_service_clicked' then 1 end) as law_and_order,
count(case when event_type = 'ACS_DEVICE_SELECTION_OPEN' then 1 end) as acs,
count(case when event_type in ('PAYMENTWASSUCCESSFULFREEDOM', 'PAYWITHKASPI') then 1 end) as kaspi_freedom_pay,
count(case when event_type = 'csat_screen_sent' then 1 end) as csat,
count(case when event_type = 'multicustomer_completed_screen_viewed' then 1 end) as multicustomer,
count(case when event_type = 'tv_plus_setup_success_viewed' then 1 end) as tv_plus_setup,
count(case when event_type = 'static_ip_connect_success_viewed' then 1 end) as static_ip,
count(case when event_type = 'turbo_activation_success_viewed' then 1 end) as turbo_button,
count(case when event_type = 'real_estate_docs_screen_shown' then 1 end) as real_estate_docs
from drb.drb_iliyas_amplitude_metrics_full
where entry_date between '{start_cur:%Y-%m-%d}' and '{end_cur:%Y-%m-%d}'
or entry_date between '{start_prev:%Y-%m-%d}' and '{end_prev:%Y-%m-%d}'
group by 1
)
, a as (
select year(created_at) as report_year, count(order_id) as adsl
from telecomkz.telecomkz_retention_service_prod_tariff_change_validations
group by 1
)
select t.report_year, my_services, traffic, payments, orders, loyalty, pay, billing_detail, viktorina, partners, tv_plus, boosters, roaming, pereoform, aitu_music, online_booking, my_docs, dz_statement, new_boosters_roaming_kcell, adsl, law_and_order, acs, kaspi_freedom_pay, csat, multicustomer,
tv_plus_setup, static_ip, turbo_button, real_estate_docs
from t
left join a on t.report_year = a.report_year
order by t.report_year
""".strip()
def fetch_by_year(conn, sql):
cur = conn.cursor()
log.info("Выполнение запроса метрик МП...")
cur.execute(sql)
rows = cur.fetchall()
names = [d[0].lower() for d in cur.description]
cur.close()
idx = {n: i for i, n in enumerate(names)}
by_year = {}
for r in rows:
year = int(round(float(r[idx["report_year"]])))
by_year[year] = {name: r[idx[name]] for name in idx}
log.info("Получено годовых строк: %s", sorted(by_year))
return by_year, idx
def _num(v):
return int(v) if v is not None else 0
def build_payload(by_year, cur_year, prev_year, start_cur, end_cur):
cur_row = by_year.get(cur_year, {})
prev_row = by_year.get(prev_year, {})
metrics = []
for key, label in METRICS:
cur_v = _num(cur_row.get(key))
prev_v = _num(prev_row.get(key))
is_new = prev_v == 0
growth = None if is_new else (cur_v - prev_v) / prev_v
metrics.append({
"key": key, "label": label,
"prev": prev_v, "cur": cur_v,
"growth": growth, "is_new": is_new,
})
period_label = (f"с 1 января по {end_cur.day} {_MONTHS_RU[end_cur.month]}")
return {
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"cur_year": cur_year,
"prev_year": prev_year,
"period_label": period_label,
"range": {"start": f"{start_cur:%Y-%m-%d}", "end": f"{end_cur:%Y-%m-%d}"},
"metrics": metrics,
}
def write_if_changed(payload) -> bool:
text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
old = ""
if OUT_PATH.exists():
old = OUT_PATH.read_text(encoding="utf-8")
# Сравниваем без учёта generated_at (чтобы не коммитить, если данные те же)
def strip_ts(s):
try:
d = json.loads(s)
d.pop("generated_at", None)
return json.dumps(d, ensure_ascii=False, sort_keys=True)
except Exception:
return s
if strip_ts(old) == strip_ts(text):
log.info("Метрики МП не изменились — коммит не требуется.")
return False
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUT_PATH.write_text(text, encoding="utf-8")
log.info("app_metrics.json обновлён (%d метрик).", len(payload["metrics"]))
return True
def main() -> int:
log.info("=" * 60)
log.info("Старт обновления Метрик МП")
cfg = base.load_config()
cur_year, prev_year, start_cur, end_cur, start_prev, end_prev = date_range()
log.info("Период: %s..%s (тек.) и %s..%s (пред.)",
start_cur, end_cur, start_prev, end_prev)
sql = build_sql(start_cur, end_cur, start_prev, end_prev)
base.patch_thrift_ssl()
conn = base.connect_impala(cfg)
try:
by_year, _ = fetch_by_year(conn, sql)
finally:
try:
conn.close()
except Exception:
pass
if cur_year not in by_year:
log.error("В ответе нет данных за %s — JSON НЕ перезаписан.", cur_year)
return 1
payload = build_payload(by_year, cur_year, prev_year, start_cur, end_cur)
if write_if_changed(payload):
base.git_commit_push(cfg, [OUT_REL],
f"data: update app metrics {dt.date.today():%Y-%m-%d}")
log.info("Готово (Метрики МП).")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as e: # noqa: BLE001
log.exception("ОШИБКА (Метрики МП): %s", e)
sys.exit(1)

View File

@ -241,26 +241,27 @@ def push_url(cfg: dict, mask_token: str):
return url return url
def commit_and_push(cfg: dict): def git_commit_push(cfg: dict, rel_paths, message: str):
"""Коммитит указанные файлы (пути относительно корня репо) и пушит в ветку.
Переиспользуется и для KPI, и для метрик МП. Если изменений нет выходит молча.
"""
git = cfg.get("git", {}) or {} git = cfg.get("git", {}) or {}
branch = git.get("branch", "pages") branch = git.get("branch", "pages")
token = (git.get("token") or "") token = (git.get("token") or "")
url = push_url(cfg, token) url = push_url(cfg, token)
# Коммитим только CSV — daily-диффы остаются чистыми. _run_git(["add", "--", *rel_paths])
_run_git(["add", "--", CSV_PATH.name])
rc, out = _run_git(["status", "--porcelain", "--", CSV_PATH.name], check=True) rc, out = _run_git(["status", "--porcelain", "--", *rel_paths], check=True)
if not out.strip(): if not out.strip():
log.info("Нет изменений CSV для коммита.") log.info("Нет изменений (%s) для коммита.", ", ".join(rel_paths))
return return
msg = f"data: update KPI {datetime.now():%Y-%m-%d}" _run_git(["commit", "-m", message])
_run_git(["commit", "-m", msg])
# Пуш с автоматическим rebase при гонке (веб-приложение тоже пушит ai-cache.json через API) # Пуш с автоматическим rebase при гонке (веб-приложение тоже пушит ai-cache.json через API)
for attempt in range(1, 4): for attempt in range(1, 4):
# подтянуть свежий remote и переставить наш коммит сверху
_run_git(["fetch", url, branch], check=True, mask=token or None) _run_git(["fetch", url, branch], check=True, mask=token or None)
# --autostash: не падать, если в дереве есть посторонние незакоммиченные правки # --autostash: не падать, если в дереве есть посторонние незакоммиченные правки
_run_git(["rebase", "--autostash", "FETCH_HEAD"], check=True) _run_git(["rebase", "--autostash", "FETCH_HEAD"], check=True)
@ -272,6 +273,11 @@ def commit_and_push(cfg: dict):
raise RuntimeError("Не удалось запушить изменения после 3 попыток.") raise RuntimeError("Не удалось запушить изменения после 3 попыток.")
def commit_and_push(cfg: dict):
# Коммитим только CSV — daily-диффы остаются чистыми.
git_commit_push(cfg, [CSV_PATH.name], f"data: update KPI {datetime.now():%Y-%m-%d}")
# ═══════════════════════ main ════════════════════════════════ # ═══════════════════════ main ════════════════════════════════
def main() -> int: def main() -> int:
log.info("=" * 60) log.info("=" * 60)