From 5d82f7b7f3cbcd86affbbf9dfe72eaae581cf2e7 Mon Sep 17 00:00:00 2001 From: Iliyas Date: Tue, 16 Jun 2026 18:00:51 +0500 Subject: [PATCH] =?UTF-8?q?feat(=D0=9C=D0=B5=D1=82=D1=80=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=9C=D0=9F):=20monthly=20cumulative=20model=20+=20YoY=20tr?= =?UTF-8?q?end=20sparklines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app_stats/index.html | 672 +++++++++++++++++++++++++++++----- updater/update_app_metrics.py | 149 +++++--- 2 files changed, 676 insertions(+), 145 deletions(-) diff --git a/app_stats/index.html b/app_stats/index.html index 984935e..aad39d6 100644 --- a/app_stats/index.html +++ b/app_stats/index.html @@ -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; }

📱 Метрики МП

-

Использование функций мобильного приложения

+

Использование функций МП · нарастающим итогом, год к году

@@ -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 = '
Ничего не найдено
'; 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 `
-
${m.label}
+
+
${m.label}
+
нарастающим итогом · ${d.cur_year} vs ${d.prev_year}
+
${badgeText(m, cls)}
${fmtInt(m.cur)} · ${d.cur_year}
-
-
- ${d.cur_year} - - ${fmtInt(m.cur)} -
-
- ${d.prev_year} - - ${fmtInt(m.prev)} -
+ ${sparkline(curCum, prevCum)} +
${labels[0] || ''}${labels[labels.length - 1] || ''}
+
+ ${d.cur_year}: ${fmtInt(m.cur)} + ${d.prev_year}: ${fmtInt(m.prev)}
`; }).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 ` + + + + `; +} + function renderAll() { document.getElementById('status-msg').style.display = 'none'; document.getElementById('content').style.display = 'block'; diff --git a/updater/update_app_metrics.py b/updater/update_app_metrics.py index ec5462a..4427ea6 100644 --- a/updater/update_app_metrics.py +++ b/updater/update_app_metrics.py @@ -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}")