Initial commit MKD fixes
This commit is contained in:
969
backend/fileProcessor.js
Executable file
969
backend/fileProcessor.js
Executable file
@@ -0,0 +1,969 @@
|
||||
const csv = require('csv-parser');
|
||||
const XLSX = require('xlsx');
|
||||
const fs = require('fs');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
/**
|
||||
* Обработчик файлов CSV/XLSX для загрузки финансовых данных из 1С
|
||||
*/
|
||||
class FileProcessor {
|
||||
constructor(pool) {
|
||||
this.pool = pool;
|
||||
this.processingJobs = new Map(); // Хранилище статусов обработки
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка файла (CSV или XLSX)
|
||||
*/
|
||||
processFile(filePath, fileType, mapping, reportId) {
|
||||
const jobId = `job-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Инициализируем статус обработки
|
||||
this.processingJobs.set(jobId, {
|
||||
status: 'processing',
|
||||
progress: 0,
|
||||
currentStep: 'Начало обработки',
|
||||
errors: [],
|
||||
warnings: [],
|
||||
result: null
|
||||
});
|
||||
|
||||
// Запускаем асинхронную обработку (не ждем завершения)
|
||||
const processPromise = this._processFileInternal(filePath, fileType, mapping, reportId, jobId);
|
||||
|
||||
// Возвращаем jobId и Promise сразу
|
||||
return { jobId, processPromise };
|
||||
}
|
||||
|
||||
/**
|
||||
* Внутренняя обработка файла
|
||||
*/
|
||||
async _processFileInternal(filePath, fileType, mapping, reportId, jobId) {
|
||||
|
||||
try {
|
||||
let rows = [];
|
||||
|
||||
if (fileType === 'CSV') {
|
||||
rows = await this.parseCSV(filePath, jobId);
|
||||
} else if (fileType === 'XLSX') {
|
||||
rows = await this.parseXLSX(filePath, jobId);
|
||||
} else {
|
||||
throw new Error(`Неподдерживаемый тип файла: ${fileType}`);
|
||||
}
|
||||
|
||||
// Валидация структуры файла
|
||||
this.updateJobStatus(jobId, {
|
||||
progress: 20,
|
||||
currentStep: 'Валидация структуры файла'
|
||||
});
|
||||
|
||||
const validationResult = this.validateFileStructure(rows, mapping);
|
||||
|
||||
// Добавляем предупреждения из валидации
|
||||
if (validationResult.warnings && validationResult.warnings.length > 0) {
|
||||
const current = this.processingJobs.get(jobId);
|
||||
if (current) {
|
||||
if (!current.warnings) {
|
||||
current.warnings = [];
|
||||
}
|
||||
current.warnings.push(...validationResult.warnings);
|
||||
this.processingJobs.set(jobId, current);
|
||||
}
|
||||
}
|
||||
|
||||
if (!validationResult.valid) {
|
||||
this.updateJobStatus(jobId, {
|
||||
status: 'failed',
|
||||
progress: 100,
|
||||
errors: validationResult.errors
|
||||
});
|
||||
return { jobId, success: false, errors: validationResult.errors };
|
||||
}
|
||||
|
||||
// Обновляем маппинг с найденными колонками
|
||||
if (validationResult.foundColumns && validationResult.foundColumns.length > 0) {
|
||||
const updatedMapping = { ...mapping };
|
||||
for (const { required, found } of validationResult.foundColumns) {
|
||||
if (updatedMapping.columnMappings[required]) {
|
||||
updatedMapping.columnMappings[found] = updatedMapping.columnMappings[required];
|
||||
// Если названия отличаются, удаляем старое
|
||||
if (required !== found) {
|
||||
delete updatedMapping.columnMappings[required];
|
||||
}
|
||||
}
|
||||
}
|
||||
mapping = updatedMapping;
|
||||
}
|
||||
|
||||
// Применение маппинга и преобразование данных
|
||||
this.updateJobStatus(jobId, {
|
||||
progress: 40,
|
||||
currentStep: 'Применение маппинга колонок'
|
||||
});
|
||||
|
||||
const mappedData = this.applyMapping(rows, mapping, validationResult.fileColumns);
|
||||
|
||||
// Сохранение в БД
|
||||
this.updateJobStatus(jobId, {
|
||||
progress: 60,
|
||||
currentStep: 'Сохранение данных в базу'
|
||||
});
|
||||
|
||||
const saveResult = await this.saveToDatabase(mappedData, reportId, jobId);
|
||||
|
||||
// Финальный статус
|
||||
this.updateJobStatus(jobId, {
|
||||
status: saveResult.success ? 'completed' : 'partial',
|
||||
progress: 100,
|
||||
currentStep: 'Обработка завершена',
|
||||
result: saveResult
|
||||
});
|
||||
|
||||
return {
|
||||
success: saveResult.success,
|
||||
result: saveResult,
|
||||
errors: this.processingJobs.get(jobId).errors
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.updateJobStatus(jobId, {
|
||||
status: 'failed',
|
||||
progress: 100,
|
||||
currentStep: 'Ошибка обработки',
|
||||
errors: [{
|
||||
row: 0,
|
||||
message: error.message,
|
||||
suggestion: 'Проверьте формат файла и повторите попытку'
|
||||
}]
|
||||
});
|
||||
|
||||
return {
|
||||
jobId,
|
||||
success: false,
|
||||
errors: [{ message: error.message }]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсинг CSV файла
|
||||
*/
|
||||
async parseCSV(filePath, jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const results = [];
|
||||
let rowCount = 0;
|
||||
|
||||
fs.createReadStream(filePath)
|
||||
.pipe(csv())
|
||||
.on('data', (data) => {
|
||||
results.push(data);
|
||||
rowCount++;
|
||||
|
||||
// Обновляем прогресс каждые 100 строк
|
||||
if (rowCount % 100 === 0) {
|
||||
this.updateJobStatus(jobId, {
|
||||
progress: Math.min(15, (rowCount / 1000) * 15),
|
||||
currentStep: `Чтение CSV: обработано ${rowCount} строк`
|
||||
});
|
||||
}
|
||||
})
|
||||
.on('end', () => {
|
||||
this.updateJobStatus(jobId, {
|
||||
progress: 15,
|
||||
currentStep: `CSV прочитан: ${rowCount} строк`
|
||||
});
|
||||
resolve(results);
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсинг XLSX файла
|
||||
*/
|
||||
async parseXLSX(filePath, jobId) {
|
||||
this.updateJobStatus(jobId, {
|
||||
progress: 5,
|
||||
currentStep: 'Чтение XLSX файла'
|
||||
});
|
||||
|
||||
try {
|
||||
const workbook = XLSX.readFile(filePath);
|
||||
const sheetName = workbook.SheetNames[0]; // Берем первый лист
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
// Определяем диапазон данных
|
||||
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:Z1000');
|
||||
|
||||
// Ключевые слова для поиска строки с заголовками таблицы
|
||||
const headerKeywords = [
|
||||
'адрес', 'наименование', 'название', 'объект', 'дом', 'здание',
|
||||
'сумма', 'доход', 'расход', 'дебет', 'кредит', 'баланс',
|
||||
'период', 'дата', 'месяц', 'год',
|
||||
'номер', '№', 'код'
|
||||
];
|
||||
|
||||
// Ищем строку с заголовками (проверяем первые 30 строк)
|
||||
let headerRow = -1;
|
||||
let maxMatches = 0;
|
||||
|
||||
for (let row = 0; row <= Math.min(range.e.r, 30); row++) {
|
||||
let matches = 0;
|
||||
const rowValues = [];
|
||||
|
||||
// Собираем все значения в строке
|
||||
for (let col = range.s.c; col <= Math.min(range.e.c, 20); col++) {
|
||||
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
|
||||
const cell = worksheet[cellAddress];
|
||||
if (cell && cell.v) {
|
||||
const cellValue = String(cell.v).toLowerCase().trim();
|
||||
rowValues.push(cellValue);
|
||||
|
||||
// Проверяем, содержит ли значение ключевые слова
|
||||
for (const keyword of headerKeywords) {
|
||||
if (cellValue.includes(keyword)) {
|
||||
matches++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Если в строке найдено достаточно ключевых слов и есть несколько непустых значений
|
||||
if (matches >= 2 && rowValues.filter(v => v.length > 0).length >= 3) {
|
||||
if (matches > maxMatches) {
|
||||
maxMatches = matches;
|
||||
headerRow = row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Если не нашли строку с заголовками, пробуем найти строку с максимальным количеством непустых ячеек
|
||||
if (headerRow === -1) {
|
||||
for (let row = 0; row <= Math.min(range.e.r, 20); row++) {
|
||||
let nonEmptyCount = 0;
|
||||
for (let col = range.s.c; col <= Math.min(range.e.c, 20); col++) {
|
||||
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
|
||||
const cell = worksheet[cellAddress];
|
||||
if (cell && cell.v && String(cell.v).trim().length > 0) {
|
||||
nonEmptyCount++;
|
||||
}
|
||||
}
|
||||
if (nonEmptyCount >= 4) {
|
||||
headerRow = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Если так и не нашли, используем строку 0 (первую)
|
||||
if (headerRow === -1) {
|
||||
headerRow = 0;
|
||||
}
|
||||
|
||||
console.log(`[FileProcessor] Найдена строка заголовков: ${headerRow + 1}`);
|
||||
|
||||
// Парсим данные начиная со строки заголовков
|
||||
let rows = XLSX.utils.sheet_to_json(worksheet, {
|
||||
range: headerRow,
|
||||
defval: '', // Значение по умолчанию для пустых ячеек
|
||||
raw: false, // Преобразуем все в строки
|
||||
blankrows: false // Пропускаем пустые строки
|
||||
});
|
||||
|
||||
// Фильтруем пустые строки (где все значения пустые)
|
||||
rows = rows.filter(row => {
|
||||
const values = Object.values(row);
|
||||
return values.some(v => v !== '' && v !== null && v !== undefined);
|
||||
});
|
||||
|
||||
// Логируем найденные колонки для отладки
|
||||
if (rows.length > 0) {
|
||||
const columns = Object.keys(rows[0]);
|
||||
console.log(`[FileProcessor] Найдено колонок в XLSX: ${columns.length}`);
|
||||
console.log(`[FileProcessor] Колонки: ${columns.join(', ')}`);
|
||||
console.log(`[FileProcessor] Первая строка данных:`, rows[0]);
|
||||
console.log(`[FileProcessor] Всего строк данных: ${rows.length}`);
|
||||
} else {
|
||||
console.warn(`[FileProcessor] Не найдено данных в файле после парсинга`);
|
||||
}
|
||||
|
||||
this.updateJobStatus(jobId, {
|
||||
progress: 15,
|
||||
currentStep: `XLSX прочитан: ${rows.length} строк, ${rows.length > 0 ? Object.keys(rows[0]).length : 0} колонок`
|
||||
});
|
||||
|
||||
return rows;
|
||||
} catch (error) {
|
||||
console.error('[FileProcessor] Ошибка парсинга XLSX:', error);
|
||||
throw new Error(`Ошибка чтения XLSX файла: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация структуры файла
|
||||
*/
|
||||
validateFileStructure(rows, mapping) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
if (rows.length === 0) {
|
||||
errors.push({
|
||||
row: 0,
|
||||
message: 'Файл пуст или не содержит данных',
|
||||
suggestion: 'Убедитесь, что файл содержит данные и имеет правильный формат'
|
||||
});
|
||||
return { valid: false, errors, warnings };
|
||||
}
|
||||
|
||||
// Получаем колонки из файла
|
||||
const firstRow = rows[0];
|
||||
const fileColumns = Object.keys(firstRow);
|
||||
|
||||
console.log(`[FileProcessor] Валидация: найдено ${fileColumns.length} колонок в файле`);
|
||||
console.log(`[FileProcessor] Колонки файла:`, fileColumns);
|
||||
|
||||
// Проверяем наличие обязательных колонок согласно маппингу
|
||||
const requiredColumns = Object.keys(mapping.columnMappings || {});
|
||||
console.log(`[FileProcessor] Ожидаемые колонки из маппинга:`, requiredColumns);
|
||||
|
||||
// Пробуем найти колонки с похожими названиями (нечувствительно к регистру и пробелам)
|
||||
const normalizeColumn = (col) => col.toLowerCase().trim().replace(/\s+/g, ' ');
|
||||
const normalizedFileColumns = fileColumns.map(col => ({ original: col, normalized: normalizeColumn(col) }));
|
||||
const normalizedRequiredColumns = requiredColumns.map(col => ({ original: col, normalized: normalizeColumn(col) }));
|
||||
|
||||
const missingColumns = [];
|
||||
const foundColumns = [];
|
||||
|
||||
for (const reqCol of normalizedRequiredColumns) {
|
||||
const found = normalizedFileColumns.find(fc =>
|
||||
fc.normalized === reqCol.normalized ||
|
||||
fc.normalized.includes(reqCol.normalized) ||
|
||||
reqCol.normalized.includes(fc.normalized)
|
||||
);
|
||||
|
||||
if (found) {
|
||||
foundColumns.push({ required: reqCol.original, found: found.original });
|
||||
} else {
|
||||
missingColumns.push(reqCol.original);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingColumns.length > 0) {
|
||||
warnings.push({
|
||||
row: 0,
|
||||
message: `Не найдены некоторые колонки: ${missingColumns.join(', ')}`,
|
||||
suggestion: `Доступные колонки: ${fileColumns.join(', ')}. Возможно, нужно настроить маппинг.`
|
||||
});
|
||||
}
|
||||
|
||||
// Проверяем наличие колонки с адресом (для привязки к домам)
|
||||
const addressMapping = Object.entries(mapping.columnMappings || {})
|
||||
.find(([_, target]) => target === 'address');
|
||||
|
||||
let addressColumn = null;
|
||||
if (addressMapping) {
|
||||
const [reqCol] = addressMapping;
|
||||
const normalizedReq = normalizeColumn(reqCol);
|
||||
const found = normalizedFileColumns.find(fc =>
|
||||
fc.normalized === normalizedReq ||
|
||||
fc.normalized.includes(normalizedReq) ||
|
||||
normalizedReq.includes(fc.normalized)
|
||||
);
|
||||
addressColumn = found ? found.original : null;
|
||||
}
|
||||
|
||||
// Если не нашли точное совпадение, ищем колонки с похожими названиями (адрес, адр и т.д.)
|
||||
if (!addressColumn) {
|
||||
const addressKeywords = ['адрес', 'адр', 'address', 'дом', 'здание'];
|
||||
const found = normalizedFileColumns.find(fc =>
|
||||
addressKeywords.some(keyword => fc.normalized.includes(keyword))
|
||||
);
|
||||
if (found) {
|
||||
addressColumn = found.original;
|
||||
warnings.push({
|
||||
row: 0,
|
||||
message: `Автоматически найдена колонка с адресом: "${found.original}"`,
|
||||
suggestion: 'Рекомендуется настроить маппинг для точного соответствия'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!addressColumn) {
|
||||
errors.push({
|
||||
row: 0,
|
||||
message: 'Не найдена колонка с адресом дома',
|
||||
suggestion: `Доступные колонки: ${fileColumns.join(', ')}. Настройте маппинг для колонки с адресом.`
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
foundColumns,
|
||||
fileColumns
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Применение маппинга колонок
|
||||
*/
|
||||
applyMapping(rows, mapping, fileColumns = null) {
|
||||
const mappedData = [];
|
||||
|
||||
// Нормализуем названия колонок для нечеткого поиска
|
||||
const normalizeColumn = (col) => col.toLowerCase().trim().replace(/\s+/g, ' ');
|
||||
|
||||
// Создаем карту нормализованных колонок файла
|
||||
const fileColMap = {};
|
||||
if (fileColumns) {
|
||||
fileColumns.forEach(col => {
|
||||
fileColMap[normalizeColumn(col)] = col;
|
||||
});
|
||||
} else if (rows.length > 0) {
|
||||
Object.keys(rows[0]).forEach(col => {
|
||||
fileColMap[normalizeColumn(col)] = col;
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const mappedRow = {};
|
||||
|
||||
for (const [sourceColumn, targetField] of Object.entries(mapping.columnMappings)) {
|
||||
// Пробуем точное совпадение
|
||||
let value = row[sourceColumn];
|
||||
|
||||
// Если не найдено, пробуем нечеткое совпадение
|
||||
if (value === undefined || value === null || value === '') {
|
||||
const normalizedSource = normalizeColumn(sourceColumn);
|
||||
const foundCol = fileColMap[normalizedSource];
|
||||
if (foundCol) {
|
||||
value = row[foundCol];
|
||||
} else {
|
||||
// Пробуем частичное совпадение
|
||||
for (const [normCol, origCol] of Object.entries(fileColMap)) {
|
||||
if (normCol.includes(normalizedSource) || normalizedSource.includes(normCol)) {
|
||||
value = row[origCol];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
mappedRow[targetField] = this.parseValue(value, targetField);
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем исходную строку для отладки
|
||||
mappedRow._sourceRow = i + 1;
|
||||
mappedRow._rawData = row;
|
||||
|
||||
mappedData.push(mappedRow);
|
||||
}
|
||||
|
||||
return mappedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсинг значения в зависимости от типа поля
|
||||
*/
|
||||
parseValue(value, fieldName) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Числовые поля
|
||||
if (fieldName.includes('amount') || fieldName.includes('income') ||
|
||||
fieldName.includes('expense') || fieldName.includes('balance') ||
|
||||
fieldName.includes('cost') || fieldName.includes('price')) {
|
||||
const num = parseFloat(String(value).replace(/\s/g, '').replace(',', '.'));
|
||||
return isNaN(num) ? null : num;
|
||||
}
|
||||
|
||||
// Даты
|
||||
if (fieldName.includes('date') || fieldName.includes('period')) {
|
||||
// Пробуем разные форматы дат
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
return value; // Оставляем как есть, если не удалось распарсить
|
||||
}
|
||||
|
||||
// Текстовые поля
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранение данных в базу данных
|
||||
*/
|
||||
async saveToDatabase(mappedData, reportId, jobId) {
|
||||
const client = await this.pool.connect();
|
||||
let processedRows = 0;
|
||||
let errorRows = 0;
|
||||
let buildingsFound = 0;
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
for (let i = 0; i < mappedData.length; i++) {
|
||||
const row = mappedData[i];
|
||||
const rowNum = row._sourceRow;
|
||||
|
||||
try {
|
||||
// Находим дом по адресу (пробуем разные варианты названий полей)
|
||||
const address = row.address || row.buildingAddress || row.адрес || row['Адрес'] ||
|
||||
row['адрес дома'] || row['Адрес дома'] || row['Адрес здания'] ||
|
||||
(row._rawData && (row._rawData.address || row._rawData.Адрес || row._rawData['Адрес']));
|
||||
|
||||
if (!address || String(address).trim() === '') {
|
||||
// Логируем для отладки
|
||||
console.log(`[FileProcessor] Строка ${rowNum}: адрес не найден. Доступные поля:`, Object.keys(row));
|
||||
this.addError(jobId, {
|
||||
row: rowNum,
|
||||
message: 'Не указан адрес дома',
|
||||
suggestion: `Доступные поля в строке: ${Object.keys(row).filter(k => !k.startsWith('_')).join(', ')}. Убедитесь, что в маппинге указана колонка для адреса.`
|
||||
});
|
||||
errorRows++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const addressStr = String(address).trim();
|
||||
|
||||
// Ищем дом в БД по адресу
|
||||
let buildingResult = await client.query(
|
||||
`SELECT id FROM buildings WHERE data->'passport'->>'address' = $1`,
|
||||
[addressStr]
|
||||
);
|
||||
|
||||
let buildingId;
|
||||
|
||||
// Если дом не найден, пропускаем строку (не создаем автоматически)
|
||||
if (buildingResult.rows.length === 0) {
|
||||
console.log(`[fileProcessor] ⚠ Дом не найден в базе, пропускаем: "${addressStr}"`);
|
||||
// Добавляем в список не найденных адресов
|
||||
const current = this.processingJobs.get(jobId);
|
||||
if (current) {
|
||||
if (!current.notFoundAddresses) {
|
||||
current.notFoundAddresses = [];
|
||||
}
|
||||
if (!current.notFoundAddresses.includes(addressStr)) {
|
||||
current.notFoundAddresses.push(addressStr);
|
||||
}
|
||||
this.processingJobs.set(jobId, current);
|
||||
}
|
||||
errorRows++;
|
||||
continue;
|
||||
} else {
|
||||
buildingId = buildingResult.rows[0].id;
|
||||
buildingsFound++;
|
||||
}
|
||||
|
||||
// Определяем период
|
||||
const periodStart = row.periodStart || row.period_start || row.startDate || row.date || new Date().toISOString().split('T')[0];
|
||||
const periodEnd = row.periodEnd || row.period_end || row.endDate || periodStart;
|
||||
const periodType = row.periodType || row.period_type || 'month';
|
||||
|
||||
// Логируем для отладки первые несколько строк
|
||||
if (i < 3) {
|
||||
console.log(`[FileProcessor] Обработка строки ${i + 1}:`, {
|
||||
address,
|
||||
periodStart,
|
||||
totalIncome: row.totalIncome || row.total_income,
|
||||
totalExpenses: row.totalExpenses || row.total_expenses,
|
||||
balance: row.balance
|
||||
});
|
||||
}
|
||||
|
||||
// Подготавливаем данные
|
||||
const incomeByItems = {};
|
||||
const expensesByItems = {};
|
||||
|
||||
// Собираем доходы и расходы по статьям
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (key.startsWith('income_') && typeof value === 'number') {
|
||||
const itemName = key.replace('income_', '').replace(/_/g, ' ');
|
||||
incomeByItems[itemName] = (incomeByItems[itemName] || 0) + value;
|
||||
} else if (key.startsWith('expense_') && typeof value === 'number') {
|
||||
const itemName = key.replace('expense_', '').replace(/_/g, ' ');
|
||||
expensesByItems[itemName] = (expensesByItems[itemName] || 0) + value;
|
||||
}
|
||||
}
|
||||
|
||||
const totalIncome = row.totalIncome || row.total_income ||
|
||||
Object.values(incomeByItems).reduce((sum, val) => sum + (val || 0), 0);
|
||||
const totalExpenses = row.totalExpenses || row.total_expenses ||
|
||||
Object.values(expensesByItems).reduce((sum, val) => sum + (val || 0), 0);
|
||||
const balance = row.balance || (totalIncome - totalExpenses);
|
||||
|
||||
// Сохраняем или обновляем данные
|
||||
await client.query(
|
||||
`INSERT INTO building_financial_data
|
||||
(building_id, report_id, period_start, period_end, period_type,
|
||||
total_income, income_by_items, total_expenses, expenses_by_items, balance, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (building_id, period_start, period_end, period_type)
|
||||
DO UPDATE SET
|
||||
total_income = EXCLUDED.total_income,
|
||||
income_by_items = EXCLUDED.income_by_items,
|
||||
total_expenses = EXCLUDED.total_expenses,
|
||||
expenses_by_items = EXCLUDED.expenses_by_items,
|
||||
balance = EXCLUDED.balance,
|
||||
metadata = EXCLUDED.metadata,
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
buildingId,
|
||||
reportId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
periodType,
|
||||
totalIncome,
|
||||
JSON.stringify(incomeByItems),
|
||||
totalExpenses,
|
||||
JSON.stringify(expensesByItems),
|
||||
balance,
|
||||
JSON.stringify(row.metadata || {})
|
||||
]
|
||||
);
|
||||
|
||||
processedRows++;
|
||||
|
||||
// Обновляем прогресс
|
||||
if (i % 10 === 0) {
|
||||
const progress = 60 + Math.floor((i / mappedData.length) * 35);
|
||||
this.updateJobStatus(jobId, {
|
||||
progress,
|
||||
currentStep: `Обработано ${i + 1} из ${mappedData.length} строк`
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.addError(jobId, {
|
||||
row: rowNum,
|
||||
message: error.message,
|
||||
suggestion: 'Проверьте данные в этой строке'
|
||||
});
|
||||
errorRows++;
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Обновляем статус отчета
|
||||
if (reportId) {
|
||||
const jobStatus = this.processingJobs.get(jobId);
|
||||
const notFoundAddresses = jobStatus?.notFoundAddresses || [];
|
||||
|
||||
// Формируем error_log с ошибками и не найденными адресами
|
||||
const errorLog = {
|
||||
errors: jobStatus?.errors || [],
|
||||
notFoundAddresses: notFoundAddresses,
|
||||
message: notFoundAddresses.length > 0
|
||||
? `Не найдено ${notFoundAddresses.length} домов из ${mappedData.length}. Данные по этим адресам не были загружены. Создайте дома вручную в системе, затем загрузите отчет повторно.`
|
||||
: undefined
|
||||
};
|
||||
|
||||
await client.query(
|
||||
`UPDATE financial_reports
|
||||
SET status = $1, processed_rows = $2, error_rows = $3, error_log = $4
|
||||
WHERE id = $5`,
|
||||
[
|
||||
errorRows === 0 && notFoundAddresses.length === 0 ? 'completed' : (processedRows > 0 ? 'partial' : 'failed'),
|
||||
processedRows,
|
||||
errorRows + notFoundAddresses.length,
|
||||
JSON.stringify(errorLog),
|
||||
reportId
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
const jobStatus = this.processingJobs.get(jobId);
|
||||
const notFoundAddresses = jobStatus?.notFoundAddresses || [];
|
||||
|
||||
return {
|
||||
success: processedRows > 0,
|
||||
totalRows: mappedData.length,
|
||||
processedRows,
|
||||
errorRows: errorRows + notFoundAddresses.length,
|
||||
buildingsFound,
|
||||
buildingsCreated: 0, // Больше не создаем дома автоматически
|
||||
notFoundAddresses
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание нового дома, если его нет в БД
|
||||
*/
|
||||
async createBuildingIfNotExists(client, address, jobId) {
|
||||
try {
|
||||
// Получаем первый доступный участок (district) для нового дома
|
||||
const districtResult = await client.query(
|
||||
'SELECT id FROM districts ORDER BY id LIMIT 1'
|
||||
);
|
||||
|
||||
const districtId = districtResult.rows.length > 0
|
||||
? districtResult.rows[0].id
|
||||
: 'd-1'; // Дефолтный участок, если нет участков
|
||||
|
||||
// Генерируем ID для нового дома
|
||||
const buildingId = `b-auto-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`;
|
||||
|
||||
// Создаем минимальную структуру Building
|
||||
const newBuilding = {
|
||||
id: buildingId,
|
||||
districtId: districtId,
|
||||
imageUrl: "https://picsum.photos/id/122/800/600",
|
||||
nps: 0,
|
||||
passport: {
|
||||
address: address,
|
||||
apartmentsCount: 0,
|
||||
general: {
|
||||
address: address,
|
||||
fiasCode: "",
|
||||
constructionYear: new Date().getFullYear(),
|
||||
commissionYear: new Date().getFullYear(),
|
||||
seriesType: "Не указано",
|
||||
floors: 0,
|
||||
undergroundFloors: 0,
|
||||
totalArea: 0,
|
||||
livingArea: 0,
|
||||
nonLivingArea: 0,
|
||||
commonArea: 0,
|
||||
cadastralNumberBuild: "",
|
||||
cadastralNumberLand: ""
|
||||
},
|
||||
construction: {
|
||||
foundationType: "",
|
||||
foundationMaterial: "",
|
||||
wallMaterial: "",
|
||||
floorMaterial: "",
|
||||
roofType: "",
|
||||
roofMaterial: "",
|
||||
roofArea: 0,
|
||||
facadeType: "",
|
||||
facadeInsulation: false,
|
||||
windowType: ""
|
||||
},
|
||||
engineering: {
|
||||
heatingType: "",
|
||||
heatingWiring: "",
|
||||
hasITP: false,
|
||||
waterSupplyMaterial: "",
|
||||
waterSupplyType: "",
|
||||
sewerMaterial: "",
|
||||
electricityEntries: 1,
|
||||
hasVRU: false,
|
||||
gasType: "",
|
||||
ventilationType: ""
|
||||
},
|
||||
meters: [],
|
||||
lifts: [],
|
||||
land: {
|
||||
area: 0,
|
||||
hasPlayground: false,
|
||||
hasSportsGround: false,
|
||||
hasParking: false,
|
||||
hasFencing: false,
|
||||
hasContainerSite: false
|
||||
},
|
||||
management: {
|
||||
contractDate: "",
|
||||
contractNumber: "",
|
||||
servicesList: [],
|
||||
tariffMaintenance: 0,
|
||||
reserveFund: 5.00,
|
||||
serviceContracts: []
|
||||
}
|
||||
},
|
||||
staff: [],
|
||||
entrances: [],
|
||||
commonSections: [],
|
||||
accounts: [],
|
||||
financials: {
|
||||
balance: 0,
|
||||
debt: 0,
|
||||
collectionRate: 0,
|
||||
topDebtors: [],
|
||||
invoices: []
|
||||
},
|
||||
requests: {
|
||||
new: 0,
|
||||
inProgress: 0,
|
||||
overdue: 0
|
||||
},
|
||||
inspectionHistory: [],
|
||||
tasks: [],
|
||||
annualPlan: [],
|
||||
inventory: [],
|
||||
writeOffHistory: [],
|
||||
residents: [],
|
||||
reports: [],
|
||||
isDirty: true
|
||||
};
|
||||
|
||||
// Сохраняем новый дом в БД
|
||||
await client.query(
|
||||
'INSERT INTO buildings (id, data) VALUES ($1, $2)',
|
||||
[buildingId, JSON.stringify(newBuilding)]
|
||||
);
|
||||
|
||||
// Автоматически создаем опрос NPS для нового дома
|
||||
try {
|
||||
const existingSurvey = await client.query(
|
||||
`SELECT id FROM nps_surveys WHERE building_id = $1`,
|
||||
[buildingId]
|
||||
);
|
||||
|
||||
if (existingSurvey.rows.length === 0) {
|
||||
const accessKey = `nps-${buildingId}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
await client.query(
|
||||
`INSERT INTO nps_surveys
|
||||
(building_id, title, description, status, access_key, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, 'system')`,
|
||||
[
|
||||
buildingId,
|
||||
'Опрос удовлетворенности жителей',
|
||||
'Помогите нам стать лучше! Поделитесь своим мнением о качестве обслуживания.',
|
||||
'draft',
|
||||
accessKey
|
||||
]
|
||||
);
|
||||
}
|
||||
} catch (npsErr) {
|
||||
console.error(`[fileProcessor] Ошибка создания опроса NPS для дома ${buildingId}:`, npsErr);
|
||||
}
|
||||
|
||||
// Автоматически создаем отчет для нового дома (текущий месяц) с заполненными данными
|
||||
try {
|
||||
const now = new Date();
|
||||
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
|
||||
|
||||
const months = [
|
||||
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||
];
|
||||
const monthName = months[now.getMonth()];
|
||||
const reportMonth = `${monthName} ${now.getFullYear()}`;
|
||||
|
||||
const existingReport = await client.query(
|
||||
`SELECT id FROM resident_reports
|
||||
WHERE building_id = $1 AND month = $2`,
|
||||
[buildingId, reportMonth]
|
||||
);
|
||||
|
||||
if (existingReport.rows.length === 0) {
|
||||
// Создаем отчет с начальными данными
|
||||
const initialContent = {
|
||||
applications: {
|
||||
total: 156,
|
||||
completed: 153,
|
||||
quality: 98
|
||||
},
|
||||
finances: {
|
||||
collected: 2400000,
|
||||
expenses: 1890000,
|
||||
balance: 560000
|
||||
},
|
||||
nps: {
|
||||
score: 72,
|
||||
totalResponses: 45
|
||||
}
|
||||
};
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO resident_reports
|
||||
(building_id, month, period_start, period_end, status, content, published_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW())`,
|
||||
[
|
||||
buildingId,
|
||||
reportMonth,
|
||||
currentMonthStart.toISOString().split('T')[0],
|
||||
currentMonthEnd.toISOString().split('T')[0],
|
||||
'published',
|
||||
JSON.stringify(initialContent)
|
||||
]
|
||||
);
|
||||
}
|
||||
} catch (reportErr) {
|
||||
console.error(`[fileProcessor] Ошибка создания отчета для дома ${buildingId}:`, reportErr);
|
||||
}
|
||||
|
||||
// Добавляем информационное сообщение (не ошибку) о создании дома
|
||||
const current = this.processingJobs.get(jobId);
|
||||
if (current) {
|
||||
if (!current.warnings) {
|
||||
current.warnings = [];
|
||||
}
|
||||
current.warnings.push({
|
||||
message: `Автоматически создан новый дом: "${address}"`,
|
||||
suggestion: 'Рекомендуется заполнить паспорт дома вручную'
|
||||
});
|
||||
this.processingJobs.set(jobId, current);
|
||||
}
|
||||
|
||||
return buildingId;
|
||||
} catch (error) {
|
||||
console.error('Ошибка создания нового дома:', error);
|
||||
this.addError(jobId, {
|
||||
row: 0,
|
||||
message: `Не удалось создать дом "${address}": ${error.message}`,
|
||||
suggestion: 'Проверьте права доступа к БД или создайте дом вручную'
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение статуса обработки
|
||||
*/
|
||||
getJobStatus(jobId) {
|
||||
return this.processingJobs.get(jobId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление статуса обработки
|
||||
*/
|
||||
updateJobStatus(jobId, updates) {
|
||||
const current = this.processingJobs.get(jobId);
|
||||
if (current) {
|
||||
this.processingJobs.set(jobId, { ...current, ...updates });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавление ошибки
|
||||
*/
|
||||
addError(jobId, error) {
|
||||
const current = this.processingJobs.get(jobId);
|
||||
if (current) {
|
||||
current.errors.push(error);
|
||||
this.processingJobs.set(jobId, current);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка старых заданий (старше 1 часа)
|
||||
*/
|
||||
cleanupOldJobs() {
|
||||
const oneHourAgo = Date.now() - 3600000;
|
||||
for (const [jobId, job] of this.processingJobs.entries()) {
|
||||
if (job.status === 'completed' || job.status === 'failed') {
|
||||
// Можно добавить timestamp и удалять старые
|
||||
// Пока оставляем все для истории
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FileProcessor;
|
||||
Reference in New Issue
Block a user