sozday-sovremennyy-veb-sayt/excel-editor.js

467 lines
18 KiB
JavaScript
Raw 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.

let mainData = null;
let templateData = null;
let mainFileName = '';
let templateFileName = '';
let mappedResult = null;
// Проверка загрузки XLSX
function getXLSX() {
if (window.XLSX) return window.XLSX;
alert('Библиотека для работы с Excel ещё не загрузилась. Подождите секунду...');
return null;
}
// Обработка выбора файла
function handleFileSelect(event, type) {
const XLSX = getXLSX();
if (!XLSX) return;
const file = event.target.files[0];
if (!file) return;
console.log('Файл выбран:', type, file.name, file.size);
const zone = document.getElementById(type + '-upload');
const info = document.getElementById(type + '-file-info');
zone.classList.add('has-file');
info.textContent = file.name + ' (' + formatFileSize(file.size) + ')';
if (type === 'main') {
mainFileName = file.name;
readExcel(file, (data) => {
console.log('Основной файл прочитан, строк:', data.length);
mainData = data;
checkFilesReady();
}, (error) => {
console.error('Ошибка чтения основного файла:', error);
alert('Ошибка чтения файла: ' + error.message);
});
} else {
templateFileName = file.name;
readExcel(file, (data) => {
console.log('Шаблон прочитан, строк:', data.length);
templateData = data;
checkFilesReady();
}, (error) => {
console.error('Ошибка чтения шаблона:', error);
alert('Ошибка чтения файла: ' + error.message);
});
}
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' Б';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' КБ';
return (bytes / (1024 * 1024)).toFixed(1) + ' МБ';
}
function readExcel(file, callback, errorCallback) {
const XLSX = window.XLSX;
if (!XLSX) {
if (errorCallback) errorCallback(new Error('Библиотека XLSX не загружена'));
return;
}
console.log('Чтение файла:', file.name);
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target.result);
console.log('Файл загружен в память, байт:', data.length);
const workbook = XLSX.read(data, { type: 'array' });
console.log('Workbook прочитан, листов:', workbook.SheetNames.length);
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const json = XLSX.utils.sheet_to_json(worksheet);
console.log('Данные конвертированы в JSON, строк:', json.length);
callback(json);
} catch (error) {
console.error('Ошибка при чтении Excel:', error);
if (errorCallback) errorCallback(error);
else alert('Ошибка при чтении файла: ' + error.message);
}
};
reader.onerror = () => {
const error = new Error('Не удалось прочитать файл');
if (errorCallback) errorCallback(error);
else alert(error.message);
};
reader.readAsArrayBuffer(file);
}
function checkFilesReady() {
const btn = document.getElementById('btn-mapping');
if (mainData && mainData.length > 0 && templateData && templateData.length > 0) {
btn.disabled = false;
}
}
function goToMapping() {
if (!mainData || !templateData) return;
document.getElementById('upload-section').classList.add('hidden');
document.getElementById('mapping-section').classList.remove('hidden');
document.getElementById('step1').classList.remove('active');
document.getElementById('step1').classList.add('completed');
document.getElementById('step2').classList.add('active');
populateFieldSelectors();
}
function goBackToUpload() {
document.getElementById('upload-section').classList.remove('hidden');
document.getElementById('mapping-section').classList.add('hidden');
document.getElementById('step1').classList.add('active');
document.getElementById('step2').classList.remove('active', 'completed');
}
function populateFieldSelectors() {
const mainFields = Object.keys(mainData[0] || {});
const templateFields = Object.keys(templateData[0] || {});
const mainSelect = document.getElementById('main-key-field');
const templateSelect = document.getElementById('template-key-field');
mainSelect.innerHTML = '';
templateSelect.innerHTML = '';
// Поиск полей с номером телефона и лицевым счетом
const phoneFields = ['телефон', 'phone', 'tel', 'номер', 'number', 'лицевой', 'account', 'лс', 'флс'];
phoneFields.forEach(field => {
const mainMatch = mainFields.find(f => f.toLowerCase().includes(field));
const templateMatch = templateFields.find(f => f.toLowerCase().includes(field));
if (mainMatch && !mainSelect.querySelector(`[value="${mainMatch}"]`)) {
mainSelect.add(new Option(mainMatch, mainMatch));
}
if (templateMatch && !templateSelect.querySelector(`[value="${templateMatch}"]`)) {
templateSelect.add(new Option(templateMatch, templateMatch));
}
});
// Добавляем все остальные поля
mainFields.forEach(field => {
if (!mainSelect.querySelector(`[value="${field}"]`)) {
mainSelect.add(new Option(field, field));
}
});
templateFields.forEach(field => {
if (!templateSelect.querySelector(`[value="${field}"]`)) {
templateSelect.add(new Option(field, field));
}
});
// Автоматически создаём сопоставления для "Продуктовые предложения" и "Абонент"
setupDefaultMappings();
}
function addMappingRow(templateField = '', mainField = '') {
const container = document.getElementById('column-mappings');
const mainFields = Object.keys(mainData[0] || {});
const templateFields = Object.keys(templateData[0] || {});
const rowId = 'mapping-' + Date.now();
const row = document.createElement('div');
row.id = rowId;
row.className = 'mapping-row';
row.style.cssText = 'display: grid; grid-template-columns: 1fr auto 1fr auto; gap: var(--kt-ai-space-3); align-items: center; margin-bottom: var(--kt-ai-space-2);';
// Заполняем столбец шаблона
let templateOptions = '';
templateFields.forEach(field => {
const selected = field === templateField ? 'selected' : '';
templateOptions += '<option value="' + escapeHtml(field) + '" ' + selected + '>' + escapeHtml(field) + '</option>';
});
// Заполняем столбец основного файла
let mainOptions = '';
mainFields.forEach(field => {
const selected = field === mainField ? 'selected' : '';
mainOptions += '<option value="' + escapeHtml(field) + '" ' + selected + '>' + escapeHtml(field) + '</option>';
});
row.innerHTML = `
<div class="kt-ai-field" style="margin: 0;">
<label style="font-size: var(--kt-ai-text-xs);">Столбец шаблона</label>
<select class="kt-ai-select template-column" onchange="processMapping()">${templateOptions}</select>
</div>
<div style="padding-top: var(--kt-ai-space-5);">
<svg class="kt-icon" style="color: var(--kt-ai-primary);" aria-hidden="true"><use href="#arrowRight"></use></svg>
</div>
<div class="kt-ai-field" style="margin: 0;">
<label style="font-size: var(--kt-ai-text-xs);">Столбец основного файла</label>
<select class="kt-ai-select main-column" onchange="processMapping()">${mainOptions}</select>
</div>
<button class="kt-ai-btn" data-variant="ghost" onclick="removeMappingRow('${rowId}')" style="margin-top: var(--kt-ai-space-5);">
<svg class="kt-icon" aria-hidden="true"><use href="#trash"></use></svg>
</button>
`;
container.appendChild(row);
processMapping();
}
function setupDefaultMappings() {
// Автоматически создаём сопоставления для конкретных полей
const mainFields = Object.keys(mainData[0] || {});
const templateFields = Object.keys(templateData[0] || {});
// Сопоставление 1: "Продуктовые предложения" (шаблон) <- "Продуктовое предложения" (основной)
const templateProductField = templateFields.find(f => f.toLowerCase().includes('продуктов'));
const mainProductField = mainFields.find(f => f.toLowerCase().includes('продуктов'));
if (templateProductField && mainProductField) {
addMappingRow(templateProductField, mainProductField);
console.log('Добавлено сопоставление:', templateProductField, '<-', mainProductField);
}
// Сопоставление 2: "Абонент" (шаблон) <- "Абонент" (основной)
const templateAbonentField = templateFields.find(f => f.toLowerCase().includes('абонент'));
const mainAbonentField = mainFields.find(f => f.toLowerCase().includes('абонент'));
if (templateAbonentField && mainAbonentField) {
addMappingRow(templateAbonentField, mainAbonentField);
console.log('Добавлено сопоставление:', templateAbonentField, '<-', mainAbonentField);
}
}
function removeMappingRow(rowId) {
const row = document.getElementById(rowId);
if (row) {
row.remove();
processMapping();
}
}
function getColumnMappings() {
const mappings = [];
document.querySelectorAll('.template-column').forEach((select, index) => {
const mainSelect = document.querySelectorAll('.main-column')[index];
if (select && mainSelect) {
mappings.push({
templateColumn: select.value,
mainColumn: mainSelect.value
});
}
});
return mappings;
}
function processMapping() {
const mainKeyField = document.getElementById('main-key-field').value;
const templateKeyField = document.getElementById('template-key-field').value;
if (!mainKeyField || !templateKeyField) return;
// Получаем ручные сопоставления столбцов
const columnMappings = getColumnMappings();
// Создаём карту соответствий
const mainMap = new Map();
mainData.forEach(row => {
const keyValue = normalizeValue(row[mainKeyField]);
if (keyValue) {
mainMap.set(keyValue, row);
}
});
// Сопоставляем данные (без мутации!)
let matched = 0;
let notMatched = 0;
let filled = 0;
const previewRows = templateData.slice(0, 10).map((templateRow, index) => {
// Создаём КОПИЮ строки, чтобы не мутировать исходные данные
const rowCopy = { ...templateRow };
const keyValue = normalizeValue(templateRow[templateKeyField]);
const mainRow = mainMap.get(keyValue);
if (mainRow) {
matched++;
// Заполняем пустые поля согласно сопоставлениям
columnMappings.forEach(mapping => {
const templateCol = mapping.templateColumn;
const mainCol = mapping.mainColumn;
const isEmpty = rowCopy[templateCol] === null || rowCopy[templateCol] === undefined || String(rowCopy[templateCol]).trim() === '';
const hasValueInMain = mainRow[mainCol] !== null && mainRow[mainCol] !== undefined;
if (isEmpty && hasValueInMain) {
filled++;
rowCopy[templateCol] = mainRow[mainCol];
}
});
} else {
notMatched++;
}
return {
templateRow: rowCopy,
originalRow: templateRow,
mainRow,
matched: !!mainRow
};
});
// Статистика
const total = templateData.length;
const matchPercent = total > 0 ? Math.round((matched / total) * 100) : 0;
document.getElementById('mapping-stats').innerHTML = `
<div class="stat-card">
<div class="stat-value">${total}</div>
<div class="stat-label">Всего записей в шаблоне</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: var(--kt-ai-success);">${matched}</div>
<div class="stat-label">Сопоставлено (${matchPercent}%)</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: var(--kt-ai-warning);">${notMatched}</div>
<div class="stat-label">Не найдено совпадений</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: var(--kt-ai-primary);">${filled}</div>
<div class="stat-label">Полей заполнено (в предпросмотре)</div>
</div>
`;
// Предпросмотр
const table = document.getElementById('mapping-preview');
const allFields = new Set([...Object.keys(templateData[0] || {}), ...fieldsToFill]);
let headerHtml = '<thead><tr>';
allFields.forEach(field => {
headerHtml += '<th>' + escapeHtml(field) + '</th>';
});
headerHtml += '<th>Статус</th></tr></thead>';
let bodyHtml = '<tbody>';
previewRows.forEach(row => {
bodyHtml += '<tr class="' + (row.matched ? 'matched' : 'no-match') + '">';
allFields.forEach(field => {
const originalValue = row.originalRow[field];
const newValue = row.templateRow[field];
// Проверяем, было ли это поле заполнено из сопоставления
let wasFilled = false;
columnMappings.forEach(mapping => {
if (mapping.templateColumn === field) {
const wasEmpty = originalValue === null || originalValue === undefined || String(originalValue).trim() === '';
const hasValueInMain = row.mainRow && row.mainRow[mapping.mainColumn] !== null && row.mainRow[mapping.mainColumn] !== undefined;
wasFilled = wasEmpty && hasValueInMain;
}
});
const cellStyle = wasFilled ? 'background: var(--kt-ai-success-bg); font-weight: 600;' : '';
bodyHtml += '<td style="' + cellStyle + '">' + escapeHtml(newValue !== null && newValue !== undefined ? newValue : '') + '</td>';
});
bodyHtml += '<td>' + (row.matched ? '✓' : '✗') + '</td></tr>';
});
bodyHtml += '</tbody>';
table.innerHTML = headerHtml + bodyHtml;
// Сохраняем результат для дальнейшей обработки
mappedResult = { templateData, columnMappings, mainMap, templateKeyField };
document.getElementById('btn-process').disabled = matched === 0;
}
function processFiles() {
if (!mappedResult) return;
const { templateData, columnMappings, mainMap, templateKeyField } = mappedResult;
// Полная обработка всех данных (без мутации!)
let totalFilled = 0;
const resultData = templateData.map(row => {
// Создаём КОПИЮ строки
const newRow = { ...row };
const keyValue = normalizeValue(row[templateKeyField]);
const mainRow = mainMap.get(keyValue);
if (mainRow) {
columnMappings.forEach(mapping => {
const templateCol = mapping.templateColumn;
const mainCol = mapping.mainColumn;
// Проверяем на пустое значение (null, undefined, пустая строка)
const isEmpty = newRow[templateCol] === null || newRow[templateCol] === undefined || String(newRow[templateCol]).trim() === '';
const hasValueInMain = mainRow[mainCol] !== null && mainRow[mainCol] !== undefined;
if (isEmpty && hasValueInMain) {
newRow[templateCol] = mainRow[mainCol];
totalFilled++;
}
});
}
return newRow;
});
// Создаём Excel файл
const worksheet = XLSX.utils.json_to_sheet(resultData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Результат');
// Генерируем имя файла
const baseName = templateFileName.replace(/\.(xlsx|xls|csv)$/i, '');
const outputFileName = baseName + '_заполненный.xlsx';
// Создаём ссылку для скачивания
const wbout = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([wbout], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const downloadLink = document.getElementById('download-link');
downloadLink.href = url;
downloadLink.download = outputFileName;
// Показываем результат
document.getElementById('mapping-section').classList.add('hidden');
document.getElementById('result-section').classList.remove('hidden');
document.getElementById('step2').classList.remove('active');
document.getElementById('step2').classList.add('completed');
document.getElementById('step3').classList.add('active');
// Итоговая статистика
const matchedCount = resultData.filter(row => {
const keyValue = normalizeValue(row[templateKeyField]);
return mainMap.has(keyValue);
}).length;
document.getElementById('result-stats').innerHTML = `
<div class="stat-card">
<div class="stat-value">${templateData.length}</div>
<div class="stat-label">Всего записей обработано</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: var(--kt-ai-success);">${matchedCount}</div>
<div class="stat-label">Найдено совпадений</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: var(--kt-ai-primary);">${totalFilled}</div>
<div class="stat-label">Полей заполнено</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: var(--kt-ai-fg-muted);">${outputFileName}</div>
<div class="stat-label">Файл готов</div>
</div>
`;
}
function normalizeValue(value) {
if (value === null || value === undefined) return '';
const str = String(value).trim();
// Удаляем все нецифровые символы для номеров и счетов
return str.replace(/\D/g, '');
}
function escapeHtml(text) {
if (text === null || text === undefined) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}