Files
mkd/backend/balanceSheetProcessor.js

488 lines
21 KiB
JavaScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
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;