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;