import * as XLSX from "xlsx";
import { EmployeeDraft } from "../schemas/employee-schemas.js";
import { DataProcessor } from "./data-processor.js";
/**
* Парсер для XLSX файлов с сотрудниками
* Поддерживает различные форматы таблиц
*/
export interface XlsxParseResult {
success: boolean;
employees: EmployeeDraft[];
errors: string[];
totalRows: number;
validRows: number;
invalidRows: number;
report: string;
}
export interface XlsxColumnMapping {
lastName?: number;
firstName?: number;
middleName?: number;
email?: number;
phone?: number;
phones?: number;
}
export class XlsxParser {
private static readonly SUPPORTED_EXTENSIONS = [".xlsx", ".xls"];
private static readonly MAX_ROWS = 100; // Согласно ТЗ: список ≤ 100 строк
private static readonly MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
/**
* Парсинг XLSX файла по URL
*/
static async parseFromUrl(fileUrl: string): Promise<XlsxParseResult> {
try {
console.log(`[XLSX] Parsing file from URL: ${fileUrl}`);
// Проверяем URL
if (!this.isValidUrl(fileUrl)) {
return this.createErrorResult(["Некорректный URL файла"]);
}
// Проверяем расширение файла
if (!this.hasSupportedExtension(fileUrl)) {
return this.createErrorResult(["Неподдерживаемый формат файла. Поддерживаются: .xlsx, .xls"]);
}
// Загружаем файл
const response = await fetch(fileUrl);
if (!response.ok) {
return this.createErrorResult([`Ошибка загрузки файла: ${response.status} ${response.statusText}`]);
}
// Проверяем размер файла
const contentLength = response.headers.get("content-length");
if (contentLength && parseInt(contentLength) > this.MAX_FILE_SIZE) {
return this.createErrorResult([
`Файл слишком большой. Максимальный размер: ${this.MAX_FILE_SIZE / 1024 / 1024}MB`,
]);
}
const arrayBuffer = await response.arrayBuffer();
return this.parseFromBuffer(arrayBuffer);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("[XLSX] Error parsing from URL:", errorMessage);
return this.createErrorResult([`Ошибка парсинга файла: ${errorMessage}`]);
}
}
/**
* Парсинг XLSX файла из буфера
*/
static parseFromBuffer(buffer: ArrayBuffer): XlsxParseResult {
try {
console.log("[XLSX] Parsing file from buffer");
// Читаем workbook
const workbook = XLSX.read(buffer, { type: "array" });
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
return this.createErrorResult(["Файл не содержит листов"]);
}
// Используем первый лист
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
if (!worksheet) {
return this.createErrorResult(["Не удалось прочитать лист"]);
}
// Конвертируем в JSON
const jsonData = XLSX.utils.sheet_to_json(worksheet, {
header: 1, // Используем массив массивов
defval: "", // Значение по умолчанию
raw: false, // Конвертируем все в строки
}) as any[][];
console.log("[XLSX] Raw JSON data:", jsonData.slice(0, 3)); // Показываем первые 3 строки
if (!Array.isArray(jsonData) || jsonData.length === 0) {
return this.createErrorResult(["Лист пуст или содержит некорректные данные"]);
}
return this.parseJsonData(jsonData);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("[XLSX] Error parsing from buffer:", errorMessage);
return this.createErrorResult([`Ошибка парсинга файла: ${errorMessage}`]);
}
}
/**
* Парсинг JSON данных из XLSX
*/
private static parseJsonData(jsonData: any[][]): XlsxParseResult {
const employees: EmployeeDraft[] = [];
const errors: string[] = [];
let validRows = 0;
let invalidRows = 0;
// Первая строка - заголовки
const headers = jsonData[0] as string[];
if (!headers || headers.length === 0) {
return this.createErrorResult(["Отсутствуют заголовки в файле"]);
}
// Нормализуем заголовки
const normalizedHeaders = headers.map((h) => this.normalizeHeader(h));
const columnMapping = this.detectColumnMapping(normalizedHeaders);
console.log("[XLSX] Detected column mapping:", columnMapping);
// Проверяем, что найдены обязательные колонки
if (
columnMapping.lastName === undefined ||
columnMapping.firstName === undefined ||
columnMapping.email === undefined
) {
return this.createErrorResult(["Не найдены обязательные колонки. Требуются: Фамилия, Имя, Email"]);
}
// Обрабатываем строки данных
const dataRows = jsonData.slice(1); // Пропускаем заголовки
if (dataRows.length > this.MAX_ROWS) {
errors.push(`Файл содержит ${dataRows.length} строк, но поддерживается максимум ${this.MAX_ROWS}`);
}
const rowsToProcess = Math.min(dataRows.length, this.MAX_ROWS);
for (let i = 0; i < rowsToProcess; i++) {
const row = dataRows[i];
const rowNumber = i + 2; // +2 потому что пропускаем заголовки и индексация с 0
try {
const employee = this.parseRow(row, columnMapping, rowNumber);
employees.push(employee);
if (employee.isValid) {
validRows++;
} else {
invalidRows++;
if (employee.errors) {
errors.push(...employee.errors);
}
}
} catch (error) {
invalidRows++;
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push(`Строка ${rowNumber}: ${errorMessage}`);
}
}
const report = this.generateReport(employees.length, validRows, invalidRows, errors);
return {
success: validRows > 0,
employees,
errors,
totalRows: rowsToProcess,
validRows,
invalidRows,
report,
};
}
/**
* Парсинг одной строки
*/
private static parseRow(row: any[], columnMapping: XlsxColumnMapping, rowNumber: number): EmployeeDraft {
const errors: string[] = [];
// Извлекаем данные по колонкам
const lastName = this.getCellValue(row, columnMapping.lastName);
const firstName = this.getCellValue(row, columnMapping.firstName);
const middleName = this.getCellValue(row, columnMapping.middleName);
const email = this.getCellValue(row, columnMapping.email);
const phone = this.getCellValue(row, columnMapping.phone) || this.getCellValue(row, columnMapping.phones);
// Проверяем обязательные поля
if (!lastName) errors.push("Фамилия не указана");
if (!firstName) errors.push("Имя не указано");
if (!email) errors.push("Email не указан");
if (!phone) errors.push("Телефон не указан");
// Создаём текст для парсинга
const text = `${lastName} ${firstName} ${middleName || ""} ${email} ${phone}`.trim();
// Используем DataProcessor для обработки
const draft = DataProcessor.createEmployeeDraft(text);
// Переопределяем данные из XLSX (более точные)
if (lastName) draft.lastName = DataProcessor.maskFullName(lastName);
if (firstName) draft.firstName = DataProcessor.maskFullName(firstName);
if (middleName) draft.middleName = DataProcessor.maskFullName(middleName);
if (email) draft.email = DataProcessor.normalizeEmail(email);
if (phone) {
const phones = DataProcessor.parsePhones(phone);
if (phones.length > 0) {
draft.phones = phones;
}
}
// Валидируем финальный результат
const validatedDraft = DataProcessor.validateEmployeeDraft(draft);
// Добавляем ошибки из XLSX парсинга
if (errors.length > 0) {
validatedDraft.errors = [...(validatedDraft.errors || []), ...errors];
validatedDraft.isValid = false;
}
return validatedDraft;
}
/**
* Получение значения ячейки
*/
private static getCellValue(row: any[], columnIndex?: number): string {
if (columnIndex === undefined || columnIndex < 0 || columnIndex >= row.length) {
return "";
}
const value = row[columnIndex];
return value ? String(value).trim() : "";
}
/**
* Нормализация заголовка
*/
private static normalizeHeader(header: string): string {
return header
.toLowerCase()
.replace(/[^\w\s\u0400-\u04FF]/g, "") // Убираем спецсимволы, оставляем кириллицу
.replace(/\s+/g, " ") // Нормализуем пробелы
.trim();
}
/**
* Определение соответствия колонок
*/
private static detectColumnMapping(headers: string[]): XlsxColumnMapping {
const mapping: XlsxColumnMapping = {};
headers.forEach((header, index) => {
const normalized = header.toLowerCase().trim();
// Фамилия
if (
normalized.includes("фамилия") ||
normalized.includes("lastname") ||
normalized.includes("surname") ||
normalized === "фамилия"
) {
mapping.lastName = index;
}
// Имя
else if (
normalized.includes("имя") ||
normalized.includes("firstname") ||
normalized.includes("name") ||
normalized === "имя"
) {
mapping.firstName = index;
}
// Отчество
else if (
normalized.includes("отчество") ||
normalized.includes("middlename") ||
normalized.includes("patronymic") ||
normalized === "отчество"
) {
mapping.middleName = index;
}
// Email
else if (
normalized.includes("email") ||
normalized.includes("почта") ||
normalized.includes("mail") ||
normalized === "email"
) {
mapping.email = index;
}
// Телефон
else if (
normalized.includes("телефон") ||
normalized.includes("phone") ||
normalized.includes("тел") ||
normalized === "телефон"
) {
mapping.phone = index;
}
// Телефоны (множественное число)
else if (normalized.includes("телефоны") || normalized.includes("phones")) {
mapping.phones = index;
}
});
console.log("[XLSX] Headers:", headers);
console.log(
"[XLSX] Normalized headers:",
headers.map((h) => h.toLowerCase().trim())
);
console.log("[XLSX] Detected mapping:", mapping);
return mapping;
}
/**
* Проверка валидности URL
*/
private static isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* Проверка поддерживаемого расширения
*/
private static hasSupportedExtension(url: string): boolean {
const lowerUrl = url.toLowerCase();
return this.SUPPORTED_EXTENSIONS.some((ext) => lowerUrl.includes(ext));
}
/**
* Создание результата с ошибкой
*/
private static createErrorResult(errors: string[]): XlsxParseResult {
return {
success: false,
employees: [],
errors,
totalRows: 0,
validRows: 0,
invalidRows: 0,
report: `Ошибки: ${errors.join(", ")}`,
};
}
/**
* Генерация отчёта
*/
private static generateReport(total: number, valid: number, invalid: number, errors: string[]): string {
let report = `Обработано строк: ${total}\n`;
report += `Валидных: ${valid}\n`;
report += `С ошибками: ${invalid}\n\n`;
if (errors.length > 0) {
report += "Ошибки:\n";
errors.slice(0, 10).forEach((error, index) => {
// Показываем только первые 10 ошибок
report += `${index + 1}. ${error}\n`;
});
if (errors.length > 10) {
report += `... и ещё ${errors.length - 10} ошибок\n`;
}
}
return report;
}
}