386 lines
14 KiB
JavaScript
386 lines
14 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));
|
||
}
|
||
});
|
||
|
||
// Заполняем чекбоксы полей для заполнения
|
||
const fieldsContainer = document.getElementById('fields-to-fill');
|
||
fieldsContainer.innerHTML = '';
|
||
|
||
templateFields.forEach(field => {
|
||
const div = document.createElement('div');
|
||
div.style.cssText = 'display: inline-flex; align-items: center; gap: var(--kt-ai-space-2); margin-right: var(--kt-ai-space-3); margin-bottom: var(--kt-ai-space-2);';
|
||
|
||
const checkbox = document.createElement('input');
|
||
checkbox.type = 'checkbox';
|
||
checkbox.id = 'field-' + field;
|
||
checkbox.value = field;
|
||
checkbox.checked = true; // По умолчанию все поля выбраны
|
||
checkbox.onchange = processMapping;
|
||
|
||
const label = document.createElement('label');
|
||
label.htmlFor = 'field-' + field;
|
||
label.textContent = field;
|
||
label.style.fontSize = 'var(--kt-ai-text-sm)';
|
||
|
||
div.appendChild(checkbox);
|
||
div.appendChild(label);
|
||
fieldsContainer.appendChild(div);
|
||
});
|
||
|
||
processMapping();
|
||
}
|
||
|
||
function processMapping() {
|
||
const mainKeyField = document.getElementById('main-key-field').value;
|
||
const templateKeyField = document.getElementById('template-key-field').value;
|
||
|
||
if (!mainKeyField || !templateKeyField) return;
|
||
|
||
// Собираем поля для заполнения
|
||
const fieldsToFill = [];
|
||
document.querySelectorAll('#fields-to-fill input[type="checkbox"]:checked').forEach(cb => {
|
||
fieldsToFill.push(cb.value);
|
||
});
|
||
|
||
// Создаём карту соответствий
|
||
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++;
|
||
// Заполняем пустые поля в копии
|
||
fieldsToFill.forEach(field => {
|
||
const isEmpty = rowCopy[field] === null || rowCopy[field] === undefined || rowCopy[field] === '';
|
||
if (isEmpty && mainRow[field] !== null && mainRow[field] !== undefined) {
|
||
filled++;
|
||
rowCopy[field] = mainRow[field];
|
||
}
|
||
});
|
||
} 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];
|
||
const wasEmpty = originalValue === null || originalValue === undefined || String(originalValue).trim() === '';
|
||
const filled = wasEmpty && newValue !== null && newValue !== undefined;
|
||
const cellStyle = filled ? '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, fieldsToFill, mainMap, templateKeyField };
|
||
|
||
document.getElementById('btn-process').disabled = matched === 0;
|
||
}
|
||
|
||
function processFiles() {
|
||
if (!mappedResult) return;
|
||
|
||
const { templateData, fieldsToFill, 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) {
|
||
fieldsToFill.forEach(field => {
|
||
// Проверяем на пустое значение (null, undefined, пустая строка)
|
||
const isEmpty = newRow[field] === null || newRow[field] === undefined || String(newRow[field]).trim() === '';
|
||
const hasValueInMain = mainRow[field] !== null && mainRow[field] !== undefined;
|
||
|
||
if (isEmpty && hasValueInMain) {
|
||
newRow[field] = mainRow[field];
|
||
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;
|
||
}
|