635 lines
24 KiB
JavaScript
635 lines
24 KiB
JavaScript
/**
|
||
* 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;
|
||
} |