Initial commit MKD fixes
This commit is contained in:
410
backend/salaryProcessor.js
Executable file
410
backend/salaryProcessor.js
Executable file
@@ -0,0 +1,410 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user