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 '' + statusLabel(status) + '';
}
function fileStatusPillHtml(status) {
const map = {
waiting: 'muted',
processing: 'info',
done: 'ok'
};
const tone = map[status] || 'muted';
return '' + statusLabel(status) + '';
}
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 =
'
' + total + 'Всего файлов
' +
'' + done + 'Готово
' +
'' + processing + 'В обработке
' +
'' + waiting + 'Ожидает
';
return html;
}
function fileRowHtml(f) {
const actions =
'' +
'' +
'' +
'
';
return '' +
'| ' + f.companyName + ' | ' +
'Excel ' + f.fileName + ' | ' +
'' + f.sentDate + ' | ' +
'' + f.template + ' | ' +
'' + fileStatusPillHtml(f.status) + ' | ' +
'' + actions + ' | ' +
'
';
}
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 '' + rev.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + 'Суммарная выручка, млн тг
' +
'' + ok + 'Значений найдено ИИ
' +
'' + warn + 'Требуют проверки
' +
'' + manual + 'В ручном режиме
';
}
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 = '';
} else if (r.status === 'ai') {
actions = '';
} else if (r.status === 'manual') {
actions = '';
}
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 '' +
'| ' + (c ? c.name : r.company) + ' | ' +
'' +
'' + r.metricName + '' +
(showRename ? '' + renameTxt + '' : '') +
' | ' +
'' +
fmtValue(r) + ' ' + r.unit +
' | ' +
'' + r.source + ' | ' +
'' + statusPillHtml(r.status) + ' ' + rowActionsForSummary(r) + ' | ' +
'
';
}
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 '' +
'' + l.time + '' +
'' + l.text + '' +
'' + l.src + '' +
'
';
}).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 = '' + COMPANIES.map(function (c) {
return '';
}).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'
? 'Ручная проверка'
: 'ИИ';
return '' +
'| ' + (c ? c.name : r.company) + ' | ' +
'' + r.metricName + ' | ' +
'' + fmtValue(r) + ' ' + r.unit + ' | ' +
'' + pill + ' | ' +
'
';
}).join('');
}
// company / metric selects
const cs = el('recheckCompanySelect');
if (cs) {
const cur = cs.value || 'c1';
cs.innerHTML = COMPANIES.map(function (c) {
return '';
}).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 '';
}).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
? 'Готово к выдаче'
: 'Ожидает утверждения';
box.innerHTML =
'' + total + 'Записей в итоговой таблице
' +
'' + rechecked + 'Выборочно сверено
' +
'' + passed + 'Совпадений
' +
'' + confirmedText + 'Статус готовности' + confirmedPill + '
';
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 '' + x.company + ', ' + x.metric + '' + txt + '
';
}).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 '';
}).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 = 'Совпадение';
toast('Данные совпадают');
} else {
statEl.innerHTML = 'Расхождение';
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 = '\n' +
'' +
'';
headers.forEach(function (h) {
xml += '| ' + h + ' |
';
});
rows.forEach(function (r) {
xml += '';
r.forEach(function (cell, ci) {
const type = typeof cell === 'number' ? 'Number' : 'String';
xml += '| ' + cell + ' | ';
});
xml += '
';
});
xml += '
';
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 = '\r\n' +
'' +
'' +
'' +
'' +
'';
const rels = '\r\n' +
'' +
'';
const wb = '\r\n' +
'';
const wbRels = '\r\n' +
'' +
'';
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 =
'' + rev.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + 'Суммарная выручка, млн тг
' +
'' + prof.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + 'Суммарная прибыль, млн тг
' +
'' + exp.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + 'Суммарные расходы, млн тг
' +
'' + ratio.toLocaleString('ru-RU', { maximumFractionDigits: 1 }) + '%Рентабельность по выручкеприбыль / выручка
';
}
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 '' +
'' + v.name.replace('КТ-', '') + '' +
'
';
}).join('');
}
function renderChatLog() {
const log = el('chatLog');
if (!log) return;
log.innerHTML = state.chat.map(function (m) {
return '' + m.text + '
';
}).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();
}
function init() {
bindAll();
showScreen(state.screen || 'upload');
}
document.addEventListener('DOMContentLoaded', init);