sozday-analiz-rashodov-s-uka/script.js

267 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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();
}
}