467 lines
18 KiB
JavaScript
467 lines
18 KiB
JavaScript
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;
|
||
}
|