From b85d743c5a0308504c3ec63d6e3ee37faa31b57d Mon Sep 17 00:00:00 2001 From: Moldabayev_d Date: Mon, 21 Sep 2026 09:41:24 +0000 Subject: [PATCH] =?UTF-8?q?v1.3:=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?KFU-=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=20(4=20=D1=88=D0=B0=D0=B3?= =?UTF-8?q?=D0=B0)=20+=20server.js=20(node:http,=20/api/ai,=20health),=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20=D1=81=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=BD=D0=B8=D0=B3=D0=B8=20=D0=9A?= =?UTF-8?q?=D0=A4=D0=A3,=20=D1=81=D1=82=D1=80=D0=B5=D1=81=D1=81-=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82,=20=D0=B4=D0=B5=D0=BC=D0=BE,=20monitor.html?= =?UTF-8?q?=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=BC=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + README.md | 39 +- index.html | 1784 ++++++++++++++++++++++++++++++++++----------- kfu-client.js | 7 +- monitor.html | 444 +++++++++++ package-lock.json | 35 + package.json | 7 + server.js | 160 ++++ 8 files changed, 2055 insertions(+), 423 deletions(-) create mode 100644 monitor.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server.js diff --git a/.gitignore b/.gitignore index d01c7fd..1ec975a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ dist/ .vibe42-run.pid .DS_Store .mimo/ +.env +kfu-agent-original-source.txt diff --git a/README.md b/README.md index 0d17c33..c87c678 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,36 @@ -# КФУ — финансовая устойчивость +# КФУ-агент — расчёт К1–К3 по Политике финансовой устойчивости -Статическое приложение, созданное через чат Vibe AI. Запуск: откройте вкладку Превью проекта; сборка npm не требуется. +Приложение с сервером (Node.js, `node:http`, без тяжёлых зависимостей) + статический фронт. +Запуск — команда **`run`** (поставит зависимости по `package.json` и поднимет `server.js`). -Пилот работает с предоставленным шаблоном факта 2025: 15 компаний и одна консолидированная группа. Источники: ДОЛГ 4Q2025_24042026; EBITDA 2025_06042026; BS 4Q2025_06042026; Группа Фонда 4Q 2025. Контрольный свод: План 2026!G5:J20. +## Что умеет +1. **Данные** — три источника: + - выгрузка СУО (SAP BPC): листы ДОЛГ / EBITDA / Баланс, распознавание структуры, подбор строк, + EBITDA «снизу вверх» по статьям ОПиУ и готовая строкой, LTM-мостик для промежуточных периодов, + структура долга по компонентам с контролем по сумме, блок «Консолидация» (TOTALADJ) для Группы Фонда; + - отчётность МСФО — извлечение показателей ИИ (PDF распковывается сервером в текст, Excel в браузере, + сканы страниц — как изображения); результат — редактируемая сводка с LTM и итогами; + - ручной ввод. +2. **Реестр показателей** — компании и периоды, долг/гарантии/EBITDA/проценты/капитал, редактирование и удаление. +3. **Расчёт и зоны** — целевые и пороговые значения по компаниям (предзаполнены из сводного файла КФУ), + свод «как в сводном файле», коэффициенты К1–К3 с зонами (зелёная/жёлтая/красная), EBITDA ≤ 0 → красная, + выгрузка в Excel (листы: Свод, Долг Группы, КФУ, Параметры, Аудит) и CSV. +4. **Агент** — чат с расчётом по методам: видит реестр, зоны и методы (реестр); подсказки: + заключение по зонам, стресс-тест (долг +10%, EBITDA −15%, проценты +20%, капитал −5% — пересчитывается + детерминированно в `stressRow`, агенту передаются готовые значения), запас до порогов, методологические риски. -Загрузите .xlsx, откройте строку компании, при необходимости измените прочие/гарантии в млн тенге. Другие показатели сценарий не редактирует. Кнопка Вернуть сбрасывает изменение. Выгрузить отчёт .xlsx сохраняет снимок текущего сценария. Листы отчёта: КФУ, Источники, Диагностика, Сверка, Пояснения. +## Архитектура +- `index.html` — всё приложение (вкладки 1–4), SheetJS из `vendor/xlsx.full.min.js`. +- `monitor.html` + `kfu-client.js` — предыдущий экран «Мониторинг» (ссылка в шапке КФУ-агента). +- `server.js` — `node:http`: статика (allowlist файлов) + `POST /api/ai` (прокси к + `AI_BASE_URL/chat/completions` с `AI_API_KEY`/`AI_MODEL` из окружения) + `GET /api/health`. + Ключи ИИ живут только на сервере; браузер ходит в `api/ai` (относительный путь). +- PDF: клиент шлёт base64 в `/api/ai` (блок `file:pdf`), сервер распковывает `pdf-parse`; + пустой PDF (скан) → честная ошибка. Лимит тела 40 МБ, таймаут ИИ 90 с. -Проверены 64/64 исходных показателя (16 объектов × 4 показателя), сценарий/сброс, фильтры, пропуски данных, отрицательные показатели и экспорт. Сверка всегда относится к исходным данным без сценария. +## Запуск +`run` → ссылка из вывода. После правок — ещё раз `run`. +Публикация — кнопка «Опубликовать» (приложение: `https://code.vibe42.kz/apps/...`, не в pages). -Формулы Excel не пересчитываются: читаются сохранённые значения исходных ячеек, затем коэффициенты рассчитываются приложением. Перед загрузкой пересчитайте и сохраните книгу в Excel. - -Пользовательский Excel читается в браузере, не отправляется приложением на сервер. Исходная книга и отчёты исключены из Git. Учебный пример содержит синтетические данные. +Данные (Excel) читаются в браузере и не покидают машину, кроме отправки PDF/снимков в ИИ для извлечения показателей. +Состояние (реестр, параметры, методика) сохраняется/загружается локальным JSON; исходные книги и отчёты исключены из Git. diff --git a/index.html b/index.html index 0366fde..4c95b27 100644 --- a/index.html +++ b/index.html @@ -1,444 +1,1400 @@ - - - + - -КФУ — финансовая устойчивость - - - - - - + +КФУ-агент — расчёт К1–К3 + + - - - -
-
-
КФУ
-
- Финансовый мониторинг - Факт 2025 -
+ +
+
+

