Удаление файла
This commit is contained in:
@@ -1,635 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user