Создан parser.js — ядро: парсинг ведомостей, транзакций, сверка, сопоставление
This commit is contained in:
@@ -0,0 +1,635 @@
|
||||
/**
|
||||
* 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