КФУ-агент

+ Расчёт К1–К3 по Политике финансовой устойчивости — Долг/EBITDA, EBITDA/Проценты, Долг/Капитал · версия 1.3 + Мониторинг → +
+
+
+
+
+
+
+ + +
-
- - - -
-
+
+
+ +
+
-
+ - -
-
-

Финансовая устойчивость

-

-
-
- - -
-
- - - - - - - - - - - - - - -
-
- -
-

Загрузите сводную книгу

-

Получите коэффициенты, зоны риска и отчёт в .xlsx

-
- - -
-

Файл обрабатывается в браузере и не отправляется на сервер

-
- - - +
+
Учебный пример: если реестр пуст, значения — синтетические (демо). Для рабочих расчётов загрузите выгрузку СУО или отчётность на шаге 1.
+ +
+
+ + +
- - + +
+
+

Сводная книга КФУ 2025

+
+ + + Берёт данные только из загруженной книги: долг/прочие/EBITDA/проценты/капитал (млн → млрд), нормативы, источники адресов. Зоны — пересчёт ПГУ. +
+
+
+
+

Файлы выгрузки

+
+ Перетащите .xlsx/.xlsm с листами ДОЛГ, EBITDA, BS/Баланс — или выберите файлы + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
Для ковенантов портфельной компании долг берётся по её собственной консолидированной отчётности: внутригрупповые займы от Фонда не элиминируются. Блок «Консолидация» и параметр TOTALADJ применимы только к строке Группы Фонда.
+
-
+
+

Распознанная структура

+
Загрузите файл — здесь появятся найденные строки и блоки.
+ +
+
- +
+

Предпросмотр по компаниям

+

