Files
driver-revenue-reconciliation/parser.js
T

635 lines
24 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.
/**
* parser.js — Парсинг и сверка данных
*
* Определение типа файла, извлечение ведомостей и транзакций,
* сопоставление по водителям + гос. номерам, расчёт расхождений.
*/
import { readExcel } from './utils.js';
/* ================================================================
КОНСТАНТЫ
================================================================ */
/** Заголовок, по которому распознаётся ведомость */
const VEDOMOST_HEADER = 'ВЕДОМОСТЬ ПРИЕМА НАЛИЧНЫХ СРЕДСТВ';
/** Столбцы, по которым распознаётся выгрузка транзакций */
const TRANSACTION_COLS = ['CONDUCTOR', 'DATE', 'TARIF_SUM'];
/** Латинские буквы, которые заменяются на кириллические в гос. номерах */
const LATIN_TO_CYRILLIC = {
'A': 'А', 'B': 'В', 'C': 'С', 'E': 'Е', 'H': 'Н',
'K': 'К', 'M': 'М', 'O': 'О', 'P': 'Р', 'T': 'Т',
'X': 'Х', 'Y': 'У'
};
/** Порог схожести ФИО для нечёткого сравнения (0..1) */
const FUZZY_THRESHOLD = 0.88;
/* ================================================================
ТИПИЗАЦИЯ ФАЙЛОВ
================================================================ */
/**
* Определить тип файла: 'vedomost' | 'transaction' | null
* @param {string} filename
* @param {string[][]} firstSheet - первый лист книги
* @returns {string|null}
*/
export function detectFileType(filename, firstSheet) {
// 1. Проверка по заголовку ведомости
if (hasHeader(firstSheet, VEDOMOST_HEADER)) {
return 'vedomost';
}
// 2. Проверка по названию файла
const lower = filename.toLowerCase();
if (lower.includes('transaction') || lower.includes('transactions') || lower.includes('выгрузка')) {
return 'transaction';
}
// 3. Проверка по столбцам транзакций (первые 3 строки)
if (hasTransactionColumns(firstSheet)) {
return 'transaction';
}
// 4. Если есть знакомые заголовки — ведомость
if (hasHeader(firstSheet, 'ВЕДОМОСТЬ')) {
return 'vedomost';
}
return null;
}
/**
* Проверить, содержит ли лист указанный заголовок
*/
function hasHeader(sheetData, header) {
if (!sheetData || sheetData.length === 0) return false;
// Проверяем первые 20 строк
for (let i = 0; i < Math.min(sheetData.length, 20); i++) {
const row = sheetData[i];
if (!row) continue;
const joined = row.filter(c => c != null).map(String).join(' ').toUpperCase();
if (joined.includes(header.toUpperCase())) return true;
}
return false;
}
/**
* Проверить, содержит ли лист столбцы транзакций
*/
function hasTransactionColumns(sheetData) {
if (!sheetData || sheetData.length === 0) return false;
// Проверяем первые 5 строк
for (let i = 0; i < Math.min(sheetData.length, 5); i++) {
const row = sheetData[i];
if (!row) continue;
const cells = row.map(c => String(c).toUpperCase().trim());
const hasConductor = cells.some(c => c.includes('CONDUCTOR'));
const hasDate = cells.some(c => c.includes('DATE'));
const hasTarif = cells.some(c => c.includes('TARIF_SUM') || c.includes('TARIF'));
if (hasConductor && hasDate && hasTarif) return true;
}
return false;
}
/* ================================================================
НОРМАЛИЗАЦИЯ ДАННЫХ
================================================================ */
/**
* Нормализовать гос. номер: заменить латиницу на кириллицу, удалить лишнее
* @param {string} plate
* @returns {string}
*/
export function normalizePlate(plate) {
if (!plate) return '';
let s = String(plate).toUpperCase().trim();
// Замена латинских букв на кириллические
s = s.split('').map(ch => LATIN_TO_CYRILLIC[ch] || ch).join('');
// Удаление пробелов, дефисов и непечатных символов, кроме букв и цифр
s = s.replace(/[^А-ЯЁA-Z0-9]/gi, '');
return s;
}
/**
* Нормализовать ФИО: верхний регистр, Ё→Е, обрезка
* @param {string} name
* @returns {string}
*/
export function normalizeName(name) {
if (!name) return '';
return String(name)
.toUpperCase()
.trim()
.replace(/Ё/g, 'Е')
.replace(/\s+/g, ' ');
}
/**
* Нечёткое сравнение ФИО (расстояние Левенштейна → схожесть)
* @param {string} a
* @param {string} b
* @returns {number} 0..1
*/
export function nameSimilarity(a, b) {
if (!a || !b) return 0;
const s1 = normalizeName(a);
const s2 = normalizeName(b);
if (s1 === s2) return 1;
if (s1.length === 0 || s2.length === 0) return 0;
const dist = levenshtein(s1, s2);
const maxLen = Math.max(s1.length, s2.length);
return 1 - dist / maxLen;
}
/**
* Расстояние Левенштейна
*/
function levenshtein(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1]
? dp[i - 1][j - 1]
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
return dp[m][n];
}
/* ================================================================
ПАРСИНГ ВЕДОМОСТИ
================================================================ */
/**
* Распарсить ведомость и вернуть структурированные данные
* @param {string[][]} sheetData - массив строк листа
* @returns {Object} - { docNumber, date, cashier, sections: { city, suburb }, drivers: [] }
*/
export function parseVedomost(sheetData) {
if (!sheetData || sheetData.length === 0) return null;
const result = {
docNumber: '',
date: '',
cashier: '',
sections: { city: [], suburb: [] },
drivers: []
};
let currentSection = null; // 'city' | 'suburb'
let headerFound = false;
let tableStarted = false;
let tableEnded = false;
for (let i = 0; i < sheetData.length; i++) {
const row = sheetData[i];
if (!row || row.length === 0) continue;
const cells = row.map(c => String(c).trim());
// Поиск заголовка ведомости
if (!headerFound) {
const joined = cells.join(' ').toUpperCase();
if (joined.includes(VEDOMOST_HEADER)) {
headerFound = true;
}
continue;
}
// Номер документа и дата
if (!result.docNumber) {
const numIdx = cells.findIndex(c => c.toUpperCase().includes('ДОКУМЕНТ'));
if (numIdx >= 0) {
result.docNumber = cells[numIdx + 1] || '';
// Дата может быть в той же строке или следующей
const dateIdx = cells.findIndex(c => /^\d{2}[./-]\d{2}[./-]\d{4}$/.test(c));
if (dateIdx >= 0) result.date = cells[dateIdx];
}
}
// Дата (если не нашли выше)
if (!result.date) {
const dateMatch = cells.find(c => /^\d{2}[./-]\d{2}[./-]\d{4}$/.test(c));
if (dateMatch) result.date = dateMatch;
}
// Кассир
if (!result.cashier) {
const cashIdx = cells.findIndex(c => c.toUpperCase().includes('КАССИР'));
if (cashIdx >= 0) {
// Ищем ФИО после слова "Кассир"
const nameIdx = cashIdx + 1;
if (nameIdx < cells.length && cells[nameIdx].length > 2) {
result.cashier = cells[nameIdx];
}
}
}
// Определение секции (Город / Пригород)
const joinedRow = cells.join(' ').toUpperCase();
if (joinedRow.includes('ГОРОД') || joinedRow.includes('ГОР.') || (joinedRow.includes('ГОР') && !joinedRow.includes('ПРИГОР'))) {
currentSection = 'city';
tableStarted = false; // Таблица начинается после заголовка секции
continue;
}
if (joinedRow.includes('ПРИГОРОД') || joinedRow.includes('ПРИГ.')) {
currentSection = 'suburb';
tableStarted = false;
continue;
}
// Пропуск пустых и служебных строк
if (cells.every(c => c === '')) {
if (tableStarted) tableEnded = true; // Пустая строка = конец таблицы
continue;
}
// Определение начала таблицы с водителями
const headerRow = cells.join(' ').toUpperCase();
if (headerRow.includes('ФИО') || headerRow.includes('Ф.И.О') || headerRow.includes('ВОДИТЕЛЬ') ||
headerRow.includes('ФАМИЛИЯ') || (headerRow.includes('ФИО') && (headerRow.includes('СУММА') || headerRow.includes('МАРШРУТ')))) {
tableStarted = true;
tableEnded = false;
continue;
}
// Если таблица началась, закончилась и мы снова видим непустую строку — возможно новая секция
if (tableEnded && !tableStarted) continue;
// Парсинг строки водителя (только внутри таблицы)
if (tableStarted && !tableEnded && currentSection) {
const driver = parseDriverRow(cells, currentSection);
if (driver) {
result.drivers.push(driver);
result.sections[currentSection].push(driver);
}
}
}
return result;
}
/**
* Распарсить строку водителя из ведомости
* @param {string[]} cells
* @param {string} section - 'city' | 'suburb'
* @returns {Object|null}
*/
function parseDriverRow(cells, section) {
if (!cells || cells.length < 2) return null;
// Фильтр: отбрасываем совсем пустые строки
const nonEmpty = cells.filter(c => c.trim() !== '');
if (nonEmpty.length < 2) return null;
// Пропускаем итоговые строки
const joined = cells.join(' ').toUpperCase();
if (joined.includes('ИТОГО') || joined.includes('ВСЕГО') || joined.includes('ПО РАЗДЕЛУ')) {
return null;
}
// Ищем ФИО — обычно первая колонка
const fio = cells[0] || '';
if (fio.length < 3 || /^\d+$/.test(fio)) return null;
// Ищем гос. номер — паттерн: буква + 3 цифры + 2 буквы + 2-3 цифры (регион)
let plate = '';
let plateIdx = -1;
for (let j = 0; j < cells.length; j++) {
const cleaned = cells[j].replace(/[\s-]/g, '').toUpperCase();
if (/^[А-ЯA-Z]{1}\d{3}[А-ЯA-Z]{2}\d{2,3}$/.test(cleaned) ||
/^[А-ЯA-Z]{1}\d{3}[А-ЯA-Z]{2}$/.test(cleaned)) {
plate = cleaned;
plateIdx = j;
break;
}
}
// Ищем маршрут (номер маршрута)
let route = '';
for (let j = 1; j < cells.length; j++) {
const c = cells[j].trim();
if (/^\d{1,3}$/.test(c) && c !== plate) {
route = c;
break;
}
}
// Ищем сумму — последнее число или после ключевых слов
let sum = 0;
for (let j = cells.length - 1; j >= 0; j--) {
const c = cells[j].trim().replace(/\s/g, '').replace(',', '.');
const num = parseFloat(c);
if (!isNaN(num) && num > 0) {
// Проверяем, что это не номер маршрута и не индекс
if (j !== plateIdx && j !== 0) {
sum = num;
break;
}
}
}
return {
fio: normalizeName(fio),
plate: normalizePlate(plate),
section,
route,
sum, // "Сдано" — итого на сумму
cash: 0, // будет заполнено позже
nonCash: 0
};
}
/* ================================================================
ПАРСИНГ ТРАНЗАКЦИЙ
================================================================ */
/**
* Распарсить выгрузку транзакций
* @param {string[][]} sheetData
* @returns {Object[]} - массив транзакций
*/
export function parseTransactions(sheetData) {
if (!sheetData || sheetData.length === 0) return [];
// Найдём строку заголовков и индексы колонок
let headerRow = -1;
let colIndexes = {};
for (let i = 0; i < Math.min(sheetData.length, 10); i++) {
const row = sheetData[i];
if (!row) continue;
const cells = row.map(c => String(c).toUpperCase().trim());
const idxDate = cells.findIndex(c => c.includes('DATE'));
const idxConductor = cells.findIndex(c => c.includes('CONDUCTOR'));
const idxTarif = cells.findIndex(c => c.includes('TARIF_SUM') || (c.includes('TARIF') && !c.includes('TARIF_PAY')));
const idxCar = cells.findIndex(c => c.includes('CAR') || c.includes('PLATE') || c.includes('AUTO'));
const idxRoute = cells.findIndex(c => c.includes('ROUTE') || c.includes('RUTE'));
const idxTarifPay = cells.findIndex(c => c.includes('TARIF_PAY') || c.includes('TARIFPAY'));
if (idxDate >= 0 && idxConductor >= 0 && idxTarif >= 0) {
headerRow = i;
colIndexes = {
date: idxDate,
conductor: idxConductor,
tarifSum: idxTarif,
car: idxCar >= 0 ? idxCar : -1,
route: idxRoute >= 0 ? idxRoute : -1,
tarifPay: idxTarifPay >= 0 ? idxTarifPay : -1
};
break;
}
}
if (headerRow < 0) return [];
// Парсинг строк данных
const transactions = [];
for (let i = headerRow + 1; i < sheetData.length; i++) {
const row = sheetData[i];
if (!row || row.length === 0) continue;
const date = String(row[colIndexes.date] || '').trim();
const conductor = String(row[colIndexes.conductor] || '').trim();
const tarifSumStr = String(row[colIndexes.tarifSum] || '0').trim().replace(',', '.');
const tarifSum = parseFloat(tarifSumStr);
// Пропускаем пустые или невалидные строки
if (!date || !conductor || isNaN(tarifSum)) continue;
// Проверка, что это строка данных, а не заголовок
if (conductor.toUpperCase().includes('CONDUCTOR')) continue;
const car = colIndexes.car >= 0 ? String(row[colIndexes.car] || '').trim() : '';
const route = colIndexes.route >= 0 ? String(row[colIndexes.route] || '').trim() : '';
transactions.push({
date: date,
conductor: normalizeName(conductor),
plate: normalizePlate(car),
route: route,
tarifSum: tarifSum, // в копейках
tarifRub: tarifSum / 100, // в рублях
tarifPay: colIndexes.tarifPay >= 0 ? parseFloat(String(row[colIndexes.tarifPay] || '0').replace(',', '.')) : 0
});
}
return transactions;
}
/* ================================================================
СВЕРКА
================================================================ */
/**
* Выполнить сверку: сопоставить ведомости и транзакции
* @param {Object[]} vedomosti - массив распарсенных ведомостей
* @param {Object[]} transactions - массив транзакций
* @returns {Object} - результат сверки
*/
export function reconcile(vedomosti, transactions) {
// 1. Собираем уникальные даты из ведомостей
const vedomostDates = new Set();
vedomosti.forEach(v => {
if (v.date) vedomostDates.add(normalizeDate(v.date));
});
// 2. Фильтруем транзакции только по датам ведомостей
const filteredTransactions = transactions.filter(t => {
const tDate = normalizeDate(t.date);
return vedomostDates.has(tDate);
});
// 3. Группируем транзакции по (водитель + гос. номер)
const txByDriver = new Map();
filteredTransactions.forEach(t => {
const key = `${t.conductor}|${t.plate || ''}`;
if (!txByDriver.has(key)) {
txByDriver.set(key, { conductor: t.conductor, plate: t.plate, totalTarif: 0, txCount: 0, dates: new Set() });
}
const entry = txByDriver.get(key);
entry.totalTarif += t.tarifRub;
entry.txCount++;
entry.dates.add(normalizeDate(t.date));
});
// 4. Собираем водителей из ведомостей и сопоставляем
const driverMap = new Map(); // ключ: нормализованное ФИО|номер
const allVedomostDrivers = [];
vedomosti.forEach(v => {
v.drivers.forEach(d => {
const key = `${d.fio}|${d.plate}`;
if (!driverMap.has(key)) {
driverMap.set(key, {
fio: d.fio,
plate: d.plate,
route: d.route,
section: d.section,
cashier: v.cashier,
vedomostDate: v.date,
totalGiven: 0 // Сдано
});
}
driverMap.get(key).totalGiven += d.sum;
allVedomostDrivers.push(d);
});
});
// 5. Сопоставление: для каждого водителя из ведомости ищем транзакции
const results = [];
const unmatchedTx = []; // транзакции без пары в ведомости
// Прямое сопоставление
const usedTxKeys = new Set();
driverMap.forEach((vd, key) => {
const [fio, plate] = key.split('|');
// Точное совпадение по (ФИО + номер)
const txKey = `${vd.fio}|${vd.plate}`;
let txEntry = txByDriver.get(txKey);
// Нечёткое совпадение по ФИО, если точного нет
if (!txEntry) {
let bestSim = 0;
let bestKey = '';
txByDriver.forEach((entry, k) => {
const [txFio, txPlate] = k.split('|');
// Сначала проверяем совпадение номера
const plateMatch = txPlate && vd.plate && normalizePlate(txPlate) === normalizePlate(vd.plate);
const sim = nameSimilarity(vd.fio, txFio);
if (plateMatch && sim >= FUZZY_THRESHOLD && sim > bestSim) {
bestSim = sim;
bestKey = k;
}
});
if (bestKey) {
txEntry = txByDriver.get(bestKey);
usedTxKeys.add(bestKey);
}
} else {
usedTxKeys.add(txKey);
}
const totalCollected = txEntry ? txEntry.totalTarif : 0;
const diff = txEntry ? Math.round(totalCollected - vd.totalGiven) : -vd.totalGiven;
results.push({
driver: vd.fio,
car: vd.plate || '—',
route: vd.route || '—',
section: vd.section === 'city' ? 'Город' : 'Пригород',
cashier: vd.cashier || '—',
date: vd.vedomostDate || '—',
given: Math.round(vd.totalGiven),
collected: Math.round(totalCollected),
diff: diff,
txCount: txEntry ? txEntry.txCount : 0,
status: diff === 0 ? 'ok' : (Math.abs(diff) <= 10 ? 'warn' : 'err')
});
});
// Не сопоставленные транзакции
txByDriver.forEach((entry, key) => {
if (!usedTxKeys.has(key)) {
unmatchedTx.push({
driver: entry.conductor,
plate: entry.plate || '—',
totalCollected: Math.round(entry.totalTarif),
txCount: entry.txCount
});
}
});
// 6. Агрегация по кассирам
const cashierMap = new Map();
results.forEach(r => {
const key = r.cashier;
if (!cashierMap.has(key)) {
cashierMap.set(key, { cashier: key, count: 0, given: 0, collected: 0, diff: 0 });
}
const c = cashierMap.get(key);
c.count++;
c.given += r.given;
c.collected += r.collected;
c.diff += r.diff;
});
// 7. Агрегация по маршрутам
const routeMap = new Map();
results.forEach(r => {
const key = r.route || 'Без маршрута';
if (!routeMap.has(key)) {
routeMap.set(key, { route: key, type: r.section, drivers: 0, sum: 0 });
}
const rm = routeMap.get(key);
rm.drivers++;
rm.sum += r.given;
});
// 8. Выводы
const totalGiven = results.reduce((s, r) => s + r.given, 0);
const totalCollected = results.reduce((s, r) => s + r.collected, 0);
const totalDiff = results.reduce((s, r) => s + r.diff, 0);
const discrepancies = results.filter(r => r.diff !== 0);
return {
drivers: results,
cashiers: Array.from(cashierMap.values()),
routes: Array.from(routeMap.values()),
unmatchedTx,
conclusions: {
totalDrivers: results.length,
totalGiven,
totalCollected,
totalDiff,
discrepanciesCount: discrepancies.length,
matchedPercent: results.length > 0
? Math.round((results.length - discrepancies.length) / results.length * 100)
: 0
}
};
}
/* ================================================================
ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
================================================================ */
/**
* Нормализовать дату к формату YYYY-MM-DD
* @param {string} dateStr
* @returns {string}
*/
function normalizeDate(dateStr) {
if (!dateStr) return '';
let d = String(dateStr).trim();
// Excel serial date number
const serial = parseInt(d);
if (!isNaN(serial) && serial > 40000 && serial < 60000) {
const date = new Date((serial - 25569) * 86400 * 1000);
return date.toISOString().split('T')[0];
}
// DD.MM.YYYY or DD/MM/YYYY
const parts = d.split(/[./-]/);
if (parts.length === 3) {
let day, month, year;
if (parts[0].length === 4) {
// YYYY-MM-DD
year = parts[0]; month = parts[1]; day = parts[2];
} else {
// DD.MM.YYYY
day = parts[0]; month = parts[1]; year = parts[2];
}
if (year.length === 2) year = '20' + year;
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
return d;
}