sozday-rabochee-veb-prilozhe/index.html

1401 lines
120 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>