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;