1401 lines
120 KiB
HTML
1401 lines
120 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>КФУ-агент — расчёт К1–К3</title>
|
||
<script src="vendor/xlsx.full.min.js"></script>
|
||
<script src="kfu-client.js"></script>
|
||
<script src="design-system/icons/kt-ai-icons.js"></script>
|
||
<style>
|
||
:root{
|
||
--bg:#F3F5F3; --panel:#FFFFFF; --ink:#18232A; --mute:#5E6A6F; --line:#D5DCD8; --soft:#EAF0ED;
|
||
--acc:#0F5F6E; --acc-dark:#0A4551; --acc-soft:#DCEBEE;
|
||
--green:#2A7A4B; --green-soft:#DDF0E3; --yellow:#A9780A; --yellow-soft:#FBEFC9; --red:#B23A2F; --red-soft:#F7DCD9;
|
||
--na:#9AA4A8; --na-soft:#EDF0EF;
|
||
--r:6px;
|
||
}
|
||
*{box-sizing:border-box}
|
||
html,body{margin:0;padding:0}
|
||
body{background:var(--bg);color:var(--ink);font:14px/1.45 "Segoe UI",system-ui,-apple-system,Roboto,"Helvetica Neue",Arial,sans-serif;font-variant-numeric:tabular-nums}
|
||
h1,h2,h3{margin:0;font-weight:600;letter-spacing:-.01em}
|
||
h1{font-size:20px}
|
||
h2{font-size:16px}
|
||
h3{font-size:14px}
|
||
p{margin:0 0 8px}
|
||
a{color:var(--acc)}
|
||
button{font:inherit;cursor:pointer;border-radius:var(--r);border:1px solid var(--line);background:#fff;color:var(--ink);padding:6px 12px}
|
||
button:hover{background:var(--soft)}
|
||
button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--acc);outline-offset:1px}
|
||
button.primary{background:var(--acc);border-color:var(--acc);color:#fff}
|
||
button.primary:hover{background:var(--acc-dark)}
|
||
button.ghost{border-color:transparent;background:transparent;color:var(--acc)}
|
||
button.ghost:hover{background:var(--acc-soft)}
|
||
button.small{padding:3px 8px;font-size:12px}
|
||
button:disabled{opacity:.5;cursor:default}
|
||
input,select,textarea{font:inherit;color:var(--ink);border:1px solid var(--line);border-radius:var(--r);padding:6px 8px;background:#fff}
|
||
input[type=number]{text-align:right;width:110px}
|
||
textarea{width:100%;min-height:90px;resize:vertical}
|
||
label{color:var(--mute);font-size:12px}
|
||
.field{display:flex;flex-direction:column;gap:3px}
|
||
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end}
|
||
.muted{color:var(--mute)}
|
||
.small{font-size:12px}
|
||
.mono{font-family:Consolas,"Courier New",monospace;font-size:12px}
|
||
.hidden{display:none!important}
|
||
|
||
header{background:var(--panel);border-bottom:1px solid var(--line);padding:12px 24px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
|
||
header .brand{display:flex;flex-direction:column}
|
||
header .brand span{color:var(--mute);font-size:12px}
|
||
header .spacer{flex:1}
|
||
.seg{display:inline-flex;border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
|
||
.seg button{border:0;border-radius:0;padding:5px 10px;font-size:12px}
|
||
.seg button.on{background:var(--acc);color:#fff}
|
||
|
||
nav.steps{display:flex;gap:0;padding:0 24px;background:var(--panel);border-bottom:1px solid var(--line)}
|
||
nav.steps button{border:0;border-radius:0;background:transparent;padding:10px 14px;border-bottom:3px solid transparent;color:var(--mute)}
|
||
nav.steps button.on{color:var(--ink);border-bottom-color:var(--acc)}
|
||
nav.steps button b{display:inline-block;width:18px;height:18px;line-height:18px;text-align:center;border-radius:50%;background:var(--soft);font-size:11px;margin-right:6px;color:var(--ink)}
|
||
nav.steps button.on b{background:var(--acc);color:#fff}
|
||
nav.steps button .cnt{margin-left:6px;font-size:11px;color:var(--mute)}
|
||
|
||
main{padding:20px 24px 60px;max-width:1500px}
|
||
section.step{display:none}
|
||
section.step.on{display:block}
|
||
.panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:16px 18px;margin-bottom:16px}
|
||
.panel h2{margin-bottom:10px}
|
||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
|
||
@media (max-width:1000px){.grid2,.grid3{grid-template-columns:1fr}}
|
||
|
||
.tabs{display:flex;gap:6px;margin-bottom:14px;flex-wrap:wrap}
|
||
.tabs button{border-radius:20px}
|
||
.tabs button.on{background:var(--ink);border-color:var(--ink);color:#fff}
|
||
|
||
.drop{border:1.5px dashed var(--line);border-radius:8px;padding:18px;text-align:center;color:var(--mute);background:#FBFCFB}
|
||
.drop.over{border-color:var(--acc);background:var(--acc-soft)}
|
||
.drop input{display:none}
|
||
.files{margin-top:8px;font-size:12px}
|
||
.files span{display:inline-block;background:var(--soft);border-radius:4px;padding:2px 8px;margin:2px 4px 2px 0}
|
||
|
||
table{border-collapse:collapse;width:100%;font-size:13px}
|
||
th,td{padding:6px 8px;border-bottom:1px solid var(--line);text-align:left;vertical-align:middle}
|
||
th{color:var(--mute);font-weight:500;font-size:12px;white-space:nowrap}
|
||
td.num,th.num{text-align:right;white-space:nowrap}
|
||
tr:hover td{background:#FAFBFA}
|
||
td input[type=number]{width:96px;padding:3px 6px}
|
||
td input[type=text]{width:100%;padding:3px 6px}
|
||
.tbl-wrap{overflow:auto}
|
||
|
||
.zone{display:inline-block;min-width:74px;text-align:center;padding:3px 8px;border-radius:4px;font-weight:600;font-size:12px}
|
||
.zone.green{background:var(--green-soft);color:var(--green)}
|
||
.zone.yellow{background:var(--yellow-soft);color:var(--yellow)}
|
||
.zone.red{background:var(--red-soft);color:var(--red)}
|
||
.zone.na{background:var(--na-soft);color:var(--na);font-weight:500}
|
||
td.kcell{text-align:right;white-space:nowrap}
|
||
td.kcell .v{font-size:16px;font-weight:600;display:block}
|
||
td.kcell .lim{font-size:11px;color:var(--mute);display:block}
|
||
td.kcell.green .v{color:var(--green)} td.kcell.yellow .v{color:var(--yellow)} td.kcell.red .v{color:var(--red)}
|
||
tr.overall-red td:first-child{box-shadow:inset 4px 0 0 var(--red)}
|
||
tr.overall-yellow td:first-child{box-shadow:inset 4px 0 0 var(--yellow)}
|
||
tr.overall-green td:first-child{box-shadow:inset 4px 0 0 var(--green)}
|
||
|
||
.kpi{display:flex;gap:24px;flex-wrap:wrap;margin-bottom:12px}
|
||
.kpi div b{display:block;font-size:22px;font-weight:600}
|
||
.kpi div span{font-size:12px;color:var(--mute)}
|
||
.kpi .green b{color:var(--green)} .kpi .yellow b{color:var(--yellow)} .kpi .red b{color:var(--red)}
|
||
|
||
.note{border-left:3px solid var(--acc);background:var(--acc-soft);padding:8px 12px;border-radius:0 var(--r) var(--r) 0;font-size:13px;margin:8px 0}
|
||
.warn{border-left-color:var(--yellow);background:var(--yellow-soft)}
|
||
.err{border-left-color:var(--red);background:var(--red-soft)}
|
||
.flags li{margin:2px 0}
|
||
.diag{font-size:12px;line-height:1.5}
|
||
.diag code{background:var(--soft);padding:1px 5px;border-radius:3px}
|
||
|
||
.chat{display:flex;flex-direction:column;height:520px;border:1px solid var(--line);border-radius:8px;overflow:hidden}
|
||
.chat .log{flex:1;overflow:auto;padding:14px;background:#FBFCFB}
|
||
.msg{max-width:78%;padding:9px 12px;border-radius:10px;margin-bottom:10px;white-space:pre-wrap;font-size:13px;line-height:1.5}
|
||
.msg.user{background:var(--acc-soft);margin-left:auto;border-bottom-right-radius:3px}
|
||
.msg.ai{background:#fff;border:1px solid var(--line);border-bottom-left-radius:3px}
|
||
.msg.sys{background:transparent;color:var(--mute);font-size:12px;max-width:100%;text-align:center}
|
||
.chat .in{display:flex;gap:8px;padding:10px;border-top:1px solid var(--line);background:#fff}
|
||
.chat .in textarea{min-height:44px;height:44px}
|
||
.quick{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}
|
||
.spin{display:inline-block;width:14px;height:14px;border:2px solid var(--line);border-top-color:var(--acc);border-radius:50%;animation:sp .8s linear infinite;vertical-align:middle;margin-right:6px}
|
||
@keyframes sp{to{transform:rotate(360deg)}}
|
||
@media (prefers-reduced-motion:reduce){.spin{animation:none}}
|
||
|
||
.modal-bg{position:fixed;inset:0;background:rgba(24,35,42,.45);display:none;align-items:center;justify-content:center;z-index:50}
|
||
.modal-bg.on{display:flex}
|
||
.modal{background:#fff;border-radius:10px;padding:20px 22px;width:min(720px,94vw);max-height:90vh;overflow:auto}
|
||
.modal h2{margin-bottom:12px}
|
||
.modal .actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
|
||
.chk{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--ink)}
|
||
.chk input{width:auto}
|
||
.comp{display:grid;grid-template-columns:1fr 130px 60px;gap:6px 10px;align-items:center;font-size:13px}
|
||
.comp .h{color:var(--mute);font-size:12px}
|
||
.comp input[type=number]{width:130px}
|
||
.total{border-top:1px solid var(--line);padding-top:6px;margin-top:4px;font-weight:600}
|
||
#toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--ink);color:#fff;padding:8px 14px;border-radius:6px;font-size:13px;display:none;z-index:60}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div class="brand">
|
||
<h1>КФУ-агент</h1>
|
||
<span>Расчёт К1–К3 по Политике финансовой устойчивости — Долг/EBITDA, EBITDA/Проценты, Долг/Капитал · версия 1.3</span>
|
||
|
||
</div>
|
||
<div class="spacer"></div>
|
||
<div class="field"><label>Единицы вывода</label>
|
||
<div class="seg" id="unitSeg"><button data-u="mn" class="on">млн ₸</button><button data-u="bn">млрд ₸</button></div>
|
||
</div>
|
||
<div class="field"><label>Состояние (цели, пороги, реестр)</label>
|
||
<div class="row" style="gap:6px">
|
||
<button class="small" id="btnSaveState">Сохранить JSON</button>
|
||
<button class="small" id="btnLoadState">Загрузить JSON</button>
|
||
<input type="file" id="stateFile" accept=".json" class="hidden">
|
||
</div>
|
||
</div>
|
||
<div class="field"><label>Демо</label>
|
||
<button class="small" id="btnDemo" style="border-color:var(--yellow);color:var(--yellow)">Учебный пример</button>
|
||
</div>
|
||
</header>
|
||
|
||
<nav class="steps">
|
||
<button data-step="data" class="on"><b>1</b>Данные</button>
|
||
<button data-step="reg"><b>2</b>Реестр показателей<span class="cnt" id="cntReg"></span></button>
|
||
<button data-step="calc"><b>3</b>Расчёт и зоны</button>
|
||
<button data-step="agent"><b>4</b>Агент</button>
|
||
</nav>
|
||
|
||
<main>
|
||
<div class="note warn small hidden" id="demoBanner" style="margin-bottom:16px">Учебный пример: реестр содержит синтетические данные (кнопка «Учебный пример»). Для рабочих расчётов загрузите выгрузку СУО, сводную книгу или отчётность на шаге 1.</div>
|
||
|
||
<!-- ============ STEP 1: DATA ============ -->
|
||
<section class="step on" id="step-data">
|
||
<div class="tabs">
|
||
<button data-src="suo" class="on">Выгрузка СУО (SAP BPC)</button>
|
||
<button data-src="ifrs">Отчётность МСФО — извлечение ИИ</button>
|
||
<button data-src="manual">Ручной ввод</button>
|
||
</div>
|
||
|
||
<!-- SUO -->
|
||
<div id="src-suo">
|
||
<div class="panel" style="margin-bottom:14px">
|
||
<h2>Сводная книга КФУ 2025</h2>
|
||
<div class="row" style="align-items:center;flex-wrap:wrap;gap:12px">
|
||
<button class="small on" id="btnKfuBook">Сводная книга КФУ 2025</button>
|
||
<input type="file" id="kfuBookFile" accept=".xlsx,.xlsm,.xls" class="hidden">
|
||
<span class="note small" id="kfuBookStatus">Импорт показателей и нормативов со сверкой по сводной книге. Данные берутся только из загруженной книги: долг/прочие/EBITDA/проценты/капитал (млн → млрд), нормативы, источники адресов.</span>
|
||
</div>
|
||
</div>
|
||
<div class="grid2">
|
||
<div class="panel">
|
||
<h2>Файлы выгрузки</h2>
|
||
<div class="drop" id="dropSuo">
|
||
Перетащите .xlsx/.xlsm с листами ДОЛГ, EBITDA, BS/Баланс — или <a href="#" onclick="document.getElementById('suoFiles').click();return false">выберите файлы</a>
|
||
<input type="file" id="suoFiles" accept=".xlsx,.xlsm,.xls" multiple>
|
||
</div>
|
||
<div class="files" id="suoFileList"></div>
|
||
<div class="row" style="margin-top:12px">
|
||
<div class="field"><label>Лист ДОЛГ</label><select id="selDebtSheet"></select></div>
|
||
<div class="field"><label>Лист EBITDA</label><select id="selEbSheet"></select></div>
|
||
<div class="field"><label>Лист Баланс</label><select id="selBsSheet"></select></div>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<div class="field"><label>Единицы выгрузки</label>
|
||
<select id="suoUnits"><option value="1e6">тыс. ₸ (стандарт СУО)</option><option value="1e3">млн ₸</option><option value="1">млрд ₸</option></select></div>
|
||
<div class="field"><label>Знаки</label>
|
||
<select id="suoSigns"><option value="suo">как в СУО: доходы, обязательства, капитал — отрицательные</option><option value="nat">естественные (все положительные)</option></select></div>
|
||
<div class="field"><label>Блок столбцов</label><select id="selBlock"></select></div>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<label class="chk"><input type="checkbox" id="grpConsol" checked>Консолидированная группа — из блока «Консолидация» (TOTALADJ), как в сводном файле (диапазон U:AM)</label>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<div class="field"><label>Фильтр столбцов по строке-заголовку (INPUT / TOTALADJ и т.п.)</label>
|
||
<div class="row" style="gap:6px"><select id="selHdrRow"><option value="">— без фильтра —</option></select><select id="selHdrVal"><option value="">—</option></select></div>
|
||
</div>
|
||
</div>
|
||
<div class="note small" style="margin-top:12px">Для ковенантов портфельной компании долг берётся по её собственной консолидированной отчётности: внутригрупповые займы от Фонда не элиминируются. Блок «Консолидация» и параметр TOTALADJ применимы только к строке Группы Фонда.</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h2>Распознанная структура</h2>
|
||
<div id="suoDiag" class="diag muted">Загрузите файл — здесь появятся найденные строки и блоки.</div>
|
||
<div id="suoMap" class="hidden">
|
||
<div class="row" style="margin-top:10px">
|
||
<div class="field"><label>Период ДОЛГ / Баланс</label><select id="selDebtPeriod"></select></div>
|
||
<div class="field"><label>Строка долга</label><select id="selDebtRow"></select></div>
|
||
<div class="field"><label>Строка капитала</label><select id="selEqRow"></select></div>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<div class="field"><label>EBITDA — база</label>
|
||
<select id="selEbMode"><option value="bottom">снизу вверх по статьям ОПиУ (Приложение 1)</option><option value="full">строка «Полная EBITDA» / выбранная строка</option></select></div>
|
||
<div class="field"><label>Строка EBITDA (если выбранная)</label><select id="selEbRow"></select></div>
|
||
<div class="field"><label>Строка процентных расходов</label><select id="selIntRow"></select></div>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<div class="field"><label>Период EBITDA — текущий</label><select id="selEbCur"></select></div>
|
||
<div class="field"><label>Прошлый год — полный (для LTM)</label><select id="selEbPfy"></select></div>
|
||
<div class="field"><label>Прошлый год — тот же период (для LTM)</label><select id="selEbPyt"></select></div>
|
||
</div>
|
||
<p class="small muted" style="margin-top:8px">EBITDA 12М = текущий период + прошлый полный год − прошлый год за тот же период (мостик LTM); проценты 12М — тем же мостиком.</p>
|
||
<div id="ebItemsDiag" class="diag" style="margin-top:10px"></div>
|
||
<div class="row" style="margin-top:10px">
|
||
<button class="small" id="btnAiMap">Подобрать строки через ИИ</button>
|
||
<span class="small muted">Если автоматика не нашла строки — ИИ сопоставит наименования статей по методике.</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel" id="suoPreviewPanel">
|
||
<h2>Предпросмотр по компаниям</h2>
|
||
<p class="small muted">Гарантии и прочие обязательства, не входящие в строку «Долг» выгрузки, вводятся вручную (млрд ₸). Отметьте компании для добавления в реестр.</p>
|
||
<div class="tbl-wrap"><table id="suoPreview"></table></div>
|
||
<div class="row" style="margin-top:12px">
|
||
<button class="primary" id="btnSuoAdd" disabled>Добавить отмеченные в реестр</button>
|
||
<button id="btnSuoAllYears" disabled>Добавить все периоды из файлов</button>
|
||
<span class="small muted" id="suoPeriodLabel"></span>
|
||
</div>
|
||
<p class="small muted" style="margin-top:8px">«Все периоды» — для файлов вида «Долг 2023 / EBITDA 2023 / Баланс 2023»: тройки листов сопоставляются по году в названии, гарантии берутся из таблицы выше по компаниям.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- IFRS AI -->
|
||
<div id="src-ifrs" class="hidden">
|
||
<div class="grid2">
|
||
<div class="panel">
|
||
<h2>Отчётность компании</h2>
|
||
<div class="drop" id="dropIfrs">
|
||
PDF отчётности МСФО (до ~100 стр.), Excel, сканы страниц (PNG/JPG) — или <a href="#" onclick="document.getElementById('ifrsFiles').click();return false">выберите файлы</a>
|
||
<input type="file" id="ifrsFiles" accept=".pdf,.xlsx,.xls,.png,.jpg,.jpeg" multiple>
|
||
</div>
|
||
<div class="files" id="ifrsFileList"></div>
|
||
<div class="field" style="margin-top:10px"><label>Или вставьте фрагменты отчётности текстом (баланс, ОПиУ, примечания по займам)</label><textarea id="ifrsText" placeholder="Текст отчётности…"></textarea></div>
|
||
<div class="row" style="margin-top:10px">
|
||
<div class="field"><label>Компания (если известна)</label><input type="text" id="ifrsCompany" placeholder="например, Казпочта" style="width:220px"></div>
|
||
<div class="field"><label>Подсказка ИИ (необязательно)</label><input type="text" id="ifrsHint" placeholder="например, финсегмент: процентные доходы в выручке" style="width:320px"></div>
|
||
</div>
|
||
<div class="row" style="margin-top:12px">
|
||
<button class="primary" id="btnExtract">Извлечь показатели</button>
|
||
<span id="extractStatus" class="small muted"></span>
|
||
</div>
|
||
<div class="note small warn" style="margin-top:12px">Большие годовые отчёты лучше подавать основными формами (баланс, ОПиУ, ОДДС, примечания по займам, аренде и финансовым расходам). Извлечённые значения — черновик: проверьте их по строкам отчётности перед добавлением в реестр.</div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>Методика извлечения (Приложение 1 Политики)</h2>
|
||
<p class="small muted">Текст передаётся ИИ как определения показателей. Скорректируйте под действующую редакцию Политики.</p>
|
||
<textarea id="methodText" style="min-height:260px"></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="panel hidden" id="ifrsReviewPanel">
|
||
<h2>Проверка извлечённых показателей</h2>
|
||
<div id="ifrsReview"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MANUAL -->
|
||
<div id="src-manual" class="hidden">
|
||
<div class="panel" style="max-width:820px">
|
||
<h2>Ручной ввод</h2>
|
||
<p class="small muted">Все значения — в млрд ₸, за 12 месяцев (EBITDA и проценты — LTM), на отчётную дату (долг, капитал).</p>
|
||
<div class="row">
|
||
<div class="field"><label>Компания</label><input type="text" id="mCompany" list="companyList" style="width:220px"><datalist id="companyList"></datalist></div>
|
||
<div class="field"><label>Период (отчётная дата)</label><input type="text" id="mPeriod" placeholder="4Q 2025 / 30.06.2026" style="width:160px"></div>
|
||
</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<div class="field"><label>Долг (СУО/отчётность)</label><input type="number" id="mDebt" step="0.01"></div>
|
||
<div class="field"><label>Гарантии и прочее</label><input type="number" id="mGuar" step="0.01" value="0"></div>
|
||
<div class="field"><label>EBITDA 12М</label><input type="number" id="mEbitda" step="0.01"></div>
|
||
<div class="field"><label>Процентные расходы 12М</label><input type="number" id="mInt" step="0.01"></div>
|
||
<div class="field"><label>Собственный капитал</label><input type="number" id="mEq" step="0.01"></div>
|
||
</div>
|
||
<div class="field" style="margin-top:10px"><label>Примечание (источник, корректировки)</label><input type="text" id="mNote"></div>
|
||
<div class="row" style="margin-top:12px"><button class="primary" id="btnManualAdd">Добавить в реестр</button><button id="btnManualCancel" class="hidden">Отмена</button></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ============ STEP 2: REGISTRY ============ -->
|
||
<section class="step" id="step-reg">
|
||
<div class="panel">
|
||
<h2>Реестр показателей</h2>
|
||
<p class="small muted">Базовые показатели по компаниям и периодам. Значения редактируются кнопкой «Изменить»; итоговый долг = долг + гарантии.</p>
|
||
<div class="tbl-wrap"><table id="regTable"></table></div>
|
||
<div class="row" style="margin-top:12px">
|
||
<button class="ghost small" id="btnRegClear">Очистить реестр</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ============ STEP 3: CALC ============ -->
|
||
<section class="step" id="step-calc">
|
||
<div class="panel">
|
||
<h2>Целевые и пороговые значения</h2>
|
||
<p class="small muted">К1 Долг/EBITDA и К3 Долг/Капитал — «не более» (цель ≤ порог); К2 EBITDA/Проценты — «не менее» (цель ≥ порог). Пустое значение — коэффициент не оценивается. Строки добавляются автоматически по компаниям реестра.</p>
|
||
<div class="tbl-wrap"><table id="paramTable"></table></div>
|
||
<div class="row" style="margin-top:10px">
|
||
<div class="field"><label>Добавить компанию</label><input type="text" id="pNew" style="width:220px"></div>
|
||
<button class="small" id="btnParamAdd">Добавить</button>
|
||
</div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>Свод в формате сводного файла КФУ</h2>
|
||
<p class="small muted">Строки — в порядке сводного файла; коэффициенты подсвечены зонами по целям/порогам (серые — цели не заданы).</p>
|
||
<div class="tbl-wrap"><table id="pivotTable"></table></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>Коэффициенты и зоны</h2>
|
||
<div class="kpi" id="kpi"></div>
|
||
<div class="tbl-wrap"><table id="calcTable"></table></div>
|
||
<div class="row" style="margin-top:14px">
|
||
<button class="primary" id="btnXlsx">Выгрузить в Excel</button>
|
||
<button id="btnCsv">CSV</button>
|
||
<span class="small muted">Зона строки — худшая из трёх; EBITDA ≤ 0 → К1 и К2 в красной зоне независимо от формулы.</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ============ STEP 4: AGENT ============ -->
|
||
<section class="step" id="step-agent">
|
||
<div class="grid2" style="grid-template-columns:2fr 1fr">
|
||
<div class="panel">
|
||
<h2>Агент по КФУ</h2>
|
||
<p class="small muted">Видит реестр, рассчитанные коэффициенты, цели/пороги и методику. Считает по данным реестра, не выдумывает цифры.</p>
|
||
<div class="quick">
|
||
<button class="small" data-q="Сформируй краткое заключение по зонам финансового риска по всем компаниям реестра в официальном стиле для служебной записки ДКФ: по каждой компании — значения К1–К3, сравнение с целевыми и пороговыми, зона; в конце — перечень компаний в жёлтой и красной зонах.">Заключение по зонам</button>
|
||
<button class="small" data-q="Проведи стресс-тест: долг +10%, EBITDA −15%, процентные расходы +20%, капитал −5%. Пересчитай К1–К3 по каждой компании и укажи, какие пороги срабатывают.">Стресс-тест</button>
|
||
<button class="small" data-q="Какие компании реестра ближе всего к пороговым значениям? Рассчитай запас по каждому коэффициенту в процентах и в абсолютном выражении (на сколько млрд ₸ может вырасти долг / снизиться EBITDA до порога).">Запас до порогов</button>
|
||
<button class="small" data-q="Проверь реестр на методологические риски: отрицательная EBITDA, отсутствие целей/порогов, промежуточные периоды без LTM, аномальные значения коэффициентов, возможное элиминирование внутригрупповых займов.">Что проверить</button>
|
||
</div>
|
||
<div class="chat">
|
||
<div class="log" id="chatLog"><div class="msg sys">Задайте вопрос по коэффициентам или используйте быстрые запросы выше.</div></div>
|
||
<div class="in"><textarea id="chatIn" placeholder="Вопрос агенту…"></textarea><button class="primary" id="btnChat">Отправить</button></div>
|
||
</div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>Контекст агента</h2>
|
||
<p class="small muted">Что передаётся в каждый запрос:</p>
|
||
<ul class="small">
|
||
<li>реестр: долг, гарантии, EBITDA, проценты, капитал, период, источник</li>
|
||
<li>расчёт: К1–К3, зоны, цели и пороги</li>
|
||
<li>методика из вкладки «Отчётность МСФО»</li>
|
||
</ul>
|
||
<div class="row"><button class="small" id="btnChatClear">Очистить диалог</button></div>
|
||
<div class="note small" style="margin-top:12px">ИИ анализирует данные текущего реестра через сервер Vibe AI. Исходный Excel остаётся в браузере; при запросе ИИ передаются показатели и выбранные фрагменты отчётности.</div>
|
||
<div class="field" style="margin-top:12px"><label>Дополнительные указания агенту</label><textarea id="agentExtra" placeholder="например: отвечай в формате таблицы; используй запятую как десятичный разделитель"></textarea></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
</main>
|
||
|
||
<div class="modal-bg" id="modalBg"><div class="modal" id="modal"></div></div>
|
||
<div id="toast"></div>
|
||
<script>
|
||
/* ===================== CORE ===================== */
|
||
const $ = s => document.querySelector(s);
|
||
const $$ = s => [...document.querySelectorAll(s)];
|
||
const uid = () => Math.random().toString(36).slice(2, 9);
|
||
const isNum = v => typeof v === 'number' && isFinite(v);
|
||
function norm(s){ return String(s ?? '').replace(/[«»"'“”]/g,'').replace(/\u00a0/g,' ').replace(/\s+/g,' ').trim().toLowerCase(); }
|
||
function stripForm(s){ let n=norm(s).replace(/\s*[-–—]\s*/g,'-'); for(let i=0;i<4;i++) n=n.replace(/^(ао|тоо|нк|нак|нгк|фнб|jsc|llp|нко|чк|чкоо)\s+/,''); return n.replace(/\s+(ао|jsc|нк)$/,'').trim(); }
|
||
function toast(t){ const e=$('#toast'); e.textContent=t; e.style.display='block'; clearTimeout(e._t); e._t=setTimeout(()=>e.style.display='none',2600); }
|
||
function esc(s){ return String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||
const nf2 = new Intl.NumberFormat('ru-RU',{minimumFractionDigits:2,maximumFractionDigits:2});
|
||
const nf1 = new Intl.NumberFormat('ru-RU',{minimumFractionDigits:1,maximumFractionDigits:1});
|
||
const nf0 = new Intl.NumberFormat('ru-RU',{maximumFractionDigits:0});
|
||
function fmt(v){ if(!isNum(v)) return '—'; return S.units==='mn' ? nf0.format(v*1000) : nf2.format(v); }
|
||
function fmtX(v){ return isNum(v) ? nf2.format(v)+'x' : '—'; }
|
||
|
||
const S = {
|
||
units:'mn',
|
||
registry:[], // {id,key,name,period,source,debt,guar,ebitda,interest,equity,note,detail,months}
|
||
params:{}, // key -> {name,t1,t2,t3,p1,p2,p3}
|
||
chat:[],
|
||
suo:{ books:{}, sheets:[] , parsed:null },
|
||
ifrs:{ files:[], extracted:null },
|
||
editId:null
|
||
};
|
||
|
||
/* ---------- companies & default parameters ---------- */
|
||
// order = order of the Fund's consolidated KFU file
|
||
// цели (t) и пороги (p) — [К1 Долг/EBITDA, К2 EBITDA/%, К3 Долг/Капитал], источник: Сводный файл КФУ, лист «Группа Фонда 4Q 2025»
|
||
const COMPANIES = [
|
||
{key:'кмг', name:'АО "НК "КазМунайГаз"', aliases:['кмг','казмунайгаз','kmg','kazmunaygas','kazmunaigas'], t:[3.6,5,0.9], p:[4.6,3,1.4]},
|
||
{key:'ктж', name:'АО "НК "КТЖ"', aliases:['ктж','казахстан темир жолы','қазақстан темір жолы','kazakhstan temir zholy','ktz','ktzh'], t:[3,5.5,0.9], p:[4,3.5,1.4]},
|
||
{key:'казахтелеком', name:'АО "Казахтелеком"', aliases:['казахтелеком','kazakhtelecom'], t:[2.5,4,0.8], p:[3.5,2,1.3]},
|
||
{key:'kegoc', name:'АО "KEGOC"', aliases:['kegoc','кегок'], t:[2.8,5,0.9], p:[3.8,3,1.4]},
|
||
{key:'казатомпром', name:'АО «НАК «Казатомпром»', aliases:['казатомпром','kazatomprom','kap'], t:[3,5,0.5], p:[4,3,1]},
|
||
{key:'казпочта', name:'АО "Казпочта"', aliases:['казпочта','kazpost'], t:[2.8,5,0.9], p:[3.8,3,1.4]},
|
||
{key:'самрук-энерго', name:'АО «Самрук-Энерго»', aliases:['самрук-энерго','samruk-energy','samruk-energo'], t:[3,4,1.5], p:[3.5,3,2]},
|
||
{key:'ск-инвест', name:'ТОО "Самрук-Казына Инвест"', aliases:['самрук-казына инвест','samruk-kazyna invest','ск инвест'], exact:true},
|
||
{key:'ск-контракт', name:'ТОО "Самрук-Казына Контракт"', aliases:['самрук-казына контракт','samruk-kazyna contract','ск контракт'], exact:true},
|
||
{key:'ск-ондеу', name:'ТОО "Samruk-Kazyna Ondeu"', aliases:['samruk-kazyna ondeu','самрук-казына ондеу','ск ондеу','sk ondeu','ондеу'], exact:true, t:[null,4,1.5], p:[null,2,2]},
|
||
{key:'тау-кен самрук', name:'АО "НГК "Таукен Самрук"', aliases:['тау-кен самрук','таукен самрук','tau-ken samruk','tauken samruk'], t:[2.8,5,0.9], p:[3.8,3,1.4]},
|
||
{key:'ск-констракшн', name:'АО «Samruk-Kazyna Construction»', aliases:['samruk-kazyna construction','самрук-казына констракшн','ск констракшн'], exact:true, t:[null,null,3], p:[null,null,3.5]},
|
||
{key:'qazaqgaz', name:'АО «НК «QazaqGaz»', aliases:['qazaqgaz','qazaqgas','казахгаз','казтрансгаз','ктг'], t:[2.8,5,0.9], p:[3.8,3,1.4]},
|
||
{key:'пгу туркестан', name:'ТОО «ПГУ Туркестан»', aliases:['пгу туркестан','пгу']},
|
||
{key:'фонд', name:'АО "ФНБ "Самрук-Казына"', aliases:['самрук-казына','самрук-қазына','samruk-kazyna','фонд','фнб'], exact:true, t:[null,null,null], p:[null,null,2]},
|
||
{key:'группа', name:'Консолидированная группа 1', aliases:['консолидированная группа 1','консолидированная группа','группа фонда','фонд конс'], exact:true, t:[null,null,null], p:[5.5,3,2]},
|
||
{key:'эйр астана', name:'АО «Эйр Астана»', aliases:['эйр астана','air astana']},
|
||
{key:'qazaq air', name:'АО «Qazaq Air»', aliases:['qazaq air']},
|
||
];
|
||
function matchCompany(name){
|
||
const n = stripForm(name); if(!n) return null;
|
||
for(const c of COMPANIES){ if(c.aliases.includes(n) || c.key===n) return c.key; }
|
||
for(const c of COMPANIES){ if(c.exact) continue; for(const a of c.aliases){ if(a.length>=5 && n.includes(a)) return c.key; } }
|
||
if(/^консолидированная группа/.test(n)) return 'группа';
|
||
return null;
|
||
}
|
||
const ORDER = COMPANIES.map(c=>c.key);
|
||
function sortKeys(a,b){ const ia=ORDER.indexOf(a.key), ib=ORDER.indexOf(b.key); const oa=ia<0?999:ia, ob=ib<0?999:ib; if(oa!==ob) return oa-ob; return String(a.period).localeCompare(String(b.period)); }
|
||
function keyFor(name){ return matchCompany(name) || stripForm(name); }
|
||
function displayName(key, fallback){ const c=COMPANIES.find(x=>x.key===key); return c ? c.name : (S.params[key]?.name || fallback || key); }
|
||
function ensureParam(key, name){
|
||
if(!S.params[key]){
|
||
const c = COMPANIES.find(x=>x.key===key);
|
||
S.params[key] = { name: c?c.name:(name||key), t1:c?.t?.[0]??null, t2:c?.t?.[1]??null, t3:c?.t?.[2]??null, p1:c?.p?.[0]??null, p2:c?.p?.[1]??null, p3:c?.p?.[2]??null };
|
||
}
|
||
return S.params[key];
|
||
}
|
||
COMPANIES.forEach(c=>ensureParam(c.key));
|
||
|
||
/* ---------- ratios & zones ---------- */
|
||
function ratios(r){
|
||
const debt = (r.debt||0) + (r.guar||0);
|
||
const e = r.ebitda, i = r.interest, q = r.equity;
|
||
const k1 = (isNum(e) && e>0) ? debt/e : (isNum(e) ? -1 : null); // -1 => EBITDA<=0
|
||
const k2 = (isNum(e) && isNum(i) && i>0) ? e/i : ((isNum(e)&&isNum(i)&&i===0) ? Infinity : null);
|
||
const k3 = (isNum(q) && q!==0) ? debt/q : null;
|
||
return {debt, k1, k2, k3, ebNeg: isNum(e) && e<=0, eqNeg: isNum(q) && q<=0};
|
||
}
|
||
// dir 'le' : value must be <= target/threshold ; 'ge' : value must be >= target/threshold
|
||
function zone(val, t, p, dir, forceRed){
|
||
if(forceRed) return 'red';
|
||
if(!isNum(val) && val!==Infinity) return 'na';
|
||
const hasT = isNum(t), hasP = isNum(p);
|
||
if(!hasT && !hasP) return 'na';
|
||
if(val===Infinity) return dir==='ge' ? 'green' : 'red';
|
||
if(dir==='le'){
|
||
if(hasP && val>p) return 'red';
|
||
if(hasT && val>t) return hasP ? 'yellow' : 'red';
|
||
return 'green';
|
||
} else {
|
||
if(hasP && val<p) return 'red';
|
||
if(hasT && val<t) return hasP ? 'yellow' : 'red';
|
||
return 'green';
|
||
}
|
||
}
|
||
const ZRANK = {na:0, green:1, yellow:2, red:3};
|
||
const ZNAME = {na:'н/д', green:'Зелёная', yellow:'Жёлтая', red:'Красная'};
|
||
function calcRow(r){
|
||
const p = ensureParam(r.key, r.name);
|
||
const x = ratios(r);
|
||
const z1 = zone(x.k1, p.t1, p.p1, 'le', x.ebNeg);
|
||
const z2 = zone(x.k2, p.t2, p.p2, 'ge', x.ebNeg);
|
||
const z3 = zone(x.k3, p.t3, p.p3, 'le', x.eqNeg && isNum(x.k3));
|
||
const worst = [z1,z2,z3].reduce((a,b)=>ZRANK[b]>ZRANK[a]?b:a,'na');
|
||
return {...x, z1,z2,z3, worst, p};
|
||
}
|
||
/* Стресс-тест: долг +10%, EBITDA −15%, проценты +20%, капитал −5% (расчёт детерминированный) */
|
||
function stressRow(r){
|
||
const debt = ((r.debt||0)+(r.guar||0))*1.10;
|
||
const e = isNum(r.ebitda) ? r.ebitda*0.85 : r.ebitda;
|
||
const i = isNum(r.interest) ? r.interest*1.20 : r.interest;
|
||
const q = isNum(r.equity) ? r.equity*0.95 : r.equity;
|
||
const p = ensureParam(r.key, r.name);
|
||
const z1 = zone(isNum(e)&&e>0?debt/e:(isNum(e)?-1:null), p.t1, p.p1, 'le', isNum(e)&&e<=0);
|
||
const z2 = zone(isNum(e)&&isNum(i)&&i>0?e/i:(isNum(e)&&isNum(i)&&i===0?Infinity:null), p.t2, p.p2, 'ge', isNum(e)&&e<=0);
|
||
const z3 = zone(isNum(q)&&q!==0?debt/q:null, p.t3, p.p3, 'le', isNum(q)&&q<=0);
|
||
const worst=[z1,z2,z3].reduce((a,b)=>ZRANK[b]>ZRANK[a]?b:a,'na');
|
||
return {debt:+debt.toFixed(3), ebitda:isNum(e)?+e.toFixed(3):null, interest:isNum(i)?+i.toFixed(3):null, equity:isNum(q)?+q.toFixed(3):null,
|
||
K1:(isNum(e)&&e>0)?+(debt/e).toFixed(3):(isNum(e)?'EBITDA<=0':null), K2:(isNum(e)&&e<=0)?'EBITDA<=0':(isNum(i)&&i>0?+(e/i).toFixed(3):(isNum(i)&&i===0?'inf':null)),
|
||
K3:(isNum(q)&&q!==0)?+(debt/q).toFixed(3):null, z1,z2,z3, worst};
|
||
}
|
||
|
||
/* ---------- registry ---------- */
|
||
function addRow(row){
|
||
row.id = row.id || uid();
|
||
row.key = row.key || keyFor(row.name);
|
||
ensureParam(row.key, row.name);
|
||
const dup = S.registry.findIndex(r => r.key===row.key && norm(r.period)===norm(row.period) && r.id!==row.id);
|
||
if(dup>=0){ S.registry[dup] = {...S.registry[dup], ...row, id:S.registry[dup].id}; }
|
||
else S.registry.push(row);
|
||
renderAll();
|
||
}
|
||
function renderRegistry(){
|
||
const t = $('#regTable');
|
||
const u = S.units==='mn' ? 'млн ₸' : 'млрд ₸';
|
||
let h = `<tr><th>Компания</th><th>Период</th><th>Источник</th><th class="num">Долг, ${u}</th><th class="num">Гарантии</th><th class="num">Итого долг</th><th class="num">EBITDA 12М</th><th class="num">% расходы 12М</th><th class="num">Капитал</th><th>Примечание</th><th></th></tr>`;
|
||
if(!S.registry.length) h += `<tr><td colspan="11" class="muted">Реестр пуст — добавьте данные на шаге 1.</td></tr>`;
|
||
for(const r of S.registry){
|
||
const flags = [];
|
||
if(isNum(r.ebitda) && r.ebitda<=0) flags.push('EBITDA ≤ 0');
|
||
if(r.months && r.months<12 && !r.ltm) flags.push(`период ${r.months} мес., не LTM`);
|
||
h += `<tr><td><b>${esc(displayName(r.key,r.name))}</b>${r.name && displayName(r.key,r.name)!==r.name?`<div class="small muted">${esc(r.name)}</div>`:''}</td>
|
||
<td>${esc(r.period||'')}</td><td class="small">${esc(r.source||'')}</td>
|
||
<td class="num">${fmt(r.debt)}</td><td class="num">${fmt(r.guar||0)}</td><td class="num"><b>${fmt((r.debt||0)+(r.guar||0))}</b></td>
|
||
<td class="num">${fmt(r.ebitda)}</td><td class="num">${fmt(r.interest)}</td><td class="num">${fmt(r.equity)}</td>
|
||
<td class="small">${esc(r.note||'')}${r.recon?`<div class="small muted">сверка базы: ${r.recon.ok}/${r.recon.total}</div>`:''}${flags.length?`<div class="small" style="color:var(--red)">${flags.join('; ')}</div>`:''}</td>
|
||
<td style="white-space:nowrap"><button class="small ghost" data-edit="${r.id}">Изменить</button><button class="small ghost" data-del="${r.id}">Удалить</button></td></tr>`;
|
||
}
|
||
t.innerHTML = h;
|
||
$('#cntReg').textContent = S.registry.length ? `(${S.registry.length})` : '';
|
||
t.querySelectorAll('[data-del]').forEach(b=>b.onclick=()=>{ S.registry=S.registry.filter(r=>r.id!==b.dataset.del); renderAll(); });
|
||
t.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editRow(b.dataset.edit));
|
||
}
|
||
function editRow(id){
|
||
const r = S.registry.find(x=>x.id===id); if(!r) return;
|
||
S.editId = id;
|
||
$('#mCompany').value = r.name || displayName(r.key); $('#mPeriod').value = r.period||'';
|
||
$('#mDebt').value = r.debt ?? ''; $('#mGuar').value = r.guar ?? 0; $('#mEbitda').value = r.ebitda ?? '';
|
||
$('#mInt').value = r.interest ?? ''; $('#mEq').value = r.equity ?? ''; $('#mNote').value = r.note||'';
|
||
$('#btnManualAdd').textContent = 'Сохранить изменения'; $('#btnManualCancel').classList.remove('hidden');
|
||
showStep('data'); showSrc('manual');
|
||
}
|
||
function manualReset(){ S.editId=null; ['mCompany','mPeriod','mDebt','mEbitda','mInt','mEq','mNote'].forEach(i=>$('#'+i).value=''); $('#mGuar').value=0; $('#btnManualAdd').textContent='Добавить в реестр'; $('#btnManualCancel').classList.add('hidden'); }
|
||
$('#btnManualCancel').onclick = manualReset;
|
||
$('#btnManualAdd').onclick = () => {
|
||
const name = $('#mCompany').value.trim(); if(!name){ toast('Укажите компанию'); return; }
|
||
const num = id => { const v=$('#'+id).value; return v===''?null:parseFloat(v); };
|
||
const row = { name, key:keyFor(name), period:$('#mPeriod').value.trim(), debt:num('mDebt'), guar:num('mGuar')||0, ebitda:num('mEbitda'), interest:num('mInt'), equity:num('mEq'), note:$('#mNote').value.trim() };
|
||
if(S.editId){ const old=S.registry.find(x=>x.id===S.editId); Object.assign(old, row, {source: old.source+(old.source.includes('ред.')?'':' (ред.)')}); renderAll(); toast('Сохранено'); }
|
||
else { row.source='Ручной'; addRow(row); toast('Добавлено в реестр'); }
|
||
manualReset();
|
||
};
|
||
$('#btnRegClear').onclick = () => { if(confirm('Очистить реестр показателей?')){ S.registry=[]; renderAll(); } };
|
||
|
||
/* ---------- parameters ---------- */
|
||
S.showAllParams = false;
|
||
function renderParams(){
|
||
const hasVal = k => ['t1','t2','t3','p1','p2','p3'].some(f=>isNum(S.params[k]?.[f]));
|
||
const all = [...new Set([...S.registry.map(r=>r.key), ...Object.keys(S.params)])];
|
||
const keys = S.showAllParams ? all : all.filter(k=>S.registry.some(r=>r.key===k) || hasVal(k));
|
||
const hiddenN = all.length - keys.length;
|
||
const t = $('#paramTable');
|
||
let h = `<tr><th>Компания</th><th class="num">К1 цель ≤</th><th class="num">К1 порог ≤</th><th class="num">К2 цель ≥</th><th class="num">К2 порог ≥</th><th class="num">К3 цель ≤</th><th class="num">К3 порог ≤</th><th></th></tr>`;
|
||
for(const k of keys){
|
||
const p = ensureParam(k);
|
||
const inReg = S.registry.some(r=>r.key===k);
|
||
const cell = f => `<td class="num"><input type="number" step="0.01" data-k="${esc(k)}" data-f="${f}" value="${isNum(p[f])?p[f]:''}"></td>`;
|
||
h += `<tr${inReg?'':' class="muted"'}><td><input type="text" data-k="${esc(k)}" data-f="name" value="${esc(p.name)}" style="width:200px"></td>${cell('t1')}${cell('p1')}${cell('t2')}${cell('p2')}${cell('t3')}${cell('p3')}<td>${inReg?'<span class="small muted">в реестре</span>':`<button class="small ghost" data-pdel="${esc(k)}">Убрать</button>`}</td></tr>`;
|
||
}
|
||
if(hiddenN>0 || S.showAllParams) h += `<tr><td colspan="8"><button class="ghost small" id="btnParamsToggle">${S.showAllParams?'Скрыть компании без значений':`Показать остальные компании (${hiddenN})`}</button></td></tr>`;
|
||
t.innerHTML = h;
|
||
const tg=$('#btnParamsToggle'); if(tg) tg.onclick=()=>{ S.showAllParams=!S.showAllParams; renderParams(); };
|
||
t.querySelectorAll('input').forEach(i=>i.onchange=()=>{ const p=S.params[i.dataset.k]; if(i.dataset.f==='name') p.name=i.value; else p[i.dataset.f]= i.value===''?null:parseFloat(i.value); renderCalc(); });
|
||
t.querySelectorAll('[data-pdel]').forEach(b=>b.onclick=()=>{ delete S.params[b.dataset.pdel]; renderParams(); });
|
||
$('#companyList').innerHTML = all.map(k=>`<option value="${esc(S.params[k].name)}">`).join('');
|
||
}
|
||
$('#btnParamAdd').onclick = () => { const n=$('#pNew').value.trim(); if(!n) return; ensureParam(keyFor(n), n); $('#pNew').value=''; renderParams(); };
|
||
|
||
/* ---------- calculation table ---------- */
|
||
function limTxt(t,p,dir){ const s=dir==='le'?'≤':'≥'; const a=isNum(t)?`цель ${s}${nf2.format(t)}`:'цель —'; const b=isNum(p)?`порог ${s}${nf2.format(p)}`:'порог —'; return `${a} · ${b}`; }
|
||
function kcell(v, z, t, p, dir, ebNeg){
|
||
const txt = ebNeg ? 'EBITDA ≤ 0' : (v===Infinity?'∞':fmtX(v));
|
||
return `<td class="kcell ${z}"><span class="v">${txt}</span><span class="lim">${limTxt(t,p,dir)}</span></td>`;
|
||
}
|
||
function renderCalc(){
|
||
const t = $('#calcTable'); const u = S.units==='mn' ? 'млн ₸' : 'млрд ₸';
|
||
let h = `<tr><th>Компания</th><th>Период</th><th class="num">Итого долг, ${u}</th><th class="num">EBITDA 12М</th><th class="num">% расходы</th><th class="num">Капитал</th><th class="num">К1 Долг/EBITDA</th><th class="num">К2 EBITDA/%</th><th class="num">К3 Долг/Капитал</th><th>Зона</th></tr>`;
|
||
const cnt = {green:0,yellow:0,red:0,na:0};
|
||
if(!S.registry.length) h += `<tr><td colspan="10" class="muted">Нет данных для расчёта.</td></tr>`;
|
||
for(const r of S.registry){
|
||
const c = calcRow(r); cnt[c.worst]++;
|
||
h += `<tr class="overall-${c.worst}"><td><b>${esc(displayName(r.key,r.name))}</b></td><td>${esc(r.period||'')}</td>
|
||
<td class="num">${fmt(c.debt)}</td><td class="num">${fmt(r.ebitda)}</td><td class="num">${fmt(r.interest)}</td><td class="num">${fmt(r.equity)}</td>
|
||
${kcell(c.k1,c.z1,c.p.t1,c.p.p1,'le',c.ebNeg)}${kcell(c.k2,c.z2,c.p.t2,c.p.p2,'ge',c.ebNeg)}${kcell(c.k3,c.z3,c.p.t3,c.p.p3,'le',false)}
|
||
<td><span class="zone ${c.worst}">${ZNAME[c.worst]}</span></td></tr>`;
|
||
}
|
||
t.innerHTML = h;
|
||
$('#kpi').innerHTML = `<div><b>${S.registry.length}</b><span>компаний/периодов</span></div><div class="green"><b>${cnt.green}</b><span>зелёная зона</span></div><div class="yellow"><b>${cnt.yellow}</b><span>жёлтая зона</span></div><div class="red"><b>${cnt.red}</b><span>красная зона</span></div><div><b>${cnt.na}</b><span>без оценки (нет целей/порогов)</span></div>`;
|
||
}
|
||
function renderPivot(){
|
||
const t=$('#pivotTable'); if(!t) return;
|
||
const periods=[...new Set(S.registry.map(r=>r.period||''))].sort();
|
||
const keys=[...new Set([...S.registry].sort(sortKeys).map(r=>r.key))];
|
||
const u = S.units==='mn'?'млн ₸':'млрд ₸';
|
||
let h=`<tr><th rowspan="2">Портфельные компании</th>${periods.map(p=>`<th colspan="4" style="text-align:center;border-left:1px solid var(--line)">${esc(p)}</th>`).join('')}${periods.map(p=>`<th colspan="3" style="text-align:center;border-left:1px solid var(--line)">${esc(p)}</th>`).join('')}</tr>`;
|
||
h+=`<tr>${periods.map(()=>`<th class="num" style="border-left:1px solid var(--line)">Долг, ${u}</th><th class="num">EBITDA</th><th class="num">Процентные расходы</th><th class="num">Капитал</th>`).join('')}${periods.map(()=>`<th class="num" style="border-left:1px solid var(--line)">Debt/EBITDA</th><th class="num">Debt/Equity</th><th class="num">EBITDA/Int.exp.</th>`).join('')}</tr>`;
|
||
for(const k of keys){
|
||
const name = S.registry.find(r=>r.key===k);
|
||
h+=`<tr><td><b>${esc(displayName(k,name?.name))}</b></td>`;
|
||
const cells1=[], cells2=[];
|
||
for(const p of periods){
|
||
const r=S.registry.find(x=>x.key===k && (x.period||'')===p);
|
||
if(!r){ cells1.push('<td class="num" style="border-left:1px solid var(--line)">—</td><td class="num">—</td><td class="num">—</td><td class="num">—</td>'); cells2.push('<td class="num" style="border-left:1px solid var(--line)">—</td><td class="num">—</td><td class="num">—</td>'); continue; }
|
||
const c=calcRow(r);
|
||
cells1.push(`<td class="num" style="border-left:1px solid var(--line)">${fmt(c.debt)}</td><td class="num">${fmt(r.ebitda)}</td><td class="num">${fmt(r.interest)}</td><td class="num">${fmt(r.equity)}</td>`);
|
||
const k1 = isNum(r.ebitda)&&r.ebitda!==0 ? c.debt/r.ebitda : null; const k2 = isNum(r.interest)&&r.interest!==0&&isNum(r.ebitda) ? r.ebitda/r.interest : null;
|
||
cells2.push(`<td class="num ${c.z1}" style="border-left:1px solid var(--line)"><span class="zone ${c.z1}" style="min-width:56px">${isNum(k1)?nf2.format(k1):'—'}</span></td><td class="num"><span class="zone ${c.z3}" style="min-width:56px">${isNum(c.k3)?nf2.format(c.k3):'—'}</span></td><td class="num"><span class="zone ${c.z2}" style="min-width:56px">${isNum(k2)?nf2.format(k2):'—'}</span></td>`);
|
||
}
|
||
h+=cells1.join('')+cells2.join('')+'</tr>';
|
||
}
|
||
if(!keys.length) h+='<tr><td class="muted">Нет данных.</td></tr>';
|
||
t.innerHTML=h;
|
||
}
|
||
function renderAll(){ S.registry.sort(sortKeys); if(S.isDemo && !S.registry.some(r=>(r.source||'').startsWith('Демо'))){ S.isDemo=false; } $('#demoBanner').hidden = !S.isDemo; renderRegistry(); renderParams(); renderCalc(); renderPivot(); }
|
||
|
||
/* ---------- export ---------- */
|
||
function calcMatrix(){
|
||
const rows = S.registry.map(r=>{ const c=calcRow(r); return {r,c}; });
|
||
return rows;
|
||
}
|
||
$('#btnXlsx').onclick = () => {
|
||
if(!S.registry.length){ toast('Реестр пуст'); return; }
|
||
const wb = XLSX.utils.book_new();
|
||
const K = S.units==='mn'?1000:1, U = S.units==='mn'?'млн ₸':'млрд ₸'; const sc = v => isNum(v)?v*K:null;
|
||
// ---- Свод: layout of the consolidated KFU file ----
|
||
const periods=[...new Set(S.registry.map(r=>r.period||''))].sort();
|
||
const keys=[...new Set([...S.registry].sort(sortKeys).map(r=>r.key))];
|
||
const sv=[['Портфельные компании', ...periods.flatMap(p=>[p,'','','']), ...periods.flatMap(p=>[p,'',''])],
|
||
['', ...periods.flatMap(()=>['факт','','','']), ...periods.flatMap(()=>['','',''])],
|
||
['', ...periods.flatMap(()=>[`Долг, ${U}`,'EBITDA','Процентные расходы','Капитал']), ...periods.flatMap(()=>['Debt/EBITDA','Debt/Equity','EBITDA/Int.exp.'])]];
|
||
const colL = i => XLSX.utils.encode_col(i);
|
||
keys.forEach((k,ki)=>{
|
||
const rn=ki+4; const row=[displayName(k, S.registry.find(r=>r.key===k)?.name)];
|
||
periods.forEach(p=>{ const r=S.registry.find(x=>x.key===k&&(x.period||'')===p); row.push(r?sc((r.debt||0)+(r.guar||0)):null, r?sc(r.ebitda):null, r?sc(r.interest):null, r?sc(r.equity):null); });
|
||
periods.forEach((p,pi)=>{ const b=1+pi*4; const D=colL(b), E=colL(b+1), I=colL(b+2), Q=colL(b+3);
|
||
row.push({f:`IF(${E}${rn}=0,"-",${D}${rn}/${E}${rn})`},{f:`IF(${Q}${rn}=0,"-",${D}${rn}/${Q}${rn})`},{f:`IF(${I}${rn}=0,"-",${E}${rn}/${I}${rn})`}); });
|
||
sv.push(row);
|
||
});
|
||
const wsv=XLSX.utils.aoa_to_sheet(sv); wsv['!cols']=[{wch:34},...Array(periods.length*7).fill({wch:15})];
|
||
const merges=[]; periods.forEach((p,pi)=>{ merges.push({s:{r:0,c:1+pi*4},e:{r:0,c:4+pi*4}}); merges.push({s:{r:1,c:1+pi*4},e:{r:1,c:4+pi*4}}); merges.push({s:{r:0,c:1+periods.length*4+pi*3},e:{r:0,c:3+periods.length*4+pi*3}}); }); wsv['!merges']=merges;
|
||
for(let i=4;i<=keys.length+3;i++){ for(let c=1;c<=periods.length*4;c++){ const a=colL(c)+i; if(wsv[a]) wsv[a].z='#,##0'; } for(let c=1+periods.length*4;c<=periods.length*7;c++){ const a=colL(c)+i; if(wsv[a]) wsv[a].z='0.00'; } }
|
||
XLSX.utils.book_append_sheet(wb, wsv, 'Свод');
|
||
// ---- Долг Группы: структура долга по компаниям (лист «Группа Фонда» Сводного файла) ----
|
||
const COMP=[['loans','Займы'],['gov','Займы Правительства РК и НБ РК'],['lease','Обязательство по финансовой аренде'],['deriv','Производные финансовые инструменты'],['stake','Задолженность за приобретение доли в проектах']];
|
||
const dg=[];
|
||
for(const p of periods){
|
||
const rr=[...S.registry].sort(sortKeys).filter(r=>(r.period||'')===p && r.comps);
|
||
if(!rr.length) continue;
|
||
const names=rr.map(r=>displayName(r.key,r.name));
|
||
dg.push([`Долг Группы — ${p}, ${U}`, ...names]);
|
||
COMP.forEach(([k,lab])=>dg.push([lab, ...rr.map(r=>sc(r.comps[k]??0))]));
|
||
dg.push(['Прочие, в т.ч. гарантии (ввод)', ...rr.map(r=>sc(r.guar||0))]);
|
||
const r0=dg.length-6+1; // first component row (1-based) for this block
|
||
dg.push(['Итого долг (сумма компонентов)', ...rr.map((r,i)=>({f:`SUM(${colL(i+1)}${r0}:${colL(i+1)}${r0+5})`}))]);
|
||
dg.push(['Строка «Долг» выгрузки + гарантии', ...rr.map(r=>sc((r.debt||0)+(r.guar||0)))]);
|
||
dg.push(['Контроль (разница)', ...rr.map((r,i)=>({f:`${colL(i+1)}${r0+6}-${colL(i+1)}${r0+7}`}))]);
|
||
dg.push(['Справочно: привлечённые средства клиентов (не в долге)', ...rr.map(r=>sc(r.comps.clients??0))]);
|
||
dg.push([]);
|
||
}
|
||
if(dg.length){ const wsd=XLSX.utils.aoa_to_sheet(dg); wsd['!cols']=[{wch:46},...Array(30).fill({wch:15})]; Object.keys(wsd).forEach(a=>{ if(a[0]!=='!' && typeof wsd[a].v==='number') wsd[a].z='#,##0'; }); XLSX.utils.book_append_sheet(wb, wsd, 'Долг Группы'); }
|
||
const head = ['Компания','Период','Источник',`Долг, ${U}`,`Гарантии, ${U}`,`Итого долг, ${U}`,`EBITDA 12М, ${U}`,`Процентные расходы 12М, ${U}`,`Капитал, ${U}`,'К1 Долг/EBITDA','К1 цель','К1 порог','Зона К1','К2 EBITDA/%','К2 цель','К2 порог','Зона К2','К3 Долг/Капитал','К3 цель','К3 порог','Зона К3','Зона','Примечание'];
|
||
const aoa = [head];
|
||
const rows = calcMatrix();
|
||
rows.forEach(({r,c},i)=>{
|
||
const n = i+2;
|
||
aoa.push([displayName(r.key,r.name), r.period||'', r.source||'', sc(r.debt), sc(r.guar??0), {f:`D${n}+E${n}`}, sc(r.ebitda), sc(r.interest), sc(r.equity),
|
||
{f:`IF(G${n}<=0,"EBITDA≤0",F${n}/G${n})`}, c.p.t1??null, c.p.p1??null, ZNAME[c.z1],
|
||
{f:`IF(G${n}<=0,"EBITDA≤0",IF(H${n}<=0,"н/д",G${n}/H${n}))`}, c.p.t2??null, c.p.p2??null, ZNAME[c.z2],
|
||
{f:`IF(I${n}=0,"н/д",F${n}/I${n})`}, c.p.t3??null, c.p.p3??null, ZNAME[c.z3], ZNAME[c.worst], r.note||'']);
|
||
});
|
||
const ws = XLSX.utils.aoa_to_sheet(aoa);
|
||
ws['!cols'] = head.map((h,i)=>({wch: i===0?22:(i===22?40:14)}));
|
||
for(let i=2;i<=rows.length+1;i++){ ['D','E','F','G','H','I'].forEach(c=>{ if(ws[c+i]) ws[c+i].z='#,##0.00'; }); ['J','N','R'].forEach(c=>{ if(ws[c+i]) ws[c+i].z='0.00"x"'; }); }
|
||
XLSX.utils.book_append_sheet(wb, ws, 'КФУ');
|
||
const pa = [['Компания','К1 цель ≤','К1 порог ≤','К2 цель ≥','К2 порог ≥','К3 цель ≤','К3 порог ≤']];
|
||
Object.values(S.params).forEach(p=>pa.push([p.name,p.t1??null,p.p1??null,p.t2??null,p.p2??null,p.t3??null,p.p3??null]));
|
||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(pa), 'Параметры');
|
||
const au = [['Компания','Период','Источник','Показатель',`Значение, ${U}`,'Детализация / источник']];
|
||
rows.forEach(({r})=>{
|
||
const d = r.detail||{};
|
||
const push=(m,v,s)=>au.push([displayName(r.key,r.name), r.period||'', r.source||'', m, sc(v), s||'']);
|
||
push('Долг', r.debt, d.debt); push('Гарантии', r.guar||0, d.guar); push('EBITDA 12М', r.ebitda, d.ebitda); push('Процентные расходы 12М', r.interest, d.interest); push('Капитал', r.equity, d.equity);
|
||
});
|
||
const wsa = XLSX.utils.aoa_to_sheet(au); wsa['!cols']=[{wch:22},{wch:12},{wch:14},{wch:24},{wch:16},{wch:90}];
|
||
XLSX.utils.book_append_sheet(wb, wsa, 'Аудит');
|
||
XLSX.writeFile(wb, `КФУ_${periods.join('_').replace(/[^\wа-яА-Я.-]/g,'_')||'расчет'}.xlsx`);
|
||
};
|
||
$('#btnCsv').onclick = () => {
|
||
if(!S.registry.length){ toast('Реестр пуст'); return; }
|
||
const rows = calcMatrix();
|
||
const lines = [['Компания','Период','Итого долг','EBITDA','Проценты','Капитал','К1','К2','К3','Зона'].join(';')];
|
||
rows.forEach(({r,c})=>lines.push([displayName(r.key,r.name), r.period||'', c.debt, r.ebitda, r.interest, r.equity, c.ebNeg?'EBITDA<=0':(c.k1??''), c.ebNeg?'EBITDA<=0':(c.k2??''), c.k3??'', ZNAME[c.worst]].map(v=>String(v??'').replace('.',',')).join(';')));
|
||
const blob = new Blob(['\ufeff'+lines.join('\r\n')],{type:'text/csv;charset=utf-8'});
|
||
const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='КФУ.csv'; a.click();
|
||
};
|
||
|
||
/* ---------- state save / load ---------- */
|
||
$('#btnSaveState').onclick = () => {
|
||
const data = {v:1, saved:new Date().toISOString(), isDemo:!!S.isDemo, params:S.params, registry:S.registry, method:$('#methodText').value, agentExtra:$('#agentExtra').value};
|
||
const blob = new Blob([JSON.stringify(data,null,1)],{type:'application/json'});
|
||
const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='КФУ_состояние.json'; a.click();
|
||
};
|
||
$('#btnLoadState').onclick = () => $('#stateFile').click();
|
||
$('#stateFile').onchange = e => {
|
||
const f=e.target.files[0]; if(!f) return;
|
||
const rd=new FileReader(); rd.onload=()=>{ try{ const d=JSON.parse(rd.result); if(d.params) S.params={...S.params,...d.params}; if(d.registry) S.registry=d.registry; if(d.method) $('#methodText').value=d.method; if(d.agentExtra) $('#agentExtra').value=d.agentExtra; S.isDemo = !!(d.isDemo && S.registry.some(r=>(r.source||'').startsWith('Демо'))); renderAll(); toast('Состояние загружено'); }catch(err){ toast('Не удалось прочитать JSON: '+err.message); } }; rd.readAsText(f); e.target.value='';
|
||
};
|
||
|
||
/* ---------- demo data (synthetic) ---------- */
|
||
const DEMO_ROWS = [
|
||
{name:'АО "НК "КазМунайГаз"', period:'2025', debt:18.4, guar:2.1, ebitda:11.2, interest:3.4, equity:62.0, note:'демо'},
|
||
{name:'АО "НК "КТЖ"', period:'2025', debt:-4.2, guar:3.5, ebitda:9.8, interest:2.9, equity:58.0, note:'демо'},
|
||
{name:'АО "Казахтелеком"', period:'2025', debt:4.0, guar:0.0, ebitda:3.2, interest:1.1, equity:6.4, note:'демо'},
|
||
{name:'АО "KEGOC"', period:'2025', debt:12.8, guar:0.5, ebitda:4.4, interest:1.7, equity:8.1, note:'демо'},
|
||
{name:'АО «НАК «Казатомпром»', period:'2025', debt:15.1, guar:1.9, ebitda:7.6, interest:2.5, equity:10.2, note:'демо'},
|
||
{name:'АО "Казпочта"', period:'2025', debt:2.2, guar:0.1, ebitda:1.4, interest:0.5, equity:3.9, note:'демо'},
|
||
{name:'АО «Самрук-Энерго»', period:'2025', debt:34.0, guar:4.3, ebitda:12.6, interest:5.0, equity:13.5, note:'демо'},
|
||
{name:'ТОО "Samruk-Kazyna Ondeu"', period:'2025', debt:28.5, guar:0.0, ebitda:4.1, interest:0.3, equity:0.0, note:'демо'},
|
||
{name:'АО "НГК "Таукен Самрук"', period:'2025', debt:9.3, guar:1.2, ebitda:2.8, interest:1.0, equity:11.4, note:'демо'},
|
||
{name:'АО «НК «QazaqGaz»', period:'2025', debt:6.8, guar:0.7, ebitda:3.9, interest:1.4, equity:7.2, note:'демо'},
|
||
{name:'ТОО «ПГУ Туркестан»', period:'2025', debt:3.1, guar:0.2, ebitda:-0.6, interest:0.4, equity:2.0, note:'демо, EBITDA отрицательная'},
|
||
{name:'АО "ФНБ "Самрук-Казына"', period:'2025', debt:0.0, guar:0.0, ebitda:0.0, interest:0.0, equity:510.0, note:'демо, без EBITDA'},
|
||
{name:'Консолидированная группа 1', period:'2025', debt:132.0, guar:8.0, ebitda:25.0, interest:9.6, equity:180.0, note:'демо, консолидация'}
|
||
];
|
||
function loadDemo(){
|
||
DEMO_ROWS.forEach(r=>addRow({name:r.name, key:keyFor(r.name), period:r.period, source:'Демо (синтетика)', debt:r.debt, guar:r.guar, ebitda:r.ebitda, interest:r.interest, equity:r.equity, note:'Синтетические данные, '+r.note}));
|
||
S.isDemo = true; renderAll();
|
||
toast('Демо: синтетические данные — '+DEMO_ROWS.length+' компаний');
|
||
}
|
||
function toggleDemo(){ if(S.isDemo && S.registry.length === S.registry.filter(r=>(r.source||'').startsWith('Демо')).length){ S.registry=S.registry.filter(r=>!(r.source||'').startsWith('Демо')); S.isDemo=false; renderAll(); toast('Демо-данные убраны'); } else loadDemo(); }
|
||
$('#btnDemo').onclick = toggleDemo;
|
||
|
||
/* ---------- navigation ---------- */
|
||
function showStep(s){ $$('nav.steps button').forEach(b=>b.classList.toggle('on',b.dataset.step===s)); $$('section.step').forEach(x=>x.classList.toggle('on',x.id==='step-'+s)); }
|
||
function showSrc(s){ $$('.tabs button[data-src]').forEach(b=>b.classList.toggle('on',b.dataset.src===s)); ['suo','ifrs','manual'].forEach(x=>$('#src-'+x).classList.toggle('hidden',x!==s)); }
|
||
$$('nav.steps button').forEach(b=>b.onclick=()=>showStep(b.dataset.step));
|
||
$$('.tabs button[data-src]').forEach(b=>b.onclick=()=>showSrc(b.dataset.src));
|
||
$$('#unitSeg button').forEach(b=>b.onclick=()=>{ S.units=b.dataset.u; $$('#unitSeg button').forEach(x=>x.classList.toggle('on',x===b)); renderAll(); if(S.suo.preview&&S.suo.preview.length) renderSuoPreview(); });
|
||
function bindDrop(id, inputId, handler){
|
||
const d=$('#'+id); ['dragenter','dragover'].forEach(ev=>d.addEventListener(ev,e=>{e.preventDefault();d.classList.add('over');}));
|
||
['dragleave','drop'].forEach(ev=>d.addEventListener(ev,e=>{e.preventDefault();d.classList.remove('over');}));
|
||
d.addEventListener('drop',e=>handler([...e.dataTransfer.files]));
|
||
$('#'+inputId).addEventListener('change',e=>{handler([...e.target.files]); e.target.value='';});
|
||
}
|
||
|
||
/* ---------- импорт сводной книги КФУ (через KfuWorkbook из kfu-client.js) ---------- */
|
||
const MN_TO_BN = 1/1000;
|
||
function mlnToBn(v){ return isNum(v) ? +(v*MN_TO_BN).toFixed(6) : v; }
|
||
function normFromBook(nv){
|
||
if(nv == null) return null;
|
||
if(typeof nv === 'number') return nv;
|
||
if(nv && nv.text != null && !nv.err){ const n = parseFloat(nv.text); if(!isNaN(n)) return n; }
|
||
return null;
|
||
}
|
||
async function importKfuBook(file){
|
||
const st = $('#kfuBookStatus');
|
||
if(!window.KfuWorkbook){ st.textContent = 'kfu-client.js не загружен — импорт недоступен.'; return; }
|
||
if(!window.XLSX){ st.textContent = 'SheetJS не загружен — импорт недоступен.'; return; }
|
||
st.textContent = 'Читаю книгу…';
|
||
try{
|
||
const buf = await file.arrayBuffer();
|
||
const wb = XLSX.read(buf, {type:'array', cellFormula:false});
|
||
const EXPECT = ['долг','ebitda','bs'];
|
||
const names = (wb.SheetNames||[]).map(s=>s.toLowerCase());
|
||
const missing = EXPECT.filter(k=>!names.some(n=>n.includes(k)));
|
||
if(missing.length || !names.some(n=>n.includes('план'))) throw new Error('в файле нет ожидаемых листов (' + [missing.join(', '),'«План 2026»'].filter(Boolean).join(', ') + '): это не сводная книга КФУ');
|
||
const r = window.KfuWorkbook.analyzeWorkbook(wb);
|
||
S.isDemo = false;
|
||
if(!r.entries || !r.entries.length) throw new Error('в книге нет распознанных записей (листы компаний пустые?)');
|
||
const bad = r.entries.filter(x => x.debt==null && x.ebitda==null && x.equity==null && x.intExp==null);
|
||
if(bad.length) throw new Error('по ' + bad.length + ' компаниям не найдено ни одного показателя (' + bad.map(x=>x.name).slice(0,4).join(', ') + '…) — проверьте файл');
|
||
let added = 0, updated = 0;
|
||
for(const en of r.entries){
|
||
const key = keyFor(en.name) || stripForm(en.name);
|
||
const par = ensureParam(key, en.name);
|
||
if(en.norms){
|
||
par.t1 = normFromBook(en.norms.k1t) ?? par.t1;
|
||
par.p1 = normFromBook(en.norms.k1th) ?? par.p1;
|
||
par.t2 = normFromBook(en.norms.k2t) ?? par.t2;
|
||
par.p2 = normFromBook(en.norms.k2th) ?? par.p2;
|
||
par.t3 = normFromBook(en.norms.k3t) ?? par.t3;
|
||
par.p3 = normFromBook(en.norms.k3th) ?? par.p3;
|
||
}
|
||
const ex = S.registry.find(x => x.key===key && norm(x.period)===norm('4Q 2025'));
|
||
ex ? updated++ : added++;
|
||
const newRow = {
|
||
name: en.name, key,
|
||
period: '4Q 2025',
|
||
source: file.name,
|
||
debt: mlnToBn(en.debt),
|
||
guar: mlnToBn(en.other),
|
||
ebitda: mlnToBn(en.ebitda),
|
||
interest: mlnToBn(en.intExp),
|
||
equity: mlnToBn(en.equity),
|
||
note: 'Сводная книга КФУ 2025',
|
||
months: 12,
|
||
recon: r.recon ? {ok:r.recon.ok, total:r.recon.total} : null
|
||
};
|
||
if(ex){ Object.assign(ex, newRow); }
|
||
else S.registry.push(newRow);
|
||
}
|
||
renderAll();
|
||
const rc = r.recon ? ` · сверка базы ${r.recon.ok}/${r.recon.total}` : '';
|
||
const dg = (r.diag||[]).length ? ` · предупреждений: ${r.diag.length}` : '';
|
||
st.textContent = `Импортировано: ${added} новых, ${updated} обновлено${rc}${dg}. Источник: только загруженная книга.`;
|
||
document.querySelector('[data-step="reg"]')?.click();
|
||
}catch(e){
|
||
st.textContent = 'Ошибка импорта (реестр не изменён): ' + e.message;
|
||
}
|
||
}
|
||
$('#btnKfuBook').onclick = () => $('#kfuBookFile').click();
|
||
$('#kfuBookFile').onchange = async e => { if(e.target.files[0]) await importKfuBook(e.target.files[0]); e.target.value=''; };
|
||
|
||
/* ===================== СУО (SAP BPC) PARSER ===================== */
|
||
S.suo = { sheets:[], files:[], guar:{}, entities:[], preview:[] };
|
||
|
||
function classifySheet(name){ const n=norm(name); if(/^(долг|debt)/.test(n)) return 'debt'; if(/ebitda/.test(n)) return 'ebitda'; if(/^(bs|баланс|бб|balance)/.test(n)) return 'bs'; return null; }
|
||
function cleanLabel(s){ return norm(s).replace(/^[\d.\s\-–—)(]+/,'').trim(); }
|
||
|
||
const DEBT_COMPS = [
|
||
{id:'loans', rx:/^займы полученные/, name:'Займы'},
|
||
{id:'gov', rx:/^средства правительства/, name:'Займы Правительства РК и НБ РК'},
|
||
{id:'lease', rx:/^обязательства по (финансовой )?аренде/, name:'Обязательство по финансовой аренде'},
|
||
{id:'deriv', rx:/^производные финансовые инструменты/, name:'Производные финансовые инструменты'},
|
||
{id:'stake', rx:/^к уплате за приобретение доли/, name:'Задолженность за приобретение доли в проектах'},
|
||
{id:'clients', rx:/^привлеченные средства клиентов/, name:'Привлечённые средства клиентов (справочно)', ref:true},
|
||
];
|
||
const RX = {
|
||
debt: [/^долг$/, /^итого долг$/, /^итого долг/, /^долг\b/, /^debt$/],
|
||
equity: [/капитал и резервы/, /^итого капитал/, /^всего капитал/, /^капитал$/, /^total equity/],
|
||
interest: [/^процентные расходы/, /^расходы по вознагражден/, /^interest expense/],
|
||
full: [/полная ebitda/, /^ebitda$/, /^ebitda\b/],
|
||
items: [
|
||
{id:'rev', sign:+1, rx:/^выручка/, name:'Выручка (и процентные доходы)'},
|
||
{id:'sub', sign:+1, rx:/государствен\S* субсид|^субсидии/, name:'Государственные субсидии'},
|
||
{id:'cogs', sign:+1, rx:/^себестоимость/, name:'Себестоимость реализации'},
|
||
{id:'ga', sign:+1, rx:/^общие и административные/, name:'Общие и административные расходы'},
|
||
{id:'sell', sign:+1, rx:/^расходы по транспортировк|^расходы по реализации|^коммерческие расходы/, name:'Расходы по транспортировке и реализации'},
|
||
{id:'assoc', sign:+1, rx:/доля в (доход|прибыл|убытк).*ассоциирован/, name:'Доля в доходах ассоциированных компаний'},
|
||
{id:'jv', sign:+1, rx:/доля в (доход|прибыл|убытк).*совместн/, name:'Доля в доходах совместных предприятий'},
|
||
{id:'da_cogs',sign:-1, rx:/^износ.*себестоимост/, name:'Износ и обесценение в себестоимости'},
|
||
{id:'da_ga', sign:-1, rx:/^износ.*(административ|оар)/, name:'Износ и обесценение в ОАР'},
|
||
{id:'da_sell',sign:-1, rx:/^износ.*(транспортировк|реализац|ртр)/, name:'Износ и обесценение в РТР'},
|
||
]
|
||
};
|
||
|
||
function analyzeSheet(sh){
|
||
if(sh.meta) return sh.meta;
|
||
const aoa = sh.aoa, nR = aoa.length;
|
||
const lastCol = Math.max(0, ...aoa.map(r=>r?r.length:0)) - 1;
|
||
// entity row: the first row whose count of company names is close to the maximum (later period blocks repeat the same header)
|
||
const scores=[]; let maxScore=0;
|
||
for(let r=0;r<Math.min(nR,200);r++){
|
||
const row=aoa[r]||[]; let score=0;
|
||
for(let c=0;c<row.length;c++){ const v=row[c]; if(typeof v==='string' && v.trim() && matchCompany(v)) score++; }
|
||
scores.push(score); if(score>maxScore) maxScore=score;
|
||
}
|
||
const meta = { lastCol, ok:false };
|
||
if(maxScore<2){ meta.reason='строка наименований компаний не найдена (менее 2 известных компаний в одной строке)'; sh.meta=meta; return meta; }
|
||
const er = scores.findIndex(s=>s>=Math.max(2, Math.ceil(maxScore*0.85))), erow = aoa[er];
|
||
// first company column, then label column = column left of it whose text labels most often sit in rows with numbers in the company columns
|
||
const firstEc = erow.findIndex(v=>typeof v==='string' && matchCompany(v));
|
||
const ecAll = []; for(let c=firstEc;c<erow.length;c++){ if(typeof erow[c]==='string' && erow[c].trim()) ecAll.push(c); }
|
||
const hasNumAt = r => ecAll.some(c=>isNum(aoa[r]?.[c]));
|
||
let labelCol=Math.max(0,firstEc-1), bestStr=-1;
|
||
for(let c=0;c<firstEc;c++){ let s=0; for(let r=er+1;r<Math.min(nR,er+400);r++){ const v=aoa[r]?.[c]; if(typeof v==='string' && /[a-zа-яё]{3,}/i.test(v) && !/^\d/.test(v.trim()) && hasNumAt(r)) s++; } if(s>=bestStr && s>0){bestStr=s;labelCol=c;} }
|
||
// company columns: strings right of the label column; the column just left of the first company (group column) is included
|
||
const ecols = []; for(let c=labelCol+1;c<erow.length;c++){ if(typeof erow[c]==='string' && erow[c].trim()) ecols.push(c); }
|
||
meta.labelCol=labelCol;
|
||
// column blocks
|
||
let blocks=[];
|
||
for(let r=0;r<er;r++){ const row=aoa[r]||[]; for(let c=labelCol+1;c<row.length;c++){ const v=row[c]; if(typeof v==='string' && /базов|консолид|consolid|\bbase\b/i.test(v) && !matchCompany(v)) blocks.push({label:v.trim(), c0:c, r}); } }
|
||
blocks.sort((a,b)=>a.c0-b.c0);
|
||
blocks = blocks.filter((b,i)=>i===0 || b.c0!==blocks[i-1].c0);
|
||
if(blocks.length){ blocks.forEach((b,i)=>{ b.c1 = i<blocks.length-1 ? blocks[i+1].c0-1 : lastCol; }); }
|
||
else blocks=[{label:'Все столбцы', c0:labelCol+1, c1:lastCol, r:-1}];
|
||
// header rows above entity row (candidate filters)
|
||
const hdrRows=[];
|
||
for(let r=0;r<er;r++){ const vals=new Set(); const row=aoa[r]||[]; for(const c of ecols){ const v=row[c]; if(typeof v==='string' && v.trim() && !/базов|консолид/i.test(v)) vals.add(v.trim()); } if(vals.size>=1 && vals.size<=12 && ![...vals].some(v=>matchCompany(v))) hdrRows.push({r, vals:[...vals]}); }
|
||
// period blocks
|
||
const pblocks=[];
|
||
for(let r=0;r<nR;r++){ const row=aoa[r]||[]; for(let c=Math.max(0,labelCol-1);c<Math.min(row.length,labelCol+2);c++){ const v=row[c]; if(typeof v==='string' && /отч[её]т\s+за|report for/i.test(v)){ const m=v.match(/за\s+(.+)$/i)||v.match(/for\s+(.+)$/i); pblocks.push({label:(m?m[1]:v).trim(), r0:r}); break; } } }
|
||
if(pblocks.length){ pblocks.forEach((b,i)=>b.r1 = i<pblocks.length-1 ? pblocks[i+1].r0-1 : nR-1); }
|
||
else pblocks.push({label:'весь лист', r0:0, r1:nR-1});
|
||
// labelled rows with numbers
|
||
const rows=[];
|
||
for(let r=er+1;r<nR;r++){ const row=aoa[r]||[]; const lab=row[labelCol]; if(typeof lab!=='string'||!lab.trim()) continue; let hasNum=false; for(const c of ecols){ if(isNum(row[c])){hasNum=true;break;} } if(hasNum) rows.push({r, label:lab.trim(), cl:cleanLabel(lab), pb: pblocks.findIndex(b=>r>=b.r0&&r<=b.r1)}); }
|
||
Object.assign(meta,{ok:true, er, ecols, blocks, hdrRows, pblocks, rows});
|
||
sh.meta=meta; return meta;
|
||
}
|
||
function findRow(meta, rxList, pb){ for(const rx of rxList){ const hit = meta.rows.find(x=>(pb==null||x.pb===pb) && rx.test(x.cl)); if(hit) return hit; } return null; }
|
||
function periodInfo(label){
|
||
const M={jan:1,feb:2,mar:3,apr:4,may:5,jun:6,jul:7,aug:8,sep:9,oct:10,nov:11,dec:12,total:12,янв:1,фев:2,мар:3,апр:4,май:5,июн:6,июл:7,авг:8,сен:9,окт:10,ноя:11,дек:12};
|
||
const m = String(label).match(/(20\d{2})[.\s\-_\/]*([A-Za-zА-Яа-я]{3,5}|\d{1,2})/);
|
||
if(!m) return null;
|
||
const y=parseInt(m[1]); let mo = M[m[2].toLowerCase().slice(0,3)] ?? (M[m[2].toLowerCase()] ?? parseInt(m[2]));
|
||
if(!isNum(mo)||mo<1||mo>12) return {y};
|
||
return {y, m:mo, q:Math.ceil(mo/3), label: mo===12 ? String(y) : `${Math.ceil(mo/3)}Q ${y}`};
|
||
}
|
||
|
||
/* ---- loading ---- */
|
||
function suoLoadFiles(files){
|
||
files.forEach(f=>{
|
||
const rd=new FileReader();
|
||
rd.onload=()=>{
|
||
try{
|
||
const wb = XLSX.read(new Uint8Array(rd.result),{type:'array'});
|
||
S.suo.sheets = S.suo.sheets.filter(s=>s.file!==f.name);
|
||
wb.SheetNames.forEach(n=>{ const aoa=XLSX.utils.sheet_to_json(wb.Sheets[n],{header:1,raw:true,defval:null}); S.suo.sheets.push({id:f.name+'|'+n, file:f.name, name:n, kind:classifySheet(n), aoa, meta:null}); });
|
||
if(!S.suo.files.includes(f.name)) S.suo.files.push(f.name);
|
||
suoAfterLoad();
|
||
}catch(err){ toast('Ошибка чтения '+f.name+': '+err.message); }
|
||
};
|
||
rd.readAsArrayBuffer(f);
|
||
});
|
||
}
|
||
bindDrop('dropSuo','suoFiles',suoLoadFiles);
|
||
function fillSheetSelect(sel, kind){
|
||
const s=$('#'+sel); const cur=s.value;
|
||
s.innerHTML = '<option value="">—</option>' + S.suo.sheets.map(x=>`<option value="${esc(x.id)}">${esc(x.file)} → ${esc(x.name)}</option>`).join('');
|
||
const pref = S.suo.sheets.filter(x=>x.kind===kind).pop();
|
||
s.value = (cur && S.suo.sheets.some(x=>x.id===cur)) ? cur : (pref?pref.id:'');
|
||
}
|
||
function suoAfterLoad(){
|
||
$('#suoFileList').innerHTML = S.suo.files.map(f=>`<span>${esc(f)}</span>`).join('');
|
||
fillSheetSelect('selDebtSheet','debt'); fillSheetSelect('selEbSheet','ebitda'); fillSheetSelect('selBsSheet','bs');
|
||
suoAnalyze();
|
||
}
|
||
const sheetById = id => S.suo.sheets.find(x=>x.id===id);
|
||
function optList(sel, items, value){ const s=$('#'+sel); s.innerHTML=items.map(o=>`<option value="${esc(o.v)}">${esc(o.t)}</option>`).join(''); if(value!=null && items.some(o=>String(o.v)===String(value))) s.value=String(value); }
|
||
|
||
function suoAnalyze(){
|
||
const dsh=sheetById($('#selDebtSheet').value), esh=sheetById($('#selEbSheet').value), bsh=sheetById($('#selBsSheet').value);
|
||
const diag=[]; let anyOk=false;
|
||
const describe=(t,sh)=>{ if(!sh){ diag.push(`<div><b>${t}</b>: лист не выбран</div>`); return null; } const m=analyzeSheet(sh); if(!m.ok){ diag.push(`<div><b>${t}</b> (${esc(sh.name)}): <span style="color:var(--red)">${esc(m.reason)}</span></div>`); return null; } anyOk=true; diag.push(`<div><b>${t}</b> (${esc(sh.name)}): строка компаний <code>${m.er+1}</code>, столбцов с данными ${m.ecols.length}, блоки: ${m.blocks.map(b=>`<code>${esc(b.label)}</code>`).join(' ')}, периоды: ${m.pblocks.map(b=>`<code>${esc(b.label)}</code>`).join(' ')}</div>`); return m; };
|
||
const dm=describe('ДОЛГ',dsh), em=describe('EBITDA',esh), bm=describe('Баланс',bsh);
|
||
$('#suoDiag').innerHTML = diag.join('') || 'Загрузите файл.';
|
||
$('#suoDiag').classList.toggle('muted', !diag.length);
|
||
$('#suoMap').classList.toggle('hidden', !anyOk);
|
||
if(!anyOk){ $('#suoPreview').innerHTML=''; $('#btnSuoAdd').disabled=true; return; }
|
||
// blocks (from debt sheet, else first available)
|
||
const bm0 = dm||em||bm;
|
||
optList('selBlock', bm0.blocks.map((b,i)=>({v:i,t:b.label})), $('#selBlock').value || bm0.blocks.findIndex(b=>/базов/i.test(b.label)));
|
||
if($('#selBlock').value==='' ) $('#selBlock').value = 0;
|
||
// header filter rows
|
||
const hs = bm0.hdrRows.map(h=>({v:h.r, t:`стр. ${h.r+1}: ${h.vals.join(', ').slice(0,60)}`}));
|
||
const curH=$('#selHdrRow').value; optList('selHdrRow', [{v:'',t:'— без фильтра —'},...hs], curH);
|
||
suoFillHdrVals(bm0);
|
||
// debt/bs period + rows
|
||
if(dm){ optList('selDebtPeriod', dm.pblocks.map((b,i)=>({v:i,t:b.label})), $('#selDebtPeriod').value||0);
|
||
const pb=parseInt($('#selDebtPeriod').value)||0; const hit=findRow(dm,RX.debt,pb)||findRow(dm,RX.debt,null);
|
||
optList('selDebtRow', dm.rows.filter(x=>x.pb===pb).map(x=>({v:x.r,t:`стр. ${x.r+1}: ${x.label.slice(0,50)}`})), hit?hit.r:''); }
|
||
else { optList('selDebtPeriod',[{v:'',t:'—'}]); optList('selDebtRow',[{v:'',t:'—'}]); }
|
||
if(bm){ const hit=findRow(bm,RX.equity,null); optList('selEqRow', bm.rows.map(x=>({v:x.r,t:`стр. ${x.r+1}: ${x.label.slice(0,50)}`})), hit?hit.r:''); }
|
||
else optList('selEqRow',[{v:'',t:'—'}]);
|
||
if(em){
|
||
const pl = em.pblocks.map((b,i)=>({v:i,t:b.label}));
|
||
optList('selEbCur', pl, $('#selEbCur').value||0);
|
||
const cur = periodInfo(em.pblocks[parseInt($('#selEbCur').value)||0].label);
|
||
const none=[{v:'',t:'— не требуется (полный год) —'}];
|
||
let pfy='', pyt='';
|
||
if(cur && cur.m && cur.m<12){
|
||
pfy = em.pblocks.findIndex(b=>{const p=periodInfo(b.label); return p&&p.y===cur.y-1&&p.m===12;});
|
||
pyt = em.pblocks.findIndex(b=>{const p=periodInfo(b.label); return p&&p.y===cur.y-1&&p.m===cur.m;});
|
||
if(pfy<0) pfy=''; if(pyt<0) pyt='';
|
||
}
|
||
optList('selEbPfy', [...none,...pl], pfy); optList('selEbPyt', [...none,...pl], pyt);
|
||
const pb=parseInt($('#selEbCur').value)||0;
|
||
const fh=findRow(em,RX.full,pb); const ih=findRow(em,RX.interest,pb)||findRow(em,RX.interest,null);
|
||
const rl = em.rows.filter(x=>x.pb===pb).map(x=>({v:x.r,t:`стр. ${x.r+1}: ${x.label.slice(0,50)}`}));
|
||
optList('selEbRow', rl, fh?fh.r:''); optList('selIntRow', rl, ih?ih.r:'');
|
||
} else { ['selEbCur','selEbPfy','selEbPyt','selEbRow','selIntRow'].forEach(s=>optList(s,[{v:'',t:'—'}])); }
|
||
suoCompute();
|
||
}
|
||
function suoFillHdrVals(m){
|
||
const r=$('#selHdrRow').value; const s=$('#selHdrVal');
|
||
if(r===''){ s.innerHTML='<option value="">—</option>'; return; }
|
||
const h=m.hdrRows.find(x=>x.r===parseInt(r)); const cur=s.value;
|
||
s.innerHTML = (h?h.vals:[]).map(v=>`<option value="${esc(v)}">${esc(v)}</option>`).join('');
|
||
if(h && h.vals.includes(cur)) s.value=cur;
|
||
}
|
||
['selDebtSheet','selEbSheet','selBsSheet'].forEach(id=>$('#'+id).onchange=()=>{ suoAnalyze(); });
|
||
['selDebtPeriod','selEbCur'].forEach(id=>$('#'+id).onchange=()=>suoAnalyze());
|
||
$('#selHdrRow').onchange=()=>{ const sh=sheetById($('#selDebtSheet').value)||sheetById($('#selEbSheet').value); if(sh) suoFillHdrVals(analyzeSheet(sh)); suoCompute(); };
|
||
['selBlock','selHdrVal','selDebtRow','selEqRow','selEbMode','selEbRow','selIntRow','selEbPfy','selEbPyt','suoUnits','suoSigns','grpConsol'].forEach(id=>$('#'+id).onchange=()=>suoCompute());
|
||
|
||
/* ---- computation ---- */
|
||
function resolveBlock(sh, m, blockLabel, baseMeta){
|
||
let b = m.blocks.find(x=>norm(x.label)===norm(blockLabel)) || m.blocks.find(x=>norm(x.label).startsWith(norm(blockLabel).slice(0,5)));
|
||
if(b) return {b, inferred:false, warn:''};
|
||
// sheet without block labels but base sheet has several blocks: split columns by the repeating name pattern
|
||
if(m.blocks.length===1 && baseMeta && baseMeta.blocks.length>1){
|
||
const g=baseMeta.blocks.length, n=m.ecols.length;
|
||
if(n%g===0){
|
||
const size=n/g; const nm=c=>norm(sh.aoa[m.er][c]); let same=true;
|
||
for(let k=1;k<g&&same;k++) for(let i=0;i<size;i++){ if(nm(m.ecols[i])!==nm(m.ecols[k*size+i])){ same=false; break; } }
|
||
const idx=baseMeta.blocks.findIndex(x=>norm(x.label)===norm(blockLabel));
|
||
if(same && idx>=0) return {b:{label:blockLabel+' (по образцу)', c0:m.ecols[idx*size], c1:m.ecols[(idx+1)*size-1]}, inferred:true, warn:''};
|
||
}
|
||
return {b:m.blocks[0], inferred:false, warn:`лист ${sh.name}: блоки не размечены, просуммированы все столбцы`};
|
||
}
|
||
return {b:m.blocks[0], inferred:false, warn:''};
|
||
}
|
||
function colsFor(sh, m, entityName, blockLabel, hdrVal, baseMeta){
|
||
if($('#grpConsol').checked && matchCompany(entityName)==='группа'){ const cb=m.blocks.find(x=>/консолид/i.test(x.label)); if(cb) blockLabel=cb.label; }
|
||
const {b, warn} = resolveBlock(sh, m, blockLabel, baseMeta);
|
||
let cols = m.ecols.filter(c=>c>=b.c0 && c<=b.c1 && norm(sh.aoa[m.er][c])===norm(entityName));
|
||
if(hdrVal){ // find header row containing the value
|
||
const hr = m.hdrRows.find(h=>h.vals.includes(hdrVal));
|
||
if(hr) cols = cols.filter(c=>String(sh.aoa[hr.r]?.[c]??'').trim()===hdrVal);
|
||
}
|
||
return {cols, block:b.label, warn};
|
||
}
|
||
function sumRow(sh, r, cols){ let s=0, n=0; for(const c of cols){ const v=sh.aoa[r]?.[c]; if(isNum(v)){ s+=v; n++; } } return {s,n}; }
|
||
function ebitdaBottom(sh, m, pb, cols){
|
||
const parts=[]; let total=0, missing=[];
|
||
for(const it of RX.items){
|
||
const ov = S.suo.itemOverride||{};
|
||
const hit = (ov[it.id] ? m.rows.find(x=>x.pb===pb && norm(x.label)===norm(ov[it.id])) : null) || m.rows.find(x=>x.pb===pb && it.rx.test(x.cl));
|
||
if(!hit){ missing.push(it.name); parts.push({name:it.name, row:null, v:0, sign:it.sign}); continue; }
|
||
const {s} = sumRow(sh, hit.r, cols); total += it.sign*s; parts.push({name:it.name, row:hit.r+1, label:hit.label, v:s, sign:it.sign});
|
||
}
|
||
return {total, parts, missing};
|
||
}
|
||
function suoCompute(){
|
||
const dsh=sheetById($('#selDebtSheet').value), esh=sheetById($('#selEbSheet').value), bsh=sheetById($('#selBsSheet').value);
|
||
const dm=dsh&&analyzeSheet(dsh), em=esh&&analyzeSheet(esh), bm=bsh&&analyzeSheet(bsh);
|
||
const base = (dm&&dm.ok&&dm)||(em&&em.ok&&em)||(bm&&bm.ok&&bm); if(!base) return;
|
||
const K = parseFloat($('#suoUnits').value); const suoSigns = $('#suoSigns').value==='suo';
|
||
const sg = (v, negative) => (suoSigns && negative) ? -v/K : v/K;
|
||
const blockLabel = base.blocks[parseInt($('#selBlock').value)||0]?.label || base.blocks[0].label;
|
||
const hdrVal = $('#selHdrRow').value==='' ? '' : $('#selHdrVal').value;
|
||
// entity list from base sheet within selected block
|
||
const bb = base.blocks.find(x=>x.label===blockLabel)||base.blocks[0];
|
||
const baseSh = (dm&&dm.ok)?dsh:((em&&em.ok)?esh:bsh);
|
||
const names=[]; const seen=new Set();
|
||
for(const c of base.ecols){ if(c<bb.c0||c>bb.c1) continue; const v=String(baseSh.aoa[base.er][c]??'').trim(); const n=norm(v); if(!n||seen.has(n)) continue; seen.add(n); names.push(v); }
|
||
// rows
|
||
const debtRow = parseInt($('#selDebtRow').value), eqRow=parseInt($('#selEqRow').value), intRow=parseInt($('#selIntRow').value), ebRow=parseInt($('#selEbRow').value);
|
||
const ebMode=$('#selEbMode').value, pbCur=parseInt($('#selEbCur').value)||0, pbPfy=$('#selEbPfy').value===''?null:parseInt($('#selEbPfy').value), pbPyt=$('#selEbPyt').value===''?null:parseInt($('#selEbPyt').value);
|
||
const dPb = parseInt($('#selDebtPeriod').value)||0;
|
||
const curLabel = em&&em.ok ? em.pblocks[pbCur].label : (dm&&dm.ok ? dm.pblocks[dPb].label : '');
|
||
const pinfo = periodInfo(curLabel);
|
||
const months = pinfo&&pinfo.m ? pinfo.m : 12;
|
||
const ltmOk = months===12 || (pbPfy!=null && pbPyt!=null);
|
||
const out=[];
|
||
for(const name of names){
|
||
const key = matchCompany(name);
|
||
const rec = {name, key: key||stripForm(name), known:!!key, detail:{}, flags:[]};
|
||
// debt
|
||
if(dm&&dm.ok&&isNum(debtRow)){ const {cols,block,warn}=colsFor(dsh,dm,name,blockLabel,hdrVal,base); if(warn) rec.flags.push(warn); const {s,n}=sumRow(dsh,debtRow,cols); rec.debt = n?sg(s,true):null; rec.detail.debt=`лист ${dsh.name}, стр. ${debtRow+1} «${dm.rows.find(x=>x.r===debtRow)?.label||''}», блок «${block}», столбцов ${cols.length}${hdrVal?`, фильтр ${hdrVal}`:''}`; if(!n) rec.flags.push('долг: нет столбцов');
|
||
// структура долга по компонентам (как на листе «Группа Фонда» Сводного файла) и контроль по сумме
|
||
const comps={}; let compSum=0; const pbD = dm.rows.find(x=>x.r===debtRow)?.pb;
|
||
for(const cp of DEBT_COMPS){ let v=0; for(const row of dm.rows){ if(row.pb===pbD && row.r>debtRow && cp.rx.test(row.cl)){ v+=sumRow(dsh,row.r,cols).s; } } comps[cp.id]=sg(v,true); if(!cp.ref) compSum+=comps[cp.id]; }
|
||
rec.comps=comps; rec.compSum=compSum;
|
||
if(isNum(rec.debt) && n){ const diff=rec.debt-compSum; if(Math.abs(diff)>0.0005){ const cl=comps.clients||0; rec.flags.push(`контроль долга: строка «Долг» − сумма компонентов = ${nf2.format(diff*1000)} млн${Math.abs(diff-cl)<0.0005&&cl?' (= привлечённые средства клиентов)':''}`); } }
|
||
rec.detail.debt += `; компоненты: займы ${nf2.format((comps.loans||0)*1000)}, Правительство/НБ ${nf2.format((comps.gov||0)*1000)}, аренда ${nf2.format((comps.lease||0)*1000)}, ПФИ ${nf2.format((comps.deriv||0)*1000)}, доля в проектах ${nf2.format((comps.stake||0)*1000)} млн; клиентские средства (не в долге) ${nf2.format((comps.clients||0)*1000)} млн`; }
|
||
// equity
|
||
if(bm&&bm.ok&&isNum(eqRow)){ const {cols,block,warn}=colsFor(bsh,bm,name,blockLabel,hdrVal,base); if(warn) rec.flags.push(warn); const {s,n}=sumRow(bsh,eqRow,cols); rec.equity = n?sg(s,true):null; rec.detail.equity=`лист ${bsh.name}, стр. ${eqRow+1} «${bm.rows.find(x=>x.r===eqRow)?.label||''}», блок «${block}», столбцов ${cols.length}`; if(!n) rec.flags.push('капитал: нет столбцов'); }
|
||
// ebitda & interest
|
||
if(em&&em.ok){
|
||
const {cols,block,warn}=colsFor(esh,em,name,blockLabel,hdrVal,base); if(warn) rec.flags.push(warn);
|
||
const ebOf = pb => { if(ebMode==='bottom'){ const b=ebitdaBottom(esh,em,pb,cols); return {v:sg(b.total,true), parts:b.parts, missing:b.missing}; } else { const r = em.rows.find(x=>x.pb===pb && x.cl===em.rows.find(y=>y.r===ebRow)?.cl); if(!r) return {v:null,missing:['строка EBITDA не найдена в периоде']}; const {s,n}=sumRow(esh,r.r,cols); return {v:n?sg(s,true):null, missing:[]}; } };
|
||
const intOf = pb => { const lab=em.rows.find(y=>y.r===intRow)?.cl; const r=em.rows.find(x=>x.pb===pb && x.cl===lab); if(!r) return null; const {s,n}=sumRow(esh,r.r,cols); return n?sg(s,false):null; };
|
||
const e0=ebOf(pbCur); rec.ebitdaPeriod=e0.v; rec.ebParts=e0.parts; if(e0.missing?.length) rec.flags.push('EBITDA: не найдены '+e0.missing.join(', '));
|
||
let bridge=0; rec.interestPeriod = intOf(pbCur);
|
||
if(months<12 && pbPfy!=null && pbPyt!=null){ const a=ebOf(pbPfy).v, b=ebOf(pbPyt).v; bridge=(a??0)-(b??0); rec.ebitda=(e0.v??0)+bridge; const ia=intOf(pbPfy), ib=intOf(pbPyt); rec.interest=(rec.interestPeriod??0)+(ia??0)-(ib??0); rec.ltm=true; }
|
||
else { rec.ebitda=e0.v; rec.interest=rec.interestPeriod; rec.ltm = months===12; }
|
||
rec.bridge=bridge;
|
||
rec.detail.ebitda = `лист ${esh.name}, период «${curLabel}», ${ebMode==='bottom'?'снизу вверх: '+(e0.parts||[]).filter(p=>p.row).map(p=>`${p.sign>0?'+':'−'}${p.label} (стр. ${p.row})`).join('; '):'строка '+(em.rows.find(y=>y.r===ebRow)?.label||'')}, блок «${block}», столбцов ${cols.length}${months<12?(rec.ltm?`; LTM = период + ${em.pblocks[pbPfy].label} − ${em.pblocks[pbPyt].label}`:'; период '+months+' мес. без приведения к LTM'):''}`;
|
||
rec.detail.interest = `лист ${esh.name}, строка «${em.rows.find(y=>y.r===intRow)?.label||''}»${rec.ltm&&months<12?' с мостиком LTM':''}`;
|
||
if(!cols.length) rec.flags.push('EBITDA: нет столбцов');
|
||
}
|
||
rec.months = months; rec.period = pinfo?.label || curLabel;
|
||
if(!rec.known) rec.flags.push('компания не распознана');
|
||
if(!ltmOk && months<12) rec.flags.push('промежуточный период без LTM — укажите блоки прошлого года');
|
||
out.push(rec);
|
||
}
|
||
S.suo.preview = out; S.suo.period = pinfo?.label || curLabel;
|
||
// items diag
|
||
if(em&&em.ok&&$('#selEbMode').value==='bottom'){ const b=ebitdaBottom(esh,em,pbCur,[]); $('#ebItemsDiag').innerHTML = '<b>Статьи EBITDA (период текущий):</b> '+b.parts.map(p=>`<span style="white-space:nowrap">${p.row?`<code>${p.sign>0?'+':'−'} ${esc(p.name)} → стр. ${p.row}</code>`:`<code style="color:var(--red)">${esc(p.name)}: не найдена</code>`}</span>`).join(' '); }
|
||
else $('#ebItemsDiag').innerHTML='';
|
||
renderSuoPreview();
|
||
}
|
||
function renderSuoPreview(){
|
||
const t=$('#suoPreview'); const rows=S.suo.preview||[]; const u = S.units==='mn'?'млн ₸':'млрд ₸';
|
||
let h=`<tr><th></th><th>Компания</th><th class="num">Долг (СУО), ${u}</th><th class="num">Гарантии, млрд ₸</th><th class="num">Итого долг</th><th class="num">EBITDA период</th><th class="num">Мостик LTM</th><th class="num">EBITDA 12М</th><th class="num">% расходы 12М</th><th class="num">Капитал</th><th>Контроль</th></tr>`;
|
||
if(!rows.length) h+=`<tr><td colspan="11" class="muted">Нет данных.</td></tr>`;
|
||
for(const r of rows){
|
||
const g = S.suo.guar[norm(r.name)] ?? 0;
|
||
h+=`<tr><td><input type="checkbox" data-chk="${esc(r.name)}" ${r.known?'checked':''}></td><td><b>${esc(r.known?displayName(r.key,r.name):r.name)}</b>${r.known&&displayName(r.key)!==r.name?`<div class="small muted">${esc(r.name)}</div>`:''}</td>
|
||
<td class="num">${fmt(r.debt)}</td><td class="num"><input type="number" step="0.01" data-guar="${esc(r.name)}" value="${g}"></td><td class="num"><b>${fmt((r.debt||0)+g)}</b></td>
|
||
<td class="num">${fmt(r.ebitdaPeriod)}</td><td class="num">${r.months<12?fmt(r.bridge):'—'}</td><td class="num"><b>${fmt(r.ebitda)}</b></td><td class="num">${fmt(r.interest)}</td><td class="num">${fmt(r.equity)}</td>
|
||
<td class="small" style="color:${r.flags.length?'var(--red)':'var(--green)'}">${r.flags.length?esc(r.flags.join('; ')):'ок'}</td></tr>`;
|
||
}
|
||
t.innerHTML=h; $('#btnSuoAdd').disabled=!rows.length; $('#btnSuoAllYears').disabled=!rows.length;
|
||
$('#suoPeriodLabel').textContent = S.suo.period ? `Период: ${S.suo.period}` : '';
|
||
t.querySelectorAll('[data-guar]').forEach(i=>i.onchange=()=>{ const g=parseFloat(i.value)||0; S.suo.guar[norm(i.dataset.guar)]=g; const r=rows.find(x=>x.name===i.dataset.guar); const cell=i.closest('td').nextElementSibling; if(cell&&r) cell.innerHTML='<b>'+fmt((r.debt||0)+g)+'</b>'; });
|
||
}
|
||
$('#btnSuoAdd').onclick=()=>{
|
||
const chk = [...$('#suoPreview').querySelectorAll('[data-chk]:checked')].map(c=>c.dataset.chk);
|
||
let n=0;
|
||
for(const r of S.suo.preview){ if(!chk.includes(r.name)) continue; const g=S.suo.guar[norm(r.name)]??0;
|
||
addRow({name:r.name, key:r.key, period:r.period, source:'СУО', debt:r.debt, guar:g, ebitda:r.ebitda, interest:r.interest, equity:r.equity, months:r.months, ltm:r.ltm, comps:r.comps, note:r.flags.filter(f=>!/не распознана/.test(f)).join('; '), detail:{...r.detail, guar: g?'ручной ввод':''}}); n++; }
|
||
toast(`Добавлено: ${n}`); showStep('reg');
|
||
};
|
||
$('#btnSuoAllYears').onclick=()=>{
|
||
const years={};
|
||
for(const sh of S.suo.sheets){ const m=sh.name.match(/(20\d{2})/); if(!m||!sh.kind) continue; (years[m[1]]=years[m[1]]||{})[sh.kind]=sh.id; }
|
||
const list=Object.entries(years).filter(([y,o])=>o.debt&&o.ebitda&&o.bs).sort();
|
||
if(!list.length){ toast('Не найдены тройки листов ДОЛГ/EBITDA/Баланс с годом в названии'); return; }
|
||
const chk=new Set([...$('#suoPreview').querySelectorAll('[data-chk]:checked')].map(c=>norm(c.dataset.chk)));
|
||
const save={d:$('#selDebtSheet').value,e:$('#selEbSheet').value,b:$('#selBsSheet').value};
|
||
const curPeriod=S.suo.period; let n=0;
|
||
for(const [y,o] of list){
|
||
$('#selDebtSheet').value=o.debt; $('#selEbSheet').value=o.ebitda; $('#selBsSheet').value=o.bs; suoAnalyze();
|
||
for(const r of S.suo.preview){ if(!(r.known||chk.has(norm(r.name)))) continue;
|
||
const same = S.suo.period===curPeriod; const g = same ? (S.suo.guar[norm(r.name)]??0) : 0;
|
||
addRow({name:r.name, key:r.key, period:r.period, source:'СУО', debt:r.debt, guar:g, ebitda:r.ebitda, interest:r.interest, equity:r.equity, months:r.months, ltm:r.ltm, comps:r.comps, note:[...r.flags.filter(f=>!/не распознана/.test(f)), same?'':'гарантии не введены'].filter(Boolean).join('; '), detail:{...r.detail, guar:g?'ручной ввод':''}}); n++; }
|
||
}
|
||
$('#selDebtSheet').value=save.d; $('#selEbSheet').value=save.e; $('#selBsSheet').value=save.b; suoAnalyze();
|
||
toast(`Добавлено: ${n}`); showStep('reg');
|
||
};
|
||
|
||
/* ===================== AI PROXY (server) ===================== */
|
||
async function callClaude(messages, system){
|
||
const body = { max_tokens:4096, messages };
|
||
if(system) body.system = system;
|
||
const res = await fetch('api/ai', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) });
|
||
const data = await res.json().catch(()=>({}));
|
||
if(!res.ok || data.error) throw new Error(data.error || ('сервер вернул ' + res.status));
|
||
const text = data.content || '';
|
||
if(!text) throw new Error('ИИ вернул пустой ответ');
|
||
return data.truncated ? text + '\n\n[ответ обрезан по длине — уточните запрос или разбейте его на части]' : text;
|
||
}
|
||
function parseJson(text){
|
||
let t = String(text||'').replace(/```json|```/g,'').trim();
|
||
const a=t.indexOf('{'), b=t.lastIndexOf('}'); if(a>=0&&b>a) t=t.slice(a,b+1);
|
||
return JSON.parse(t);
|
||
}
|
||
function fileToBase64(f){ return new Promise((res,rej)=>{ const r=new FileReader(); r.onload=()=>res(r.result.split(',')[1]); r.onerror=()=>rej(new Error('не удалось прочитать '+f.name)); r.readAsDataURL(f); }); }
|
||
function fileToArray(f){ return new Promise((res,rej)=>{ const r=new FileReader(); r.onload=()=>res(new Uint8Array(r.result)); r.onerror=()=>rej(new Error('не удалось прочитать '+f.name)); r.readAsArrayBuffer(f); }); }
|
||
|
||
/* ===================== METHODOLOGY (default text) ===================== */
|
||
const METHOD_DEFAULT = `Долг — обязательства по заимствованиям по консолидированной отчётности МСФО самой компании, без элиминирования внутригрупповых займов (в том числе от Фонда): банковские кредиты и займы, выпущенные облигации (долгосрочная и краткосрочная части, включая начисленное вознаграждение, если оно учтено в составе займов), прочие процентные обязательства. Отдельно показать: обязательства по аренде, обязательства по РЕПО, выданные гарантии и поручительства за третьих лиц. Не включать кредиторскую задолженность, обязательства по деривативам, отложенные налоги, резервы.
|
||
|
||
EBITDA (Приложение 1 Политики) — за 12 месяцев: выручка + государственные субсидии − себестоимость − общие и административные расходы − расходы по транспортировке и реализации + доля в прибыли ассоциированных и совместных предприятий + износ, амортизация и обесценение. Не включать: курсовые разницы, финансовые доходы и расходы, прочие неоперационные доходы и расходы, результат выбытия дочерних организаций. Для компаний финансового сегмента (банковская, почтово-финансовая деятельность) процентные доходы от основной деятельности включаются в выручку, процентные расходы по клиентским средствам — в себестоимость.
|
||
|
||
Процентные расходы — расходы по вознаграждению за 12 месяцев по кредитам, займам и облигациям. Отдельно показать: проценты по аренде, амортизацию дисконта (unwinding) по резервам и обязательствам, проценты по РЕПО.
|
||
|
||
Капитал — итого собственный капитал по балансу, включая неконтролирующие доли (НКД показать отдельно).`;
|
||
$('#methodText').value = METHOD_DEFAULT;
|
||
|
||
/* ===================== IFRS EXTRACTION ===================== */
|
||
bindDrop('dropIfrs','ifrsFiles',files=>{ S.ifrs.files.push(...files); renderIfrsFiles(); });
|
||
function renderIfrsFiles(){ $('#ifrsFileList').innerHTML = S.ifrs.files.map((f,i)=>`<span>${esc(f.name)} <a href="#" data-rm="${i}" style="text-decoration:none">×</a></span>`).join(''); $('#ifrsFileList').querySelectorAll('[data-rm]').forEach(a=>a.onclick=e=>{e.preventDefault(); S.ifrs.files.splice(parseInt(a.dataset.rm),1); renderIfrsFiles();}); }
|
||
|
||
const EXTRACT_SCHEMA = `{"company":"","period_end":"ГГГГ-ММ-ДД","period_months":12,"units":"тыс.|млн|млрд","currency":"KZT",
|
||
"debt":{"loans_noncurrent":0,"loans_current":0,"bonds_noncurrent":0,"bonds_current":0,"other_interest_bearing":0,"lease_noncurrent":0,"lease_current":0,"repo":0,"guarantees_issued":0},
|
||
"ebitda":{"revenue":0,"subsidies":0,"cost_of_sales":0,"ga_expenses":0,"selling_expenses":0,"share_of_associates_jv":0,"da_and_impairment":0,"operating_profit_reported":null,"interest_income_core_not_in_revenue":0},
|
||
"interest":{"interest_on_borrowings":0,"lease_interest":0,"unwinding_discount":0,"repo_interest":0,"finance_costs_total":0},
|
||
"equity":{"total_equity":0,"nci":0},
|
||
"sources":["форма или страница — статья"],"flags":["допущения и риски, кратко"]}`;
|
||
|
||
$('#btnExtract').onclick = async () => {
|
||
const files = S.ifrs.files, text = $('#ifrsText').value.trim();
|
||
if(!files.length && !text){ toast('Добавьте файл или текст отчётности'); return; }
|
||
const st=$('#extractStatus'); st.innerHTML='<span class="spin"></span>читаю отчётность…'; $('#btnExtract').disabled=true;
|
||
try{
|
||
const content=[];
|
||
for(const f of files){
|
||
const ext=f.name.toLowerCase().split('.').pop();
|
||
if(ext==='pdf'){ if(f.size>32*1024*1024) throw new Error(f.name+': PDF больше 32 МБ — разделите файл'); content.push({type:'file', file:'pdf', name:f.name, data: await fileToBase64(f)}); }
|
||
else if(['png','jpg','jpeg'].includes(ext)){ if(f.size>10*1024*1024) throw new Error(f.name+': изображение больше 10 МБ'); content.push({type:'file', file:'image', media: ext==='png'?'image/png':'image/jpeg', name:f.name, data: await fileToBase64(f)}); }
|
||
else if(['xlsx','xls','xlsm'].includes(ext)){ const wb=XLSX.read(await fileToArray(f),{type:'array'}); let txt=''; for(const n of wb.SheetNames){ txt+=`\n=== Лист: ${n} ===\n`+XLSX.utils.sheet_to_csv(wb.Sheets[n],{FS:';'}).replace(/;{3,}/g,';;').slice(0,60000); if(txt.length>150000){ txt+='\n[обрезано]'; break; } } content.push({type:'text', text:`Файл ${f.name} (Excel):\n${txt}`}); }
|
||
}
|
||
if(text) content.push({type:'text', text:'Фрагменты отчётности:\n'+text});
|
||
const hint=$('#ifrsHint').value.trim(), comp=$('#ifrsCompany').value.trim();
|
||
content.push({type:'text', text:`Извлеки показатели для расчёта КФУ (К1 Долг/EBITDA, К2 EBITDA/Проценты, К3 Долг/Капитал) по приложенной отчётности${comp?` компании «${comp}»`:''}.
|
||
Методика:
|
||
${$('#methodText').value}
|
||
${hint?'Указание: '+hint+'\n':''}
|
||
Правила: берём последнюю отчётную дату из документа; для баланса — значения на отчётную дату, для ОПиУ — за период с начала года (period_months = число месяцев периода); все суммы — в единицах отчётности (units), расходы и обязательства — положительными числами; если статьи нет — 0, если определить нельзя — null и пояснение во flags; da_and_impairment — износ, амортизация и обесценение за период (из примечаний или ОДДС); в sources — где найдена каждая величина.
|
||
Ответь только валидным JSON строго по схеме, без markdown и пояснений:
|
||
${EXTRACT_SCHEMA}`});
|
||
const raw = await callClaude([{role:'user', content}], 'Ты — аналитик Департамента корпоративных финансов. Отвечаешь только JSON.');
|
||
let js; try{ js=parseJson(raw); }catch(e){ throw new Error('ИИ вернул не JSON: '+raw.slice(0,300)); }
|
||
S.ifrs.extracted = js; renderIfrsReview(js); st.textContent='Готово — проверьте значения ниже.';
|
||
}catch(err){ st.innerHTML=`<span style="color:var(--red)">${esc(err.message)}</span>`; }
|
||
finally{ $('#btnExtract').disabled=false; }
|
||
};
|
||
|
||
function unitFactor(u){ const n=norm(u); if(/тыс|thous/.test(n)) return 1e-6; if(/млн|mln|mill/.test(n)) return 1e-3; if(/млрд|bn|bill/.test(n)) return 1; return 1e-6; }
|
||
function renderIfrsReview(js){
|
||
const d=js.debt||{}, e=js.ebitda||{}, it=js.interest||{}, q=js.equity||{};
|
||
const g = v => isNum(v)?v:(v==null?0:parseFloat(v)||0);
|
||
const pm = g(js.period_months)||12;
|
||
const compName = js.company || $('#ifrsCompany').value || '';
|
||
const R = (id,label,val,sign='+',chk)=>`<span>${chk?`<label class="chk"><input type="checkbox" id="c_${id}" ${chk==='on'?'checked':''}>${esc(label)}</label>`:esc(label)}</span><input type="number" step="0.001" id="v_${id}" value="${g(val)}"><span class="muted">${sign}</span>`;
|
||
$('#ifrsReview').innerHTML = `
|
||
<div class="row">
|
||
<div class="field"><label>Компания</label><input type="text" id="r_company" list="companyList" value="${esc(compName)}" style="width:220px"></div>
|
||
<div class="field"><label>Отчётная дата</label><input type="text" id="r_period" value="${esc(js.period_end||'')}" style="width:140px"></div>
|
||
<div class="field"><label>Месяцев в периоде ОПиУ</label><input type="number" id="r_months" value="${pm}" style="width:80px"></div>
|
||
<div class="field"><label>Единицы отчётности</label><select id="r_units"><option value="тыс." ${/тыс/.test(js.units||'')?'selected':''}>тыс.</option><option value="млн" ${/млн/.test(js.units||'')?'selected':''}>млн</option><option value="млрд" ${/млрд/.test(js.units||'')?'selected':''}>млрд</option></select></div>
|
||
<div class="field"><label>Валюта</label><input type="text" id="r_cur" value="${esc(js.currency||'KZT')}" style="width:70px"></div>
|
||
</div>
|
||
<div class="grid2" style="margin-top:14px">
|
||
<div>
|
||
<h3>Долг</h3>
|
||
<div class="comp"><span class="h">Статья</span><span class="h">Значение</span><span class="h"></span>
|
||
${R('ln','Займы долгосрочные',d.loans_noncurrent)}${R('lc','Займы краткосрочные',d.loans_current)}${R('bn','Облигации долгосрочные',d.bonds_noncurrent)}${R('bc','Облигации краткосрочные',d.bonds_current)}${R('oi','Прочие процентные обязательства',d.other_interest_bearing)}
|
||
${R('lsn','Аренда долгосрочная',d.lease_noncurrent,'+','off')}${R('lsc','Аренда краткосрочная',d.lease_current,'+','off')}${R('rp','РЕПО',d.repo,'+','off')}
|
||
<span class="total">Долг</span><span class="total" id="t_debt"></span><span></span>
|
||
${R('gu','Гарантии выданные (в итого долг)',d.guarantees_issued,'+','on')}
|
||
</div>
|
||
<h3 style="margin-top:14px">Капитал</h3>
|
||
<div class="comp">${R('eq','Итого капитал',q.total_equity)}${R('nci','минус НКД',q.nci,'−','off')}<span class="total">Капитал</span><span class="total" id="t_eq"></span><span></span></div>
|
||
</div>
|
||
<div>
|
||
<h3>EBITDA</h3>
|
||
<div class="row" style="margin-bottom:6px"><label class="chk"><input type="radio" name="ebm" value="bottom" checked>по статьям ОПиУ</label><label class="chk"><input type="radio" name="ebm" value="op">от операционной прибыли</label></div>
|
||
<div class="comp">
|
||
${R('rev','Выручка',e.revenue)}${R('sub','Госсубсидии',e.subsidies)}${R('cogs','Себестоимость',e.cost_of_sales,'−')}${R('ga','ОАР',e.ga_expenses,'−')}${R('sell','Расходы по реализации',e.selling_expenses,'−')}
|
||
${R('op','Операционная прибыль (отчётная)',e.operating_profit_reported)}
|
||
${R('as','Доля в ассоц./СП',e.share_of_associates_jv,'+','on')}${R('da','Износ, амортизация, обесценение',e.da_and_impairment)}${R('ii','Процентные доходы осн. деятельности (финсегмент, не в выручке)',e.interest_income_core_not_in_revenue,'+','off')}
|
||
<span class="total">EBITDA за период</span><span class="total" id="t_ebp"></span><span></span>
|
||
</div>
|
||
<h3 style="margin-top:14px">Процентные расходы</h3>
|
||
<div class="comp">${R('ib','По займам и облигациям',it.interest_on_borrowings)}${R('il','По аренде',it.lease_interest,'+','off')}${R('iu','Амортизация дисконта',it.unwinding_discount,'+','off')}${R('ir','По РЕПО',it.repo_interest,'+','off')}${R('ift','Финансовые расходы итого (справочно)',it.finance_costs_total,'')}
|
||
<span class="total">Проценты за период</span><span class="total" id="t_intp"></span><span></span></div>
|
||
</div>
|
||
</div>
|
||
<div id="ltmBox" class="panel" style="margin-top:14px;background:#FBFCFB">
|
||
<h3>Приведение к 12 месяцам</h3>
|
||
<p class="small muted">Период короче года. Введите значения прошлого года в млрд ₸ (LTM = период + прошлый год − прошлый год за тот же период) или аннуализируйте.</p>
|
||
<div class="row">
|
||
<div class="field"><label>EBITDA прошлый год (12М)</label><input type="number" step="0.01" id="l_epfy"></div>
|
||
<div class="field"><label>EBITDA прошлый год — тот же период</label><input type="number" step="0.01" id="l_epyt"></div>
|
||
<div class="field"><label>Проценты прошлый год (12М)</label><input type="number" step="0.01" id="l_ipfy"></div>
|
||
<div class="field"><label>Проценты прошлый год — тот же период</label><input type="number" step="0.01" id="l_ipyt"></div>
|
||
<label class="chk"><input type="checkbox" id="l_ann">аннуализировать ×12/n (если данных прошлого года нет)</label>
|
||
</div>
|
||
</div>
|
||
<div class="note" style="margin-top:12px"><b>Итог, млрд ₸:</b> долг <b id="s_debt"></b> · гарантии <b id="s_guar"></b> · EBITDA 12М <b id="s_eb"></b> · проценты 12М <b id="s_int"></b> · капитал <b id="s_eq"></b> → К1 <b id="s_k1"></b>, К2 <b id="s_k2"></b>, К3 <b id="s_k3"></b></div>
|
||
${(js.flags||[]).length?`<div class="note warn"><b>Замечания ИИ:</b><ul class="flags small">${(js.flags||[]).map(f=>`<li>${esc(f)}</li>`).join('')}</ul></div>`:''}
|
||
${(js.sources||[]).length?`<div class="small muted" style="margin-top:8px"><b>Источники:</b> ${(js.sources||[]).map(esc).join('; ')}</div>`:''}
|
||
<div class="row" style="margin-top:14px"><button class="primary" id="btnIfrsAdd">Добавить в реестр</button><span class="small muted">Значения сохраняются с детализацией статей в лист «Аудит».</span></div>`;
|
||
$('#ifrsReviewPanel').classList.remove('hidden');
|
||
$('#ifrsReview').querySelectorAll('input,select').forEach(x=>x.addEventListener('input',ifrsRecalc));
|
||
$('#btnIfrsAdd').onclick = ifrsAdd;
|
||
ifrsRecalc();
|
||
}
|
||
function ifrsTotals(){
|
||
const v = id => parseFloat($('#v_'+id)?.value)||0, on = id => !!$('#c_'+id)?.checked;
|
||
const f = unitFactor($('#r_units').value);
|
||
const debt = (v('ln')+v('lc')+v('bn')+v('bc')+v('oi') + (on('lsn')?v('lsn'):0) + (on('lsc')?v('lsc'):0) + (on('rp')?v('rp'):0))*f;
|
||
const guar = (on('gu')?v('gu'):0)*f;
|
||
const mode = document.querySelector('input[name=ebm]:checked')?.value||'bottom';
|
||
const core = mode==='bottom' ? v('rev')+v('sub')-v('cogs')-v('ga')-v('sell') : v('op');
|
||
const ebp = (core + (on('as')?v('as'):0) + v('da') + (on('ii')?v('ii'):0))*f;
|
||
let intp = (v('ib') + (on('il')?v('il'):0) + (on('iu')?v('iu'):0) + (on('ir')?v('ir'):0));
|
||
if(intp===0 && v('ift')>0) intp = v('ift');
|
||
intp *= f;
|
||
const eq = (v('eq') - (on('nci')?v('nci'):0))*f;
|
||
const months = parseInt($('#r_months').value)||12;
|
||
let eb=ebp, intr=intp, ltm=months===12, how='';
|
||
if(months<12){
|
||
const n = id => { const x=$('#'+id).value; return x===''?null:parseFloat(x); };
|
||
const epfy=n('l_epfy'), epyt=n('l_epyt'), ipfy=n('l_ipfy'), ipyt=n('l_ipyt');
|
||
if(epfy!=null && epyt!=null){ eb = ebp + epfy - epyt; ltm=true; how='LTM по мостику'; }
|
||
else if($('#l_ann').checked){ eb = ebp*12/months; ltm=true; how='аннуализация ×12/'+months; }
|
||
if(ipfy!=null && ipyt!=null) intr = intp + ipfy - ipyt; else if($('#l_ann').checked) intr = intp*12/months;
|
||
}
|
||
return {debt,guar,ebp,intp,eb,intr,eq,months,ltm,how,mode,f};
|
||
}
|
||
function ifrsRecalc(){
|
||
const t=ifrsTotals();
|
||
$('#ltmBox').classList.toggle('hidden', t.months>=12);
|
||
const u = v => nf2.format(v);
|
||
$('#t_debt').textContent = u(t.debt/t.f); $('#t_eq').textContent=u(t.eq/t.f); $('#t_ebp').textContent=u(t.ebp/t.f); $('#t_intp').textContent=u(t.intp/t.f);
|
||
$('#s_debt').textContent=u(t.debt); $('#s_guar').textContent=u(t.guar); $('#s_eb').textContent=u(t.eb)+(t.how?` (${t.how})`:''); $('#s_int').textContent=u(t.intr); $('#s_eq').textContent=u(t.eq);
|
||
const x = ratios({debt:t.debt,guar:t.guar,ebitda:t.eb,interest:t.intr,equity:t.eq});
|
||
$('#s_k1').textContent = x.ebNeg?'EBITDA≤0':fmtX(x.k1); $('#s_k2').textContent = x.ebNeg?'EBITDA≤0':(x.k2===Infinity?'∞':fmtX(x.k2)); $('#s_k3').textContent=fmtX(x.k3);
|
||
}
|
||
function ifrsAdd(){
|
||
const t=ifrsTotals(); const name=$('#r_company').value.trim(); if(!name){ toast('Укажите компанию'); return; }
|
||
const v = id => parseFloat($('#v_'+id)?.value)||0, on = id => !!$('#c_'+id)?.checked;
|
||
const per = $('#r_period').value.trim();
|
||
const pi = per.match(/(\d{4})-(\d{2})/); const plabel = pi ? `${Math.ceil(parseInt(pi[2])/3)}Q ${pi[1]}` : per;
|
||
const cur=$('#r_cur').value.trim();
|
||
const flags=[]; if(cur && !/kzt|тенге|₸/i.test(cur)) flags.push('валюта отчётности '+cur+' — расчёт должен быть в тенге');
|
||
if(t.months<12 && !t.ltm) flags.push(`период ${t.months} мес. без LTM`);
|
||
const detail = {
|
||
debt:`займы ${v('ln')}+${v('lc')}, облигации ${v('bn')}+${v('bc')}, прочие ${v('oi')}${on('lsn')||on('lsc')?`, аренда ${v('lsn')}+${v('lsc')}`:''}${on('rp')?`, РЕПО ${v('rp')}`:''} (${$('#r_units').value})`,
|
||
guar: on('gu')?`гарантии выданные ${v('gu')} (${$('#r_units').value})`:'',
|
||
ebitda:`${t.mode==='bottom'?`выручка ${v('rev')} + субсидии ${v('sub')} − с/с ${v('cogs')} − ОАР ${v('ga')} − реализация ${v('sell')}`:`операционная прибыль ${v('op')}`}${on('as')?` + ассоц./СП ${v('as')}`:''} + D&A ${v('da')}${on('ii')?` + процентные доходы осн. деят. ${v('ii')}`:''}${t.how?'; '+t.how:''}`,
|
||
interest:`по займам ${v('ib')}${on('il')?` + аренда ${v('il')}`:''}${on('iu')?` + дисконт ${v('iu')}`:''}${on('ir')?` + РЕПО ${v('ir')}`:''}`,
|
||
equity:`итого капитал ${v('eq')}${on('nci')?` − НКД ${v('nci')}`:''}`
|
||
};
|
||
addRow({name, key:keyFor(name), period:plabel, source:'МСФО (ИИ)', debt:t.debt, guar:t.guar, ebitda:t.eb, interest:t.intr, equity:t.eq, months:t.months, ltm:t.ltm, note:flags.join('; '), detail});
|
||
toast('Добавлено в реестр'); showStep('reg');
|
||
}
|
||
|
||
/* ===================== AI ROW MAPPING FOR СУО ===================== */
|
||
$('#btnAiMap').onclick = async () => {
|
||
const dsh=sheetById($('#selDebtSheet').value), esh=sheetById($('#selEbSheet').value), bsh=sheetById($('#selBsSheet').value);
|
||
const labs = (sh, pbOnly) => { if(!sh) return []; const m=analyzeSheet(sh); if(!m.ok) return []; const pb = pbOnly==null?null:pbOnly; return [...new Set(m.rows.filter(x=>pb==null||x.pb===pb).map(x=>x.label))].slice(0,250); };
|
||
const dl=labs(dsh, parseInt($('#selDebtPeriod').value)||0), el=labs(esh, parseInt($('#selEbCur').value)||0), bl=labs(bsh,null);
|
||
if(!dl.length&&!el.length&&!bl.length){ toast('Нет распознанных строк'); return; }
|
||
const b=$('#btnAiMap'); b.disabled=true; b.innerHTML='<span class="spin"></span>подбираю…';
|
||
try{
|
||
const prompt = `Ниже — наименования строк из выгрузки СУО (SAP BPC). Сопоставь их с показателями методики КФУ. Верни только JSON, значения — точные строки из списков (или null, если строки нет):
|
||
{"debt_row":"...", "equity_row":"...", "interest_row":"...", "full_ebitda_row":"...", "items":{"rev":"выручка","sub":"государственные субсидии","cogs":"себестоимость","ga":"общие и административные расходы","sell":"расходы по транспортировке и реализации","assoc":"доля в доходах ассоциированных компаний","jv":"доля в доходах совместных предприятий","da_cogs":"износ и обесценение в себестоимости","da_ga":"износ и обесценение в ОАР","da_sell":"износ и обесценение в расходах по реализации"}}
|
||
Строки листа ДОЛГ:\n${dl.join('\n')}\n\nСтроки листа EBITDA (текущий период):\n${el.join('\n')}\n\nСтроки листа Баланс:\n${bl.join('\n')}`;
|
||
const js = parseJson(await callClaude([{role:'user',content:prompt}], 'Отвечай только JSON.'));
|
||
const setSel=(id,label)=>{ if(!label) return false; const s=$('#'+id); const o=[...s.options].find(x=>norm(x.text.replace(/^стр\. \d+: /,''))===norm(label)); if(o){ s.value=o.value; return true; } return false; };
|
||
let n=0; if(setSel('selDebtRow',js.debt_row)) n++; if(setSel('selEqRow',js.equity_row)) n++; if(setSel('selIntRow',js.interest_row)) n++; if(setSel('selEbRow',js.full_ebitda_row)) n++;
|
||
S.suo.itemOverride = {}; for(const [k,v] of Object.entries(js.items||{})){ if(v) { S.suo.itemOverride[k]=v; n++; } }
|
||
suoCompute(); toast(`ИИ сопоставил строк: ${n}`);
|
||
}catch(err){ toast('Не удалось: '+err.message); }
|
||
finally{ b.disabled=false; b.textContent='Подобрать строки через ИИ'; }
|
||
};
|
||
|
||
/* ===================== AGENT CHAT ===================== */
|
||
function stressRows(){
|
||
return S.registry.map(r=>({компания: displayName(r.key,r.name), период:r.period, ...stressRow(r)}));
|
||
}
|
||
function agentContext(){
|
||
const rows = S.registry.map(r=>{ const c=calcRow(r); return {
|
||
компания: displayName(r.key,r.name), период:r.period, источник:r.source,
|
||
долг_млрд:+((r.debt||0)).toFixed(3), гарантии_млрд:+((r.guar||0)).toFixed(3), итого_долг_млрд:+c.debt.toFixed(3),
|
||
ebitda_12м_млрд: isNum(r.ebitda)?+r.ebitda.toFixed(3):null, проценты_12м_млрд:isNum(r.interest)?+r.interest.toFixed(3):null, капитал_млрд:isNum(r.equity)?+r.equity.toFixed(3):null,
|
||
К1: c.ebNeg?'EBITDA<=0':(isNum(c.k1)?+c.k1.toFixed(3):null), К2: c.ebNeg?'EBITDA<=0':(c.k2===Infinity?'inf':(isNum(c.k2)?+c.k2.toFixed(3):null)), К3: isNum(c.k3)?+c.k3.toFixed(3):null,
|
||
цели:{К1:c.p.t1,К2:c.p.t2,К3:c.p.t3}, пороги:{К1:c.p.p1,К2:c.p.p2,К3:c.p.p3}, зоны:{К1:c.z1,К2:c.z2,К3:c.z3,итог:c.worst},
|
||
месяцев:r.months||12, ltm:r.ltm!==false, примечание:r.note||'' }; });
|
||
return JSON.stringify(rows);
|
||
}
|
||
function agentSystem(){
|
||
return `Ты — аналитик Департамента корпоративных финансов АО «Самрук-Қазына». Помогаешь с расчётом коэффициентов финансовой устойчивости (КФУ) портфельных компаний по Политике финансовой устойчивости: К1 = Долг/EBITDA (не более цели/порога), К2 = EBITDA/Процентные расходы (не менее), К3 = Долг/Капитал (не более). Зона: зелёная — все коэффициенты в пределах целевых; жёлтая — между целевым и пороговым; красная — за порогом или EBITDA ≤ 0. Единицы — млрд тенге, все расчёты в тенге.
|
||
Правила: считай только по данным контекста; если данных нет — скажи, чего не хватает; не выдумывай цифры; при пересчётах показывай формулу и результат; отвечай кратко, официальным деловым языком, без лишних вступлений; десятичный разделитель — запятая; коэффициенты — с двумя знаками и «x».
|
||
Методика (определения показателей):
|
||
${$('#methodText').value}
|
||
${$('#agentExtra').value.trim()?'Дополнительные указания пользователя: '+$('#agentExtra').value.trim():''}
|
||
Данные реестра и расчёта (JSON):
|
||
${agentContext()}
|
||
Стресс-тест (уже пересчитан: долг +10%, EBITDA −15%, процентные расходы +20%, капитал −5%) — используй только эти значения, не пересчитывай самостоятельно (JSON):
|
||
${JSON.stringify(stressRows())}`;
|
||
}
|
||
function chatAppend(role, text){ const log=$('#chatLog'); const d=document.createElement('div'); d.className='msg '+role; d.textContent=text; log.appendChild(d); log.scrollTop=log.scrollHeight; return d; }
|
||
async function chatSend(q){
|
||
q=(q||$('#chatIn').value).trim(); if(!q) return;
|
||
$('#chatIn').value='';
|
||
chatAppend('user', q); S.chat.push({role:'user', content:q});
|
||
const wait=chatAppend('ai',''); wait.innerHTML='<span class="spin"></span>считаю…';
|
||
$('#btnChat').disabled=true;
|
||
try{
|
||
const hist = S.chat.slice(-12); while(hist.length && hist[0].role!=='user') hist.shift();
|
||
const ans = await callClaude(hist, agentSystem());
|
||
wait.textContent = ans; S.chat.push({role:'assistant', content:ans});
|
||
}catch(err){ wait.textContent='Ошибка: '+err.message; S.chat.pop(); }
|
||
finally{ $('#btnChat').disabled=false; $('#chatLog').scrollTop=$('#chatLog').scrollHeight; }
|
||
}
|
||
$('#btnChat').onclick=()=>chatSend();
|
||
$('#chatIn').addEventListener('keydown',e=>{ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); chatSend(); } });
|
||
$$('.quick button').forEach(b=>b.onclick=()=>{ if(!S.registry.length){ toast('Реестр пуст — сначала добавьте данные'); return; } chatSend(b.dataset.q); });
|
||
$('#btnChatClear').onclick=()=>{ S.chat=[]; $('#chatLog').innerHTML='<div class="msg sys">Диалог очищен.</div>'; };
|
||
|
||
/* ===================== INIT ===================== */
|
||
renderAll();
|
||
</script>
|
||
</body>
|
||
</html>
|