1003 lines
49 KiB
JavaScript
1003 lines
49 KiB
JavaScript
/* КФУ — финансовая устойчивость. Статическое приложение, весь анализ — в браузере. */
|
||
(function () {
|
||
"use strict";
|
||
|
||
var GROUP_NAME = "Консолидированная группа 1";
|
||
var EBI_ROWS = [[26, 1], [27, 1], [28, 1], [31, 1], [32, 1], [40, 1], [41, 1], [45, -1], [56, -1], [64, -1]];
|
||
var EBI_ROWS_TXT = "26+27+28+31+32+40+41−45−56−64";
|
||
|
||
var S = {
|
||
source: null,
|
||
sheetNames: [],
|
||
diag: [],
|
||
entries: [],
|
||
isMock: false,
|
||
sort: { k: "name", d: 1 },
|
||
q: "",
|
||
zone: "",
|
||
norm: "",
|
||
onlyRisk: false,
|
||
scen: {}
|
||
};
|
||
|
||
/* ---------- утилиты ---------- */
|
||
function $(s) { return document.querySelector(s); }
|
||
function normName(s) {
|
||
if (s == null) return "";
|
||
return String(s).replace(/[«»„“”]/g, '"').replace(/[’‘]/g, "'").replace(/[–—−]/g, "-")
|
||
.replace(/\s+/g, " ").trim().toLowerCase();
|
||
}
|
||
function colOfLetter(s) {
|
||
s = String(s).toUpperCase().replace(/\$/g, "").replace(/\d+$/, "").trim();
|
||
var n = 0;
|
||
for (var i = 0; i < s.length; i++) n = n * 26 + (s.charCodeAt(i) - 64);
|
||
return n;
|
||
}
|
||
function toNum(v) {
|
||
if (typeof v === "number") return isFinite(v) ? v : null;
|
||
if (typeof v === "string") {
|
||
var t = v.replace(/\s|,/g, "");
|
||
if (t === "" ) return null;
|
||
var n = parseFloat(t);
|
||
return isFinite(n) ? n : null;
|
||
}
|
||
return null;
|
||
}
|
||
function fmtMoney(v) {
|
||
if (v == null) return '<span class="none">нет данных</span>';
|
||
return nfmt(v, 1);
|
||
}
|
||
function fmtR(v) {
|
||
if (v == null) return '<span class="none">нет данных</span>';
|
||
if (v === Infinity) return '<span class="inf">∞</span>';
|
||
return nfmt(v, 2);
|
||
}
|
||
var _nfCache = {};
|
||
function nfmt(v, d) {
|
||
d = d == null ? 2 : d;
|
||
if (!_nfCache[d]) {
|
||
_nfCache[d] = new Intl.NumberFormat("ru-RU", { minimumFractionDigits: d, maximumFractionDigits: d, useGrouping: true });
|
||
}
|
||
return _nfCache[d].format(v).replace(/-\s?/g, "−").replace(/-/g, "−");
|
||
}
|
||
function fmtC(v) {
|
||
if (v == null) return "—";
|
||
if (typeof v === "number") return nfmt(Number.isInteger(v) ? v : Math.round(v * 1e6) / 1e6, 6);
|
||
return String(v);
|
||
}
|
||
function pct(v) { return nfmt(v, 4); }
|
||
function esc(s) {
|
||
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
function toast(msg, isErr) {
|
||
var el = document.createElement("div");
|
||
el.className = "kfu-toast" + (isErr ? " err" : "");
|
||
el.textContent = msg;
|
||
$("#toasts").appendChild(el);
|
||
setTimeout(function () { el.remove(); }, 4200);
|
||
}
|
||
|
||
/* ---------- извлечение ячеек из листа (SheetJS: Sheets[name] — объект A1→cell) ---------- */
|
||
function getCell(wb, sheet, r, c) {
|
||
var ws = wb.Sheets[sheet];
|
||
if (!ws) return null;
|
||
var addr = XLSX.utils.encode_cell({ r: r - 1, c: c - 1 });
|
||
return ws[addr] || null;
|
||
}
|
||
function cellNum(wb, sheet, r, c) {
|
||
var cell = getCell(wb, sheet, r, c);
|
||
if (cell == null) return null;
|
||
if (cell.t === "e") return null; /* #DIV/0! и т.п. — не число */
|
||
var n = toNum(cell.v != null ? cell.v : cell.w);
|
||
return n;
|
||
}
|
||
function cellStr(wb, sheet, r, c) {
|
||
var cell = getCell(wb, sheet, r, c);
|
||
if (cell == null) return null;
|
||
var s = cell.w != null ? cell.w : cell.v;
|
||
s = String(s == null ? "" : s).trim();
|
||
return s || null;
|
||
}
|
||
|
||
function findSheet(names, subs) {
|
||
var low = names.map(function (n) { return n.toLowerCase(); });
|
||
for (var i = 0; i < subs.length; i++) {
|
||
for (var j = 0; j < low.length; j++) {
|
||
if (low[j].indexOf(subs[i]) !== -1) return names[j];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/* ---------- чтение одного объекта (строка с именами + данные) ---------- */
|
||
function readEntry(wb, name, isGroup, maps) {
|
||
var d = { name: name, group: isGroup, debt: null, other: null, ebitda: null, intExp: 0,
|
||
equity: null, addr: {}, issues: [] };
|
||
var colL = XLSX.utils.encode_col;
|
||
var normN = normName(name);
|
||
|
||
if (maps.dol) {
|
||
var c = isGroup ? GROUP_COL.dol : maps.dol[normN];
|
||
var v = c ? cellNum(wb, maps.dolSheet, 24, c) : null;
|
||
d.debt = v == null ? null : v / -1000;
|
||
d.addr.debt = colL(c - 1) + "24 / −1000";
|
||
d.addr.debtSheet = maps.dolSheet;
|
||
if (v == null && !isGroup) d.issues.push("долг: пустая ячейка " + colL(c - 1) + "24 (обнулять нельзя)");
|
||
} else if (!isGroup) {
|
||
d.issues.push("долг: компания не найдена в листе долга");
|
||
}
|
||
|
||
if (maps.grp) {
|
||
var gc = isGroup ? null : maps.grp[normN];
|
||
if (isGroup) {
|
||
var s9 = 0, has9 = false;
|
||
for (var cs = 3; cs <= 17; cs++) {
|
||
var v9 = cellNum(wb, maps.grpSheet, 9, cs);
|
||
if (v9 != null) { s9 += v9; has9 = true; }
|
||
}
|
||
/* группа: при любом отсутствующем C9:Q9 — null (долг+прочие считается null) */
|
||
d.other = has9 ? s9 * 1000 : null;
|
||
d.addr.other = "C9:Q9 · млрд ₸ × 1000";
|
||
if (!has9) d.issues.push("прочие/гарантии: весь диапазон C9:Q9 пуст — долг+прочие недоступен");
|
||
} else if (gc) {
|
||
var gv = cellNum(wb, maps.grpSheet, 9, gc);
|
||
d.other = gv == null ? null : gv * 1000;
|
||
d.addr.other = colL(gc - 1) + "9 · млрд ₸ × 1000";
|
||
if (gv == null) d.issues.push("прочие/гарантии: пустая ячейка — показатель отсутствует");
|
||
} else {
|
||
d.other = null;
|
||
d.issues.push("прочие/гарантии: компания не найдена в листе группы — показатель отсутствует");
|
||
}
|
||
d.addr.otherSheet = maps.grpSheet;
|
||
} else if (!isGroup) {
|
||
d.other = null;
|
||
d.issues.push("прочие/гарантии: лист группы не найден — показатель отсутствует");
|
||
}
|
||
|
||
if (maps.ebi) {
|
||
var ec = isGroup ? GROUP_COL.ebi : maps.ebi[normN];
|
||
/* итоговая EBITDA (row 83) — проверка наличия; само значение = сумма слагаемых */
|
||
var tot = ec ? cellNum(wb, maps.ebiSheet, 83, ec) : null;
|
||
if (tot == null) {
|
||
d.ebitda = null;
|
||
d.issues.push("EBITDA: итог (row 83) пуст — расчёт заблокирован");
|
||
} else {
|
||
var parts = [];
|
||
var badEbi = null;
|
||
for (var i = 0; i < EBI_ROWS.length; i++) {
|
||
var rr = EBI_ROWS[i][0], sg = EBI_ROWS[i][1];
|
||
var pv = ec ? cellNum(wb, maps.ebiSheet, rr, ec) : null;
|
||
if (pv == null) {
|
||
var raw = getCell(wb, maps.ebiSheet, rr, ec);
|
||
if (raw && raw.t === "e") { badEbi = rr; break; }
|
||
/* пустое слагаемое — 0 */
|
||
} else parts.push(sg * pv);
|
||
}
|
||
if (badEbi != null) { d.ebitda = null; d.issues.push("EBITDA: ошибка в слагаемой строке " + badEbi + " — расчёт заблокирован"); }
|
||
else d.ebitda = parts.length ? parts.reduce(function (a, b) { return a + b; }, 0) / -1000 : null;
|
||
}
|
||
/* проценты: отсутствуют = отсутствием остаются (не 0), делится на 1000 */
|
||
var iv = ec ? cellNum(wb, maps.ebiSheet, 43, ec) : null;
|
||
d.intExp = iv == null ? null : iv / 1000;
|
||
d.addr.ebitda = colL(ec - 1) + " стр." + EBI_ROWS_TXT + " / −1000 (итог row83)";
|
||
d.addr.ebitdaSheet = maps.ebiSheet;
|
||
d.addr.intexp = colL(ec - 1) + "43 / 1000";
|
||
d.addr.intexpSheet = maps.ebiSheet;
|
||
if (iv == null) d.issues.push("проценты: ячейка 43 пуста — показатель отсутствует (не заменяется нулём)");
|
||
} else if (!isGroup) {
|
||
d.issues.push("EBITDA: компания не найдена в листе EBITDA");
|
||
}
|
||
|
||
if (maps.bs) {
|
||
var bc = isGroup ? GROUP_COL.bs : maps.bs[normN];
|
||
var bv = bc ? cellNum(wb, maps.bsSheet, 74, bc) : null;
|
||
d.equity = bv == null ? null : bv / -1000;
|
||
d.addr.equity = colL(bc - 1) + "74 / −1000";
|
||
d.addr.equitySheet = maps.bsSheet;
|
||
if (bv == null) d.issues.push("капитал: пустая ячейка (обнулять нельзя)");
|
||
} else if (!isGroup) {
|
||
d.issues.push("капитал: компания не найдена в балансе");
|
||
}
|
||
return d;
|
||
}
|
||
|
||
/* ---------- парсер книги ---------- */
|
||
/* собственный столбец консолидированной группы (ищем по имени из строки имён) */
|
||
var GROUP_COL = { dol: null, ebi: null, bs: null };
|
||
|
||
function analyzeWorkbook(wb, opts) {
|
||
opts = opts || {};
|
||
var names = wb.SheetNames;
|
||
var out = { diag: [], diagErrors: [], entries: [], sheetNames: names };
|
||
/* диагностика: все ячейки с cell.t === "e" по всем листам */
|
||
for (var si0 = 0; si0 < names.length; si0++) {
|
||
var ws0 = wb.Sheets[names[si0]];
|
||
if (!ws0) continue;
|
||
var range = XLSX.utils.decode_range(ws0["!ref"] || "A1");
|
||
for (var r0 = range.s.r; r0 <= range.e.r; r0++) {
|
||
for (var c0 = range.s.c; c0 <= range.e.c; c0++) {
|
||
var addr0 = XLSX.utils.encode_cell({ r: r0, c: c0 });
|
||
var cell0 = ws0[addr0];
|
||
if (cell0 && cell0.t === "e") {
|
||
/* cell.v — числовой код ошибки (23=REF, 7=VALUE); показываем как текст */
|
||
var codeMap = { 5: "#NAME?", 7: "#VALUE!", 15: "#DIV/0!", 23: "#REF!", 24: "#N/A", 27: "#NULL!", 29: "#NUM!" };
|
||
var txt = cell0.w != null ? cell0.w : (codeMap[cell0.v] != null ? codeMap[cell0.v] : "#" + cell0.v);
|
||
out.diagErrors.push({ sheet: names[si0], addr: addr0, val: txt });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function diagErr(wb, sheet, r, c, field) {
|
||
var addr = XLSX.utils.encode_cell({ r: r - 1, c: c - 1 });
|
||
var cell = getCell(wb, sheet, r, c);
|
||
var val;
|
||
if (cell == null) val = "пусто";
|
||
else if (cell.t === "e") val = "ошибка " + cell.v;
|
||
else val = String(cell.w != null ? cell.w : cell.v);
|
||
out.diag.push("«" + sheet + "» " + addr + " (" + field + "): " + val);
|
||
}
|
||
|
||
var dolSheet = opts.sheetOverrides ? opts.sheetOverrides.dol : findSheet(names, ["долг 4q2025_24042026", "долг"]);
|
||
var ebiSheet = opts.sheetOverrides ? opts.sheetOverrides.ebi : findSheet(names, ["ebitda 2025_06042026", "ebitda"]);
|
||
var bsSheet = opts.sheetOverrides ? opts.sheetOverrides.bs : findSheet(names, ["bs 4q2025_06042026", "bs"]);
|
||
var grpSheet = opts.sheetOverrides ? opts.sheetOverrides.grp : findSheet(names, ["группа фонда", "группа"]);
|
||
|
||
if (!dolSheet) out.diag.push("не найден лист долга — долг недоступен");
|
||
if (!ebiSheet) out.diag.push("не найден лист EBITDA");
|
||
if (!bsSheet) out.diag.push("не найден лист баланса BS");
|
||
if (!grpSheet) out.diag.push("не найден лист «Группа Фонда» — прочие/гарантии и нормативы недоступны");
|
||
|
||
function findGroupCol(sheet, nr, c1, c2) {
|
||
if (!sheet) return null;
|
||
for (var c = c1; c <= c2; c++) {
|
||
var v = cellStr(wb, sheet, nr, c);
|
||
if (v && normName(v) === normName(GROUP_NAME)) return c;
|
||
}
|
||
return null;
|
||
}
|
||
GROUP_COL.dol = findGroupCol(dolSheet, 22, 21, 39);
|
||
GROUP_COL.ebi = findGroupCol(ebiSheet, 22, 21, 39);
|
||
GROUP_COL.bs = findGroupCol(bsSheet, 21, 15, 40);
|
||
if (!GROUP_COL.dol) out.diag.push("лист долга: столбец «" + GROUP_NAME + "» не найден — долг группы недоступен");
|
||
if (!GROUP_COL.ebi) out.diag.push("лист EBITDA: столбец «" + GROUP_NAME + "» не найден");
|
||
if (!GROUP_COL.bs) out.diag.push("лист BS: столбец «" + GROUP_NAME + "» не найден");
|
||
|
||
var maps = {};
|
||
|
||
function readNames(wb, sheet, r, c1, c2) {
|
||
var m = {}, order = [];
|
||
for (var c = c1; c <= c2; c++) {
|
||
var v = cellStr(wb, sheet, r, c);
|
||
if (v) {
|
||
var k = normName(v);
|
||
if (!m[k]) { m[k] = c; order.push(k); }
|
||
}
|
||
}
|
||
return { map: m, order: order };
|
||
}
|
||
|
||
var planSheet = opts.sheetOverrides ? opts.sheetOverrides.plan : findSheet(names, ["план 2026", "план"]);
|
||
|
||
var planNames = [];
|
||
if (planSheet) {
|
||
for (var r = 5; r <= 20; r++) {
|
||
var nm = cellStr(wb, planSheet, r, 1);
|
||
if (nm) planNames.push(nm);
|
||
}
|
||
}
|
||
|
||
for (var si = 0; si < 4; si++) {
|
||
/* строка/диапазон имён для каждого листа */
|
||
var defs = [
|
||
{ key: "dol", sheet: dolSheet, nr: 22, c1: 21, c2: 39 },
|
||
{ key: "ebi", sheet: ebiSheet, nr: 22, c1: 21, c2: 39 },
|
||
{ key: "bs", sheet: bsSheet, nr: 21, c1: 15, c2: 40 },
|
||
{ key: "grp", sheet: grpSheet, nr: 3, c1: 2, c2: 17 }
|
||
];
|
||
var def = defs[si];
|
||
if (def.sheet) {
|
||
var res = readNames(wb, def.sheet, def.nr, def.c1, def.c2);
|
||
maps[def.key] = res.map;
|
||
maps[def.key + "Sheet"] = def.sheet;
|
||
}
|
||
}
|
||
|
||
/* нормативы из листа группы: K1 строки 36(порог)/37(цель), K2 40/41, K3 32/33;
|
||
B — группа, C..Q — компании по строке 3 */
|
||
function normVal(sheet, r, c) {
|
||
var cell = getCell(wb, sheet, r, c);
|
||
if (cell == null) return null;
|
||
if (cell.t === "e") return { err: "#" + cell.v };
|
||
var n = toNum(cell.v != null ? cell.v : cell.w);
|
||
if (n != null) return n;
|
||
var s = String(cell.w != null ? cell.w : cell.v).trim().toLowerCase();
|
||
if (s === "n/a" || s === "н/а" || s === "") return null;
|
||
return { text: s };
|
||
}
|
||
|
||
var norms = {};
|
||
if (grpSheet) {
|
||
var names3 = {};
|
||
for (var c3 = 2; c3 <= 17; c3++) {
|
||
var n3 = cellStr(wb, grpSheet, 3, c3);
|
||
if (n3) names3[normName(n3)] = c3;
|
||
}
|
||
for (var nk in names3) {
|
||
var cc = names3[nk];
|
||
norms[nk] = {
|
||
k1th: normVal(grpSheet, 36, cc), k1t: normVal(grpSheet, 37, cc),
|
||
k2th: normVal(grpSheet, 40, cc), k2t: normVal(grpSheet, 41, cc),
|
||
k3th: normVal(grpSheet, 32, cc), k3t: normVal(grpSheet, 33, cc)
|
||
};
|
||
}
|
||
var keys = ["k1th", "k1t", "k2th", "k2t", "k3th", "k3t"];
|
||
for (var nk2 in norms) {
|
||
for (var ki2 = 0; ki2 < keys.length; ki2++) {
|
||
var vv = norms[nk2][keys[ki2]];
|
||
if (vv && typeof vv === "object" && vv.err) {
|
||
out.diag.push("норматив " + keys[ki2] + " в «" + grpSheet + "»: ошибка " + vv.err);
|
||
}
|
||
}
|
||
}
|
||
/* нормативы группы — из столбца B */
|
||
var gN = {
|
||
k1th: normVal(grpSheet, 36, 2), k1t: normVal(grpSheet, 37, 2),
|
||
k2th: normVal(grpSheet, 40, 2), k2t: normVal(grpSheet, 37, 2), /* исправим ниже */
|
||
k3th: normVal(grpSheet, 32, 2), k3t: normVal(grpSheet, 33, 2)
|
||
};
|
||
gN.k2t = normVal(grpSheet, 41, 2);
|
||
norms.group = gN;
|
||
}
|
||
|
||
/* список объектов: 15 из плана + группа */
|
||
var list = [];
|
||
if (planNames.length === 15 || planNames.length >= 14) {
|
||
for (var pi = 0; pi < planNames.length; pi++) list.push({ name: planNames[pi], group: planNames[pi] === GROUP_NAME });
|
||
} else {
|
||
var pool = {};
|
||
if (maps.dol) { var dd = order_of(maps.dol); for (var x = 0; x < dd.length; x++) pool[normName(dd[x])] = dd[x]; }
|
||
for (var k in pool) list.push({ name: k, group: false });
|
||
out.diag.push("список компаний из «План 2026» не распознан — использованы имена листов");
|
||
}
|
||
|
||
var entries = [];
|
||
for (var ei = 0; ei < list.length; ei++) {
|
||
var e = list[ei];
|
||
var nm = e.name;
|
||
var isG = e.group;
|
||
var en = readEntry(wb, nm, isG, {
|
||
dol: maps.dol, dolSheet: dolSheet,
|
||
ebi: maps.ebi, ebiSheet: ebiSheet,
|
||
bs: maps.bs, bsSheet: bsSheet,
|
||
grp: maps.grp, grpSheet: grpSheet
|
||
});
|
||
var nkey = isG ? "group" : normName(nm);
|
||
en.norms = norms[nkey] || null;
|
||
coefFor(en, 0);
|
||
entries.push(en);
|
||
}
|
||
|
||
/* диагностика ключевых ячеек */
|
||
for (var di = 0; di < entries.length; di++) {
|
||
var en2 = entries[di];
|
||
(function (en) {
|
||
if (en.debt == null && en.addr.debtSheet) diagErr(wb, en.addr.debtSheet, 24, colOfLetter(en.addr.debt), "долг");
|
||
if (en.ebitda == null && en.addr.ebitdaSheet) diagErr(wb, en.addr.ebitdaSheet, 83, colOfLetter(en.addr.ebitda), "EBITDA");
|
||
if (en.equity == null && en.addr.equitySheet) diagErr(wb, en.addr.equitySheet, 74, colOfLetter(en.addr.equity), "капитал");
|
||
})(en2);
|
||
}
|
||
|
||
/* сверка с План 2026!G5:J20 */
|
||
var recon = null;
|
||
if (planSheet && planNames.length) {
|
||
var rows = [];
|
||
var total = 0, ok = 0;
|
||
for (var ri = 0; ri < planNames.length; ri++) {
|
||
var rn = planNames[ri];
|
||
var en = null;
|
||
for (var ej = 0; ej < entries.length; ej++) if (normName(entries[ej].name) === normName(rn)) en = entries[ej];
|
||
if (!en) continue;
|
||
for (var ci = 0; ci < 4; ci++) {
|
||
var ref = cellNum(wb, planSheet, ri + 5, 7 + ci);
|
||
var val = [en.debtTotal, en.ebitda, en.intExp, en.equity][ci];
|
||
var labels = ["Долг (+прочие)", "EBITDA", "Проценты", "Капитал"];
|
||
var pass = ref == null && val == null ? null : (ref != null && val != null && Math.abs(ref - val) <= 0.01);
|
||
total++;
|
||
if (pass === true) ok++;
|
||
rows.push({ name: rn, field: labels[ci], ref: ref, val: val, pass: pass });
|
||
}
|
||
}
|
||
recon = { total: total, ok: ok, rows: rows, sheet: planSheet, addr: "G5:J" + (4 + planNames.length) };
|
||
}
|
||
|
||
out.entries = entries;
|
||
out.recon = recon;
|
||
out.diag = out.diag;
|
||
return out;
|
||
}
|
||
|
||
function order_of(map) {
|
||
var out = [];
|
||
for (var k in map) out.push(k);
|
||
return out;
|
||
}
|
||
|
||
/* ---------- коэфф и зоны ---------- */
|
||
function normNum(v) {
|
||
if (v == null) return null;
|
||
if (typeof v === "object") return null;
|
||
if (typeof v === "number") return v;
|
||
return null;
|
||
}
|
||
function coefFor(en, delta) {
|
||
var other = en.other; /* null сохраняется */
|
||
var debtTotal = (en.debt == null || other == null) ? null : en.debt + other + (delta || 0);
|
||
var e = en.ebitda, q = en.equity, intr = en.intExp;
|
||
var k1 = null, k2 = null, k3 = null, why = { k1: null, k2: null, k3: null };
|
||
|
||
if (en.debt == null) why.k1 = "нет долга";
|
||
if (en.ebitda == null) {
|
||
why.k1 = why.k1 || "нет EBITDA";
|
||
why.k2 = "нет EBITDA";
|
||
} else if (en.ebitda <= 0) {
|
||
why.k1 = why.k1 || "EBITDA ≤ 0";
|
||
why.k2 = "EBITDA ≤ 0";
|
||
} else {
|
||
if (debtTotal != null) k1 = debtTotal / e;
|
||
if (intr == null) why.k2 = "нет процентов";
|
||
else if (intr === 0) { k2 = Infinity; why.k2 = "проценты = 0 (∞)"; }
|
||
else if (intr < 0) why.k2 = "проценты < 0";
|
||
else k2 = e / intr;
|
||
}
|
||
if (en.equity == null) why.k3 = "нет капитала";
|
||
else if (en.equity === 0) why.k3 = "капитал = 0 (нет данных)";
|
||
else if (en.equity < 0) why.k3 = "капитал < 0";
|
||
else if (debtTotal != null) k3 = debtTotal / q;
|
||
|
||
z(k1, "k1", "green", why.k1, en.norms && en.norms.k1t, en.norms && en.norms.k1th, false, en, debtTotal);
|
||
z(k2, "k2", "green", why.k2, en.norms && en.norms.k2t, en.norms && en.norms.k2th, true, en, debtTotal);
|
||
z(k3, "k3", "green", why.k3, en.norms && en.norms.k3t, en.norms && en.norms.k3th, false, en, debtTotal);
|
||
|
||
en.debtTotal = debtTotal;
|
||
en.k1 = k1; en.k2 = k2; en.k3 = k3;
|
||
en.why = why;
|
||
/* если долг/капитал отсутствует — коэффициент не вычислен и не зелёный */
|
||
if (en.debt == null && k1 != null) { en.k1 = null; en.why.k1 = "нет долга"; }
|
||
if (en.debt == null && k3 != null) { en.k3 = null; en.why.k3 = "нет долга"; }
|
||
return en;
|
||
}
|
||
function z(v, key, dummy, reason, target, threshold, higherBetter, en, debtTotal) {
|
||
var t = normNum(target), th = normNum(threshold);
|
||
var zone, label;
|
||
if (t == null && th == null) {
|
||
/* «Без норматива» проверяется ПЕРВЫМ — даже при EBITDA ≤ 0 или отсутствующих данных */
|
||
zone = "nodefault"; label = "Без норматива";
|
||
} else if (v == null) {
|
||
if (reason && /EBITDA ≤ 0/.test(reason)) { zone = "red"; label = "EBITDA ≤ 0"; }
|
||
else if (reason && /≤ 0/.test(reason)) { zone = "red"; label = "EBITDA ≤ 0"; }
|
||
else if (reason && /< 0/.test(reason)) { zone = "red"; label = "нарушено"; }
|
||
else if (reason && /= 0 \(нет данных\)/.test(reason)) { zone = "nodata"; label = "нет данных"; }
|
||
else { zone = "nodata"; label = "нет данных"; }
|
||
} else {
|
||
var ok = higherBetter ? v >= (t != null ? t : th) : v <= (t != null ? t : th);
|
||
var bad = th != null && (higherBetter ? v < th : v > th);
|
||
zone = bad ? "red" : (ok ? "green" : "yellow");
|
||
label = zone === "red" ? "нарушен порог" : (zone === "yellow" ? "между целью и порогом" : "в норме");
|
||
}
|
||
en[key + "Zone"] = zone;
|
||
en[key + "ZoneLabel"] = label;
|
||
en[key + "Target"] = t;
|
||
en[key + "Threshold"] = th;
|
||
if (reason && v == null && zone !== "red") en[key + "ZoneReason"] = reason;
|
||
}
|
||
function worst(en) {
|
||
var order = { nodata: 0, nodefault: 1, green: 2, yellow: 3, red: 4 };
|
||
var zs = [en.k1Zone || "nodata", en.k2Zone || "nodata", en.k3Zone || "nodata"];
|
||
/* если есть красный или жёлтый — риск, возвращаем его */
|
||
for (var i = 0; i < zs.length; i++) if (zs[i] === "red" || zs[i] === "yellow") return zs[i] === "red" ? "red" : "yellow";
|
||
/* если есть nodata — итог «Нет данных» (регулируемый показатель отсутствует) */
|
||
for (var j = 0; j < zs.length; j++) if (zs[j] === "nodata") return "nodata";
|
||
/* иначе — самое худшее из nodefault/green */
|
||
var w = zs[0];
|
||
for (var k = 1; k < zs.length; k++) if (order[zs[k]] > order[w]) w = zs[k];
|
||
return w;
|
||
}
|
||
function zoneLabel(z) {
|
||
return { green: "Зелёная", yellow: "Жёлтая", red: "Красная", nodefault: "Без норматива", nodata: "Нет данных" }[z] || z;
|
||
}
|
||
|
||
/* ---------- учебный (синтетический) пример ---------- */
|
||
function syntheticDemo() {
|
||
function E(name, debt, other, ebitda, intexp, equity, norms) {
|
||
var en = { name: name, group: false, debt: debt, other: other || 0, ebitda: ebitda, intExp: intexp == null ? 0 : intexp,
|
||
equity: equity, addr: {}, issues: [], norms: norms || null };
|
||
en.addr.debt = "синтетические данные"; en.addr.ebitda = "синтетические данные";
|
||
en.addr.intexp = "синтетические данные"; en.addr.equity = "синтетические данные";
|
||
en.addr.other = "синтетические данные";
|
||
coefFor(en, 0);
|
||
return en;
|
||
}
|
||
var list = [
|
||
E("ТОО «ВостокСтрой»", 12000, 800, 3200, 620, 9500, { k1th: 3, k1t: 2, k2th: 2, k2t: 5, k3th: 1, k3t: 0.7 }),
|
||
E("АО «МегаХим»", 8500, 120, 4100, 950, 15200, { k1th: 3.5, k1t: 2.5, k2th: 2.5, k2t: 6, k3th: 1.2, k3t: 0.9 }),
|
||
E("ТОО «ТрансЛогистик»", 2200, 0, 700, 310, 5400, null),
|
||
E("АО «ЮгСталь»", 6800, 250, 620, 480, 7800, { k1th: 4, k1t: 3, k2th: 1.5, k2t: 4, k3th: 1.5, k3t: 1.1 }),
|
||
E("ТОО «АгроДержава»", 1500, 60, 40, 55, 2200, null),
|
||
E("АО «СеверЭнерго»", null, 0, 1800, 300, 6000, { k1th: 3, k1t: 2, k2th: 2, k2t: 5, k3th: 1, k3t: 0.7 }),
|
||
E("ТОО «АлтынМаркет»", 3200, 0, 0, 0, 4100, null),
|
||
E("АО «Самрук-Инвест Демо»", 5400, 100, 2900, 12, 300, null),
|
||
E("ТОО «Полярис Ритейл»", 900, 0, 1400, 0, 2600, { k1th: 2.5, k1t: 1.5, k2th: 1, k2t: 3, k3th: 0.8, k3t: 0.5 }),
|
||
E("АО «Казах-Нефть Демо»", 9800, 460, 5100, 1500, 12000, { k1th: 3, k1t: 2, k2th: 2, k2t: 6, k3th: 1, k3t: 0.6 })
|
||
];
|
||
var g = E(GROUP_NAME, 38200, 2000, 19160, 4227, 58600, { k1th: 3, k1t: 2.5, k2th: 2, k2t: 4, k3th: 1, k3t: 0.8 });
|
||
g.group = true;
|
||
list.push(g);
|
||
return { source: null, sheetNames: ["<демо>"], diag: [], entries: list, recon: null, isMock: true };
|
||
}
|
||
|
||
/* ---------- вычисление зон (обёртка) ---------- */
|
||
|
||
/* ---------- рендер ---------- */
|
||
function screenEntry(e) {
|
||
return recomputeFor(e, S.scen[e.name] || 0);
|
||
}
|
||
function visibleEntries() {
|
||
var q = S.q.toLowerCase();
|
||
var out = [];
|
||
for (var i = 0; i < S.entries.length; i++) {
|
||
var e = S.entries[i];
|
||
var sc = screenEntry(e);
|
||
if (q && e.name.toLowerCase().indexOf(q) === -1) continue;
|
||
var wzone = worst(sc);
|
||
if (S.zone) {
|
||
if (S.zone === "red" ? wzone !== "red" : wzone !== S.zone) continue;
|
||
}
|
||
function hasNumericNorm(x) {
|
||
var n = x.norms; if (!n) return false;
|
||
var keys = ["k1t", "k1th", "k2t", "k2th", "k3t", "k3th"];
|
||
for (var j = 0; j < keys.length; j++) if (typeof n[keys[j]] === "number") return true;
|
||
return false;
|
||
}
|
||
if (S.norm === "with" && !hasNumericNorm(e)) continue;
|
||
if (S.norm === "without" && hasNumericNorm(e)) continue;
|
||
if (S.onlyRisk && !(sc.k1Zone === "red" || sc.k2Zone === "red" || sc.k3Zone === "red")) continue;
|
||
sc._name = e.name;
|
||
out.push(sc);
|
||
}
|
||
var k = S.sort.k, d = S.sort.d;
|
||
out.sort(function (a, b) {
|
||
var va = sortVal(a, k), vb = sortVal(b, k);
|
||
if (va == null && vb == null) return 0;
|
||
if (va == null) return 1;
|
||
if (vb == null) return -1;
|
||
if (typeof va === "string") return va.localeCompare(vb) * d;
|
||
return (va - vb) * d;
|
||
});
|
||
if (S.entries.length) {
|
||
/* группа всегда последней */
|
||
out.sort(function (a, b) { return (a.group ? 1 : 0) - (b.group ? 1 : 0); });
|
||
}
|
||
return out;
|
||
}
|
||
function sortVal(e, k) {
|
||
if (k === "name") return e.name;
|
||
if (k === "debt") return e.debt == null ? null : e.debt;
|
||
if (k === "other") return e.other;
|
||
if (k === "ebitda") return e.ebitda;
|
||
if (k === "intexp") return e.intExp;
|
||
if (k === "equity") return e.equity;
|
||
if (k === "k1") return e.k1 == null ? null : e.k1;
|
||
if (k === "k2") return e.k2 == null ? null : e.k2;
|
||
if (k === "k3") return e.k3 == null ? null : e.k3;
|
||
if (k === "k1n") return (e.norms && e.norms.k1t) ? 1 : 0;
|
||
if (k === "k2n") return (e.norms && e.norms.k2t) ? 1 : 0;
|
||
if (k === "k3n") return (e.norms && e.norms.k3t) ? 1 : 0;
|
||
return 0;
|
||
}
|
||
function pill(e, key) {
|
||
var zn = e[key + "Zone"];
|
||
if (!zn) return '<span class="zone-pill z-nodata">—</span>';
|
||
var cls = { green: "z-green", yellow: "z-yellow", red: "z-red", nodefault: "z-nodefault", nodata: "z-nodata" }[zn];
|
||
return "<span class=\"zone-pill " + cls + "\" title=\"" + esc(e[key + "ZoneLabel"] || "") + "\">" + zoneLabel(zn) + "</span>";
|
||
}
|
||
function normCell(e, key) {
|
||
var n = e.norms;
|
||
if (!n) return '<span class="none">—</span>';
|
||
var t = n[key + (key === "k1" ? "t" : key === "k2" ? "t" : "t")];
|
||
var th = n[key + "th"];
|
||
var tv = normNum(t), hv = normNum(th);
|
||
function f(x) { return x == null ? "N/A" : nfmt(x, 2); }
|
||
return esc(f(tv) + " / " + f(hv));
|
||
}
|
||
function render() {
|
||
var body = $("#tbody");
|
||
if (!S.source && !S.isMock) {
|
||
$("#kpis").hidden = true; $("#toolbar").hidden = true; $("#tableWrap").hidden = true; $("#btnExport").hidden = true;
|
||
$("#mSource").textContent = "";
|
||
$("#mockBanner").hidden = true;
|
||
return;
|
||
}
|
||
$("#kpis").hidden = false; $("#toolbar").hidden = false; $("#tableWrap").hidden = false; $("#btnExport").hidden = false;
|
||
$("#btnExport").title = "Снимок текущего расчёта: коэффициенты, нормативы, сверка и источники";
|
||
$("#mSource").textContent = S.isMock ? "учебный пример (синтетика)" : (S.source || "");
|
||
$("#mockBanner").hidden = !S.isMock;
|
||
|
||
renderDiag();
|
||
renderKpis();
|
||
var rows = visibleEntries();
|
||
if (!rows.length) { body.innerHTML = ""; $("#emptyState").hidden = false; return; }
|
||
$("#emptyState").hidden = true;
|
||
var html = "";
|
||
for (var i = 0; i < rows.length; i++) {
|
||
var e = rows[i];
|
||
var wzone = worst(e);
|
||
var wcls = { green: "z-green", yellow: "z-yellow", red: "z-red", nodefault: "z-nodefault", nodata: "z-nodata" }[wzone] || "z-nodata";
|
||
html += "<tr data-name=\"" + esc(e._name || e.name) + "\" class=\"" + (e.group ? "is-group" : "") + "\">" +
|
||
"<td title=\"" + esc(e._name || e.name) + "\">" + esc(e._name || e.name) + (e.group ? " <span class='kt-ai-chip' data-tone='blue' style='margin-left:6px'>группа</span>" : "") + "</td>" +
|
||
"<td><span class='zone-pill " + wcls + "'></span> " + zoneLabel(wzone) + "</td>" +
|
||
"<td>" + fmtR(e.k1) + " " + pill(e, "k1") + "</td>" +
|
||
"<td>" + fmtR(e.k2) + " " + pill(e, "k2") + "</td>" +
|
||
"<td>" + fmtR(e.k3) + " " + pill(e, "k3") + "</td>" +
|
||
"</tr>";
|
||
}
|
||
body.innerHTML = html;
|
||
/* сортировка */
|
||
var ths = document.querySelectorAll(".kfu-table th[data-k]");
|
||
for (var t = 0; t < ths.length; t++) {
|
||
var k = ths[t].getAttribute("data-k");
|
||
ths[t].className = k === S.sort.k ? (S.sort.d === 1 ? "sorted-asc" : "sorted-desc") : "";
|
||
}
|
||
}
|
||
function renderKpis() {
|
||
var kpis = $("#kpis");
|
||
var co = 0, grp = 0;
|
||
var g = 0, y = 0, r = 0, nd = 0, nn = 0;
|
||
function hasNorm(e) {
|
||
var n = e.norms; if (!n) return false;
|
||
var keys = ["k1t", "k1th", "k2t", "k2th", "k3t", "k3th"];
|
||
for (var i = 0; i < keys.length; i++) {
|
||
var v = n[keys[i]];
|
||
if (typeof v === "number") return true;
|
||
}
|
||
return false;
|
||
}
|
||
var grpZone = "";
|
||
for (var i = 0; i < S.entries.length; i++) {
|
||
var e = S.entries[i];
|
||
var sc = screenEntry(e);
|
||
if (e.group) { grp++; grpZone = worst(sc); continue; }
|
||
co++;
|
||
var w = worst(sc);
|
||
if (w === "red") r++;
|
||
else if (w === "yellow") y++;
|
||
else if (w === "nodata") nd++;
|
||
else if (w === "nodefault") {
|
||
if (hasNorm(e)) g++; else nn++;
|
||
}
|
||
else g++;
|
||
}
|
||
var grpCls = { green: "var(--kt-ai-status-ok-fg)", yellow: "var(--kt-ai-status-warn-fg)", red: "var(--kt-ai-status-risk-fg)", nodata: "var(--kt-ai-fg-muted)", nodefault: "var(--kt-ai-fg-muted)" }[grpZone] || "";
|
||
var reconOk = S.recon ? S.recon.ok + " / " + S.recon.total : "—";
|
||
kpis.innerHTML =
|
||
"<div class='kt-ai-kpi'><span class='label'>Компании</span><span class='value'>" + co + "</span><span class='hint'>из файла</span></div>" +
|
||
"<div class='kt-ai-kpi'><span class='label'>Группа</span><span class='value'>" + grp + "</span><span class='hint'>консолидированная</span></div>" +
|
||
"<div class='kt-ai-kpi'><span class='label'>Зелёные</span><span class='value' style='color:var(--kt-ai-status-ok-fg)'>" + g + "</span><span class='hint'>все зоны в норме</span></div>" +
|
||
"<div class='kt-ai-kpi'><span class='label'>Жёлтые</span><span class='value' style='color:var(--kt-ai-status-warn-fg)'>" + y + "</span><span class='hint'>между целью и порогом</span></div>" +
|
||
"<div class='kt-ai-kpi'><span class='label'>Красные</span><span class='value' style='color:var(--kt-ai-status-risk-fg)'>" + r + "</span><span class='hint'>нарушен порог или EBITDA≤0</span></div>" +
|
||
(nn ? "<div class='kt-ai-kpi'><span class='label'>Без норматива</span><span class='value' style='color:var(--kt-ai-fg-muted)'>" + nn + "</span><span class='hint'>нет числовых норм</span></div>" : "") +
|
||
(nd ? "<div class='kt-ai-kpi'><span class='label'>Нет данных</span><span class='value' style='color:var(--kt-ai-fg-muted)'>" + nd + "</span><span class='hint'>отсутствуют исходные</span></div>" : "") +
|
||
(S.recon ? "<div class='kt-ai-kpi'><span class='label'>Сверка (G5:J20)</span><span class='value'>" + reconOk + "</span><span class='hint'>допуск 0,01 млн ₸</span></div>" : "");
|
||
}
|
||
|
||
/* ---------- drawer ---------- */
|
||
function openDrawer(name) {
|
||
var en = null;
|
||
for (var i = 0; i < S.entries.length; i++) if (S.entries[i].name === name) en = S.entries[i];
|
||
if (!en) return;
|
||
S.scen = S.scen || {};
|
||
if (S.scen[name] == null) S.scen[name] = 0;
|
||
renderDrawer(en);
|
||
$("#drawer").classList.add("open");
|
||
}
|
||
function closeDrawer() { $("#drawer").classList.remove("open"); }
|
||
function metricHtml(e, key, title, formula) {
|
||
var v = e[key];
|
||
var zn = e[key + "Zone"] || "nodata";
|
||
var cls = { green: "var(--kt-ai-status-ok-fg)", yellow: "var(--kt-ai-status-warn-fg)", red: "var(--kt-ai-status-risk-fg)", nodefault: "var(--kt-ai-fg-muted)", nodata: "var(--kt-ai-fg-muted)" }[zn];
|
||
return "<div class='kfu-metric'><div class='mlabel'>" + title + " " + pill(e, key) + "</div>" +
|
||
"<div class='mval' style='color:" + cls + "'>" + fmtR(v).replace(/<[^>]+>/g, function (m) { return m === "<span class=\"none\">нет данных<\/span>" ? "<span class='none'>нет данных</span>" : m; }) + "</div>" +
|
||
"<div class='mhint'>" + normLine(e, key) + "</div>" +
|
||
(e.why && e.why[key] ? "<div class='mhint' style='color:var(--kt-ai-status-warn-fg)'>" + esc(e.why[key]) + "</div>" : "") +
|
||
"<div class='kfu-formula'>" + formula + "</div></div>";
|
||
}
|
||
function normLine(e, key) {
|
||
var n = e.norms;
|
||
if (!n) return "Норматив не задан → «Без норматива»";
|
||
var t = normNum(n[key + "t"]), th = normNum(n[key + "th"]);
|
||
var s = "цель " + (t == null ? "N/A" : t) + " · порог " + (th == null ? "N/A" : th);
|
||
return s;
|
||
}
|
||
function renderDrawer(en) {
|
||
var delta = S.scen[en.name] || 0;
|
||
/* экранная копия — coefFor с дельтой, база en не меняется */
|
||
var sc = recomputeFor(en, delta);
|
||
var baseOther = en.other;
|
||
var newOther = (baseOther == null ? null : baseOther + delta);
|
||
var body = "";
|
||
body += "<div class='kfu-drawer-head'><div><h2 id='drawerTitle'>" + esc(en.name) + "</h2>" +
|
||
"<span class='kt-ai-meta'>" + (en.group ? "Консолидированная группа — считается отдельно, не складывается с компаниями" : "Портфельная компания") + "</span></div>" +
|
||
"<button class='kt-ai-icon-button' id='btnCloseDrawer' aria-label='Закрыть'><svg class='kt-icon' aria-hidden='true'><use href='#x'></use></svg></button></div>";
|
||
|
||
body += "<div class='kfu-drawer-section'><h3>Коэффициенты</h3><div class='kfu-metrics'>" +
|
||
metricHtml(sc, "k1", "К1", "(долг + прочие/гарантии) / EBITDA · меньше лучше") +
|
||
metricHtml(sc, "k2", "К2", "EBITDA / процентные расходы · больше лучше") +
|
||
metricHtml(sc, "k3", "К3", "(долг + прочие/гарантии) / капитал · меньше лучше") +
|
||
"</div></div>";
|
||
|
||
body += "<div class='kfu-drawer-section'><h3>Исходные данные (млн ₸)</h3><table class='kfu-sources'>" +
|
||
srcRow("Долг", fmtMoney(en.debt), addrVal(en, "debt")) +
|
||
srcRow("Прочие, в т.ч. гарантии", fmtMoney(newOther), addrVal(en, "other") + "<br><span class='kt-ai-meta'>в т.ч. сценарий " + pctStr(delta) + "</span>") +
|
||
srcRow("EBITDA", fmtMoney(en.ebitda), addrVal(en, "ebitda")) +
|
||
srcRow("Процентные расходы", fmtMoney(en.intExp), addrVal(en, "intexp")) +
|
||
srcRow("Капитал", fmtMoney(en.equity), addrVal(en, "equity")) +
|
||
"</table></div>";
|
||
|
||
if (en.issues && en.issues.length) {
|
||
body += "<div class='kfu-drawer-section'><h3>Внимание</h3>" + en.issues.map(function (s) {
|
||
return "<div class='kfu-delta' style='margin-bottom:6px'>⚠ " + esc(s) + "</div>";
|
||
}).join("") + "</div>";
|
||
}
|
||
|
||
body += "<div class='kfu-drawer-section'><h3>Сценарий: изменение прочих/гарантий</h3>" +
|
||
"<div class='kfu-scenario-lbl'>Первичные прочие/гарантии: <b>" + fmtMoney(baseOther) + "</b> млн ₸ · изменение: <b>" + pctStr(delta) + "</b> млн ₸</div>" +
|
||
"<div class='kfu-scenario-row'><input class='kt-ai-input' id='scenInput' type='text' inputmode='decimal' value='" + pctStr(delta) + "'>" +
|
||
"<button class='kt-ai-btn' id='btnScenReset' data-variant='ghost'>Вернуть</button></div>" +
|
||
"<div class='kfu-delta'>Сценарий влияет на коэффициенты и выгрузку. Сверка показывает исходные данные.</div>" +
|
||
"</div>";
|
||
|
||
body += "<div class='kfu-drawer-actions'><button class='kt-ai-btn' data-variant='primary' id='btnCloseDrawer2'>Понятно</button></div>";
|
||
|
||
$("#drawerBody").innerHTML = body;
|
||
$("#btnCloseDrawer").onclick = closeDrawer;
|
||
$("#btnCloseDrawer2").onclick = closeDrawer;
|
||
$("#btnScenReset").onclick = function () {
|
||
S.scen[en.name] = 0;
|
||
document.getElementById("scenInput").value = "0";
|
||
renderDrawer(en);
|
||
render();
|
||
};
|
||
var inp = document.getElementById("scenInput");
|
||
inp.addEventListener("input", function () {
|
||
var r = parseDelta(inp.value);
|
||
inp.dataset.v = r.v;
|
||
inp.style.borderColor = r.ok ? "" : "var(--kt-ai-status-risk-fg)";
|
||
if (!r.ok) toast("Введите число (допускаются −, +, запятая)", true);
|
||
clearTimeout(inp._t);
|
||
inp._t = setTimeout(function () {
|
||
S.scen[en.name] = r.v;
|
||
renderDrawer(en);
|
||
render();
|
||
var ni = document.getElementById("scenInput");
|
||
if (ni) { ni.focus(); }
|
||
}, 350);
|
||
});
|
||
}
|
||
/* возвращает { v: число, ok: true } или { v: 0, ok: false } для нечислового */
|
||
function parseDelta(s) {
|
||
if (s == null) return { v: 0, ok: true };
|
||
var t = String(s).trim().replace(",", ".");
|
||
if (t === "") return { v: 0, ok: true };
|
||
var sign = 1;
|
||
if (/^[-–—−]/.test(t)) { sign = -1; t = t.slice(1); }
|
||
else if (/^\+/.test(t)) t = t.slice(1);
|
||
t = t.replace(/\s/g, "");
|
||
if (t === "" || !/^\d*\.?\d*$/.test(t) || t === ".") return { v: 0, ok: false };
|
||
var parts = t.split(".");
|
||
var ip = parseInt(parts[0] || "0", 10);
|
||
var fp = parseInt((parts[1] || "").slice(0, 4), 10);
|
||
if (isNaN(ip)) return { v: 0, ok: false };
|
||
return { v: sign * (ip + (fp ? fp / Math.pow(10, Math.min((parts[1] || "").length, 4)) : 0)), ok: true };
|
||
}
|
||
function pctStr(v) {
|
||
if (!v) return "0";
|
||
var s = Math.abs(v) < 0.001 ? 0 : v;
|
||
var parts = String(Math.abs(s)).replace(".", ",").split(".");
|
||
var a = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||
var f = parts[1] ? "." + parts[1] : "";
|
||
return (s < 0 ? "−" : "") + a + f;
|
||
}
|
||
function addrVal(e, key) {
|
||
if (!e.addr) return "";
|
||
var a = e.addr;
|
||
var sheet = a[key + "Sheet"];
|
||
var val = a[key] || "";
|
||
return esc(val) + (sheet ? "<br><span class='kt-ai-meta'>лист «" + esc(sheet) + "»</span>" : "");
|
||
}
|
||
function srcRow(label, val, addr) {
|
||
return "<tr><td>" + label + "</td><td>" + val + "<br>" + (addr || "") + "</td></tr>";
|
||
}
|
||
|
||
/* ---------- выгрузка ---------- */
|
||
function exportXlsx() {
|
||
var wb = XLSX.utils.book_new();
|
||
var deltaOf = function (n) { return S.scen[n] || 0; };
|
||
var rows = [];
|
||
S.entries.forEach(function (e) {
|
||
var d = deltaOf(e.name);
|
||
var en = recomputeFor(e, d);
|
||
rows.push({
|
||
"Объект": e.name, "Группа": e.group ? "да" : "нет",
|
||
"Долг, млн ₸": e.debt, "Прочие/гарантии, млн ₸": (e.other || 0), "Изменение прочих (сценарий), млн ₸": d,
|
||
"Долг+прочие, млн ₸": e.debt == null ? null : e.debt + (e.other || 0) + d,
|
||
"EBITDA, млн ₸": e.ebitda, "Проценты, млн ₸": e.intExp, "Капитал, млн ₸": e.equity,
|
||
"К1 (Debt/EBITDA)": fmtNum(en.k1), "Зона К1": zoneLabel(en.k1Zone) + (en.k1Zone === "nodata" && en.why.k1 ? " (" + en.why.k1 + ")" : ""),
|
||
"Цель К1": normNum(e.norms && e.norms.k1t), "Порог К1": normNum(e.norms && e.norms.k1th),
|
||
"К2 (EBITDA/Int)": fmtNum(en.k2), "Зона К2": zoneLabel(en.k2Zone) + (en.k2Zone === "nodata" && en.why.k2 ? " (" + en.why.k2 + ")" : ""),
|
||
"Цель К2": normNum(e.norms && e.norms.k2t), "Порог К2": normNum(e.norms && e.norms.k2th),
|
||
"К3 (Debt/Equity)": fmtNum(en.k3), "Зона К3": zoneLabel(en.k3Zone) + (en.k3Zone === "nodata" && en.why.k3 ? " (" + en.why.k3 + ")" : ""),
|
||
"Цель К3": normNum(e.norms && e.norms.k3t), "Порог К3": normNum(e.norms && e.norms.k3th)
|
||
});
|
||
});
|
||
var ws1 = XLSX.utils.json_to_sheet(rows);
|
||
ws1["!cols"] = rows.length ? Object.keys(rows[0]).map(function (k) { return { wch: 16 }; }) : [];
|
||
XLSX.utils.book_append_sheet(wb, ws1, "КФУ");
|
||
|
||
/* нормативы и источники */
|
||
var src = [];
|
||
S.entries.forEach(function (e) {
|
||
["debt", "other", "ebitda", "intexp", "equity"].forEach(function (k) {
|
||
var sheet = e.addr[k + "Sheet"];
|
||
if (e.addr[k]) src.push({ "Объект": e.name, "Показатель": k, "Формула/адрес": e.addr[k], "Лист": sheet || (S.isMock ? "<демо>" : "") });
|
||
});
|
||
});
|
||
var ws2 = XLSX.utils.json_to_sheet(src);
|
||
ws2["!cols"] = [{ wch: 30 }, { wch: 12 }, { wch: 26 }, { wch: 22 }];
|
||
XLSX.utils.book_append_sheet(wb, ws2, "Источники");
|
||
if (S.diag.length || (S.diagErrors && S.diagErrors.length)) {
|
||
var diagRows = [["Раздел", "Позиция", "Значение"]];
|
||
S.diag.forEach(function (d) { diagRows.push(["Импорт", d, ""]); });
|
||
(S.diagErrors || []).forEach(function (d) { diagRows.push(["Ошибки ячеек", d.sheet + "!" + d.addr, d.val]); });
|
||
var ws3 = XLSX.utils.aoa_to_sheet(diagRows);
|
||
ws3["!cols"] = [{ wch: 20 }, { wch: 80 }, { wch: 30 }];
|
||
XLSX.utils.book_append_sheet(wb, ws3, "Диагностика");
|
||
}
|
||
/* сверка */
|
||
if (S.recon) {
|
||
var rr = [];
|
||
S.recon.rows.forEach(function (x) {
|
||
rr.push({ "Объект": x.name, "Показатель": x.field,
|
||
"Контрольный свод (План!G:J)": x.ref == null ? "—" : x.ref,
|
||
"Расчёт приложения": x.val == null ? "—" : x.val,
|
||
"Совпадение (≤0,01)": x.pass == null ? "нет ссылок" : (x.pass ? "да" : "НЕТ") });
|
||
});
|
||
var ws4 = XLSX.utils.json_to_sheet(rr);
|
||
ws4["!cols"] = [{ wch: 30 }, { wch: 16 }, { wch: 18 }, { wch: 16 }, { wch: 18 }];
|
||
XLSX.utils.book_append_sheet(wb, ws4, "Сверка");
|
||
}
|
||
/* примечание о снапшоте */
|
||
var note = [[
|
||
"Отчёт — снимок текущего расчёта КФУ.",
|
||
"Сформирован: " + new Date().toLocaleString("ru-RU"),
|
||
"Источник: " + (S.isMock ? "Учебный пример (синтетические данные)" : (S.source || "файл")),
|
||
"Зоны: зелёная — до цели (включая), жёлтая — между целью и порогом, красная — при нарушении порога или EBITDA≤0.",
|
||
"При отсутствии нормативов — «Без норматива». При отсутствии долга/капитала коэффициент не вычисляется (не заменяется нулём)."
|
||
]];
|
||
var ws5 = XLSX.utils.aoa_to_sheet(note);
|
||
ws5["!cols"] = [{ wch: 100 }];
|
||
XLSX.utils.book_append_sheet(wb, ws5, "Пояснения");
|
||
try {
|
||
XLSX.writeFile(wb, "КФУ_финансовая_устойчивость_2025.xlsx");
|
||
toast("Отчёт .xlsx сохранён (снимок текущего расчёта)");
|
||
} catch (err) {
|
||
toast("Не удалось сохранить файл: " + err.message, true);
|
||
}
|
||
}
|
||
function fmtNum(v) {
|
||
if (v == null) return null;
|
||
if (v === Infinity) return "∞";
|
||
return Math.round(v * 10000) / 10000;
|
||
}
|
||
function recomputeFor(e, delta) {
|
||
var clone = JSON.parse(JSON.stringify(e));
|
||
return coefFor(clone, delta);
|
||
}
|
||
|
||
/* ---------- загрузка файла ---------- */
|
||
function onFile(file) {
|
||
if (!file) return;
|
||
var r = new FileReader();
|
||
r.onload = function (ev) {
|
||
try {
|
||
var wb = XLSX.read(new Uint8Array(ev.target.result), { type: "array" });
|
||
var res = analyzeWorkbook(wb);
|
||
applyResult(res, file.name);
|
||
if (res.diag.length) {
|
||
toast("Импорт завершён с замечаниями: " + res.diag.length, true);
|
||
} else {
|
||
toast("Импорт завершён: компаний " + res.entries.length);
|
||
}
|
||
if (res.recon && res.recon.ok < res.recon.total) {
|
||
toast("Сверка: " + res.recon.ok + " из " + res.recon.total + " показателей совпали", true);
|
||
}
|
||
} catch (err) {
|
||
toast("Ошибка чтения файла: " + err.message, true);
|
||
}
|
||
};
|
||
r.onerror = function () { toast("Не удалось прочитать файл", true); };
|
||
r.readAsArrayBuffer(file);
|
||
}
|
||
|
||
function renderDiag() {
|
||
if (S.diagErrors && S.diagErrors.length) {
|
||
$("#footnote").innerHTML = "<details class='kfu-diag'><summary>В книге " + S.diagErrors.length + " ошибок в посторонних ячейках — раскрыть</summary>" +
|
||
"<ul>" + S.diagErrors.map(function (d) {
|
||
return "<li>«" + esc(d.sheet) + "» " + esc(d.addr) + " → " + esc(d.val || "#err") + "</li>";
|
||
}).join("") + "</ul></details>";
|
||
} else {
|
||
$("#footnote").innerHTML = "";
|
||
}
|
||
}
|
||
function applyResult(res, sourceName) {
|
||
S.source = sourceName || null;
|
||
S.sheetNames = res.sheetNames || [];
|
||
S.diag = res.diag || [];
|
||
S.diagErrors = res.diagErrors || [];
|
||
S.entries = res.entries || [];
|
||
S.recon = res.recon || null;
|
||
S.isMock = !!(res.isMock);
|
||
S.scen = {};
|
||
render();
|
||
}
|
||
|
||
/* ---------- инициализация ---------- */
|
||
function init() {
|
||
|
||
$("#fileInput").addEventListener("change", function (e) {
|
||
if (e.target.files && e.target.files[0]) onFile(e.target.files[0]);
|
||
e.target.value = "";
|
||
});
|
||
var dz = $("#dropzone");
|
||
dz.addEventListener("dragover", function (e) { e.preventDefault(); dz.style.borderColor = "var(--kt-ai-primary)"; });
|
||
dz.addEventListener("dragleave", function () { dz.style.borderColor = ""; });
|
||
dz.addEventListener("drop", function (e) {
|
||
e.preventDefault(); dz.style.borderColor = "";
|
||
if (e.dataTransfer.files && e.dataTransfer.files[0]) onFile(e.dataTransfer.files[0]);
|
||
});
|
||
$("#btnSample").addEventListener("click", function () {
|
||
var res = syntheticDemo();
|
||
applyResult(res, null);
|
||
toast("Загружен учебный пример (синтетические данные)");
|
||
});
|
||
$("#btnClearMock").addEventListener("click", function () {
|
||
S = { source: null, sheetNames: [], diag: [], entries: [], isMock: false, sort: { k: "name", d: 1 }, q: "", zone: "", norm: "", onlyRisk: false, scen: {} };
|
||
render();
|
||
});
|
||
$("#btnExport").addEventListener("click", exportXlsx);
|
||
|
||
$("#q").addEventListener("input", function (e) { S.q = e.target.value; render(); });
|
||
$("#zoneFilter").addEventListener("change", function (e) { S.zone = e.target.value; render(); });
|
||
$("#normFilter").addEventListener("change", function (e) { S.norm = e.target.value; render(); });
|
||
$("#onlyRisk").addEventListener("change", function (e) { S.onlyRisk = e.target.checked; render(); });
|
||
|
||
document.querySelectorAll(".kfu-table th[data-k]").forEach(function (th) {
|
||
th.addEventListener("click", function () {
|
||
var k = th.getAttribute("data-k");
|
||
if (S.sort.k === k) S.sort.d *= -1;
|
||
else S.sort = { k: k, d: k === "name" ? 1 : (["debt", "k1", "k3"].indexOf(k) >= 0 ? -1 : 1) };
|
||
render();
|
||
});
|
||
});
|
||
|
||
$("#tbody").addEventListener("click", function (e) {
|
||
var tr = e.target.closest("tr[data-name]");
|
||
if (tr) openDrawer(tr.getAttribute("data-name"));
|
||
});
|
||
document.addEventListener("keydown", function (e) {
|
||
if (e.key === "Escape") closeDrawer();
|
||
});
|
||
render();
|
||
}
|
||
document.addEventListener("DOMContentLoaded", init);
|
||
})();
|