const { Pool } = require('pg'); /** * Обработчик зарплатных данных из 1С * Расширяет функциональность fileProcessor для работы с зарплатными отчетами */ class SalaryProcessor { constructor(pool) { this.pool = pool; } /** * Обработка зарплатных данных из файла * @param {Array} rows - Строки из файла после парсинга * @param {Object} mapping - Маппинг полей из файла в структуру данных * @param {number} reportId - ID отчета в БД * @returns {Promise} Результат обработки */ async processSalaryData(rows, mapping, reportId) { const client = await this.pool.connect(); let processedRows = 0; let errorRows = 0; let employeesUpdated = 0; const errors = []; try { await client.query('BEGIN'); for (let i = 0; i < rows.length; i++) { const row = rows[i]; const rowNum = i + 2; // +2 потому что первая строка - заголовок, нумерация с 1 try { // Применяем маппинг полей const mappedData = this.applyMapping(row, mapping); // Валидация обязательных полей const validation = this.validateSalaryRow(mappedData); if (!validation.valid) { errors.push({ row: rowNum, message: validation.error, data: mappedData }); errorRows++; continue; } // Находим сотрудника по идентификатору const employee = await this.findEmployee(client, mappedData); if (!employee) { errors.push({ row: rowNum, message: `Сотрудник не найден: ${mappedData.employeeIdentifier || 'не указан'}`, suggestion: 'Проверьте ФИО, ИНН или табельный номер в файле' }); errorRows++; continue; } // Парсим период const period = this.parsePeriod(mappedData.period); if (!period) { errors.push({ row: rowNum, message: 'Не удалось определить период из данных', data: mappedData.period }); errorRows++; continue; } // Сохраняем историю зарплаты await this.saveSalaryHistory(client, { employeeId: employee.id, reportId: reportId, periodMonth: period.month, periodYear: period.year, baseSalary: this.parseNumber(mappedData.baseSalary) || 0, actualSalary: this.parseNumber(mappedData.actualSalary) || 0, bonuses: this.parseNumber(mappedData.bonuses) || 0, deductions: this.parseNumber(mappedData.deductions) || 0, netSalary: this.parseNumber(mappedData.netSalary) || 0, workedDays: this.parseNumber(mappedData.workedDays), workedHours: this.parseNumber(mappedData.workedHours), vacationDays: this.parseNumber(mappedData.vacationDays) || 0, sickLeaveDays: this.parseNumber(mappedData.sickLeaveDays) || 0, metadata: this.extractMetadata(mappedData) }); // Обновляем текущую зарплату сотрудника (берем последний период) if (mappedData.actualSalary || mappedData.baseSalary) { const newSalary = this.parseNumber(mappedData.actualSalary) || this.parseNumber(mappedData.baseSalary) || employee.salary; if (newSalary !== employee.salary) { await client.query( 'UPDATE employees SET salary = $1, updated_at = NOW() WHERE id = $2', [newSalary, employee.id] ); employeesUpdated++; } } processedRows++; } catch (error) { console.error(`Ошибка обработки строки ${rowNum}:`, error); errors.push({ row: rowNum, message: error.message, data: row }); errorRows++; } } await client.query('COMMIT'); return { success: true, processedRows, errorRows, employeesUpdated, errors: errors.slice(0, 50) // Ограничиваем количество ошибок в ответе }; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } /** * Применение маппинга полей из файла */ applyMapping(row, mapping) { const mapped = {}; const columnMappings = mapping.columnMappings || {}; // Стандартные поля const fieldMappings = { employeeIdentifier: ['ФИО', 'Имя', 'Сотрудник', 'СотрудникФИО', 'employeeName', 'name'], inn: ['ИНН', 'inn'], snils: ['СНИЛС', 'snils'], period: ['Период', 'Месяц', 'Дата', 'period', 'month', 'date'], baseSalary: ['Оклад', 'Базовая зарплата', 'baseSalary', 'salary'], actualSalary: ['Зарплата', 'Начислено', 'actualSalary', 'accrued'], bonuses: ['Премия', 'Премии', 'bonuses', 'bonus'], deductions: ['Удержано', 'Удержания', 'deductions', 'deduction'], netSalary: ['К выплате', 'К выплате', 'netSalary', 'toPay'], workedDays: ['Отработано дней', 'Дней', 'workedDays', 'days'], workedHours: ['Отработано часов', 'Часов', 'workedHours', 'hours'], vacationDays: ['Отпуск', 'Дни отпуска', 'vacationDays', 'vacation'], sickLeaveDays: ['Больничный', 'Дни больничного', 'sickLeaveDays', 'sickLeave'] }; // Применяем маппинг for (const [targetField, sourceFields] of Object.entries(fieldMappings)) { for (const sourceField of sourceFields) { // Сначала проверяем маппинг из настроек const mappedField = columnMappings[sourceField]; if (mappedField && row[mappedField] !== undefined) { mapped[targetField] = row[mappedField]; break; } // Затем проверяем прямое совпадение if (row[sourceField] !== undefined) { mapped[targetField] = row[sourceField]; break; } } } return mapped; } /** * Валидация строки зарплатных данных */ validateSalaryRow(data) { if (!data.employeeIdentifier && !data.inn) { return { valid: false, error: 'Не указан идентификатор сотрудника (ФИО или ИНН)' }; } if (!data.period) { return { valid: false, error: 'Не указан период (месяц/год)' }; } return { valid: true }; } /** * Поиск сотрудника по идентификатору */ async findEmployee(client, data) { // Сначала пробуем найти по ИНН (самый точный способ) if (data.inn) { const result = await client.query( `SELECT e.* FROM employees e INNER JOIN employee_accounting_data a ON e.id = a.employee_id WHERE a.inn = $1`, [data.inn] ); if (result.rows.length > 0) { return result.rows[0]; } } // Затем по СНИЛС if (data.snils) { const result = await client.query( `SELECT e.* FROM employees e INNER JOIN employee_accounting_data a ON e.id = a.employee_id WHERE a.snils = $1`, [data.snils] ); if (result.rows.length > 0) { return result.rows[0]; } } // Затем по ФИО (частичное совпадение) if (data.employeeIdentifier) { const nameParts = data.employeeIdentifier.trim().split(/\s+/); if (nameParts.length >= 2) { // Ищем по фамилии и имени const lastName = nameParts[0]; const firstName = nameParts[1]; const result = await client.query( `SELECT * FROM employees WHERE name ILIKE $1 OR name ILIKE $2`, [`%${lastName}%${firstName}%`, `%${firstName}%${lastName}%`] ); if (result.rows.length === 1) { return result.rows[0]; } else if (result.rows.length > 1) { // Несколько совпадений - нужен более точный поиск // Пробуем точное совпадение const exactMatch = result.rows.find(e => e.name.toLowerCase() === data.employeeIdentifier.toLowerCase() ); if (exactMatch) { return exactMatch; } } } } return null; } /** * Парсинг периода из строки * Поддерживает форматы: "01.2024", "январь 2024", "2024-01", "01/2024" */ parsePeriod(periodStr) { if (!periodStr) return null; const str = String(periodStr).trim(); // Формат "01.2024" или "01/2024" const dotMatch = str.match(/^(\d{1,2})[./](\d{4})$/); if (dotMatch) { const month = parseInt(dotMatch[1], 10); const year = parseInt(dotMatch[2], 10); if (month >= 1 && month <= 12 && year >= 2000) { return { month, year }; } } // Формат "2024-01" const dashMatch = str.match(/^(\d{4})-(\d{1,2})$/); if (dashMatch) { const year = parseInt(dashMatch[1], 10); const month = parseInt(dashMatch[2], 10); if (month >= 1 && month <= 12 && year >= 2000) { return { month, year }; } } // Формат "январь 2024" или "Январь 2024" const monthNames = { 'январь': 1, 'февраль': 2, 'март': 3, 'апрель': 4, 'май': 5, 'июнь': 6, 'июль': 7, 'август': 8, 'сентябрь': 9, 'октябрь': 10, 'ноябрь': 11, 'декабрь': 12 }; for (const [monthName, monthNum] of Object.entries(monthNames)) { const regex = new RegExp(`${monthName}\\s+(\\d{4})`, 'i'); const match = str.match(regex); if (match) { const year = parseInt(match[1], 10); if (year >= 2000) { return { month: monthNum, year }; } } } // Пробуем распарсить как дату const date = new Date(str); if (!isNaN(date.getTime())) { return { month: date.getMonth() + 1, year: date.getFullYear() }; } return null; } /** * Парсинг числа из строки */ parseNumber(value) { if (value === null || value === undefined || value === '') { return null; } if (typeof value === 'number') { return value; } // Убираем пробелы и заменяем запятую на точку const cleaned = String(value) .replace(/\s/g, '') .replace(',', '.'); const parsed = parseFloat(cleaned); return isNaN(parsed) ? null : parsed; } /** * Извлечение дополнительных метаданных */ extractMetadata(data) { const metadata = {}; const knownFields = [ 'employeeIdentifier', 'inn', 'snils', 'period', 'baseSalary', 'actualSalary', 'bonuses', 'deductions', 'netSalary', 'workedDays', 'workedHours', 'vacationDays', 'sickLeaveDays' ]; for (const [key, value] of Object.entries(data)) { if (!knownFields.includes(key) && value !== null && value !== undefined) { metadata[key] = value; } } return Object.keys(metadata).length > 0 ? metadata : null; } /** * Сохранение истории зарплаты */ async saveSalaryHistory(client, data) { await client.query( `INSERT INTO employee_salary_history ( employee_id, report_id, period_month, period_year, base_salary, actual_salary, bonuses, deductions, net_salary, worked_days, worked_hours, vacation_days, sick_leave_days, metadata, imported_from_1c ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) ON CONFLICT (employee_id, period_month, period_year) DO UPDATE SET base_salary = EXCLUDED.base_salary, actual_salary = EXCLUDED.actual_salary, bonuses = EXCLUDED.bonuses, deductions = EXCLUDED.deductions, net_salary = EXCLUDED.net_salary, worked_days = EXCLUDED.worked_days, worked_hours = EXCLUDED.worked_hours, vacation_days = EXCLUDED.vacation_days, sick_leave_days = EXCLUDED.sick_leave_days, metadata = EXCLUDED.metadata, report_id = EXCLUDED.report_id, updated_at = NOW()`, [ data.employeeId, data.reportId, data.periodMonth, data.periodYear, data.baseSalary, data.actualSalary, data.bonuses, data.deductions, data.netSalary, data.workedDays, data.workedHours, data.vacationDays, data.sickLeaveDays, data.metadata ? JSON.stringify(data.metadata) : null, true ] ); } } module.exports = SalaryProcessor;