Гарантии и прочие обязательства, не входящие в строку «Долг» выгрузки, вводятся вручную (млрд ₸). Отметьте компании для добавления в реестр.

+
+
+ + + +
+

«Все периоды» — для файлов вида «Долг 2023 / EBITDA 2023 / Баланс 2023»: тройки листов сопоставляются по году в названии, гарантии берутся из таблицы выше по компаниям.

+
+
- + + + + + +
+ + +
+
+

Реестр показателей

+

Базовые показатели по компаниям и периодам. Значения редактируются кнопкой «Изменить»; итоговый долг = долг + гарантии.

+
+
+ +
+
+
+ + +
+
+

Целевые и пороговые значения

+

К1 Долг/EBITDA и К3 Долг/Капитал — «не более» (цель ≤ порог); К2 EBITDA/Проценты — «не менее» (цель ≥ порог). Пустое значение — коэффициент не оценивается. Строки добавляются автоматически по компаниям реестра.

+
+
+
+ +
+
+
+

Свод в формате сводного файла КФУ

+

Строки — в порядке сводного файла; коэффициенты подсвечены зонами по целям/порогам (серые — цели не заданы).

+
+
+
+

Коэффициенты и зоны

+
+
+
+ + + Зона строки — худшая из трёх; EBITDA ≤ 0 → К1 и К2 в красной зоне независимо от формулы. +
+
+
+ + +
+
+
+

Агент по КФУ

+

Видит реестр, рассчитанные коэффициенты, цели/пороги и методику. Считает по данным реестра, не выдумывает цифры.

+
+ + + + +
+
+
Задайте вопрос по коэффициентам или используйте быстрые запросы выше.
+
+
+
+
+

Контекст агента

+

Что передаётся в каждый запрос:

+
    +
  • реестр: долг, гарантии, EBITDA, проценты, капитал, период, источник
  • +
  • расчёт: К1–К3, зоны, цели и пороги
  • +
  • методика из вкладки «Отчётность МСФО»
  • +
+
+
Функции ИИ (извлечение из отчётности, подбор строк, агент) работают при открытии файла внутри Claude. Разбор выгрузки СУО, ручной ввод, расчёт и выгрузка в Excel работают и автономно в браузере.
+
+
+
+
+ +
+ + +
+ diff --git a/kfu-client.js b/kfu-client.js index 00e9a47..d3e13d4 100644 --- a/kfu-client.js +++ b/kfu-client.js @@ -1098,6 +1098,8 @@ render(); } + window.KfuWorkbook = { analyzeWorkbook: analyzeWorkbook }; + /* ---------- инициализация ---------- */ function init() { var fileInput = $("#fileInput"); @@ -1170,5 +1172,8 @@ render(); } - document.addEventListener("DOMContentLoaded", init); + document.addEventListener("DOMContentLoaded", function () { + if (!document.getElementById("uploadPanel")) return; + init(); + }); })(); diff --git a/monitor.html b/monitor.html new file mode 100644 index 0000000..0366fde --- /dev/null +++ b/monitor.html @@ -0,0 +1,444 @@ + + + + + + + +КФУ — финансовая устойчивость + + + + + + + + + + + + +
+
+
КФУ
+
+ Финансовый мониторинг + Факт 2025 +
+
+
+ + + +
+
+ +
+ + +
+
+

Финансовая устойчивость

+

+
+
+ + +
+
+ + + + + + + + + + + + + + +
+
+ +
+

Загрузите сводную книгу

+

Получите коэффициенты, зоны риска и отчёт в .xlsx

+
+ + +
+

Файл обрабатывается в браузере и не отправляется на сервер

