970 lines
35 KiB
JavaScript
970 lines
35 KiB
JavaScript
|
|
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;
|