sozday-rabochee-veb-prilozhe/kfu-client.js

1180 lines
54 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

/* КФУ — финансовая устойчивость. Статическое приложение, весь анализ — в браузере. */
(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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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();
});
})();