1180 lines
54 KiB
JavaScript
1180 lines
54 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);
|
||
}
|
||
/* для отображения: EBITDA ≤ 0 вместо «нет данных» у К1/К2 */
|
||
function coefDisplay(e, key) {
|
||
var v = e[key];
|
||
if (v == null && e.why && e.why[key] && /EBITDA/i.test(e.why[key]) && e.ebitda != null && e.ebitda <= 0) {
|
||
return "EBITDA ≤ 0";
|
||
}
|
||
return typeof v === "number" ? nfmt(v, 2) : fmtR(v);
|
||
}
|
||
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 zonePriority(z) {
|
||
var p = { red: 0, yellow: 1, nodata: 2, green: 3, nodefault: 4 }[z];
|
||
return p === undefined ? 9 : p;
|
||
}
|
||
function filterSortList(list) {
|
||
var k = S.sort.k, d = S.sort.d;
|
||
list.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;
|
||
});
|
||
list.sort(function (a, b) { return zonePriority(worst(a)) - zonePriority(worst(b)); });
|
||
return list;
|
||
}
|
||
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 hasNumericNorm(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++) if (typeof n[keys[i]] === "number") return true;
|
||
return false;
|
||
}
|
||
function statusHtml(zone) {
|
||
var cls = { green: "s-green", yellow: "s-yellow", red: "s-red", nodefault: "s-nodefault", nodata: "s-nodata" }[zone] || "s-nodata";
|
||
var screenLabel = { green: "В норме", yellow: "Наблюдение", red: "Нарушение", nodefault: "Без норматива", nodata: "Нет данных" }[zone] || zone;
|
||
return "<span class='kfu-status " + cls + "'>" + screenLabel + "</span>";
|
||
}
|
||
function coefCell(e, key) {
|
||
var v = e[key];
|
||
var zn = e[key + "Zone"] || "nodata";
|
||
var dotCls = { green: "c-green", yellow: "c-yellow", red: "c-red", nodefault: "c-nodefault", nodata: "c-nodata" }[zn] || "c-nodata";
|
||
var valHtml;
|
||
if (v == null) {
|
||
if (e.why && e.why[key] && /EBITDA/i.test(e.why[key]) && e.ebitda != null && e.ebitda <= 0) {
|
||
valHtml = "<span class='cv-none'>EBITDA ≤ 0</span>";
|
||
} else {
|
||
valHtml = "<span class='cv-none'>—</span>";
|
||
}
|
||
} else {
|
||
var cvCls = zn === "red" ? " cv-red" : (zn === "yellow" ? " cv-yellow" : "");
|
||
valHtml = "<span class='cv" + cvCls + "'>" + nfmt(v, 2) + "</span>";
|
||
}
|
||
return "<span class='kfu-coef'>" + valHtml + "<span class='cd " + dotCls + "'></span></span>";
|
||
}
|
||
function causeFor(e) {
|
||
var parts = [];
|
||
/* «Без норматива» — отдельная фраза, без числовых сравнений */
|
||
if (worst(e) === "nodefault" && !hasNumericNorm(e)) {
|
||
return "<span class='kfu-cause'>Нормативы не установлены</span>";
|
||
}
|
||
if (e.ebitda != null && e.ebitda <= 0 && (e.k1Zone === "red" || e.k2Zone === "red")) {
|
||
parts.push("Отрицательная EBITDA");
|
||
}
|
||
var keys = ["k1", "k2", "k3"];
|
||
var missOrder = ["debt", "other", "ebitda", "intExp", "equity"];
|
||
var missName = { debt: "долг", other: "прочие/гарантии", ebitda: "EBITDA", intExp: "проценты", equity: "капитал" };
|
||
for (var i = 0; i < keys.length; i++) {
|
||
var k = keys[i];
|
||
var zone = e[k + "Zone"] || "nodata";
|
||
if (zone === "green" || zone === "nodefault") continue;
|
||
if (zone === "nodata" || e[k] == null) {
|
||
/* конкретный недостающий показатель */
|
||
var fieldMiss = k === "k1" ? "debt" : k === "k2" ? "intExp" : "equity";
|
||
if (fieldMiss === "intExp" && e.ebitda != null && e.ebitda <= 0) continue; /* уже фразой выше */
|
||
if (fieldMiss === "debt" && e.debt == null) parts.push("нет долга");
|
||
else if (fieldMiss === "debt" && e.other == null) parts.push("нет прочих/гарантий");
|
||
else if (fieldMiss === "intExp") parts.push("нет процентов");
|
||
else if (fieldMiss === "equity") parts.push("нет капитала");
|
||
continue;
|
||
}
|
||
var th = normNum(e.norms && e.norms[k + "th"]);
|
||
var tg = normNum(e.norms && e.norms[k + "t"]);
|
||
var val = nfmt(e[k], 2);
|
||
if (zone === "red") {
|
||
if (k === "k2") parts.push("К2 " + val + " ниже порога " + nfmt(th, 2));
|
||
else parts.push("К" + (i + 1) + " " + val + " выше порога " + nfmt(th, 2));
|
||
} else if (zone === "yellow") {
|
||
if (k === "k2") parts.push("К2 " + val + " ниже цели " + nfmt(tg, 2));
|
||
else parts.push("К" + (i + 1) + " " + val + " выше цели " + nfmt(tg, 2));
|
||
}
|
||
}
|
||
if (!parts.length) return "<span class='kfu-dash'>—</span>";
|
||
var w = worst(e);
|
||
var cls = w === "red" ? "danger" : (w === "yellow" ? "caution" : "");
|
||
return "<span class='kfu-cause " + cls + "'>" + esc(parts.join("; ")) + "</span>";
|
||
}
|
||
function tableRow(e, isGroup) {
|
||
var wzone = worst(e);
|
||
var name = e._name || e.name;
|
||
return "<tr data-name=\"" + esc(name) + "\" class=\"" + (isGroup ? "is-group" : "") + "\">" +
|
||
"<td><div class='co-name' title=\"" + esc(name) + "\">" + esc(name) + "</div></td>" +
|
||
"<td>" + statusHtml(wzone) + "</td>" +
|
||
"<td>" + coefCell(e, "k1") + "</td>" +
|
||
"<td>" + coefCell(e, "k2") + "</td>" +
|
||
"<td>" + coefCell(e, "k3") + "</td>" +
|
||
"<td>" + (wzone === "green" ? "<span class='kfu-dash'>—</span>" : causeFor(e)) + "</td>" +
|
||
"</tr>";
|
||
}
|
||
|
||
/* ---------- основной рендер ---------- */
|
||
function render() {
|
||
var hasData = !!(S.source || S.isMock);
|
||
/* первая загрузка */
|
||
if (!hasData) {
|
||
$("#headerSub").textContent = "";
|
||
$("#reconChip").hidden = true;
|
||
$("#brief").hidden = true;
|
||
$("#toolbar").hidden = true;
|
||
$("#tableWrap").hidden = true;
|
||
$("#groupBlock").hidden = true;
|
||
$("#diagBlock").hidden = true;
|
||
$("#uploadPanel").hidden = false;
|
||
$("#fileBadge").hidden = true;
|
||
$("#btnReplace").hidden = true;
|
||
$("#btnExport").hidden = true;
|
||
$("#mockChip").hidden = true;
|
||
return;
|
||
}
|
||
$("#uploadPanel").hidden = true;
|
||
$("#headerSub").hidden = false;
|
||
$("#brief").hidden = false;
|
||
$("#toolbar").hidden = false;
|
||
$("#tableWrap").hidden = false;
|
||
$("#btnReplace").hidden = false;
|
||
$("#btnExport").hidden = false;
|
||
$("#mockChip").hidden = !S.isMock;
|
||
|
||
/* файл-бейдж: реальное имя + постоянная метка учебного режима */
|
||
var shortName = S.source || "учебный пример";
|
||
if (shortName.length > 28) shortName = shortName.slice(0, 26) + "…";
|
||
$("#fileBadge").hidden = false;
|
||
$("#fileBadgeText").textContent = S.isMock ? shortName + " · учебный пример (синтетика)" : shortName;
|
||
|
||
/* данные: компании + группа, по экрану (сценарий) */
|
||
var comps = [], grp = null;
|
||
for (var i = 0; i < S.entries.length; i++) {
|
||
var se = screenEntry(S.entries[i]);
|
||
if (S.entries[i].group) grp = se; else comps.push(se);
|
||
}
|
||
|
||
/* заголовок + счётчики: зелёные только при worst === "green" */
|
||
var r = 0, y = 0, g = 0, nn = 0, nd = 0, co = comps.length;
|
||
for (var ci = 0; ci < comps.length; ci++) {
|
||
var c = comps[ci];
|
||
var w = worst(c);
|
||
if (w === "red") r++;
|
||
else if (w === "yellow") y++;
|
||
else if (w === "green") g++;
|
||
else if (w === "nodefault") nn++;
|
||
else nd++;
|
||
}
|
||
var subParts = [];
|
||
if (r > 0) subParts.push("<b>" + r + "</b> " + plural(r, "компании", "компании", "компаний") + " нарушают нормативы");
|
||
if (y > 0) subParts.push("<b>" + y + "</b> " + plural(y, "компания требует", "компании требуют", "компаний требуют") + " внимания");
|
||
if (r === 0 && y === 0 && g === co) subParts.push("Все компании в норме");
|
||
else if (!subParts.length) subParts.push("Нарушений нет");
|
||
if (nn > 0) subParts.push("Без нормативов: <b>" + nn + "</b>");
|
||
if (nd > 0) subParts.push("Неполные данные: <b>" + nd + "</b>");
|
||
$("#headerSub").innerHTML = subParts.join(", ");
|
||
|
||
/* сверка — исходные данные */
|
||
if (S.recon) {
|
||
$("#reconChip").hidden = false;
|
||
$("#reconChipText").textContent = "Сверка " + S.recon.ok + "/" + S.recon.total + " по исходным";
|
||
} else {
|
||
$("#reconChip").hidden = true;
|
||
}
|
||
|
||
/* сводка: зелёные = только в норме; без нормативов и нет данных — отдельно */
|
||
$("#briefRed").textContent = r;
|
||
$("#briefYellow").textContent = y;
|
||
$("#briefGreen").textContent = g;
|
||
$("#briefGreenSub").textContent = nd > 0 ? " + " + nd + " нет данных" : "";
|
||
$("#briefNoNorm").textContent = nn;
|
||
|
||
renderDiag();
|
||
|
||
/* компании (без группы): поиск и зона комбинируются независимо */
|
||
var q = (S.q || "").toLowerCase();
|
||
var filtered = comps.filter(function (e) {
|
||
if (q && e.name.toLowerCase().indexOf(q) === -1) return false;
|
||
if (S.zone) {
|
||
var wz = worst(e);
|
||
if (S.zone === "nodefault") return wz === "nodefault";
|
||
return wz === S.zone;
|
||
}
|
||
return true;
|
||
});
|
||
filtered = filterSortList(filtered);
|
||
var body = $("#tbody");
|
||
if (!filtered.length) {
|
||
body.innerHTML = "";
|
||
$("#tbody").hidden = true;
|
||
$("#emptyState").hidden = false;
|
||
} else {
|
||
$("#tbody").hidden = false;
|
||
$("#emptyState").hidden = true;
|
||
var html = "";
|
||
for (var j = 0; j < filtered.length; j++) html += tableRow(filtered[j], false);
|
||
body.innerHTML = html;
|
||
}
|
||
$("#tableCount").textContent = filtered.length + " из " + co;
|
||
|
||
/* блок группы: отдельно из S.entries, скрывается только под фильтром/поиском, несовместимым с её зоной */
|
||
var showGroup = !!grp && (!S.q || (grp.name.toLowerCase().indexOf(S.q.toLowerCase()) !== -1)) &&
|
||
(!S.zone || worst(grp) === S.zone);
|
||
if (showGroup) {
|
||
$("#groupBlock").hidden = false;
|
||
$("#groupTbody").innerHTML = tableRow(grp, true);
|
||
} else {
|
||
$("#groupBlock").hidden = true;
|
||
}
|
||
|
||
/* сортировка */
|
||
var ths = document.querySelectorAll("#tableWrap .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") : "";
|
||
}
|
||
|
||
/* активный фильтр-чип */
|
||
var chips = document.querySelectorAll(".kfu-chip-filter");
|
||
for (var c = 0; c < chips.length; c++) {
|
||
chips[c].classList.toggle("active", (chips[c].getAttribute("data-zone") || "") === S.zone);
|
||
}
|
||
}
|
||
function plural(n, a, b, c) {
|
||
var n10 = n % 10, n100 = n % 100;
|
||
if (n10 === 1 && n100 !== 11) return a;
|
||
if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return b;
|
||
return c;
|
||
}
|
||
|
||
/* ---------- 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 dotCls = { green: "c-green", yellow: "c-yellow", red: "c-red", nodefault: "c-nodefault", nodata: "c-nodata" }[zn] || "c-nodata";
|
||
var valCls = zn === "red" ? " mval-red" : (zn === "yellow" ? " mval-yellow" : "");
|
||
var valHtml;
|
||
if (v == null) {
|
||
if (e.why && e.why[key] && /EBITDA/i.test(e.why[key]) && e.ebitda != null && e.ebitda <= 0) {
|
||
valHtml = "<div class='mval mval-none'>EBITDA ≤ 0</div>";
|
||
} else {
|
||
valHtml = "<div class='mval mval-none'>—</div>";
|
||
}
|
||
} else {
|
||
valHtml = "<div class='mval" + valCls + "'>" + nfmt(v, 2) + "</div>";
|
||
}
|
||
return "<div class='kfu-metric'>" +
|
||
"<div class='mlabel'>" + title + " <span class='sdot " + dotCls + "'></span></div>" +
|
||
valHtml +
|
||
"<div class='mhint'>" + normLine(e, key) + "</div>" +
|
||
(e.why && e.why[key] ? "<div class='mwhy'>" + esc(e.why[key]) + "</div>" : "") +
|
||
"<div class='mformula'>" + 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;
|
||
var sc = recomputeFor(en, delta);
|
||
var baseOther = en.other;
|
||
var newOther = (baseOther == null ? null : baseOther + delta);
|
||
var wzone = worst(sc);
|
||
var body = "";
|
||
|
||
/* заголовок: имя + статус */
|
||
body += "<div class='kfu-drawer-head'><div><h2 id='drawerTitle'>" + esc(en.name) + "</h2>" +
|
||
statusHtml(wzone) + "</div>" +
|
||
"<button class='close-btn' 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>" +
|
||
"<div class='kfu-scenario-lbl'>Прочие/гарантии: <b>" + fmtMoney(baseOther) + "</b> млн ₸ · изменение: <b>" + pctStr(delta) + "</b> млн ₸</div>" +
|
||
"<div class='kfu-scenario-row'><input class='kfu-input' id='scenInput' type='text' inputmode='decimal' value='" + pctStr(delta) + "'>" +
|
||
"<button class='kfu-btn' id='btnScenReset'>Вернуть</button></div>" +
|
||
"<div class='kfu-scenario-hint'>Сценарий обновляет коэффициенты, таблицу, счётчики и выгрузку. Сверка остаётся базовой.</div>" +
|
||
"</div>";
|
||
|
||
/* источники — раскрываемый блок */
|
||
body += "<details class='kfu-sources-block kfu-drawer-section'><summary>Исходные данные и формулы</summary>" +
|
||
"<table class='kfu-sources'>" +
|
||
srcRow("Долг", fmtMoney(en.debt), addrVal(en, "debt")) +
|
||
srcRow("Прочие, в т.ч. гарантии", fmtMoney(newOther), addrVal(en, "other")) +
|
||
srcRow("EBITDA", fmtMoney(en.ebitda), addrVal(en, "ebitda")) +
|
||
srcRow("Процентные расходы", fmtMoney(en.intExp), addrVal(en, "intexp")) +
|
||
srcRow("Капитал", fmtMoney(en.equity), addrVal(en, "equity")) +
|
||
"</table>" +
|
||
"<div style='margin-top:10px;font-size:11px;color:var(--kfu-ink-3);line-height:1.5'>" +
|
||
"К1 = (долг + прочие/гарантии) / EBITDA<br>" +
|
||
"К2 = EBITDA / процентные расходы<br>" +
|
||
"К3 = (долг + прочие/гарантии) / капитал<br>" +
|
||
(en.group ? "Группа считается отдельно, не складывается с компаниями." : "Портфельная компания.") +
|
||
"</div></details>";
|
||
|
||
if (en.issues && en.issues.length) {
|
||
body += "<div class='kfu-drawer-section'><h3>Внимание</h3>" + en.issues.map(function (s) {
|
||
return "<div class='kfu-scenario-hint' style='color:var(--kfu-warn)'>" + esc(s) + "</div>";
|
||
}).join("") + "</div>";
|
||
}
|
||
|
||
$("#drawerBody").innerHTML = body;
|
||
$("#btnCloseDrawer").onclick = closeDrawer;
|
||
$("#btnScenReset").onclick = function () {
|
||
S.scen[en.name] = 0;
|
||
var si = document.getElementById("scenInput");
|
||
if (si) si.value = "0";
|
||
renderDrawer(en);
|
||
render();
|
||
};
|
||
var inp = document.getElementById("scenInput");
|
||
if (inp) {
|
||
inp.addEventListener("input", function () {
|
||
var r = parseDelta(inp.value);
|
||
inp.style.borderColor = r.ok ? "" : "var(--kfu-risk)";
|
||
if (!r.ok) {
|
||
/* нечисловой ввод: последнее допустимое значение остаётся в расчёте, показываем ошибку */
|
||
if (!inp._err) { inp._err = true; toast("Введите число (допускаются −, +, запятая)", true); }
|
||
return;
|
||
}
|
||
inp._err = false;
|
||
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);
|
||
});
|
||
inp.addEventListener("change", function () {
|
||
var r = parseDelta(inp.value);
|
||
if (!r.ok) {
|
||
/* при переводе фокуса восстанавливаем последнее допустимое значение */
|
||
inp.value = (S.scen[en.name] != null && S.scen[en.name] !== 0) ? pctStr(S.scen[en.name]) : "0";
|
||
inp.style.borderColor = "";
|
||
if (S.scen[en.name] != null && S.scen[en.name] !== 0) { renderDrawer(en); render(); }
|
||
}
|
||
});
|
||
}
|
||
}
|
||
/* возвращает { 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 "<span class='src-addr'>" + esc(val) + (sheet ? " · " + 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) {
|
||
$("#diagBlock").hidden = false;
|
||
$("#diagSummary").textContent = "В книге " + S.diagErrors.length + " ошибок в посторонних ячейках — раскрыть";
|
||
$("#diagList").innerHTML = S.diagErrors.map(function (d) {
|
||
return "<li>«" + esc(d.sheet) + "» " + esc(d.addr) + " → " + esc(d.val || "#err") + "</li>";
|
||
}).join("");
|
||
} else {
|
||
$("#diagBlock").hidden = true;
|
||
}
|
||
}
|
||
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();
|
||
}
|
||
|
||
window.KfuWorkbook = { analyzeWorkbook: analyzeWorkbook };
|
||
|
||
/* ---------- инициализация ---------- */
|
||
function init() {
|
||
var fileInput = $("#fileInput");
|
||
|
||
fileInput.addEventListener("change", function (e) {
|
||
if (e.target.files && e.target.files[0]) onFile(e.target.files[0]);
|
||
e.target.value = "";
|
||
});
|
||
|
||
/* загрузить (первый экран) + заменить файл --> */
|
||
var btnUpload = $("#btnUpload");
|
||
if (btnUpload) btnUpload.addEventListener("click", function () { fileInput.click(); });
|
||
var btnReplace = $("#btnReplace");
|
||
if (btnReplace) btnReplace.addEventListener("click", function () { fileInput.click(); });
|
||
|
||
/* drag-and-drop на страницу --> */
|
||
document.addEventListener("dragover", function (e) { e.preventDefault(); });
|
||
document.addEventListener("drop", function (e) {
|
||
e.preventDefault();
|
||
if (e.dataTransfer.files && e.dataTransfer.files[0]) onFile(e.dataTransfer.files[0]);
|
||
});
|
||
|
||
$("#btnSample").addEventListener("click", function () {
|
||
var res = syntheticDemo();
|
||
applyResult(res, null);
|
||
toast("Загружен учебный пример (синтетические данные)");
|
||
});
|
||
|
||
/* выгрузка отчёта (topbar, hidden до загрузки) */
|
||
var be = $("#btnExport");
|
||
if (be) be.addEventListener("click", exportXlsx);
|
||
|
||
$("#q").addEventListener("input", function (e) { S.q = e.target.value; render(); });
|
||
|
||
/* фильтр-чипы */
|
||
var chips = document.querySelectorAll(".kfu-chip-filter");
|
||
for (var c = 0; c < chips.length; c++) {
|
||
chips[c].addEventListener("click", function () {
|
||
var zone = this.getAttribute("data-zone");
|
||
S.zone = zone || "";
|
||
for (var j = 0; j < chips.length; j++) chips[j].classList.remove("active");
|
||
this.classList.add("active");
|
||
render();
|
||
});
|
||
}
|
||
|
||
/* сортировка в таблице компаний */
|
||
document.querySelectorAll("#tableWrap .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"));
|
||
});
|
||
$("#groupTbody").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", function () {
|
||
if (!document.getElementById("uploadPanel")) return;
|
||
init();
|
||
});
|
||
})();
|