411 lines
14 KiB
JavaScript
Executable File
411 lines
14 KiB
JavaScript
Executable File
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;
|