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

970 lines
35 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const csv = require('csv-parser');
const XLSX = require('xlsx');
const fs = require('fs');
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;