feat(Метрики МП): monthly cumulative model + YoY trend sparklines

- updater: group by report_period_id (YYYYmm), current month capped to
  yesterday symmetrically for both years; per-year cumulative reset each Jan;
  adsl CTE now date-bounded for equal-period comparison
- JSON adds months/month_labels + per-metric cur_cum/prev_cum
- page: per-card cumulative sparkline (2025 dashed vs 2026 solid) + legend
This commit is contained in:
Iliyas 2026-06-16 18:00:51 +05:00
parent 4acad9a25c
commit 5d82f7b7f3
2 changed files with 676 additions and 145 deletions

View File

@ -94,14 +94,20 @@ select.txt-input { cursor: pointer; background: #fff; }
.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; }
.card-sub { font-size: 11px; color: var(--color-text-secondary); margin-top: 2px; }
/* накопительный мини-график (нарастающим итогом по месяцам) */
.spark { width: 100%; height: 52px; display: block; margin-top: 12px; overflow: visible; }
.spark polyline { fill: none; stroke-width: 2; vector-effect: non-scaling-stroke; stroke-linecap: round; stroke-linejoin: round; }
.spark .sp-cur { stroke: var(--color-brand); }
.spark .sp-prev { stroke: #C7D2E8; stroke-dasharray: 3 3; }
.spark .dot-cur { fill: var(--color-brand); }
.spark-x { display: flex; justify-content: space-between; font-size: 9px; color: var(--color-text-secondary); font-family: var(--font-mono); margin-top: 2px; }
.legend { display: flex; gap: 14px; margin-top: 10px; font-size: 11px; }
.legend span { display: flex; align-items: center; gap: 5px; color: var(--color-text-secondary); }
.legend b { font-family: var(--font-mono); color: var(--color-text-primary); font-weight: 600; }
.legend .dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.legend .dot.cur { background: var(--color-brand); }
.legend .dot.prev { background: #C7D2E8; }
.empty-note { text-align: center; color: var(--color-text-secondary); padding: 40px; grid-column: 1 / -1; }
/* loading / error */
@ -132,7 +138,7 @@ select.txt-input { cursor: pointer; background: #fff; }
<div class="header-logo">KT</div>
<div>
<h1>📱 Метрики МП</h1>
<p>Использование функций мобильного приложения</p>
<p>Использование функций МП · нарастающим итогом, год к году</p>
</div>
</div>
<div class="header-right">
@ -168,7 +174,7 @@ const DATA_URL = 'app_metrics.json';
// Резервные данные (на случай file:// или недоступности файла). Обновляются скриптом updater.
const EMBEDDED_METRICS = /*__DATA__*/{
"generated_at": "2026-06-16T17:30:19",
"generated_at": "2026-06-16T17:55:07",
"cur_year": 2026,
"prev_year": 2025,
"period_label": "с 1 января по 15 июня",
@ -176,230 +182,695 @@ const EMBEDDED_METRICS = /*__DATA__*/{
"start": "2026-01-01",
"end": "2026-06-15"
},
"months": [
1,
2,
3,
4,
5,
6
],
"month_labels": [
"Янв",
"Фев",
"Мар",
"Апр",
"Май",
"Июн"
],
"cumulative": true,
"metrics": [
{
"key": "my_services",
"label": "Мои услуги",
"prev": 1459571,
"cur": 1769470,
"growth": 0.21232197680003234,
"is_new": false
"prev": 1464715,
"growth": 0.2080643674708049,
"is_new": false,
"cur_cum": [
321989,
621597,
980877,
1327884,
1676590,
1769470
],
"prev_cum": [
308264,
594530,
857453,
1108674,
1341165,
1464715
]
},
{
"key": "traffic",
"label": "Детализация трафика",
"prev": 1079736,
"cur": 1271563,
"growth": 0.17766102084213178,
"is_new": false
"prev": 1083912,
"growth": 0.17312383293108666,
"is_new": false,
"cur_cum": [
246604,
463349,
727262,
978019,
1179021,
1271563
],
"prev_cum": [
214948,
425004,
634594,
827602,
1001634,
1083912
]
},
{
"key": "payments",
"label": "Платежи",
"prev": 553808,
"cur": 730185,
"growth": 0.3184804119839367,
"is_new": false
"prev": 555155,
"growth": 0.31528131783015556,
"is_new": false,
"cur_cum": [
111523,
203960,
358189,
523887,
653394,
730185
],
"prev_cum": [
120062,
232395,
331976,
427765,
512345,
555155
]
},
{
"key": "orders",
"label": "Заявки",
"prev": 635621,
"cur": 826255,
"growth": 0.2999177182629271,
"is_new": false
"prev": 638026,
"growth": 0.2950177578970136,
"is_new": false,
"cur_cum": [
173075,
312944,
461361,
601607,
742766,
826255
],
"prev_cum": [
153918,
301147,
409051,
500862,
587480,
638026
]
},
{
"key": "loyalty",
"label": "Лояльность",
"prev": 464365,
"cur": 470969,
"growth": 0.014221571393192856,
"is_new": false
"prev": 465354,
"growth": 0.012066083024965939,
"is_new": false,
"cur_cum": [
86429,
152587,
264979,
351819,
432695,
470969
],
"prev_cum": [
88583,
196751,
284932,
371536,
431175,
465354
]
},
{
"key": "pay",
"label": "Оплата",
"prev": 302510,
"cur": 337103,
"growth": 0.11435324452084229,
"is_new": false
"prev": 303542,
"growth": 0.11056460061540084,
"is_new": false,
"cur_cum": [
73119,
141842,
198550,
248838,
304803,
337103
],
"prev_cum": [
50344,
101847,
150536,
205493,
269251,
303542
]
},
{
"key": "billing_detail",
"label": "Детали счета",
"prev": 358290,
"cur": 496915,
"growth": 0.3869072539004717,
"is_new": false
"prev": 358855,
"growth": 0.38472363489431666,
"is_new": false,
"cur_cum": [
81176,
147137,
241789,
343223,
431096,
496915
],
"prev_cum": [
62482,
134395,
200427,
260559,
315518,
358855
]
},
{
"key": "viktorina",
"label": "Викторина KT Club",
"prev": 298475,
"cur": 213879,
"prev": 298475,
"growth": -0.28342742273222216,
"is_new": false
"is_new": false,
"cur_cum": [
0,
1,
118526,
213879,
213879,
213879
],
"prev_cum": [
0,
0,
49414,
298475,
298475,
298475
]
},
{
"key": "partners",
"label": "Акции партнеров",
"prev": 94639,
"cur": 197009,
"growth": 1.08168936696288,
"is_new": false
"prev": 94796,
"growth": 1.0782416979619394,
"is_new": false,
"cur_cum": [
40566,
65001,
117646,
149938,
185887,
197009
],
"prev_cum": [
27730,
49011,
65023,
75359,
89282,
94796
]
},
{
"key": "tv_plus",
"label": "TV+",
"prev": 95647,
"cur": 64104,
"growth": -0.32978556567377965,
"is_new": false
"prev": 95883,
"growth": -0.33143518663370986,
"is_new": false,
"cur_cum": [
11947,
20939,
33969,
45372,
56323,
64104
],
"prev_cum": [
24830,
44479,
61671,
76824,
90692,
95883
]
},
{
"key": "boosters",
"label": "Бустеры",
"prev": 53649,
"cur": 121065,
"growth": 1.2566124252082984,
"is_new": false
"prev": 54053,
"growth": 1.2397461750504135,
"is_new": false,
"cur_cum": [
15882,
33486,
61530,
88553,
114321,
121065
],
"prev_cum": [
7244,
12487,
18093,
31284,
49552,
54053
]
},
{
"key": "roaming",
"label": "Роуминг",
"prev": 39200,
"cur": 22160,
"growth": -0.4346938775510204,
"is_new": false
"prev": 39403,
"growth": -0.43760627363398724,
"is_new": false,
"cur_cum": [
2924,
5494,
8974,
11894,
18915,
22160
],
"prev_cum": [
6678,
13021,
19649,
27201,
35474,
39403
]
},
{
"key": "pereoform",
"label": "Переоформление",
"prev": 23537,
"cur": 34570,
"growth": 0.46875132769681777,
"is_new": false
"prev": 23644,
"growth": 0.46210455083742175,
"is_new": false,
"cur_cum": [
7556,
12691,
18718,
24710,
30986,
34570
],
"prev_cum": [
4876,
9244,
13556,
17422,
21572,
23644
]
},
{
"key": "aitu_music",
"label": "Aitu Music",
"prev": 0,
"cur": 8651,
"prev": 0,
"growth": null,
"is_new": true
"is_new": true,
"cur_cum": [
0,
1048,
4000,
5553,
7270,
8651
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "online_booking",
"label": "Онлайн очередь",
"prev": 5421,
"cur": 22144,
"growth": 3.084855192768862,
"is_new": false
"prev": 5449,
"growth": 3.063864929344834,
"is_new": false,
"cur_cum": [
5636,
10095,
13892,
17262,
20514,
22144
],
"prev_cum": [
0,
0,
0,
1848,
4407,
5449
]
},
{
"key": "my_docs",
"label": "Мои документы",
"prev": 0,
"cur": 53376,
"prev": 0,
"growth": null,
"is_new": true
"is_new": true,
"cur_cum": [
14980,
24658,
33590,
41765,
49552,
53376
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "dz_statement",
"label": "Справка о ДЗ",
"prev": 0,
"cur": 132795,
"prev": 0,
"growth": null,
"is_new": true
"is_new": true,
"cur_cum": [
35454,
64672,
85004,
100574,
119910,
132795
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "new_boosters_roaming_kcell",
"label": "Новая линейка бустеров и роумингов Кселл",
"prev": 0,
"cur": 28626,
"prev": 0,
"growth": null,
"is_new": true
"is_new": true,
"cur_cum": [
1927,
4523,
13318,
21056,
28239,
28626
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "adsl",
"label": "ADSL отключение услуги",
"prev": 0,
"cur": 69,
"prev": 0,
"growth": null,
"is_new": true
"is_new": true,
"cur_cum": [
20,
33,
44,
55,
65,
69
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "law_and_order",
"label": "Закон и порядок",
"prev": 0,
"cur": 1555,
"prev": 0,
"growth": null,
"is_new": true
"is_new": true,
"cur_cum": [
0,
232,
671,
1034,
1377,
1555
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "acs",
"label": "ACS",
"prev": 0,
"cur": 9154,
"prev": 0,
"growth": null,
"is_new": true
"is_new": true,
"cur_cum": [
0,
2329,
7499,
7800,
8174,
9154
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "kaspi_freedom_pay",
"label": "Прием платежей через Freedom и Kaspi",
"prev": 0,
"cur": 61481,
"prev": 0,
"growth": null,
"is_new": true
"is_new": true,
"cur_cum": [
0,
8427,
24816,
39893,
53444,
61481
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "csat",
"label": "CSAT",
"prev": 0,
"cur": 2486,
"prev": 0,
"growth": null,
"is_new": true
"is_new": true,
"cur_cum": [
0,
0,
0,
0,
0,
2486
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "multicustomer",
"label": "Мультикастомер",
"prev": 0,
"cur": 164,
"prev": 0,
"growth": null,
"is_new": true
"is_new": true,
"cur_cum": [
0,
0,
0,
0,
99,
164
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "tv_plus_setup",
"label": "Настройка TV+",
"prev": 0,
"cur": 4545,
"prev": 0,
"growth": null,
"is_new": true
"is_new": true,
"cur_cum": [
0,
0,
0,
0,
2628,
4545
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "static_ip",
"label": "Статический IP",
"prev": 0,
"cur": 108,
"prev": 0,
"growth": null,
"is_new": true
"is_new": true,
"cur_cum": [
0,
0,
0,
35,
75,
108
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "turbo_button",
"label": "Turbo кнопка",
"prev": 0,
"cur": 4312,
"prev": 0,
"growth": null,
"is_new": true
"is_new": true,
"cur_cum": [
0,
0,
0,
498,
2789,
4312
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
},
{
"key": "real_estate_docs",
"label": "Справка о недвижимости",
"prev": 0,
"cur": 78,
"prev": 0,
"growth": null,
"is_new": true
"is_new": true,
"cur_cum": [
0,
23,
44,
52,
70,
78
],
"prev_cum": [
0,
0,
0,
0,
0,
0
]
}
]
};
@ -477,34 +948,47 @@ function renderCards() {
const el = document.getElementById('cards');
if (!list.length) { el.innerHTML = '<div class="empty-note">Ничего не найдено</div>'; return; }
const labels = d.month_labels || [];
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);
const curCum = m.cur_cum || [m.cur];
const prevCum = m.prev_cum || [m.prev];
return `
<div class="card ${cls}">
<div class="card-head">
<div>
<div class="card-title">${m.label}</div>
<div class="card-sub">нарастающим итогом · ${d.cur_year} vs ${d.prev_year}</div>
</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>
${sparkline(curCum, prevCum)}
<div class="spark-x"><span>${labels[0] || ''}</span><span>${labels[labels.length - 1] || ''}</span></div>
<div class="legend">
<span><i class="dot cur"></i>${d.cur_year}: <b>${fmtInt(m.cur)}</b></span>
<span><i class="dot prev"></i>${d.prev_year}: <b>${fmtInt(m.prev)}</b></span>
</div>
</div>`;
}).join('');
}
// Накопительный мини-график: две кривые (пунктир — прошлый год, сплошная — текущий)
function sparkline(curCum, prevCum) {
const W = 100, H = 34, pad = 3;
const n = Math.max(curCum.length, prevCum.length, 1);
const max = Math.max(1, ...curCum, ...prevCum);
const x = i => n <= 1 ? W / 2 : pad + i * (W - 2 * pad) / (n - 1);
const y = v => H - pad - (v / max) * (H - 2 * pad);
const pts = arr => arr.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
const lastI = curCum.length - 1;
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
<polyline class="sp-prev" points="${pts(prevCum)}"/>
<polyline class="sp-cur" points="${pts(curCum)}"/>
<circle class="dot-cur" cx="${x(lastI).toFixed(1)}" cy="${y(curCum[lastI] || 0).toFixed(1)}" r="2.2"/>
</svg>`;
}
function renderAll() {
document.getElementById('status-msg').style.display = 'none';
document.getElementById('content').style.display = 'block';

View File

@ -3,11 +3,17 @@
"""
Ежедневное обновление статистики «Метрики МП» из Impala.
Сравнивает использование функций мобильного приложения за текущий год
(с 1 января по ВЧЕРАШНИЙ день) с аналогичным периодом прошлого года.
Результат пишется в ../app_stats/app_metrics.json и пушится в ветку pages
его читает страница app_stats/index.html.
Модель данных:
Грузим помесячно (группировка по report_period_id = YYYYmm), начиная с января
прошлого года и до текущего месяца текущего года.
Текущий (незавершённый) месяц обрезаем по entry_date до ВЧЕРАШНЕГО дня
симметрично для обоих лет (например, на 16 июня берём данные по 15 июня
включительно и в 2026, и в 2025), чтобы сравнение было «равный период».
Внутри каждого года считаем нарастающий итог (кумулятив) по месяцам; на старте
нового года накопление сбрасывается.
Сравнение в карточке = кумулятив-на-дату текущего года vs прошлого года.
Результат пишется в ../app_stats/app_metrics.json и пушится в ветку pages.
Подключение к Impala, конфиг и git-push переиспользуются из update_kpi.py.
"""
@ -54,31 +60,43 @@ METRICS = [
("turbo_button", "Turbo кнопка"),
("real_estate_docs", "Справка о недвижимости"),
]
METRIC_KEYS = [k for k, _ in METRICS]
_MONTHS_RU = ["", "января", "февраля", "марта", "апреля", "мая", "июня",
_MONTHS_RU_GEN = ["", "января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря"]
_MONTHS_RU_SHORT = ["", "Янв", "Фев", "Мар", "Апр", "Май", "Июн",
"Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"]
def date_range(today: dt.date | None = None):
"""Возвращает (cur_year, prev_year, start_cur, end_cur, start_prev, end_prev) как date."""
def date_bounds(today: dt.date | None = None):
"""Границы выборки. Текущий месяц обрезается по вчерашнему дню (симметрично по годам)."""
today = today or dt.date.today()
end_cur = today - dt.timedelta(days=1) # вчера
cur_year = today.year
prev_year = cur_year - 1
cap_month, cap_day = end_cur.month, end_cur.day
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
end_prev = dt.date(prev_year, cap_month, cap_day)
except ValueError: # 29 февраля в невисокосный год
end_prev = dt.date(prev_year, cap_month, 28)
return {
"cur_year": cur_year, "prev_year": prev_year,
"cap_month": cap_month, "cap_day": cap_day,
"start_cur": start_cur, "end_cur": end_cur,
"start_prev": start_prev, "end_prev": end_prev,
}
def build_sql(start_cur, end_cur, start_prev, end_prev) -> str:
def build_sql(b: dict) -> str:
# created_at — timestamp, поэтому верхнюю границу берём как «< следующий день»
cur_end_excl = b["end_cur"] + dt.timedelta(days=1)
prev_end_excl = b["end_prev"] + dt.timedelta(days=1)
return f"""
with t as (
select round(report_period_id/100) as report_year,
select report_period_id,
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,
@ -107,77 +125,104 @@ with t as (
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
where entry_date between '{b['start_cur']:%Y-%m-%d}' and '{b['end_cur']:%Y-%m-%d}'
or entry_date between '{b['start_prev']:%Y-%m-%d}' and '{b['end_prev']:%Y-%m-%d}'
group by report_period_id
)
, a as (
select year(created_at) as report_year, count(order_id) as adsl
select (year(created_at) * 100 + month(created_at)) as report_period_id,
count(order_id) as adsl
from telecomkz.telecomkz_retention_service_prod_tariff_change_validations
where (created_at >= '{b['start_cur']:%Y-%m-%d}' and created_at < '{cur_end_excl:%Y-%m-%d}')
or (created_at >= '{b['start_prev']:%Y-%m-%d}' and created_at < '{prev_end_excl:%Y-%m-%d}')
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,
select t.report_period_id, 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
left join a on t.report_period_id = a.report_period_id
order by t.report_period_id
""".strip()
def fetch_by_year(conn, sql):
def fetch_monthly(conn, sql):
cur = conn.cursor()
log.info("Выполнение запроса метрик МП...")
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 = {}
out = []
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
rec = {"report_period_id": int(r[idx["report_period_id"]])}
for key in METRIC_KEYS:
v = r[idx[key]] if key in idx else None
rec[key] = int(v) if v is not None else 0
out.append(rec)
log.info("Получено помесячных строк: %d (периоды: %s)",
len(out), ", ".join(str(x["report_period_id"]) for x in out))
return out
def _num(v):
return int(v) if v is not None else 0
def _cumsum(arr):
acc, out = 0, []
for v in arr:
acc += v
out.append(acc)
return out
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, {})
def build_payload(rows, b: dict):
cur_year, prev_year, cap_month = b["cur_year"], b["prev_year"], b["cap_month"]
months = list(range(1, cap_month + 1))
month_labels = [_MONTHS_RU_SHORT[m] for m in months]
# key -> {year -> [monthly values по месяцам months]}
monthly = {k: {cur_year: [0] * len(months), prev_year: [0] * len(months)} for k in METRIC_KEYS}
for rec in rows:
rpid = rec["report_period_id"]
y, m = rpid // 100, rpid % 100
if y not in (cur_year, prev_year) or m not in months:
continue
i = months.index(m)
for k in METRIC_KEYS:
monthly[k][y][i] = rec[k]
metrics = []
for key, label in METRICS:
cur_v = _num(cur_row.get(key))
prev_v = _num(prev_row.get(key))
cur_cum = _cumsum(monthly[key][cur_year])
prev_cum = _cumsum(monthly[key][prev_year])
cur_v = cur_cum[-1] if cur_cum else 0
prev_v = prev_cum[-1] if prev_cum else 0
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,
"cur": cur_v, "prev": prev_v,
"growth": growth, "is_new": is_new,
"cur_cum": cur_cum, "prev_cum": prev_cum,
})
period_label = (f"с 1 января по {end_cur.day} {_MONTHS_RU[end_cur.month]}")
end_cur = b["end_cur"]
period_label = f"с 1 января по {end_cur.day} {_MONTHS_RU_GEN[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}"},
"range": {"start": f"{b['start_cur']:%Y-%m-%d}", "end": f"{end_cur:%Y-%m-%d}"},
"months": months,
"month_labels": month_labels,
"cumulative": True,
"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")
old = OUT_PATH.read_text(encoding="utf-8") if OUT_PATH.exists() else ""
# Сравниваем без учёта generated_at (чтобы не коммитить, если данные те же)
def strip_ts(s):
try:
d = json.loads(s)
@ -192,7 +237,8 @@ def write_if_changed(payload) -> bool:
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"]))
log.info("app_metrics.json обновлён (%d метрик, %d мес.).",
len(payload["metrics"]), len(payload["months"]))
return True
@ -201,26 +247,27 @@ def main() -> int:
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)
b = date_bounds()
log.info("Период (тек.): %s..%s | (пред.): %s..%s | месяцев: %d",
b["start_cur"], b["end_cur"], b["start_prev"], b["end_prev"], b["cap_month"])
sql = build_sql(b)
base.patch_thrift_ssl()
conn = base.connect_impala(cfg)
try:
by_year, _ = fetch_by_year(conn, sql)
rows = fetch_monthly(conn, sql)
finally:
try:
conn.close()
except Exception:
pass
if cur_year not in by_year:
cur_year = b["cur_year"]
if not any(r["report_period_id"] // 100 == cur_year for r in rows):
log.error("В ответе нет данных за %s — JSON НЕ перезаписан.", cur_year)
return 1
payload = build_payload(by_year, cur_year, prev_year, start_cur, end_cur)
payload = build_payload(rows, b)
if write_if_changed(payload):
base.git_commit_push(cfg, [OUT_REL],
f"data: update app metrics {dt.date.today():%Y-%m-%d}")