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