488 lines
21 KiB
JavaScript
Executable File
488 lines
21 KiB
JavaScript
Executable File
const csv = require('csv-parser');
|
||
const XLSX = require('xlsx');
|
||
const fs = require('fs');
|
||
|
||
/**
|
||
* Процессор для обработки оборотно-сальдовой ведомости из 1С
|
||
*/
|
||
class BalanceSheetProcessor {
|
||
constructor(pool) {
|
||
this.pool = pool;
|
||
}
|
||
|
||
/**
|
||
* Проверка, что строка похожа на данные (в колонке 0 текст, в 3 или 4 — число)
|
||
*/
|
||
_isDataRow(rowArray) {
|
||
if (!rowArray || rowArray.length < 5) return false;
|
||
const firstCell = String(rowArray[0] || '').trim();
|
||
const col3 = this.parseAmount(rowArray[3]);
|
||
const col4 = this.parseAmount(rowArray[4]);
|
||
return firstCell.length >= 1 && (col3 > 0 || col4 > 0);
|
||
}
|
||
|
||
/**
|
||
* Парсинг CSV файла ведомости (UTF-8, с автоопределением начала данных и обработкой BOM)
|
||
*/
|
||
async parseBalanceSheetCSV(filePath) {
|
||
return new Promise((resolve, reject) => {
|
||
const allRows = [];
|
||
let currentRowIndex = 0;
|
||
let dataStartRowIndex = -1;
|
||
|
||
fs.createReadStream(filePath, { encoding: 'utf8' })
|
||
.pipe(csv({ separator: ';', headers: false }))
|
||
.on('data', (row) => {
|
||
const rowArray = Object.values(row);
|
||
// Убираем BOM с первой ячейки первой строки
|
||
if (currentRowIndex === 0 && rowArray[0] !== undefined) {
|
||
const first = String(rowArray[0]).replace(/^\uFEFF/, '');
|
||
rowArray[0] = first;
|
||
}
|
||
allRows.push({ index: currentRowIndex, data: rowArray });
|
||
currentRowIndex++;
|
||
})
|
||
.on('end', () => {
|
||
// Автоопределение строки начала данных: ищем первую строку, где колонка 0 — текст, колонка 3 или 4 — число
|
||
for (let i = 0; i < allRows.length; i++) {
|
||
const rowArray = allRows[i].data;
|
||
const firstCell = String(rowArray[0] || '').trim();
|
||
if (firstCell.includes('Обороты за период') || firstCell.includes('Счет, Наименование')) {
|
||
dataStartRowIndex = i + 1;
|
||
break;
|
||
}
|
||
if (this._isDataRow(rowArray)) {
|
||
dataStartRowIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
if (dataStartRowIndex < 0) dataStartRowIndex = 9;
|
||
const rows = allRows.filter(r => r.index >= dataStartRowIndex);
|
||
console.log(`[BalanceSheetProcessor] Начало данных с строки ${dataStartRowIndex}, строк данных: ${rows.length}`);
|
||
resolve(rows);
|
||
})
|
||
.on('error', reject);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Парсинг XLSX файла ведомости
|
||
*/
|
||
async parseBalanceSheetXLSX(filePath) {
|
||
try {
|
||
const workbook = XLSX.readFile(filePath);
|
||
const sheetName = workbook.SheetNames[0];
|
||
const worksheet = workbook.Sheets[sheetName];
|
||
|
||
// Конвертируем в массив массивов
|
||
const rawData = XLSX.utils.sheet_to_json(worksheet, {
|
||
header: 1,
|
||
defval: '',
|
||
raw: false
|
||
});
|
||
|
||
// Пропускаем метаданные (первые 9 строк)
|
||
const rows = [];
|
||
for (let i = 9; i < rawData.length; i++) {
|
||
rows.push({
|
||
index: i,
|
||
data: rawData[i] || []
|
||
});
|
||
}
|
||
|
||
return rows;
|
||
} catch (error) {
|
||
throw new Error(`Ошибка чтения XLSX файла: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Определение адреса дома из строки
|
||
*/
|
||
isBuildingAddress(rowData) {
|
||
if (!rowData || rowData.length === 0) return false;
|
||
|
||
const firstCell = String(rowData[0] || '').trim();
|
||
|
||
// Пропускаем служебные строки
|
||
if (firstCell.startsWith('<...>') ||
|
||
firstCell === '' ||
|
||
firstCell.includes('Оборотно-сальдовая') ||
|
||
firstCell.includes('Выводимые данные') ||
|
||
firstCell.includes('Счет, Наименование') ||
|
||
firstCell.includes('Подразделение') ||
|
||
firstCell.includes('Номенклатурные группы') ||
|
||
firstCell.includes('Статьи затрат')) {
|
||
return false;
|
||
}
|
||
|
||
// Не является счетом (20, 20.01, 20.01.01)
|
||
if (/^\d+\.?\d*\.?\d*$/.test(firstCell)) return false;
|
||
|
||
// Не является статьей затрат (содержит типичные слова статей без адреса)
|
||
const expenseKeywords = [
|
||
'Административно-управленческие расходы',
|
||
'Текущее обслуживание общедомового имущества',
|
||
'Ремонт кровли',
|
||
'Благоустройство территории',
|
||
'Услуги специализированных организаций',
|
||
'Техническое обслуживание',
|
||
'Расходные материалы',
|
||
'Механизированная уборка',
|
||
'Хозяйственные расходы',
|
||
'Резервный фонд',
|
||
'Судебные издержки',
|
||
'Обслуживание инженерного оборудования'
|
||
];
|
||
|
||
// Если строка содержит только ключевые слова статей без адреса - это не адрес
|
||
const isOnlyExpenseKeyword = expenseKeywords.some(keyword =>
|
||
firstCell === keyword || firstCell.startsWith(keyword + ';')
|
||
);
|
||
if (isOnlyExpenseKeyword) return false;
|
||
|
||
// Адрес должен содержать буквы (название улицы/участка)
|
||
const hasLetters = /[А-Яа-я]/.test(firstCell);
|
||
|
||
// Проверяем наличие сумм в строке (колонки 3-6: обороты и сальдо)
|
||
// Если есть большие суммы - это может быть адрес участка/комплекса
|
||
const hasAmounts = rowData.length > 3 && (
|
||
this.parseAmount(rowData[3]) > 0 || // Дебет обороты
|
||
this.parseAmount(rowData[4]) > 0 || // Кредит обороты
|
||
this.parseAmount(rowData[5]) > 0 || // Дебет сальдо
|
||
this.parseAmount(rowData[6]) > 0 // Кредит сальдо
|
||
);
|
||
|
||
// Адрес с номером дома (стандартный случай)
|
||
const hasNumber = /\d/.test(firstCell);
|
||
|
||
// Паттерны адресов
|
||
const addressPatterns = [
|
||
/\d+\/\d+/, // "6/1", "152/4" - номер дома с корпусом
|
||
/^[А-Яа-я]+\s+[А-Яа-я]+.*\d/, // "Булата Имашева 6/1"
|
||
/ул\.|пер\.|пр\.|проспект.*\d/i, // "ул. Ленина, д.12"
|
||
/д\.\s*\d/i, // "д. 12"
|
||
/^[А-Яа-я]+.*\d+[\/\-]?\d*/, // Общий паттерн: буквы + цифры
|
||
/^[А-Яа-я]+\s+[А-Яа-я]+$/ // Название участка/комплекса без номера (например, "Зеленая Роща")
|
||
];
|
||
|
||
const hasAddressPattern = addressPatterns.some(pattern => pattern.test(firstCell));
|
||
|
||
// Адрес обычно не слишком длинный (до 100 символов) и не слишком короткий (больше 3)
|
||
const validLength = firstCell.length >= 4 && firstCell.length < 100;
|
||
|
||
// Если есть паттерн адреса, буквы и валидная длина - это адрес
|
||
// Дополнительно: если есть суммы в строке и это похоже на название участка (2 слова без цифр) - тоже адрес
|
||
const isDistrictName = /^[А-Яа-я]+\s+[А-Яа-я]+$/.test(firstCell) && hasAmounts;
|
||
|
||
return hasAddressPattern && hasLetters && validLength && (hasNumber || isDistrictName);
|
||
}
|
||
|
||
/**
|
||
* Парсинг ведомости и группировка по домам
|
||
*/
|
||
async parseBalanceSheet(filePath, fileType) {
|
||
let rows;
|
||
|
||
if (fileType === 'CSV') {
|
||
rows = await this.parseBalanceSheetCSV(filePath);
|
||
} else if (fileType === 'XLSX') {
|
||
rows = await this.parseBalanceSheetXLSX(filePath);
|
||
} else {
|
||
throw new Error(`Неподдерживаемый тип файла: ${fileType}`);
|
||
}
|
||
|
||
const buildings = {};
|
||
let currentBuilding = null;
|
||
let currentGroup = null;
|
||
let currentArticle = null;
|
||
|
||
console.log(`[BalanceSheetProcessor] Начало парсинга, строк в файле: ${rows.length}`);
|
||
|
||
for (const row of rows) {
|
||
const rowData = row.data;
|
||
if (!rowData || rowData.length === 0) continue;
|
||
|
||
const firstCell = String(rowData[0] || '').trim();
|
||
const debitTurnover = this.parseAmount(rowData[3]); // Обороты за период - Дебет
|
||
const creditTurnover = this.parseAmount(rowData[4]); // Обороты за период - Кредит
|
||
const endDebit = this.parseAmount(rowData[5]); // Сальдо на конец периода - Дебет
|
||
const endCredit = this.parseAmount(rowData[6]); // Сальдо на конец периода - Кредит
|
||
|
||
// Пропускаем пустые строки
|
||
if (!firstCell || firstCell === '') continue;
|
||
|
||
// Определяем адрес дома
|
||
if (this.isBuildingAddress(rowData)) {
|
||
// Если это тот же адрес, что и текущий - не сбрасываем группу (может быть повтор)
|
||
if (currentBuilding !== firstCell) {
|
||
currentBuilding = firstCell;
|
||
currentGroup = null;
|
||
currentArticle = null;
|
||
console.log(`[BalanceSheetProcessor] Найден адрес дома: "${currentBuilding}"`);
|
||
}
|
||
|
||
if (!buildings[currentBuilding]) {
|
||
buildings[currentBuilding] = {
|
||
address: currentBuilding,
|
||
groups: {},
|
||
totalExpenses: 0,
|
||
totalIncome: 0,
|
||
balance: 0
|
||
};
|
||
}
|
||
// Учитываем сумму в строке адреса, чтобы расход по дому не был нулевым при отсутствии статей ниже
|
||
if (debitTurnover > 0) {
|
||
buildings[currentBuilding].totalExpenses += debitTurnover;
|
||
if (!buildings[currentBuilding].groups['Итого по дому']) {
|
||
buildings[currentBuilding].groups['Итого по дому'] = {
|
||
name: 'Итого по дому',
|
||
articles: {},
|
||
total: 0
|
||
};
|
||
buildings[currentBuilding].groups['Итого по дому'].articles[currentBuilding] = {
|
||
name: currentBuilding,
|
||
amount: 0,
|
||
details: []
|
||
};
|
||
}
|
||
buildings[currentBuilding].groups['Итого по дому'].articles[currentBuilding].amount += debitTurnover;
|
||
buildings[currentBuilding].groups['Итого по дому'].total += debitTurnover;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Если дом не определен, пропускаем строку
|
||
if (!currentBuilding) continue;
|
||
|
||
// Определяем номенклатурную группу: известные названия (точное/частичное) или произвольное название из ОСВ
|
||
// Список типичных групп — у разных УК формулировки могут отличаться, поэтому проверяем и по вхождению
|
||
const groupKeywords = [
|
||
'Административно-управленческие расходы',
|
||
'Судебные издержки',
|
||
'Услуги специализированных организаций',
|
||
'Благоустройство территории',
|
||
'Техническое обслуживание конструктивных элементов',
|
||
'Обслуживание инженерного оборудования',
|
||
'Расходные материалы',
|
||
'Текущее обслуживание общедомового имущества',
|
||
'Механизированная уборка',
|
||
'Хозяйственные расходы',
|
||
'Резервный фонд'
|
||
];
|
||
const firstCellLower = firstCell.toLowerCase();
|
||
const matchedGroup = groupKeywords.find(keyword =>
|
||
firstCell === keyword ||
|
||
firstCell.startsWith(keyword + ';') ||
|
||
firstCell.startsWith(keyword) ||
|
||
firstCellLower.includes(keyword.toLowerCase())
|
||
);
|
||
|
||
if (matchedGroup) {
|
||
currentGroup = matchedGroup;
|
||
currentArticle = null;
|
||
|
||
if (!buildings[currentBuilding].groups[currentGroup]) {
|
||
buildings[currentBuilding].groups[currentGroup] = {
|
||
name: currentGroup,
|
||
articles: {},
|
||
total: 0
|
||
};
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Динамическая группа: если есть сумма и строка не служебная — считаем название группы/услуги из ОСВ (у разных компаний названия разные)
|
||
if (currentBuilding && debitTurnover > 0 && !firstCell.startsWith('<...>') && firstCell.length >= 2) {
|
||
const dynamicGroupName = firstCell.trim();
|
||
if (!buildings[currentBuilding].groups[dynamicGroupName]) {
|
||
buildings[currentBuilding].groups[dynamicGroupName] = {
|
||
name: dynamicGroupName,
|
||
articles: {},
|
||
total: 0
|
||
};
|
||
}
|
||
currentGroup = dynamicGroupName;
|
||
currentArticle = firstCell;
|
||
if (!buildings[currentBuilding].groups[currentGroup].articles[currentArticle]) {
|
||
buildings[currentBuilding].groups[currentGroup].articles[currentArticle] = {
|
||
name: currentArticle,
|
||
amount: 0,
|
||
details: []
|
||
};
|
||
}
|
||
buildings[currentBuilding].groups[currentGroup].articles[currentArticle].amount += debitTurnover;
|
||
buildings[currentBuilding].groups[currentGroup].total += debitTurnover;
|
||
buildings[currentBuilding].totalExpenses += debitTurnover;
|
||
if (rowData.length > 1) {
|
||
const secondCell = String(rowData[1] || '').trim();
|
||
if (secondCell && secondCell !== '' && !secondCell.match(/^\d+[\s,\.]*\d*$/)) {
|
||
buildings[currentBuilding].groups[currentGroup].articles[currentArticle].details.push({
|
||
description: secondCell,
|
||
amount: debitTurnover
|
||
});
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Определяем статью затрат (если есть отступ или вложенность)
|
||
if (currentGroup && debitTurnover > 0) {
|
||
// Пропускаем строки с "<...>" - это служебные строки
|
||
if (firstCell.startsWith('<...>')) {
|
||
continue;
|
||
}
|
||
|
||
// Это статья затрат
|
||
if (!currentArticle || firstCell !== currentArticle) {
|
||
currentArticle = firstCell;
|
||
|
||
if (!buildings[currentBuilding].groups[currentGroup].articles[currentArticle]) {
|
||
buildings[currentBuilding].groups[currentGroup].articles[currentArticle] = {
|
||
name: currentArticle,
|
||
amount: 0,
|
||
details: []
|
||
};
|
||
}
|
||
}
|
||
|
||
// Добавляем сумму
|
||
buildings[currentBuilding].groups[currentGroup].articles[currentArticle].amount += debitTurnover;
|
||
buildings[currentBuilding].groups[currentGroup].total += debitTurnover;
|
||
buildings[currentBuilding].totalExpenses += debitTurnover;
|
||
|
||
// Сохраняем детализацию если есть (вторая колонка может содержать описание)
|
||
if (rowData.length > 1) {
|
||
const secondCell = String(rowData[1] || '').trim();
|
||
if (secondCell && secondCell !== '' && !secondCell.match(/^\d+[\s,\.]*\d*$/)) {
|
||
// Это не число, значит описание
|
||
buildings[currentBuilding].groups[currentGroup].articles[currentArticle].details.push({
|
||
description: secondCell,
|
||
amount: debitTurnover
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const buildingCount = Object.keys(buildings).length;
|
||
console.log(`[BalanceSheetProcessor] Найдено домов: ${buildingCount}`);
|
||
if (buildingCount > 0) {
|
||
console.log(`[BalanceSheetProcessor] Адреса домов:`, Object.keys(buildings).slice(0, 5).join(', '), buildingCount > 5 ? '...' : '');
|
||
for (const [addr, data] of Object.entries(buildings)) {
|
||
const articleCount = Object.values(data.groups || {}).reduce((s, g) => s + Object.keys(g.articles || {}).length, 0);
|
||
console.log(`[BalanceSheetProcessor] Дом "${addr}": totalExpenses=${data.totalExpenses}, статей в expenses_by_items: ${articleCount}`);
|
||
}
|
||
} else {
|
||
console.warn(`[BalanceSheetProcessor] ⚠ Дома не найдены в ведомости!`);
|
||
}
|
||
|
||
return buildings;
|
||
}
|
||
|
||
/**
|
||
* Парсинг суммы из строки (удаление пробелов, запятых)
|
||
*/
|
||
parseAmount(value) {
|
||
if (!value) return 0;
|
||
const str = String(value).replace(/\s/g, '').replace(',', '.');
|
||
const num = parseFloat(str);
|
||
return isNaN(num) ? 0 : num;
|
||
}
|
||
|
||
/**
|
||
* Преобразование данных ведомости в формат для БД
|
||
*/
|
||
convertToBuildingFinancialData(buildings, reportId, periodStart, periodEnd) {
|
||
const result = [];
|
||
|
||
for (const [address, buildingData] of Object.entries(buildings)) {
|
||
// Формируем expenses_by_items
|
||
const expensesByItems = {};
|
||
|
||
for (const [groupName, group] of Object.entries(buildingData.groups)) {
|
||
for (const [articleName, article] of Object.entries(group.articles)) {
|
||
const key = `${groupName} > ${articleName}`;
|
||
expensesByItems[key] = article.amount;
|
||
}
|
||
}
|
||
|
||
result.push({
|
||
address: address,
|
||
totalIncome: buildingData.totalIncome || 0,
|
||
totalExpenses: buildingData.totalExpenses || 0,
|
||
balance: buildingData.balance || (buildingData.totalIncome - buildingData.totalExpenses),
|
||
expensesByItems: expensesByItems,
|
||
periodStart: periodStart,
|
||
periodEnd: periodEnd,
|
||
periodType: this.determinePeriodType(periodStart, periodEnd),
|
||
metadata: {
|
||
groups: buildingData.groups,
|
||
rawData: buildingData
|
||
}
|
||
});
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Определение типа периода
|
||
*/
|
||
determinePeriodType(start, end) {
|
||
const startDate = new Date(start);
|
||
const endDate = new Date(end);
|
||
const diffMonths = (endDate.getFullYear() - startDate.getFullYear()) * 12 +
|
||
(endDate.getMonth() - startDate.getMonth());
|
||
|
||
if (diffMonths === 0) return 'month';
|
||
if (diffMonths <= 3) return 'quarter';
|
||
if (diffMonths <= 12) return 'year';
|
||
return 'year';
|
||
}
|
||
|
||
/**
|
||
* Извлечение периода из названия файла или метаданных
|
||
*/
|
||
extractPeriodFromFilename(filename) {
|
||
// Пытаемся найти год в названии файла
|
||
const yearMatch = filename.match(/20\d{2}/);
|
||
const year = yearMatch ? parseInt(yearMatch[0]) : new Date().getFullYear();
|
||
|
||
// Пытаемся найти месяц в названии файла (например, "за_2025_г" или "01.2025")
|
||
const monthMatch = filename.match(/(\d{1,2})[\.\/](\d{4})|(\d{4})[\.\/](\d{1,2})/);
|
||
|
||
if (monthMatch) {
|
||
// Найден месяц и год
|
||
let month, yearFromMatch;
|
||
if (monthMatch[1] && monthMatch[2]) {
|
||
month = parseInt(monthMatch[1]);
|
||
yearFromMatch = parseInt(monthMatch[2]);
|
||
} else if (monthMatch[3] && monthMatch[4]) {
|
||
yearFromMatch = parseInt(monthMatch[3]);
|
||
month = parseInt(monthMatch[4]);
|
||
}
|
||
|
||
if (month && yearFromMatch) {
|
||
const startDate = new Date(yearFromMatch, month - 1, 1);
|
||
const endDate = new Date(yearFromMatch, month, 0); // Последний день месяца
|
||
|
||
return {
|
||
start: `${yearFromMatch}-${String(month).padStart(2, '0')}-01`,
|
||
end: `${yearFromMatch}-${String(month).padStart(2, '0')}-${String(endDate.getDate()).padStart(2, '0')}`,
|
||
type: 'month'
|
||
};
|
||
}
|
||
}
|
||
|
||
// Если месяц не найден, используем весь год
|
||
return {
|
||
start: `${year}-01-01`,
|
||
end: `${year}-12-31`,
|
||
type: 'year'
|
||
};
|
||
}
|
||
}
|
||
|
||
module.exports = BalanceSheetProcessor;
|