import { EmployeeDraft, EmployeeDraftSchema } from "../schemas/employee-schemas.js";
/**
* Утилиты для обработки и маскировки данных сотрудников
* Согласно ТЗ: ФИО → Title Case, телефоны → +7XXXXXXXXXX, email → lower-case
*/
export class DataProcessor {
/**
* Маскировка ФИО в Title Case
* "иванов иван" → "Иванов Иван"
*/
static maskFullName(fullName: string): string {
if (!fullName || typeof fullName !== "string") {
return "";
}
return fullName
.trim()
.split(/\s+/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
/**
* Нормализация российских номеров телефонов
* Приводит к формату +7XXXXXXXXXX
*/
static normalizePhoneNumber(phone: string): string {
if (!phone || typeof phone !== "string") {
return "";
}
// Удаляем все символы кроме цифр
const digits = phone.replace(/\D/g, "");
// Если номер начинается с 8, заменяем на 7
if (digits.startsWith("8") && digits.length === 11) {
return "+7" + digits.slice(1);
}
// Если номер начинается с 7 и имеет 11 цифр
if (digits.startsWith("7") && digits.length === 11) {
return "+" + digits;
}
// Если номер имеет 10 цифр, добавляем +7
if (digits.length === 10) {
return "+7" + digits;
}
// Возвращаем как есть, если не соответствует формату
return phone;
}
/**
* Нормализация email в lower-case
*/
static normalizeEmail(email: string): string {
if (!email || typeof email !== "string") {
return "";
}
return email.trim().toLowerCase();
}
/**
* Валидация email
*/
static isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* Валидация российского номера телефона
*/
static isValidPhone(phone: string): boolean {
const normalized = this.normalizePhoneNumber(phone);
const phoneRegex = /^\+7\d{10}$/;
return phoneRegex.test(normalized);
}
/**
* Парсинг ФИО из текста
* Извлекает фамилию, имя, отчество из строки
*/
static parseFullName(text: string): { lastName?: string; firstName?: string; middleName?: string } {
if (!text || typeof text !== "string") {
return {};
}
const words = text
.trim()
.split(/\s+/)
.filter((word) => word.length > 0);
if (words.length === 0) {
return {};
}
const result: { lastName?: string; firstName?: string; middleName?: string } = {};
if (words.length >= 1) {
result.lastName = this.maskFullName(words[0]);
}
if (words.length >= 2) {
result.firstName = this.maskFullName(words[1]);
}
if (words.length >= 3) {
result.middleName = this.maskFullName(words[2]);
}
return result;
}
/**
* Парсинг email из текста
*/
static parseEmail(text: string): string | null {
if (!text || typeof text !== "string") {
return null;
}
const emailRegex = /[^\s@]+@[^\s@]+\.[^\s@]+/g;
const matches = text.match(emailRegex);
if (matches && matches.length > 0) {
return this.normalizeEmail(matches[0]);
}
return null;
}
/**
* Парсинг телефонов из текста
*/
static parsePhones(text: string): string[] {
if (!text || typeof text !== "string") {
return [];
}
// Регулярное выражение для поиска российских номеров
const phoneRegex = /(?:\+7|8)?[\s\-]?\(?(\d{3})\)?[\s\-]?(\d{3})[\s\-]?(\d{2})[\s\-]?(\d{2})/g;
const matches = text.match(phoneRegex);
if (!matches) {
return [];
}
return matches.map((phone) => this.normalizePhoneNumber(phone)).filter((phone) => this.isValidPhone(phone));
}
/**
* Создание EmployeeDraft из текста
*/
static createEmployeeDraft(rawText: string): EmployeeDraft {
const errors: string[] = [];
// Парсим данные из текста
const nameData = this.parseFullName(rawText);
const email = this.parseEmail(rawText);
const phones = this.parsePhones(rawText);
// Валидация
if (!nameData.lastName && !nameData.firstName) {
errors.push("Не удалось извлечь ФИО из текста");
}
if (!email) {
errors.push("Email не найден в тексте");
} else if (!this.isValidEmail(email)) {
errors.push("Некорректный формат email");
}
if (phones.length === 0) {
errors.push("Телефон не найден в тексте");
}
const isValid = errors.length === 0;
return {
lastName: nameData.lastName,
firstName: nameData.firstName,
middleName: nameData.middleName,
email: email || undefined,
phones: phones.length > 0 ? phones : undefined,
rawText,
errors: errors.length > 0 ? errors : undefined,
isValid,
};
}
/**
* Валидация EmployeeDraft
*/
static validateEmployeeDraft(draft: EmployeeDraft): EmployeeDraft {
const errors: string[] = [];
// Проверяем обязательные поля
if (!draft.lastName) {
errors.push("Фамилия обязательна");
}
if (!draft.firstName) {
errors.push("Имя обязательно");
}
if (!draft.email) {
errors.push("Email обязателен");
} else if (!this.isValidEmail(draft.email)) {
errors.push("Некорректный формат email");
}
if (!draft.phones || draft.phones.length === 0) {
errors.push("Телефон обязателен");
} else {
// Проверяем все телефоны
for (const phone of draft.phones) {
if (!this.isValidPhone(phone)) {
errors.push(`Некорректный формат телефона: ${phone}`);
}
}
}
return {
...draft,
errors: errors.length > 0 ? errors : undefined,
isValid: errors.length === 0,
};
}
/**
* Создание отчёта об ошибках
*/
static createErrorReport(drafts: EmployeeDraft[]): string {
const validCount = drafts.filter((d) => d.isValid).length;
const invalidCount = drafts.filter((d) => !d.isValid).length;
let report = `Обработано записей: ${drafts.length}\n`;
report += `Валидных: ${validCount}\n`;
report += `С ошибками: ${invalidCount}\n\n`;
if (invalidCount > 0) {
report += "Ошибки:\n";
drafts.forEach((draft, index) => {
if (!draft.isValid && draft.errors) {
report += `${index + 1}. ${draft.rawText || "Неизвестно"}\n`;
draft.errors.forEach((error) => {
report += ` - ${error}\n`;
});
report += "\n";
}
});
}
return report;
}
/**
* Продвинутый парсинг текста с поддержкой различных форматов
*/
static parseAdvancedText(text: string): EmployeeDraft {
const errors: string[] = [];
// Нормализуем текст
const normalizedText = text.trim().replace(/\s+/g, " ");
// Пытаемся определить формат
const format = this.detectTextFormat(normalizedText);
let draft: EmployeeDraft;
switch (format) {
case "structured":
draft = this.parseStructuredText(normalizedText);
break;
case "natural":
draft = this.parseNaturalText(normalizedText);
break;
case "csv":
draft = this.parseCsvText(normalizedText);
break;
default:
draft = this.createEmployeeDraft(normalizedText);
}
// Дополнительная валидация
const validatedDraft = this.validateEmployeeDraft(draft);
return validatedDraft;
}
/**
* Определение формата текста
*/
private static detectTextFormat(text: string): "structured" | "natural" | "csv" | "unknown" {
// CSV формат (разделители: запятая, точка с запятой, табуляция)
if (text.includes(",") || text.includes(";") || text.includes("\t")) {
return "csv";
}
// Структурированный формат (содержит ключевые слова)
const structuredKeywords = ["фамилия:", "имя:", "email:", "телефон:", "phone:", "email:"];
if (structuredKeywords.some((keyword) => text.toLowerCase().includes(keyword))) {
return "structured";
}
// Естественный формат (обычный текст)
return "natural";
}
/**
* Парсинг структурированного текста
*/
private static parseStructuredText(text: string): EmployeeDraft {
const lines = text
.split(/\n|\|/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
const data: Record<string, string> = {};
lines.forEach((line) => {
const colonIndex = line.indexOf(":");
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim().toLowerCase();
const value = line.substring(colonIndex + 1).trim();
data[key] = value;
}
});
// Маппинг ключей
const lastName = data["фамилия"] || data["lastname"] || data["surname"] || "";
const firstName = data["имя"] || data["firstname"] || data["name"] || "";
const middleName = data["отчество"] || data["middlename"] || data["patronymic"] || "";
const email = data["email"] || data["почта"] || data["mail"] || "";
const phone = data["телефон"] || data["phone"] || data["тел"] || "";
return {
lastName: lastName ? this.maskFullName(lastName) : undefined,
firstName: firstName ? this.maskFullName(firstName) : undefined,
middleName: middleName ? this.maskFullName(middleName) : undefined,
email: email ? this.normalizeEmail(email) : undefined,
phones: phone ? this.parsePhones(phone) : undefined,
rawText: text,
isValid: false, // Будет проверено в validateEmployeeDraft
};
}
/**
* Парсинг естественного текста
*/
private static parseNaturalText(text: string): EmployeeDraft {
// Используем существующий метод
return this.createEmployeeDraft(text);
}
/**
* Парсинг CSV текста
*/
private static parseCsvText(text: string): EmployeeDraft {
// Определяем разделитель
let delimiter = ",";
if (text.includes(";")) delimiter = ";";
else if (text.includes("\t")) delimiter = "\t";
const parts = text.split(delimiter).map((part) => part.trim());
if (parts.length < 3) {
return {
rawText: text,
errors: ["Недостаточно данных в CSV формате"],
isValid: false,
};
}
// Предполагаем порядок: Фамилия, Имя, Отчество, Email, Телефон
const lastName = parts[0] || "";
const firstName = parts[1] || "";
const middleName = parts[2] || "";
const email = parts[3] || "";
const phone = parts[4] || "";
return {
lastName: lastName ? this.maskFullName(lastName) : undefined,
firstName: firstName ? this.maskFullName(firstName) : undefined,
middleName: middleName ? this.maskFullName(middleName) : undefined,
email: email ? this.normalizeEmail(email) : undefined,
phones: phone ? this.parsePhones(phone) : undefined,
rawText: text,
isValid: false, // Будет проверено в validateEmployeeDraft
};
}
/**
* Очистка и нормализация текста
*/
static cleanText(text: string): string {
return text
.trim()
.replace(/\s+/g, " ") // Нормализуем пробелы
.replace(/[^\w\s@.+()-\u0400-\u04FF]/g, " ") // Убираем спецсимволы, оставляем кириллицу, email символы, телефонные символы
.replace(/\s+/g, " ") // Повторно нормализуем пробелы
.trim();
}
/**
* Проверка качества данных
*/
static validateDataQuality(draft: EmployeeDraft): { score: number; issues: string[] } {
const issues: string[] = [];
let score = 100;
// Проверяем полноту данных
if (!draft.lastName) {
issues.push("Отсутствует фамилия");
score -= 25;
}
if (!draft.firstName) {
issues.push("Отсутствует имя");
score -= 25;
}
if (!draft.email) {
issues.push("Отсутствует email");
score -= 25;
}
if (!draft.phones || draft.phones.length === 0) {
issues.push("Отсутствует телефон");
score -= 25;
}
// Проверяем качество email
if (draft.email && !this.isValidEmail(draft.email)) {
issues.push("Некорректный формат email");
score -= 10;
}
// Проверяем качество телефонов
if (draft.phones) {
const invalidPhones = draft.phones.filter((phone) => !this.isValidPhone(phone));
if (invalidPhones.length > 0) {
issues.push(`Некорректные телефоны: ${invalidPhones.join(", ")}`);
score -= 10;
}
}
return { score: Math.max(0, score), issues };
}
}