diff --git a/main.js b/main.js deleted file mode 100644 index 0637abd..0000000 --- a/main.js +++ /dev/null @@ -1,520 +0,0 @@ -/** - * 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 = ` -
-
-
- ${c.totalDiff === 0 ? '✓' : (c.totalDiff > 0 ? '⚠' : 'ℹ')} -
-
-
- Итого сверено: ${c.totalDrivers} водителей -
-
- Сдано: ${formatMoney(c.totalGiven)} ₽ · - Собрано: ${formatMoney(c.totalCollected)} ₽ · - Расхождение: - - ${c.totalDiff > 0 ? '+' : ''}${formatMoney(c.totalDiff)} ₽ - -
-
- Совпадений: ${c.totalDrivers - c.discrepanciesCount} из ${c.totalDrivers} (${c.matchedPercent}%) - ${c.discrepanciesCount > 0 ? ` · Расхождений: ${c.discrepanciesCount}` : ''} -
-
-
- ${r.unmatchedTx.length > 0 ? ` -
-
-
-
- Не сопоставлено транзакций: ${r.unmatchedTx.length} -
-
- Эти водители есть в выгрузке, но не найдены в ведомостях. - Возможные причины: опечатки в ФИО, разные гос. номера, или водитель работал без ведомости. -
-
-
` : ''} -
`; -} - -/* ================================================================ - ЭКСПОРТ - ================================================================ */ - -function onExportCsv() { - if (!state.result) return; - const data = state.result.drivers.map(r => ({ - driver: r.driver, - car: r.car, - route: r.route, - cashier: r.cashier, - date: r.date, - given: r.given, - collected: r.collected, - diff: r.diff, - status: r.status === 'ok' ? 'Совпадает' : (r.status === 'warn' ? 'Мелкое расхождение' : 'Расхождение') - })); - const csv = arrayToCsv(data, ['driver', 'car', 'route', 'cashier', 'date', 'given', 'collected', 'diff', 'status']); - downloadFile(csv, 'sverka_vyruchki.csv'); - showStatus('CSV-файл скачан', 'success'); -} - -function onDownloadExcel() { - if (!state.result) return; - const data = state.result.drivers.map(r => ({ - 'Водитель': r.driver, - 'Гос. номер': r.car, - 'Маршрут': r.route, - 'Кассир': r.cashier, - 'Дата': r.date, - 'Сдано (₽)': r.given, - 'Собрано (₽)': r.collected, - 'Расхождение (₽)': r.diff, - 'Статус': r.status === 'ok' ? 'Совпадает' : (r.status === 'warn' ? 'Мелкое расхождение' : 'Расхождение') - })); - downloadExcel(data, 'sverka_vyruchki.xlsx'); - showStatus('Excel-отчёт скачан', 'success'); -} - -/* ================================================================ - ОЧИСТКА - ================================================================ */ - -function onClear() { - if (state.isProcessing) return; - state.files = []; - state.fileTypes = {}; - state.parsedVedomosti = []; - state.parsedTransactions = []; - state.result = null; - dom.fileInput.value = ''; - renderFileList(); - updateUI(); - hideResult(); - showStatus('Все файлы удалены', 'info'); -} - -/* ================================================================ - ОБНОВЛЕНИЕ UI - ================================================================ */ - -function updateUI() { - const hasFiles = state.files.length > 0; - const hasResult = state.result !== null; - - dom.btnReconcile.disabled = !hasFiles || state.isProcessing; - dom.btnClear.disabled = !hasFiles || state.isProcessing; - dom.btnExportCsv.disabled = !hasResult; - dom.btnDownload.disabled = !hasResult; -} - -function hideResult() { - dom.emptyState.style.display = ''; - dom.resultActions.style.display = 'none'; - dom.tabsContainer.style.display = 'none'; -} - -/* ================================================================ - СТАТУС-БАР - ================================================================ */ - -function showStatus(message, type = 'info') { - const bar = dom.statusBar; - if (!bar) return; - bar.textContent = message; - bar.className = 'status-bar'; - bar.classList.add('status-' + type); - bar.style.display = ''; - // Авто-скрытие через 8 секунд для успеха и информации - if (type === 'success' || type === 'info') { - clearTimeout(bar._hideTimer); - bar._hideTimer = setTimeout(() => { - bar.style.display = 'none'; - }, 8000); - } -} \ No newline at end of file