/** * main.js — Главный модуль приложения * * Инициализация, обработчики событий, координация модулей. */ import { readExcel, validateFile, arrayToCsv, downloadFile, downloadExcel, formatMoney } from './utils.js'; import { renderFileItem, renderFileError, renderTableBody, enableSorting, initTabs, initTheme, showProgress, resetProgress } from './ui.js'; import { detectFileType, parseVedomost, parseTransactions, reconcile } from './parser.js'; /* ================================================================ СОСТОЯНИЕ ПРИЛОЖЕНИЯ ================================================================ */ const state = { files: [], // загруженные File-объекты fileTypes: {}, // { index: 'vedomost'|'transaction' } parsedVedomosti: [], // распарсенные ведомости parsedTransactions: [], // распарсенные транзакции result: null, // результат сверки isProcessing: false // флаг блокировки }; /* ================================================================ DOM-ССЫЛКИ ================================================================ */ const $ = (id) => document.getElementById(id); const dom = { dropzone: $('dropzone'), fileInput: $('fileInput'), fileList: $('fileList'), btnReconcile: $('btnReconcile'), btnClear: $('btnClear'), btnExportCsv: $('btnExportCsv'), btnDownload: $('btnDownload'), btnToggleTheme: $('btnToggleTheme'), progressBar: $('progressBar'), progressFill: $('progressFill'), emptyState: $('emptyState'), resultActions: $('resultActions'), tabsContainer: $('tabsContainer'), // Таблицы tableDiscrepancies: $('tableDiscrepancies'), tableAll: $('tableAll'), tableCashiers: $('tableCashiers'), tableRoutes: $('tableRoutes'), // Счётчики countDisc: $('countDisc'), countAll: $('countAll'), // Выводы conclusionsContent: $('conclusionsContent'), // Статус statusBar: $('statusBar') }; /* ================================================================ ИНИЦИАЛИЗАЦИЯ ================================================================ */ document.addEventListener('DOMContentLoaded', () => { initTheme('btnToggleTheme'); initTabs(); bindEvents(); updateUI(); showStatus('Загрузите файлы ведомостей и выгрузку транзакций для начала работы', 'info'); }); /* ================================================================ СОБЫТИЯ ================================================================ */ function bindEvents() { // Dropzone dom.dropzone.addEventListener('click', () => dom.fileInput.click()); dom.dropzone.addEventListener('dragover', onDragOver); dom.dropzone.addEventListener('dragleave', onDragLeave); dom.dropzone.addEventListener('drop', onDrop); dom.fileInput.addEventListener('change', onFileInputChange); // Кнопки dom.btnReconcile.addEventListener('click', onReconcile); dom.btnClear.addEventListener('click', onClear); dom.btnExportCsv.addEventListener('click', onExportCsv); dom.btnDownload.addEventListener('click', onDownloadExcel); } /* ================================================================ ОБРАБОТЧИКИ ФАЙЛОВ ================================================================ */ function onDragOver(e) { e.preventDefault(); dom.dropzone.classList.add('dragover'); } function onDragLeave() { dom.dropzone.classList.remove('dragover'); } function onDrop(e) { e.preventDefault(); dom.dropzone.classList.remove('dragover'); handleFiles(e.dataTransfer.files); } function onFileInputChange(e) { handleFiles(e.target.files); } async function handleFiles(fileList) { if (state.isProcessing) { showStatus('Дождитесь завершения текущей операции', 'warning'); return; } let added = 0; for (const file of fileList) { if (!validateFile(file)) continue; // Проверка на дубликат if (state.files.some(f => f.name === file.name && f.size === file.size)) { showStatus(`Файл "${file.name}" уже загружен`, 'warning'); continue; } state.files.push(file); added++; } if (added === 0) return; // Определяем типы файлов асинхронно await detectTypes(); // Проверяем, что есть хотя бы одна ведомость и одна выгрузка const hasVedomost = Object.values(state.fileTypes).includes('vedomost'); const hasTransaction = Object.values(state.fileTypes).includes('transaction'); renderFileList(); updateUI(); if (hasVedomost && hasTransaction) { showStatus('Файлы загружены. Нажмите «Провести сверку»', 'success'); } else if (hasVedomost) { showStatus('Загружена ведомость. Добавьте выгрузку транзакций (TRANSACTIONS_EXT)', 'info'); } else if (hasTransaction) { showStatus('Загружена выгрузка транзакций. Добавьте ведомости', 'info'); } else { showStatus('Файлы не распознаны. Загрузите ведомости (.xlsx) и/или выгрузку TRANSACTIONS_EXT', 'warning'); } } /** * Определить типы всех загруженных файлов */ async function detectTypes() { for (let i = 0; i < state.files.length; i++) { if (state.fileTypes[i]) continue; // уже определён try { const sheets = await readExcel(state.files[i]); const firstSheet = sheets[0]?.data || []; const type = detectFileType(state.files[i].name, firstSheet); if (type) { state.fileTypes[i] = type; } else { state.fileTypes[i] = 'unknown'; } } catch (err) { console.error('Ошибка чтения файла:', state.files[i].name, err); state.fileTypes[i] = 'error'; } } } /** * Рендер списка файлов */ function renderFileList() { dom.fileList.innerHTML = ''; state.files.forEach((file, i) => { const type = state.fileTypes[i]; if (type === 'error' || type === 'unknown') { const msg = type === 'error' ? 'Ошибка чтения файла' : 'Не удалось определить тип'; dom.fileList.appendChild(renderFileError(file.name, msg)); } else { dom.fileList.appendChild(renderFileItem(file, i, type)); } }); // Кнопки удаления dom.fileList.querySelectorAll('.file-remove').forEach(btn => { btn.addEventListener('click', (e) => { const idx = parseInt(e.currentTarget.dataset.i); state.files.splice(idx, 1); // Перестраиваем индексы const newTypes = {}; Object.keys(state.fileTypes).forEach(k => { const ki = parseInt(k); if (ki < idx) newTypes[ki] = state.fileTypes[k]; else if (ki > idx) newTypes[ki - 1] = state.fileTypes[k]; }); state.fileTypes = newTypes; state.parsedVedomosti = []; state.parsedTransactions = []; state.result = null; renderFileList(); updateUI(); hideResult(); showStatus('Файл удалён', 'info'); }); }); } /* ================================================================ СВЕРКА ================================================================ */ async function onReconcile() { if (state.isProcessing) return; state.isProcessing = true; dom.btnReconcile.disabled = true; try { showProgress(1200); // Парсим все файлы state.parsedVedomosti = []; state.parsedTransactions = []; for (let i = 0; i < state.files.length; i++) { const type = state.fileTypes[i]; if (type === 'unknown' || type === 'error') continue; const sheets = await readExcel(state.files[i]); const data = sheets[0]?.data || []; if (type === 'vedomost') { const ved = parseVedomost(data); if (ved && ved.drivers.length > 0) { state.parsedVedomosti.push(ved); } else { showStatus(`Ведомость "${state.files[i].name}" не содержит данных о водителях`, 'warning'); } } else if (type === 'transaction') { const txs = parseTransactions(data); if (txs.length > 0) { state.parsedTransactions.push(...txs); } else { showStatus(`Выгрузка "${state.files[i].name}" не содержит транзакций`, 'warning'); } } } // Проверка наличия данных if (state.parsedVedomosti.length === 0) { showStatus('Не найдено ни одной ведомости с данными о водителях', 'error'); state.isProcessing = false; dom.btnReconcile.disabled = false; resetProgress(); return; } if (state.parsedTransactions.length === 0) { showStatus('Не найдено транзакций для сверки', 'error'); state.isProcessing = false; dom.btnReconcile.disabled = false; resetProgress(); return; } // Выполняем сверку state.result = reconcile(state.parsedVedomosti, state.parsedTransactions); if (state.result.drivers.length === 0) { showStatus('Не удалось сопоставить ни одного водителя. Проверьте формат файлов', 'error'); state.isProcessing = false; dom.btnReconcile.disabled = false; resetProgress(); return; } renderResult(); updateUI(); showStatus( `✅ Сверка завершена: ${state.result.drivers.length} водителей, ` + `${state.result.conclusions.discrepanciesCount} расхождений`, state.result.conclusions.discrepanciesCount > 0 ? 'warning' : 'success' ); } catch (err) { console.error('Ошибка сверки:', err); showStatus('❌ Ошибка при выполнении сверки: ' + err.message, 'error'); } finally { state.isProcessing = false; dom.btnReconcile.disabled = false; resetProgress(); } } /* ================================================================ РЕНДЕР РЕЗУЛЬТАТА ================================================================ */ function renderResult() { if (!state.result) return; const r = state.result; // Скрываем пустое состояние, показываем результат dom.emptyState.style.display = 'none'; dom.resultActions.style.display = ''; dom.tabsContainer.style.display = ''; // --- Расхождения --- const discData = r.drivers.filter(d => d.diff !== 0); renderTableBody(dom.tableDiscrepancies, discData, ['driver', 'car', 'given', 'collected', 'diff', 'status'], (val, key, row) => { if (key === 'status') { const labels = { ok: '✓ Совпадает', warn: `⚠ ${formatMoney(row.diff)} ₽`, err: `✕ ${formatMoney(Math.abs(row.diff))} ₽` }; return `${labels[val] || val}`; } if (key === 'diff') { if (val === 0) return `0 ₽`; return `${val > 0 ? '+' : ''}${formatMoney(val)} ₽`; } if (typeof val === 'number') return formatMoney(val); return val || '—'; }); enableSorting('tableDiscrepancies', discData, (sorted) => { renderTableBody(dom.tableDiscrepancies, sorted, ['driver', 'car', 'given', 'collected', 'diff', 'status']); }); dom.countDisc.textContent = discData.length; // --- Все водители --- const allData = r.drivers.map(d => ({ date: d.date, driver: d.driver, route: d.route, section: d.section, given: d.given, collected: d.collected, txCount: d.txCount })); renderTableBody(dom.tableAll, allData, ['date', 'driver', 'route', 'section', 'given', 'collected', 'txCount'], (val, key) => { if (key === 'section') { return val === 'Город' ? '🏙️ Город' : '🚌 Пригород'; } if (typeof val === 'number') return formatMoney(val); return val || '—'; }); enableSorting('tableAll', allData, (sorted) => { renderTableBody(dom.tableAll, sorted, ['date', 'driver', 'route', 'section', 'given', 'collected', 'txCount']); }); dom.countAll.textContent = allData.length; // --- По кассирам --- renderTableBody(dom.tableCashiers, r.cashiers, ['cashier', 'count', 'given', 'collected', 'diff'], (val, key) => { if (key === 'diff') { if (val === 0) return `0 ₽`; return `${val > 0 ? '+' : ''}${formatMoney(val)} ₽`; } if (typeof val === 'number') return formatMoney(val); return val || '—'; }); enableSorting('tableCashiers', r.cashiers, (sorted) => { renderTableBody(dom.tableCashiers, sorted, ['cashier', 'count', 'given', 'collected', 'diff']); }); // --- По маршрутам --- renderTableBody(dom.tableRoutes, r.routes, ['route', 'type', 'drivers', 'sum'], (val, key) => { if (typeof val === 'number') return formatMoney(val); return val || '—'; }); enableSorting('tableRoutes', r.routes, (sorted) => { renderTableBody(dom.tableRoutes, sorted, ['route', 'type', 'drivers', 'sum']); }); // --- Выводы --- const c = r.conclusions; const diffClass = c.totalDiff === 0 ? '' : (c.totalDiff > 0 ? 'err' : 'warn'); dom.conclusionsContent.innerHTML = `