Files
mkd/backend/fileProcessor.js

970 lines
35 KiB
JavaScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
const csv = require('csv-parser');
const XLSX = require('xlsx');
const fs = require('fs');
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;