From 01326e93c621cb4ecaa1c9fd16a828d8bce2743f Mon Sep 17 00:00:00 2001 From: host Date: Thu, 30 Jul 2026 16:54:52 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=20main.js?= =?UTF-8?q?=20=E2=80=94=20=D0=B3=D0=BB=D0=B0=D0=B2=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C:=20=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D0=B8=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F,?= =?UTF-8?q?=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=87=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B9,=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B5=D1=80=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D0=B0=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.js | 520 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 520 insertions(+) create mode 100644 main.js diff --git a/main.js b/main.js new file mode 100644 index 0000000..0637abd --- /dev/null +++ b/main.js @@ -0,0 +1,520 @@ +/** + * 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