Files
mkd/backend/balanceSheetProcessor.js
2026-02-04 00:17:04 +05:00

488 lines
21 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;