Files
mkd/backend/salaryProcessor.js

411 lines
14 KiB
JavaScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
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<Object>} Результат обработки
*/
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;