svod-finansovyh-otchetov-doc/script.js

1116 lines
41 KiB
JavaScript
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.

const COMPANIES = [
{ id: 'c1', name: 'КТ-Алматы', template: 'Шаблон 2024-А' },
{ id: 'c2', name: 'КТ-Астана', template: 'Шаблон 2023-Б' },
{ id: 'c3', name: 'КТ-Шымкент', template: 'Шаблон 2024-А' },
{ id: 'c4', name: 'КТ-Караганда', template: 'Шаблон 2022-Г' },
{ id: 'c5', name: 'КТ-Актау', template: 'Фирменный' },
{ id: 'c6', name: 'КТ-Павлодар', template: 'Шаблон 2023-Б' },
{ id: 'c7', name: 'КТ-Семей', template: 'Шаблон 2024-А' },
{ id: 'c8', name: 'КТ-Костанай', template: 'Шаблон 2022-Г' },
{ id: 'c9', name: 'КТ-Тараз', template: 'Фирменный' },
{ id: 'c10', name: 'КТ-Усть-Каменогорск', template: 'Шаблон 2023-Б' },
{ id: 'c11', name: 'КТ-Атырау', template: 'Шаблон 2024-А' },
{ id: 'c12', name: 'КТ-Петропавловск', template: 'Фирменный' },
{ id: 'c13', name: 'КТ-Кызылорда', template: 'Шаблон 2022-Г' },
{ id: 'c14', name: 'КТ-Уинт', template: 'Шаблон 2023-Б' },
{ id: 'c15', name: 'КТ-Каспий', template: 'Фирменный' }
];
const METRICS = [
{ key: 'revenue', name: 'Выручка', unit: 'млн тг' },
{ key: 'profit', name: 'Прибыль', unit: 'млн тг' },
{ key: 'expenses', name: 'Расходы', unit: 'млн тг' }
];
const METRIC_SOURCE = {
revenue: 'Выручка от продаж (млн тг)',
profit: 'Чистая прибыль, тыс. тенге',
expenses: 'Общие расходы (млн)'
};
const STORAGE_KEY = 'svod-fin-otchetov-state-v1';
function now() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return p(d.getDate()) + '.' + p(d.getMonth() + 1) + '.' + d.getFullYear();
}
function nowTime() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return p(d.getHours()) + ':' + p(d.getMinutes());
}
function rand(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function randDecimal(min, max, dec) {
return (Math.random() * (max - min) + min).toFixed(dec);
}
function generateDefaultFile(company, idx) {
const statuses = ['done', 'done', 'done', 'done', 'done', 'done', 'done', 'done', 'done', 'processing', 'processing', 'waiting', 'waiting', 'waiting', 'waiting'];
return {
id: 'f' + (idx + 1),
company: company.id,
companyName: company.name,
fileName: 'financial_report_' + company.id + '_' + (2024 + (idx % 3)) + '.xlsx',
sentDate: (20 + (idx % 10)) + '.09.2026',
template: company.template,
status: statuses[idx] || 'waiting'
};
}
function generateDefaultRows() {
const rows = [];
const pattern = ['ai', 'ai', 'ai', 'ai-uncertain', 'manual'];
COMPANIES.forEach(function (c, ci) {
METRICS.forEach(function (m, mi) {
const base = mi === 0 ? 4200 : mi === 1 ? 1120 : 3100;
const value = Number(randDecimal(base - 400, base + 400, 2));
rows.push({
id: c.id + '-' + m.key,
company: c.id,
metric: m.key,
key: m.key,
metricName: m.name,
unit: m.unit,
value: value,
source: c.template + ' / лист «Отчёт» / B' + ((ci + 2) * 7 + mi + 3),
status: pattern[(ci + mi) % 5],
originalName: m.name
});
});
});
return rows;
}
function defaultState() {
const files = COMPANIES.map(generateDefaultFile);
const rows = generateDefaultRows();
return {
screen: 'upload',
files: files,
rows: rows,
aiLog: [
{ time: '09:14', text: 'Найдено 24 значения показателя «Выручка»', src: 'КТ-Алматы, КТ-Астана, …' },
{ time: '09:14', text: 'Показатель «Чистая прибыль, тыс. тенге» стандартизирован как «Прибыль»', src: 'КТ-Актау, КТ-Тараз' },
{ time: '09:15', text: '4 значения требуют ручного подтверждения', src: 'см. выделенные ячейки' },
{ time: '09:16', text: 'Единая сводная таблица заполнена', src: '15 компаний × 3 показателя' }
],
recheckLog: [],
confirmed: false,
chat: [
{ role: 'bot', text: 'Здравствуйте! Спросите меня про показатели, компании или статус обработки.' }
]
};
}
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && parsed.files && parsed.rows) {
return parsed;
}
}
} catch (e) {}
return defaultState();
}
function saveState() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
let state = loadState();
function el(id) {
return document.getElementById(id);
}
function toast(msg, tone) {
const stack = el('toastStack');
if (!stack) return;
const t = document.createElement('div');
t.className = 'kt-ai-toast';
if (tone) t.setAttribute('data-tone', tone);
t.textContent = msg;
stack.appendChild(t);
setTimeout(function () {
if (t.parentNode) t.parentNode.removeChild(t);
}, 2600);
}
function companyById(id) {
for (let i = 0; i < COMPANIES.length; i++) {
if (COMPANIES[i].id === id) return COMPANIES[i];
}
return null;
}
function rowsForCompany(companyId) {
const out = [];
for (let i = 0; i < state.rows.length; i++) {
if (state.rows[i].company === companyId) out.push(state.rows[i]);
}
return out;
}
function allRowsForMetric(metricKey) {
const out = [];
for (let i = 0; i < state.rows.length; i++) {
if (state.rows[i].metric === metricKey) out.push(state.rows[i]);
}
return out;
}
function sumMetric(metricKey) {
const rows = allRowsForMetric(metricKey);
let s = 0;
for (let i = 0; i < rows.length; i++) s += rows[i].value;
return s;
}
function statusLabel(status) {
const map = {
waiting: 'Ожидает',
processing: 'Обработка',
done: 'Готово',
ai: 'Найдено ИИ',
'ai-uncertain': 'Требует проверки',
manual: 'Ручной режим'
};
return map[status] || status;
}
function statusPillHtml(status) {
const map = {
waiting: 'muted',
processing: 'info',
done: 'ok',
ai: 'ok',
'ai-uncertain': 'warn',
manual: 'muted'
};
const tone = map[status] || 'muted';
return '<span class="kt-ai-status-pill" data-status="' + tone + '">' + statusLabel(status) + '</span>';
}
function fileStatusPillHtml(status) {
const map = {
waiting: 'muted',
processing: 'info',
done: 'ok'
};
const tone = map[status] || 'muted';
return '<span class="kt-ai-status-pill" data-status="' + tone + '">' + statusLabel(status) + '</span>';
}
function navUploadKpi() {
const total = state.files.length;
let done = 0, processing = 0, waiting = 0;
state.files.forEach(function (f) {
if (f.status === 'done') done++;
else if (f.status === 'processing') processing++;
else waiting++;
});
const html =
'<div class="kt-ai-kpi"><span class="value">' + total + '</span><span class="label">Всего файлов</span></div>' +
'<div class="kt-ai-kpi"><span class="value">' + done + '</span><span class="label">Готово</span></div>' +
'<div class="kt-ai-kpi"><span class="value">' + processing + '</span><span class="label">В обработке</span></div>' +
'<div class="kt-ai-kpi"><span class="value">' + waiting + '</span><span class="label">Ожидает</span></div>';
return html;
}
function fileRowHtml(f) {
const actions =
'<div class="kt-ai-row-actions">' +
'<button class="kt-ai-btn" data-size="sm" data-act="file-process" data-id="' + f.id + '" title="Запустить поиск показателей">Обработать</button>' +
'<button class="kt-ai-btn" data-size="sm" data-variant="danger" data-act="file-delete" data-id="' + f.id + '" title="Удалить файл">Удалить</button>' +
'</div>';
return '<tr>' +
'<td>' + f.companyName + '</td>' +
'<td class="file-cell"><span class="kt-ai-chip" data-tone="gray">Excel</span> ' + f.fileName + '</td>' +
'<td>' + f.sentDate + '</td>' +
'<td>' + f.template + '</td>' +
'<td>' + fileStatusPillHtml(f.status) + '</td>' +
'<td>' + actions + '</td>' +
'</tr>';
}
function renderUpload() {
const box = el('uploadKpi');
if (box) box.innerHTML = navUploadKpi();
const tbody = el('uploadTableBody');
if (tbody) {
tbody.innerHTML = state.files.map(fileRowHtml).join('');
}
const count = el('navUploadCount');
if (count) count.textContent = state.files.length;
}
function summaryKpiHtml() {
let ok = 0, warn = 0, manual = 0;
state.rows.forEach(function (r) {
if (r.status === 'ai') ok++;
else if (r.status === 'ai-uncertain') warn++;
else if (r.status === 'manual') manual++;
});
const rev = sumMetric('revenue');
return '<div class="kt-ai-kpi"><span class="value">' + rev.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + '</span><span class="label">Суммарная выручка, млн тг</span></div>' +
'<div class="kt-ai-kpi"><span class="value">' + ok + '</span><span class="label">Значений найдено ИИ</span></div>' +
'<div class="kt-ai-kpi"><span class="value">' + warn + '</span><span class="label">Требуют проверки</span></div>' +
'<div class="kt-ai-kpi"><span class="value">' + manual + '</span><span class="label">В ручном режиме</span></div>';
}
function fmtValue(r) {
const dec = r.unit === 'млн тг' ? 1 : 2;
let s = r.value.toFixed(dec);
const parts = s.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '\u00a0');
return parts.join(',');
}
function rowActionsForSummary(r) {
let actions = '';
if (r.status === 'ai-uncertain') {
actions = '<button class="kt-ai-btn" data-size="sm" data-act="confirm-row" data-id="' + r.id + '">Подтвердить</button>';
} else if (r.status === 'ai') {
actions = '<button class="kt-ai-btn" data-size="sm" data-variant="ghost" data-act="manual-row" data-id="' + r.id + '">В ручной режим</button>';
} else if (r.status === 'manual') {
actions = '<button class="kt-ai-btn" data-size="sm" data-act="confirm-row" data-id="' + r.id + '">Подтвердить</button>';
}
return actions;
}
function summaryRowHtml(r) {
const c = companyById(r.company);
const cellCls = r.status === 'ai-uncertain' ? 'cell kt-ai-cell-warning' : (r.status === 'manual' ? 'cell kt-ai-cell-manual' : 'cell');
const showRename = r.originalName !== r.metricName;
const renameTxt = showRename ? 'Было: ' + r.originalName + ' → ' + r.metricName : r.originalName;
return '<tr data-row="' + r.id + '">' +
'<td>' + (c ? c.name : r.company) + '</td>' +
'<td><div class="kt-ai-metric-cell">' +
'<span class="metric-name">' + r.metricName + '</span>' +
(showRename ? '<span class="metric-source">' + renameTxt + '</span>' : '') +
'</div></td>' +
'<td class="num ' + cellCls + ' kt-ai-cell-editable" data-cell="' + r.id + '" title="Нажмите, чтобы отредактировать">' +
fmtValue(r) + ' ' + r.unit +
'</td>' +
'<td>' + r.source + '</td>' +
'<td class="kt-ai-status-cell"><div class="kt-ai-status-cell-in">' + statusPillHtml(r.status) + '<div class="kt-ai-cell-actions">' + rowActionsForSummary(r) + '</div></div></td>' +
'</tr>';
}
function renderSummary() {
const box = el('summaryKpi');
if (box) box.innerHTML = summaryKpiHtml();
const tbody = el('summaryTableBody');
if (tbody) tbody.innerHTML = state.rows.map(summaryRowHtml).join('');
const count = el('navSummaryCount');
if (count) count.textContent = state.rows.length;
}
function renderAiLog() {
const list = el('aiLogList');
if (!list) return;
list.innerHTML = state.aiLog.map(function (l) {
return '<div class="kt-ai-log-item">' +
'<span class="log-time">' + l.time + '</span>' +
'<span class="log-text">' + l.text + '</span>' +
'<span class="log-src">' + l.src + '</span>' +
'</div>';
}).join('');
}
function renderAll() {
renderUpload();
renderSummary();
renderAiLog();
renderVerify();
renderDashboard();
updateNav();
}
/* ================= Navigation ================= */
const SCREEN_TITLES = {
upload: 'Загрузка отчётов',
summary: 'Сводная таблица',
verify: 'Проверка и утверждение',
dashboard: 'Дашборд руководства',
about: 'О решении'
};
function showScreen(id) {
const screens = document.querySelectorAll('.kt-ai-screen');
screens.forEach(function (s) { s.hidden = true; });
const target = el('screen-' + id);
if (target) target.hidden = false;
state.screen = id;
saveState();
const crumb = el('topbarCrumb');
if (crumb) crumb.textContent = SCREEN_TITLES[id] || id;
const navItems = document.querySelectorAll('.kt-ai-nav-item');
navItems.forEach(function (n) {
if (n.getAttribute('data-screen') === id) n.setAttribute('data-active', 'true');
else n.removeAttribute('data-active');
});
renderScreen(id);
}
function renderScreen(id) {
if (id === 'upload') renderUpload();
if (id === 'summary') renderSummary();
if (id === 'verify') renderVerify();
if (id === 'dashboard') renderDashboard();
}
function updateTopbar() {
const actions = el('topbarActions');
if (!actions) return;
actions.innerHTML = '';
}
function updateNav() {
const navItems = document.querySelectorAll('.kt-ai-nav-item');
navItems.forEach(function (n) {
const id = n.getAttribute('data-screen');
if (id === state.screen) n.setAttribute('data-active', 'true');
else n.removeAttribute('data-active');
});
}
function bindNavigation() {
const nav = el('sidebarNav');
if (nav) {
nav.addEventListener('click', function (e) {
const item = e.target.closest('.kt-ai-nav-item');
if (!item) return;
const id = item.getAttribute('data-screen');
if (id) showScreen(id);
});
}
const btnNew = el('btnNewReport');
if (btnNew) btnNew.addEventListener('click', function () { showScreen('upload'); });
}
/* ================= Upload screen ================= */
function bindUpload() {
const btnProcessAll = el('btnProcessAll');
if (btnProcessAll) btnProcessAll.addEventListener('click', processAllFiles);
const btnDeleteWaiting = el('btnDeleteAllWaiting');
if (btnDeleteWaiting) btnDeleteWaiting.addEventListener('click', function () {
const before = state.files.length;
state.files = state.files.filter(function (f) { return f.status !== 'waiting'; });
const removed = before - state.files.length;
if (removed > 0) {
saveState();
renderUpload();
toast('Удалено файлов: ' + removed);
} else {
toast('Ожидающих файлов нет');
}
});
const statusFilter = el('uploadStatusFilter');
if (statusFilter) statusFilter.addEventListener('change', applyUploadFilter);
const search = el('uploadSearch');
if (search) search.addEventListener('input', applyUploadFilter);
const tbody = el('uploadTableBody');
if (tbody) {
tbody.addEventListener('click', function (e) {
const btn = e.target.closest('button[data-act]');
if (!btn) return;
const act = btn.getAttribute('data-act');
const id = btn.getAttribute('data-id');
if (act === 'file-process') processFile(id);
if (act === 'file-delete') deleteFile(id);
});
}
}
function applyUploadFilter() {
const status = el('uploadStatusFilter') ? el('uploadStatusFilter').value : 'all';
const search = el('uploadSearch') ? el('uploadSearch').value.toLowerCase() : '';
const rows = state.files.filter(function (f) {
const okStatus = status === 'all' || f.status === status;
const okSearch = !search || f.companyName.toLowerCase().includes(search) || f.fileName.toLowerCase().includes(search);
return okStatus && okSearch;
});
// render filtered
const tbody = el('uploadTableBody');
if (tbody) tbody.innerHTML = rows.map(fileRowHtml).join('');
}
function processAllFiles() {
const waiting = state.files.filter(function (f) { return f.status === 'waiting' || f.status === 'processing'; });
if (!waiting.length) {
toast('Все файлы уже обработаны');
return;
}
waiting.forEach(function (f) { f.status = 'processing'; });
saveState();
renderUpload();
toast('Обработка запущена для ' + waiting.length + ' файлов…');
let i = 0;
function next() {
if (i >= waiting.length) {
finishProcessing(waiting);
return;
}
const f = waiting[i];
f.status = 'done';
i++;
saveState();
renderUpload();
setTimeout(next, 220);
}
setTimeout(next, 300);
}
function processFile(id) {
let f = null;
state.files.forEach(function (x) { if (x.id === id) f = x; });
if (!f) return;
if (f.status === 'done') {
toast('Файл уже обработан');
return;
}
f.status = 'processing';
saveState();
renderUpload();
setTimeout(function () {
f.status = 'done';
state.aiLog.unshift({
time: nowTime(),
text: 'Показатели компании «' + f.companyName + '» найдены и добавлены в сводную таблицу',
src: f.fileName
});
saveState();
renderUpload();
renderAiLog();
toast('Файл «' + f.companyName + '» обработан');
}, 600);
}
function deleteFile(id) {
state.files = state.files.filter(function (f) { return f.id !== id; });
saveState();
renderUpload();
toast('Файл удалён');
}
function finishProcessing(files) {
const names = files.map(function (f) { return f.companyName; });
state.aiLog.unshift({
time: nowTime(),
text: 'ИИ завершил обработку файлов',
src: names.join(', ')
});
saveState();
renderAiLog();
toast('Обработка завершена');
}
/* ================= Summary screen ================= */
function bindSummary() {
const btnSave = el('btnSaveSummary');
if (btnSave) btnSave.addEventListener('click', function () {
saveState();
toast('Изменения сохранены');
});
const btnRerun = el('btnRerunAi');
if (btnRerun) btnRerun.addEventListener('click', function () {
state.aiLog.unshift({
time: nowTime(),
text: 'Пересчёт соответствий показателей выполнен',
src: '15 компаний'
});
saveState();
renderSummary();
renderAiLog();
toast('ИИ пересчитал соответствия');
});
const btnClearLog = el('btnClearAiLog');
if (btnClearLog) btnClearLog.addEventListener('click', function () {
state.aiLog = [];
saveState();
renderAiLog();
});
// filters
const companySel = el('summaryCompanyFilter');
if (companySel) {
METRICS; // no-op
const cur = companySel.value;
companySel.innerHTML = '<option value="all">Все компании</option>' + COMPANIES.map(function (c) {
return '<option value="' + c.id + '">' + c.name + '</option>';
}).join('');
companySel.value = cur;
companySel.addEventListener('change', applySummaryFilter);
}
const statusSel = el('summaryStatusFilter');
if (statusSel) statusSel.addEventListener('change', applySummaryFilter);
const search = el('summarySearch');
if (search) search.addEventListener('input', applySummaryFilter);
const tbody = el('summaryTableBody');
if (tbody) {
tbody.addEventListener('click', function (e) {
const btn = e.target.closest('button[data-act]');
if (btn) {
const act = btn.getAttribute('data-act');
const id = btn.getAttribute('data-id');
if (act === 'confirm-row') confirmRow(id);
if (act === 'manual-row') manualRow(id);
return;
}
const cell = e.target.closest('[data-cell]');
if (cell) editCell(cell.getAttribute('data-cell'));
});
}
}
function applySummaryFilter() {
const c = el('summaryCompanyFilter') ? el('summaryCompanyFilter').value : 'all';
const s = el('summaryStatusFilter') ? el('summaryStatusFilter').value : 'all';
const q = el('summarySearch') ? el('summarySearch').value.toLowerCase() : '';
const tbody = el('summaryTableBody');
if (!tbody) return;
const filtered = state.rows.filter(function (r) {
const okC = c === 'all' || r.company === c;
const okS = s === 'all' || r.status === s;
const okQ = !q || r.metricName.toLowerCase().includes(q) || (companyById(r.company) || { name: '' }).name.toLowerCase().includes(q);
return okC && okS && okQ;
});
tbody.innerHTML = filtered.map(summaryRowHtml).join('');
}
function findRow(id) {
for (let i = 0; i < state.rows.length; i++) {
if (state.rows[i].id === id) return state.rows[i];
}
return null;
}
function editCell(id) {
const r = findRow(id);
if (!r) return;
const cur = prompt('Введите новое значение (' + r.unit + '):', r.value);
if (cur === null) return;
const num = parseFloat(String(cur).replace(',', '.'));
if (isNaN(num)) {
toast('Некорректное значение');
return;
}
r.value = num;
r.status = 'manual';
state.aiLog.unshift({
time: nowTime(),
text: 'Значение «' + r.metricName + '» для «' + companyById(r.company).name + '» изменено вручную',
src: r.source
});
saveState();
applySummaryFilter();
renderAiLog();
toast('Значение обновлено');
}
function confirmRow(id) {
const r = findRow(id);
if (!r) return;
r.status = 'manual';
state.aiLog.unshift({
time: nowTime(),
text: 'Соответствие «' + r.metricName + '» подтверждено вручную',
src: r.source
});
saveState();
applySummaryFilter();
renderAiLog();
toast('Показатель подтверждён');
}
function manualRow(id) {
const r = findRow(id);
if (!r) return;
r.status = 'manual';
saveState();
applySummaryFilter();
toast('Переведено в ручной режим');
}
/* ================= Verify screen ================= */
function renderVerify() {
const box = el('verifyKpi');
if (!box) return;
// final table body
const tbody = el('verifyTableBody');
if (tbody) {
tbody.innerHTML = state.rows.map(function (r) {
const c = companyById(r.company);
const checkStatus = r.status === 'manual' ? 'Ручная проверка' : (r.status === 'ai' ? 'ИИ' : 'ИИ');
const pill = r.status === 'manual'
? '<span class="kt-ai-status-pill" data-status="muted">Ручная проверка</span>'
: '<span class="kt-ai-status-pill" data-status="ok">ИИ</span>';
return '<tr>' +
'<td>' + (c ? c.name : r.company) + '</td>' +
'<td>' + r.metricName + '</td>' +
'<td class="num">' + fmtValue(r) + ' ' + r.unit + '</td>' +
'<td>' + pill + '</td>' +
'</tr>';
}).join('');
}
// company / metric selects
const cs = el('recheckCompanySelect');
if (cs) {
const cur = cs.value || 'c1';
cs.innerHTML = COMPANIES.map(function (c) {
return '<option value="' + c.id + '">' + c.name + '</option>';
}).join('');
const exists = state.rows.some(function (r) { return r.company === cur; });
cs.value = exists ? cur : 'c1';
}
const ms = el('recheckMetricSelect');
if (ms) {
const cid = cs.value;
const compRows = rowsForCompany(cid);
const metricIds = compRows.map(function (r) { return r.metric; });
const cur = ms.value;
ms.innerHTML = METRICS.map(function (m) {
const val = compRows.filter(function (r) { return r.metric === m.key; })[0];
return '<option value="' + m.key + '">' + m.name + (val ? ' (' + fmtValue(val) + ' ' + val.unit + ')' : '') + '</option>';
}).join('');
if (metricIds.indexOf(cur) === -1) ms.value = metricIds[0] || 'revenue';
}
// KPIs
let rechecked = state.recheckLog.length;
let passed = state.recheckLog.filter(function (x) { return x.match; }).length;
const total = state.rows.length;
const confirmedText = state.confirmed ? 'Подтверждена' : 'Не подтверждена';
const confirmedPill = state.confirmed
? '<span class="kt-ai-status-pill" data-status="ok">Готово к выдаче</span>'
: '<span class="kt-ai-status-pill" data-status="warn">Ожидает утверждения</span>';
box.innerHTML =
'<div class="kt-ai-kpi"><span class="value">' + total + '</span><span class="label">Записей в итоговой таблице</span></div>' +
'<div class="kt-ai-kpi"><span class="value">' + rechecked + '</span><span class="label">Выборочно сверено</span></div>' +
'<div class="kt-ai-kpi"><span class="value">' + passed + '</span><span class="label">Совпадений</span></div>' +
'<div class="kt-ai-kpi"><span class="value">' + confirmedText + '</span><span class="label">Статус готовности</span><span class="hint">' + confirmedPill + '</span></div>';
renderRecheckLog();
}
function renderRecheckLog() {
const box = el('recheckLog');
if (!box) return;
box.innerHTML = state.recheckLog.map(function (x) {
const cls = x.match ? 'ok' : 'bad';
const txt = x.match ? 'Совпадение' : 'Отклонение ' + x.diff.toFixed(2);
return '<div class="kt-ai-recheck-item"><span>' + x.company + ', ' + x.metric + '</span><span class="' + cls + '">' + txt + '</span></div>';
}).join('');
}
function bindVerify() {
const cs = el('recheckCompanySelect');
if (cs) cs.addEventListener('change', function () {
const cid = cs.value;
const ms = el('recheckMetricSelect');
if (ms) ms.innerHTML = METRICS.map(function (m) {
const val = rowsForCompany(cid).filter(function (r) { return r.metric === m.key; })[0];
return '<option value="' + m.key + '">' + m.name + (val ? ' (' + fmtValue(val) + ' ' + val.unit + ')' : '') + '</option>';
}).join('');
});
const btnRecheck = el('btnDoRecheck');
if (btnRecheck) btnRecheck.addEventListener('click', doRecheck);
const btnConfirm = el('btnConfirmFinal');
if (btnConfirm) btnConfirm.addEventListener('click', function () {
state.confirmed = true;
saveState();
renderVerify();
toast('Итоговая таблица подтверждена');
});
const btnBack = el('btnBackToSummary');
if (btnBack) btnBack.addEventListener('click', function () { showScreen('summary'); });
const btnSendBack = el('btnSendBack');
if (btnSendBack) btnSendBack.addEventListener('click', function () {
let n = 0;
state.rows.forEach(function (r) {
if (r.status === 'ai') {
r.status = 'ai-uncertain';
n++;
}
});
state.confirmed = false;
state.aiLog.unshift({
time: nowTime(),
text: 'Таблица отправлена на доработку: значения возвращены в режим проверки',
src: n + ' ячеек'
});
saveState();
renderVerify();
renderAiLog();
toast('Таблица отправлена на доработку', 'warn');
});
const btnDl = el('btnDownloadExcel');
if (btnDl) btnDl.addEventListener('click', downloadXlsx);
}
function doRecheck() {
const cid = el('recheckCompanySelect').value;
const mk = el('recheckMetricSelect').value;
const manual = parseFloat(el('recheckManualValue').value);
if (isNaN(manual)) {
toast('Введите значение для сверки');
return;
}
const row = state.rows.filter(function (r) { return r.company === cid && r.metric === mk; })[0];
const c = companyById(cid);
const m = METRICS.filter(function (x) { return x.key === mk; })[0];
const diff = manual - row.value;
const match = Math.abs(diff) < 0.01;
state.recheckLog.unshift({
company: c.name,
metric: m.name,
diff: diff,
match: match
});
saveState();
renderVerify();
const res = el('recheckResult');
res.hidden = false;
const diffEl = el('recheckDiff');
const statEl = el('recheckStatus');
diffEl.textContent = 'Отклонение: ' + (match ? '0.00' : diff.toFixed(2)) + ' ' + m.unit;
if (match) {
statEl.innerHTML = '<span class="kt-ai-status-pill" data-status="ok">Совпадение</span>';
toast('Данные совпадают');
} else {
statEl.innerHTML = '<span class="kt-ai-status-pill" data-status="risk">Расхождение</span>';
toast('Расхождение ' + diff.toFixed(2), 'warn');
}
}
/* ================= Excel export ================= */
function csvEscape(v) {
const s = String(v);
if (/[",\n;]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
function buildSheetXml(headers, rows) {
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n' +
'<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">' +
'<Worksheet ss:Name="Свод"><Table>';
headers.forEach(function (h) {
xml += '<Row><Cell><Data ss:Type="String">' + h + '</Data></Cell></Row>';
});
rows.forEach(function (r) {
xml += '<Row>';
r.forEach(function (cell, ci) {
const type = typeof cell === 'number' ? 'Number' : 'String';
xml += '<Cell><Data ss:Type="' + type + '">' + cell + '</Data></Cell>';
});
xml += '</Row>';
});
xml += '</Table></Worksheet></Workbook>';
return xml;
}
function addZipU32(buf, off, v) {
buf[off] = v & 0xff;
buf[off + 1] = (v >> 8) & 0xff;
buf[off + 2] = (v >> 16) & 0xff;
buf[off + 3] = (v >> 24) & 0xff;
}
function addZipU16(buf, off, v) {
buf[off] = v & 0xff;
buf[off + 1] = (v >> 8) & 0xff;
}
function crc32(buf) {
let c, table = crc32.table;
if (!table) {
table = crc32.table = [];
for (let n = 0; n < 256; n++) {
c = n;
for (let k = 0; k < 8; k++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
table[n] = c >>> 0;
}
}
let crc = 0 ^ (-1);
for (let i = 0; i < buf.length; i++) {
crc = (crc >>> 8) ^ table[(crc ^ buf[i]) & 0xFF];
}
return (crc ^ (-1)) >>> 0;
}
function makeZip(files) {
const chunks = [];
const central = [];
let offset = 0;
const enc = new TextEncoder();
files.forEach(function (file) {
const nameBytes = enc.encode(file.name);
const dataBytes = enc.encode(file.data);
const crc = crc32(dataBytes);
const local = new ArrayBuffer(30 + nameBytes.length);
const lview = new DataView(local);
addZipU32(lview, 0, 0x04034b50);
addZipU16(lview, 4, 20);
addZipU16(lview, 6, 0);
addZipU16(lview, 8, 0);
addZipU16(lview, 10, 0);
addZipU16(lview, 12, 0);
addZipU32(lview, 14, crc);
addZipU32(lview, 18, dataBytes.length);
addZipU32(lview, 22, dataBytes.length);
addZipU16(lview, 26, nameBytes.length);
addZipU16(lview, 28, 0);
const larr = new Uint8Array(local);
larr.set(nameBytes, 30);
chunks.push(larr, dataBytes);
const cen = new ArrayBuffer(46 + nameBytes.length);
const cview = new DataView(cen);
addZipU32(cview, 0, 0x02014b50);
addZipU16(cview, 4, 20);
addZipU16(cview, 6, 20);
addZipU16(cview, 8, 0);
addZipU16(cview, 10, 0);
addZipU16(cview, 12, 0);
addZipU16(cview, 14, 0);
addZipU32(cview, 16, crc);
addZipU32(cview, 20, dataBytes.length);
addZipU32(cview, 24, dataBytes.length);
addZipU16(cview, 28, nameBytes.length);
addZipU16(cview, 30, 0);
addZipU16(cview, 32, 0);
addZipU16(cview, 34, 0);
addZipU16(cview, 36, 0);
addZipU32(cview, 38, 0);
addZipU32(cview, 42, offset);
const carr = new Uint8Array(cen);
carr.set(nameBytes, 46);
central.push(carr);
offset += larr.length + dataBytes.length;
});
const centralSize = central.reduce(function (s, a) { return s + a.length; }, 0);
const eocd = new ArrayBuffer(22);
const ev = new DataView(eocd);
addZipU32(ev, 0, 0x06054b50);
addZipU16(ev, 4, 0);
addZipU16(ev, 6, 0);
addZipU16(ev, 8, files.length);
addZipU16(ev, 10, files.length);
addZipU32(ev, 12, centralSize);
addZipU32(ev, 16, offset);
addZipU16(ev, 20, 0);
return new Blob(chunks.concat(central, [new Uint8Array(eocd)]), { type: 'application/vnd.ms-excel' });
}
function downloadXlsx() {
const headers = ['Компания', 'Показатель', 'Значение', 'Единица', 'Источник', 'Статус проверки'];
const rows = state.rows.map(function (r) {
const c = companyById(r.company);
return [c.name, r.metricName, r.value, r.unit, r.source, statusLabel(r.status)];
});
const sheet = buildSheetXml(headers, rows);
const ct = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
'</Types>';
const rels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
'</Relationships>';
const wb = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
'<sheets><sheet name="Свод" sheetId="1" r:id="rId1"/></sheets></workbook>';
const wbRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' +
'</Relationships>';
const blob = makeZip([
{ name: '[Content_Types].xml', data: ct },
{ name: '_rels/.rels', data: rels },
{ name: 'xl/workbook.xml', data: wb },
{ name: 'xl/_rels/workbook.xml.rels', data: wbRels },
{ name: 'xl/worksheets/sheet1.xml', data: sheet }
]);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'svod_finansovyh_otchetov.xlsx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast('Единый Excel-файл сформирован');
}
/* ================= Dashboard screen ================= */
function renderDashboard() {
const metric = el('dashChartMetric') ? el('dashChartMetric').value : 'revenue';
const box = el('dashKpi');
if (box) {
const rev = sumMetric('revenue');
const prof = sumMetric('profit');
const exp = sumMetric('expenses');
const ratio = rev ? (prof / rev * 100) : 0;
box.innerHTML =
'<div class="kt-ai-kpi"><span class="value">' + rev.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + '</span><span class="label">Суммарная выручка, млн тг</span></div>' +
'<div class="kt-ai-kpi"><span class="value">' + prof.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + '</span><span class="label">Суммарная прибыль, млн тг</span></div>' +
'<div class="kt-ai-kpi"><span class="value">' + exp.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + '</span><span class="label">Суммарные расходы, млн тг</span></div>' +
'<div class="kt-ai-kpi"><span class="value">' + ratio.toLocaleString('ru-RU', { maximumFractionDigits: 1 }) + '%</span><span class="label">Рентабельность по выручке</span><span class="hint">прибыль / выручка</span></div>';
}
renderDashChart(metric);
renderChatLog();
}
function renderDashChart(metric) {
const chart = el('dashChart');
if (!chart) return;
const values = COMPANIES.map(function (c) {
const r = state.rows.filter(function (x) { return x.company === c.id && x.metric === metric; })[0];
return { name: c.name, value: r ? r.value : 0 };
});
const max = Math.max.apply(null, values.map(function (v) { return v.value; }));
const m = METRICS.filter(function (x) { return x.key === metric; })[0];
chart.innerHTML = values.map(function (v) {
const h = max ? (v.value / max) * 100 : 0;
return '<div class="bar" data-kt-tip="' + v.name + ': ' + v.value.toFixed(1) + ' ' + m.unit + '" style="height:' + h.toFixed(1) + '%">' +
'<span class="bar-label">' + v.name.replace('КТ-', '') + '</span>' +
'</div>';
}).join('');
}
function renderChatLog() {
const log = el('chatLog');
if (!log) return;
log.innerHTML = state.chat.map(function (m) {
return '<div class="kt-ai-chat-msg kt-ai-chat-' + (m.role === 'bot' ? 'bot' : 'user') + '">' + m.text + '</div>';
}).join('');
log.scrollTop = log.scrollHeight;
}
function bindDashboard() {
const sel = el('dashChartMetric');
if (sel) sel.addEventListener('change', function () { renderDashChart(sel.value); });
const form = el('chatForm');
if (form) form.addEventListener('submit', function (e) {
e.preventDefault();
const input = el('chatInput');
const q = input.value.trim();
if (!q) return;
input.value = '';
state.chat.push({ role: 'user', text: q });
state.chat.push({ role: 'bot', text: chatReply(q) });
saveState();
renderChatLog();
});
// simple chart tooltip via title fallback
document.addEventListener('mouseover', function (e) {
const tip = e.target.closest('[data-kt-tip]');
if (tip) { /* handled by design-system if present */ }
});
}
function chatReply(text) {
const q = text.toLowerCase();
const rev = sumMetric('revenue');
const prof = sumMetric('profit');
const exp = sumMetric('expenses');
const done = state.files.filter(function (f) { return f.status === 'done'; }).length;
const waiting = state.files.filter(function (f) { return f.status === 'waiting'; }).length;
const processing = state.files.filter(function (f) { return f.status === 'processing'; }).length;
if (q.includes('выручк')) {
const max = allRowsForMetric('revenue').slice().sort(function (a, b) { return b.value - a.value; })[0];
return 'Суммарная выручка — ' + rev.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + ' млн тг. Максимум у «' + companyById(max.company).name + '»: ' + fmtValue(max) + ' млн тг.';
}
if (q.includes('прибыл')) {
const max = allRowsForMetric('profit').slice().sort(function (a, b) { return b.value - a.value; })[0];
return 'Суммарная прибыль — ' + prof.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + ' млн тг. Максимум у «' + companyById(max.company).name + '»: ' + fmtValue(max) + ' млн тг.';
}
if (q.includes('расход')) {
return 'Суммарные расходы — ' + exp.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + ' млн тг. Средняя на компанию: ' + (exp / COMPANIES.length).toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + ' млн тг.';
}
if (q.includes('файл') || q.includes('статус') || q.includes('обработ')) {
return 'Обработано файлов: ' + done + ' из ' + state.files.length + '. В обработке: ' + processing + ', ожидает: ' + waiting + '.';
}
if (q.includes('компани')) {
return 'В сводной таблице ' + state.rows.length + ' записей по ' + COMPANIES.length + ' дочерним компаниям и 3 показателям.';
}
if (q.includes('рентабельност') || q.includes('эффектив')) {
const ratio = rev ? (prof / rev * 100) : 0;
return 'Рентабельность по выручке — ' + ratio.toLocaleString('ru-RU', { maximumFractionDigits: 1 }) + '% (прибыль ' + prof.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + ' / выручка ' + rev.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + ' млн тг).';
}
return 'Спросите про выручку, прибыль, расходы, рентабельность, статусы файлов или компании.';
}
/* ================= Init ================= */
function bindAll() {
bindNavigation();
bindUpload();
bindSummary();
bindVerify();
bindDashboard();
const btnToggle = el('btnToggleTheme');
if (btnToggle) btnToggle.addEventListener('click', function () {
const cur = document.documentElement.getAttribute('data-theme') || 'light';
document.documentElement.setAttribute('data-theme', cur === 'light' ? 'dark' : 'light');
});
}
function init() {
bindAll();
showScreen(state.screen || 'upload');
}
document.addEventListener('DOMContentLoaded', init);