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

411 lines
14 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 { 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;