Files
driver-revenue-reconciliation/main.js
T

520 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 `<span class="badge badge-${val}">${labels[val] || val}</span>`;
}
if (key === 'diff') {
if (val === 0) return `<span class="diff-zero">0 ₽</span>`;
return `<span class="${val > 0 ? 'diff-positive' : 'diff-negative'}">${val > 0 ? '+' : ''}${formatMoney(val)} ₽</span>`;
}
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 `<span class="diff-zero">0 ₽</span>`;
return `<span class="${val > 0 ? 'diff-positive' : 'diff-negative'}">${val > 0 ? '+' : ''}${formatMoney(val)} ₽</span>`;
}
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 = `
<div class="summary-card">
<div class="info-block ${diffClass}">
<div class="info-block-icon ${c.totalDiff === 0 ? 'green' : (c.totalDiff > 0 ? 'rose' : 'amber')}">
${c.totalDiff === 0 ? '✓' : (c.totalDiff > 0 ? '⚠' : '')}
</div>
<div>
<div class="info-block-title" style="color:${c.totalDiff === 0 ? 'var(--success-text)' : 'var(--danger-text)'}">
Итого сверено: ${c.totalDrivers} водителей
</div>
<div class="info-block-text">
<strong>Сдано:</strong> ${formatMoney(c.totalGiven)} ₽ ·
<strong>Собрано:</strong> ${formatMoney(c.totalCollected)} ₽ ·
<strong>Расхождение:</strong>
<span style="color:${c.totalDiff === 0 ? 'var(--text-muted)' : (c.totalDiff > 0 ? 'var(--danger)' : 'var(--success)')};font-weight:600">
${c.totalDiff > 0 ? '+' : ''}${formatMoney(c.totalDiff)}
</span>
</div>
<div class="info-block-text" style="margin-top:0.5rem;">
Совпадений: ${c.totalDrivers - c.discrepanciesCount} из ${c.totalDrivers} (${c.matchedPercent}%)
${c.discrepanciesCount > 0 ? `<span style="color:var(--danger)"> · Расхождений: ${c.discrepanciesCount}</span>` : ''}
</div>
</div>
</div>
${r.unmatchedTx.length > 0 ? `
<div class="info-block warn" style="margin-top:1rem;">
<div class="info-block-icon amber">⚠</div>
<div>
<div class="info-block-title" style="color:var(--warning-text)">
Не сопоставлено транзакций: ${r.unmatchedTx.length}
</div>
<div class="info-block-text">
Эти водители есть в выгрузке, но не найдены в ведомостях.
Возможные причины: опечатки в ФИО, разные гос. номера, или водитель работал без ведомости.
</div>
</div>
</div>` : ''}
</div>`;
}
/* ================================================================
ЭКСПОРТ
================================================================ */
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);
}
}