267 lines
13 KiB
JavaScript
267 lines
13 KiB
JavaScript
"use strict";
|
||
|
||
const B = 1e9;
|
||
|
||
function fmt(v) {
|
||
return (v === null || v === undefined) ? "—" : Math.round(v).toLocaleString("ru-RU");
|
||
}
|
||
function fmtB(v, dec) {
|
||
if (v === null || v === undefined) return "—";
|
||
return (v / B).toFixed(dec || 2).replace(".", ",") + " млрд";
|
||
}
|
||
function fmtPct(v) {
|
||
if (v === null || v === undefined || !isFinite(v)) return "—";
|
||
return (v > 0 ? "+" : "") + v.toFixed(1).replace(".", ",") + " %";
|
||
}
|
||
function esc(s) {
|
||
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
function icon(name) {
|
||
return '<svg aria-hidden="true"><use href="design-system/icons/kt-ai-lucide-sprite.svg#' + name + '"></use></svg>';
|
||
}
|
||
function tip(title, rows) {
|
||
let h = '<span class="tip-title">' + esc(title) + "</span>";
|
||
if (rows && rows.length) {
|
||
h += '<dl class="tip-rows">' + rows.map((r) =>
|
||
"<dt>" + esc(r[0]) + "</dt><dd>" + esc(r[1]) + "</dd>"
|
||
).join("") + "</dl>";
|
||
}
|
||
return h;
|
||
}
|
||
|
||
let DATA = null;
|
||
|
||
async function init() {
|
||
const res = await fetch("data.json", { cache: "no-store" });
|
||
if (!res.ok) throw new Error("data.json: " + res.status);
|
||
DATA = await res.json();
|
||
renderKPI();
|
||
renderYearChart();
|
||
renderDev25();
|
||
renderDev26();
|
||
renderNominal();
|
||
renderMain("");
|
||
renderConclusions();
|
||
}
|
||
|
||
function totals() {
|
||
const rows = DATA.rows;
|
||
const s = (k) => rows.reduce((a, r) => a + (r[k] || 0), 0);
|
||
const c25 = rows.filter((r) => r.y2025 != null).length;
|
||
const c26 = rows.filter((r) => r.q1_2026 != null).length;
|
||
return { t23: s("y2023"), t24: s("y2024"), t25: s("y2025"), q26: s("q1_2026"), c25, c26 };
|
||
}
|
||
|
||
function renderKPI() {
|
||
const t = totals();
|
||
const dYo = (t.t25 - t.t24) / t.t24 * 100;
|
||
const dQ = (t.q26 - t.t25 / 2) / (t.t25 / 2) * 100;
|
||
const cells = [
|
||
{
|
||
lbl: "Всего за 2025 факт",
|
||
num: fmtB(t.t25),
|
||
ic: dYo < 0 ? "arrow-left" : "arrow-right",
|
||
cls: dYo < 0 ? "down" : "up",
|
||
note: fmtPct(dYo) + " к 2024"
|
||
},
|
||
{
|
||
lbl: "1пг 2026 факт",
|
||
num: fmtB(t.q26),
|
||
ic: dQ < 0 ? "arrow-left" : "arrow-right",
|
||
cls: dQ < 0 ? "down" : "up",
|
||
note: fmtPct(dQ) + " к 1пг 2025"
|
||
},
|
||
{
|
||
lbl: "2024 факт (пик)",
|
||
num: fmtB(t.t24),
|
||
ic: "scale",
|
||
cls: "flat",
|
||
note: "максимум за 3 года"
|
||
},
|
||
{
|
||
lbl: "Позиций с фактом 2025 / 1пг 2026",
|
||
num: t.c25 + " / " + t.c26,
|
||
ic: "check",
|
||
cls: "flat",
|
||
note: "всего в файле 103"
|
||
}
|
||
];
|
||
document.getElementById("kpiBlock").innerHTML = cells.map((c) =>
|
||
'<div class="kpi-cell"><span class="lbl">' + esc(c.lbl) + "</span>" +
|
||
'<span class="num">' + esc(c.num) + "</span>" +
|
||
'<span class="note ' + c.cls + '"><span class="arr">' + icon(c.ic) + "</span>" + esc(c.note) + "</span></div>"
|
||
).join("");
|
||
}
|
||
|
||
function renderYearChart() {
|
||
const svg = document.getElementById("yearChart");
|
||
const t = totals();
|
||
const items = [
|
||
{ l: "2023", v: t.t23, tip: [fmtB(t.t23, 3)] },
|
||
{ l: "2024", v: t.t24, tip: [fmtB(t.t24, 3), "пик за период"] },
|
||
{ l: "2025", v: t.t25, tip: [fmtB(t.t25, 3), fmtPct((t.t25 - t.t24) / t.t24 * 100) + " к 2024"] },
|
||
{ l: "1пг 2026", v: t.q26, tip: [fmtB(t.q26, 3), fmtPct((t.q26 - t.t25 / 2) / (t.t25 / 2) * 100) + " к 1пг 2025"] },
|
||
{ l: "2026 (проект.)", v: t.q26 * 2, muted: true, tip: [fmtB(t.q26 * 2, 3), "1пг 2026 × 2 (темп)"] }
|
||
];
|
||
const W = 720, H = 260, padL = 10, padR = 10, padT = 28, padB = 40;
|
||
const max = Math.max(...items.map((i) => i.v)) * 1.08;
|
||
const plotW = W - padL - padR, plotH = H - padT - padB;
|
||
const n = items.length;
|
||
const slot = plotW / n;
|
||
const barW = Math.min(slot * 0.52, 84);
|
||
let s = "";
|
||
for (let i = 0; i <= 4; i++) {
|
||
const y = padT + plotH - (plotH * i) / 4;
|
||
s += '<line class="grid-line" x1="' + padL + '" y1="' + y + '" x2="' + (W - padR) + '" y2="' + y + '"/>';
|
||
s += '<text class="lbl" x="' + (W - padR) + '" y="' + (y - 5) + '" text-anchor="end">' + ((max * i) / 4 / B).toFixed(1).replace(".", ",") + "</text>";
|
||
}
|
||
items.forEach((it, i) => {
|
||
const x = padL + slot * i + (slot - barW) / 2;
|
||
const h = plotH * (it.v / max);
|
||
const y = padT + plotH - h;
|
||
s += '<g data-kt-tip="' + esc(tip(it.l, it.tip.map((x2) => ["", x2]))) + '">' +
|
||
'<rect class="bar' + (it.muted ? " muted" : "") + '" x="' + x + '" y="' + y + '" width="' + barW + '" height="' + h + '" rx="4"></rect>' +
|
||
'<text class="val" x="' + (x + barW / 2) + '" y="' + (y - 8) + '" text-anchor="middle">' + (it.v / B).toFixed(2).replace(".", ",") + "</text>" +
|
||
"<text class='lbl' x='" + (x + barW / 2) + "' y='" + (H - 14) + "' text-anchor='middle'>" + esc(it.l) + "</text>" +
|
||
"</g>";
|
||
});
|
||
svg.innerHTML = s;
|
||
}
|
||
|
||
function renderDev25() {
|
||
const out = [];
|
||
for (const r of DATA.rows) {
|
||
if (r.d25 != null && Math.abs(r.d25) >= 1e8) out.push(r);
|
||
}
|
||
out.sort((a, b) => Math.abs(b.d25) - Math.abs(a.d25));
|
||
const arr = out.slice(0, 10);
|
||
const maxAbs = Math.max.apply(null, arr.map((r) => Math.abs(r.d25))) || 1;
|
||
const tb = document.querySelector("#dev25 tbody");
|
||
tb.innerHTML = arr.map((r) => {
|
||
const cls = r.d25 > 0 ? "pos" : "neg";
|
||
return "<tr>" +
|
||
'<td class="row-name"><span class="nm">' + esc(r.name) + "</span></td>" +
|
||
'<td class="ta-r dim">' + fmt(r.y2024) + "</td>" +
|
||
'<td class="ta-r">' + fmt(r.y2025) + "</td>" +
|
||
'<td class="ta-r ' + cls + '"><span class="d-val">' + fmt(r.d25) + '</span><span class="dim d-pct"> (' + fmtPct(r.p25) + ")</span></td>" +
|
||
'<td class="ta-c dim">' + fmtPct(r.p25) + "</td>" +
|
||
'<td class="barcell"><span class="mini-bar ' + (r.d25 > 0 ? "up" : "down") + '" style="width:' + Math.max(3, Math.round(Math.abs(r.d25) / maxAbs * 140)) + 'px"></span></td>' +
|
||
"</tr>";
|
||
}).join("");
|
||
}
|
||
|
||
function renderDev26() {
|
||
const out = [];
|
||
for (const r of DATA.rows) {
|
||
if (r.q1_2026 != null && r.y2025 != null && Math.abs(r.d26) >= 5e7) out.push(r);
|
||
}
|
||
out.sort((a, b) => Math.abs(b.d26) - Math.abs(a.d26));
|
||
const known = out.slice(0, 8);
|
||
document.querySelector("#dev26 tbody").innerHTML = known.map((r) => {
|
||
const cls = r.d26 > 0 ? "pos" : "neg";
|
||
return "<tr>" +
|
||
'<td class="row-name"><span class="nm">' + esc(r.name) + '</span><span class="badge-new">ориентир 2025/2</span></td>' +
|
||
'<td class="ta-r dim">' + fmt(r.y2025 / 2) + "</td>" +
|
||
'<td class="ta-r">' + fmt(r.q1_2026) + "</td>" +
|
||
'<td class="ta-r ' + cls + '"><span class="d-val">' + fmt(r.d26) + "</span></td>" +
|
||
'<td class="ta-c dim">' + fmtPct(r.p26) + "</td>" +
|
||
"</tr>";
|
||
}).join("") || '<tr><td colspan="5" class="dim" style="text-align:center;padding:24px">Нет позиций с отклонением</td></tr>';
|
||
|
||
const newArr = DATA.rows.filter((r) => r.q1_2026 != null && r.y2025 == null)
|
||
.sort((a, b) => b.q1_2026 - a.q1_2026);
|
||
document.getElementById("new26").innerHTML = newArr.map((r) =>
|
||
"<tr><td>" + esc(r.name) + '</td><td class="ta-r">' + fmt(r.q1_2026) + "</td></tr>"
|
||
).join("");
|
||
}
|
||
|
||
function renderNominal() {
|
||
const new25 = DATA.rows.filter((r) => r.y2025 != null && r.y2024 == null).sort((a, b) => b.y2025 - a.y2025);
|
||
const gone25 = DATA.rows.filter((r) => r.y2024 != null && r.y2025 == null).sort((a, b) => b.y2024 - a.y2024);
|
||
document.getElementById("new25").innerHTML = new25.slice(0, 8).map((r) =>
|
||
"<tr><td>" + esc(r.name) + '</td><td class="ta-r">' + fmt(r.y2025) + "</td></tr>"
|
||
).join("") +
|
||
"<tr><td class='dim'>и ещё " + (new25.length - 8) + " поз.</td><td class='ta-r dim'>" + fmt(new25.slice(8).reduce((a, r) => a + r.y2025, 0)) + "</td></tr>";
|
||
document.getElementById("gone25").innerHTML = gone25.slice(0, 8).map((r) =>
|
||
"<tr><td>" + esc(r.name) + '</td><td class="ta-r">' + fmt(r.y2024) + "</td></tr>"
|
||
).join("") +
|
||
"<tr><td class='dim'>и ещё " + (gone25.length - 8) + " поз.</td><td class='ta-r dim'>" + fmt(gone25.slice(8).reduce((a, r) => a + r.y2024, 0)) + "</td></tr>";
|
||
}
|
||
|
||
function renderMain(query) {
|
||
const q = query.trim().toLowerCase();
|
||
const rows = DATA.rows.filter((r) => !q || r.name.toLowerCase().includes(q));
|
||
const tb = document.querySelector("#mainTable tbody");
|
||
tb.innerHTML = rows.map((r) => {
|
||
const d25cls = r.d25 == null ? "dim" : (r.d25 > 0 ? "pos" : "neg");
|
||
const d25txt = r.d25 == null ? "—" :
|
||
fmt(r.d25) + (r.p25 != null ? " (" + fmtPct(r.p25) + ")" : " (новая)");
|
||
return "<tr>" +
|
||
'<td class="row-name">' + esc(r.name) + "</td>" +
|
||
'<td class="ta-r dim">' + fmt(r.y2023) + "</td>" +
|
||
'<td class="ta-r">' + fmt(r.y2024) + "</td>" +
|
||
'<td class="ta-r">' + fmt(r.y2025) + "</td>" +
|
||
'<td class="ta-r ' + d25cls + '">' + d25txt + "</td>" +
|
||
'<td class="ta-r">' + fmt(r.q1_2026) + "</td>" +
|
||
"</tr>";
|
||
}).join("") || '<tr class="empty-row"><td colspan="6">Ничего не найдено</td></tr>';
|
||
|
||
const t = totals();
|
||
document.querySelector("#mainTable tfoot").innerHTML =
|
||
"<tr><td>Итого по файлу</td><td class='ta-r'>" + fmt(t.t23) + "</td><td class='ta-r'>" + fmt(t.t24) + "</td><td class='ta-r'>" + fmt(t.t25) + "</td><td class='ta-r'></td><td class='ta-r'>" + fmt(t.q26) + "</td></tr>";
|
||
|
||
document.getElementById("rowCount").textContent =
|
||
(q ? "Найдено: " + rows.length : "Всего позиций: " + rows.length) +
|
||
" из 103; итоговая строка — по всем позициям файла.";
|
||
}
|
||
|
||
function renderConclusions() {
|
||
const t = totals();
|
||
const items = [
|
||
{
|
||
c: "warn", ic: "arrow-left",
|
||
h: "Тренд на 3 года: 9,58 → 10,83 → 8,35 млрд тг. Рост 2024 (+13,1%) полностью отыгран падением 2025 (−" + fmtB(t.t24 - t.t25, 2) + " тг; прирост 2024 был только " + fmtB(t.t24 - t.t23, 2) + " тг). Падение 2025 — сильнее роста 2024, поэтому оно не объясняется экономией."
|
||
},
|
||
{
|
||
c: "risk", ic: "alert",
|
||
h: "Спад 2025 на −2,48 млрд тг — эффект номенклатуры, а не экономии: 5,14 млрд тг статей 2024 «исчезли» (FLM Expenses 3,48 млрд, Prочие 0,50 млрд и др.), а «появились» 3,6 млрд тг новых. Пример переноса: <b>«FLM Expenses» (3,48 млрд в 2024) → «FLM» (3,23 млрд в 2025, 2,18 млрд за 1пг 2026).</b> Внутри FLM рост: +35,1% к 1пг 2025 (+568 млн тг)."
|
||
},
|
||
{
|
||
c: "risk", ic: "alert",
|
||
h: "1пг 2026: 4,227 млрд тг — на 52 млн тг (1,3%) выше темпа 2025. Драйверы: новая статья <b>«Ericsson (общий новый договор)» 0,827 млрд</b> (объединены CS part и 5G part), Oracle HW 376,0 млн; ускорение <b>Checkpoint Diamond</b> (+64,9 млн к темпу) и MS EA (+15,7 млн). Сокращения (CORM-Ронекс −26,9 млн, Aitu Passport −21,9 млн, Nexign −68 млн) почти их компенсируют."
|
||
},
|
||
{
|
||
c: "warn", ic: "alert",
|
||
h: "«Серверные лицензии MS EA» (базовая строка): <b>630 млн тг за 1пг 2026 — ровно как за весь 2025</b>. При сохранении темпа год даст ~1,26 млрд, то есть ~+100% по этой строке. Строки «3 транш / COGS» (513 млн) и «3 транш / G&A» (45 млн) в 2026 не фигурируют — сверить периметр."
|
||
},
|
||
{
|
||
c: "ok", ic: "check-circle",
|
||
h: "Стабильные сокращения по ключевым статьям: Ericsson CS part <b>−695 млн</b> в 2025 (−72%), 5G part <b>−174 млн</b> (−56%), а суммарно поддержка Ericsson упала с 1,186 млрд (2024) до 790 млн (2025) — минус 33%; при этом 5G-объём перешёл в «общий новый договор» 2026."
|
||
},
|
||
{
|
||
c: "info", ic: "scale",
|
||
h: "Номенклатура: 103 позиции. 19 впервые в 2025 (3,6 млрд тг), 4 впервые в 1пг 2026 (1,276 млрд тг). Прямое сравнение «по строке» требует сверки периметра договоров — часть отклонений на графике это переносы, а не рост/падение."
|
||
}
|
||
];
|
||
document.getElementById("conclusions").innerHTML = items.map((i) =>
|
||
'<li class="' + i.c + '"><span class="ic">' + icon(i.ic) + "</span><span>" + i.h + "</span></li>"
|
||
).join("");
|
||
}
|
||
|
||
function boot() {
|
||
init().catch((e) => {
|
||
const el = document.querySelector(".page-intro .sub");
|
||
if (el) el.innerHTML = '<span class="kt-ai-banner" data-tone="risk" style="display:block;padding:12px;border-radius:8px">Не удалось загрузить данные (data.json). ' + esc(e.message) + "</span>";
|
||
console.error(e);
|
||
});
|
||
document.getElementById("tblSearch").addEventListener("input", (e) => renderMain(e.target.value));
|
||
}
|
||
|
||
if (typeof document !== "undefined") {
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", boot);
|
||
} else {
|
||
boot();
|
||
}
|
||
}
|