+
+ + + + +
+ + + + +
+ + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f749c16 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,35 @@ +{ + "name": "kfu-agent", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kfu-agent", + "dependencies": { + "pdf-parse": "^1.1.1" + } + }, + "node_modules/node-ensure": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz", + "integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==", + "license": "MIT" + }, + "node_modules/pdf-parse": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.4.tgz", + "integrity": "sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==", + "license": "MIT", + "dependencies": { + "node-ensure": "^0.0.0" + }, + "engines": { + "node": ">=6.8.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6e33300 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "kfu-agent", + "private": true, + "type": "commonjs", + "scripts": { "start": "node server.js" }, + "dependencies": { "pdf-parse": "^1.1.1" } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..6c8c7d5 --- /dev/null +++ b/server.js @@ -0,0 +1,160 @@ +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const PORT = process.env.PORT || 3000; +const MAX_BODY = 40 * 1024 * 1024; +const AI_TIMEOUT_MS = 90000; + +const ALLOWED = { + "/": "index.html", + "/index.html": "index.html", + "/monitor.html": "monitor.html", + "/kfu-client.js": "kfu-client.js", + "/vendor/xlsx.full.min.js": "vendor/xlsx.full.min.js", + "/design-system/kt-ai-fonts.css": "design-system/kt-ai-fonts.css", + "/design-system/kt-ai-tokens.css": "design-system/kt-ai-tokens.css", + "/design-system/kt-ai-components.css": "design-system/kt-ai-components.css", + "/design-system/kt-ai-page.css": "design-system/kt-ai-page.css", + "/design-system/kt-ai-print.css": "design-system/kt-ai-print.css", + "/design-system/vibe-theme.css": "design-system/vibe-theme.css", + "/design-system/kt-ai-chart-tip.js": "design-system/kt-ai-chart-tip.js", + "/design-system/kt-ai-composer.js": "design-system/kt-ai-composer.js", + "/design-system/kt-ai-feedback.js": "design-system/kt-ai-feedback.js", + "/design-system/kt-ai-orb.js": "design-system/kt-ai-orb.js", + "/design-system/icons/kt-ai-icons.js": "design-system/icons/kt-ai-icons.js", + "/design-system/icons/kt-ai-lucide-sprite.svg": "design-system/icons/kt-ai-lucide-sprite.svg", + "/design-system/fonts/InterVariable-subset.woff2": "design-system/fonts/InterVariable-subset.woff2", + "/design-system/fonts/JetBrainsMono-subset.woff2": "design-system/fonts/JetBrainsMono-subset.woff2" +}; +const MIME = { + ".html": "text/html", ".css": "text/css", ".js": "application/javascript", + ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", + ".ico": "image/x-icon", ".woff2": "font/woff2" +}; + +function loadPdfParse() { + try { return require("pdf-parse"); } catch (e) {} + if (process.env.NODE_PATH) { + for (const p of String(process.env.NODE_PATH).split(path.delimiter)) { + if (p) { try { return require(path.join(p, "pdf-parse")); } catch (e) {} } + } + } + return null; +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let s = ""; + req.setEncoding("utf8"); + req.on("data", (c) => { s += c; if (s.length > MAX_BODY) { reject(new Error("body too large")); req.destroy(); } }); + req.on("end", () => { try { resolve(s ? JSON.parse(s) : {}); } catch (e) { reject(new Error("invalid JSON body")); } }); + req.on("error", reject); + }); +} + +function send(res, code, obj) { + res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(obj)); +} + +// Anthropic blocks -> OpenAI user content parts +async function toOpenAIContent(content, pdfParse) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return JSON.stringify(content); + const parts = []; + for (const b of content) { + if (!b || typeof b !== "object") continue; + if (b.type === "text" && typeof b.text === "string") { + parts.push({ type: "text", text: b.text }); + } else if (b.type === "file" && b.file === "image" && b.data) { + parts.push({ type: "image_url", image_url: { url: "data:" + (b.media || "image/png") + ";base64," + b.data } }); + } else if (b.type === "file" && b.file === "pdf" && b.data) { + if (!pdfParse) throw new Error("PDF-парсер недоступен на сервере"); + const buf = Buffer.from(b.data, "base64"); + if (buf.length > 32 * 1024 * 1024) throw new Error("PDF больше 32 МБ"); + let t; + try { t = (await pdfParse(buf)).text; } catch (e) { throw new Error("не удалось разобрать PDF: " + (e.message || e)); } + t = String(t || "").trim(); + if (!t) throw new Error("из PDF не удалось извлечь текст (скан?) — добавьте фрагменты текстом"); + parts.push({ type: "text", text: (b.name ? "PDF «" + b.name + "»:\n" : "PDF:\n") + t.slice(0, 400000) }); + } + } + return parts.length ? parts : ""; +} + +async function handleAI(req, res) { + const body = await readBody(req); + const model = process.env.AI_MODEL; + if (!process.env.AI_BASE_URL || !process.env.AI_API_KEY || !model) { + return send(res, 503, { error: "ИИ не настроен на сервере (нет AI_BASE_URL/AI_API_KEY/AI_MODEL). Перезапустите проект (run), чтобы обновить окружение." }); + } + const pdfParse = loadPdfParse(); + const out = []; + if (typeof body.system === "string" && body.system) out.push({ role: "system", content: body.system }); + const msgs = Array.isArray(body.messages) ? body.messages : []; + if (!msgs.length) return send(res, 400, { error: "нет сообщений" }); + for (const m of msgs) { + if (!m || (m.role !== "user" && m.role !== "assistant")) continue; + const c = await toOpenAIContent(m.content, pdfParse); + if (c === "" && m.role === "user") continue; + out.push({ role: m.role, content: c }); + } + const maxTok = Math.min(Math.max(parseInt(body.max_tokens, 10) || 4096, 256), 8192); + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), AI_TIMEOUT_MS); + try { + const r = await fetch(String(process.env.AI_BASE_URL).replace(/\/+$/, "") + "/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer " + process.env.AI_API_KEY }, + body: JSON.stringify({ model, messages: out, max_tokens: maxTok }), + signal: ctrl.signal + }); + const j = await r.json().catch(() => ({})); + if (!r.ok) { + const msg = (j && (j.error && (j.error.message || j.error) || j.message)) || ("сервер ИИ вернул " + r.status); + const msgStr = typeof msg === "string" ? msg : JSON.stringify(msg); + return send(res, 502, { error: "ИИ: " + msgStr.slice(0, 500) }); + } + const choice = (j.choices && j.choices[0]) || {}; + const text = (choice.message && choice.message.content) || ""; + return send(res, 200, { content: String(text), truncated: choice.finish_reason === "length" }); + } catch (e) { + const what = e.name === "AbortError" ? "таймаут (90 с) — запрос слишком большой" : (e.message || String(e)); + return send(res, 502, { error: "ИИ недоступен: " + what.slice(0, 300) }); + } finally { + clearTimeout(t); + } +} + +http.createServer(async (req, res) => { + const url = (req.url || "/").split("?")[0]; + try { + if (req.method === "POST" && url === "/api/ai") return await handleAI(req, res); + if ((req.method === "GET" || req.method === "POST") && url === "/api/health") { + return send(res, 200, { ok: true, version: "1.3", ai: !!(process.env.AI_BASE_URL && process.env.AI_API_KEY && process.env.AI_MODEL) }); + } + if (req.method === "GET") { + let file = ALLOWED[url]; + if (!file && (url === "/" || url.endsWith("/"))) file = "index.html"; + if (!file && url.startsWith("/design-system/")) { + let rel = path.normalize(decodeURIComponent(url.replace(/%20/g, " "))); + if (!rel.startsWith("../") && !path.isAbsolute(rel)) file = rel; + } + if (file) { + const full = path.join(__dirname, file); + if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) { + const ext = path.extname(full); + res.writeHead(200, { "Content-Type": (MIME[ext] || "application/octet-stream") + "; charset=utf-8", "Cache-Control": "no-cache" }); + return res.end(fs.readFileSync(full)); + } + } + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + return res.end("Not found"); + } + res.writeHead(404, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ error: "not found" })); + } catch (e) { + try { send(res, 500, { error: (e && e.message) || "ошибка сервера" }); } catch (_) {} + } +}).listen(PORT, "0.0.0.0", () => console.log("КФУ-агент сервер на порту " + PORT));