';
return rows;
}).join('');
}
lucide.createIcons();
}
// ── Раскрыть/скрыть результат классификации под строкой файла ──
window.toggleClassifyDetail = async function(i) {
var detailRow = document.getElementById('detail_' + i);
var expandIcon = document.getElementById('expand_' + i);
if (!detailRow) return;
if (detailRow.style.display !== 'none') {
detailRow.style.display = 'none';
if (expandIcon) expandIcon.textContent = '▸';
return;
}
var f = fileQueue[i];
if (!f || !f.doc_id) return;
detailRow.style.display = '';
if (expandIcon) expandIcon.textContent = '▾';
try {
var resp = await fetch(VM_API + '/api/documents/' + f.doc_id);
var qData = await resp.json();
if (!qData.ok) { detailRow.cells[0].innerHTML = 'Ошибка загрузки'; return; }
var html = '';
if (qData.classify_status === 'classified') {
html += '
🏷️ Результат классификации
';
html += '
';
if (qData.doc_type) html += 'Тип: ' + escHtml(qData.doc_type) + '';
if (qData.own_number) html += 'Номер: ' + escHtml(qData.own_number) + '';
if (qData.parent_number) html += 'Родительский: ' + escHtml(qData.parent_number) + '';
if (qData.doc_date) html += 'Дата: ' + escHtml(qData.doc_date) + '';
if (qData.counterparty) html += 'Контрагент: ' + escHtml(qData.counterparty) + '';
html += '
';
if (qData.classify_raw) {
html += 'Сырой ответ LLM';
html += '
' + escHtml(qData.classify_raw) + '
';
html += '';
}
} else if (qData.classify_status === 'failed') {
html += '✗ Ошибка классификации: ' + escHtml(qData.error_message || '?') + '';
} else {
html += 'Ещё не классифицирован';
}
detailRow.cells[0].innerHTML = html;
} catch(e) {
detailRow.cells[0].innerHTML = 'Ошибка: ' + escHtml(e.message) + '';
}
};
// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js
// ── Автозагрузка при выборе файлов ──────────────────────────
fileInput.addEventListener('change', async function() {
var newFiles = Array.from(fileInput.files);
if (newFiles.length === 0) return;
fileInput.disabled = true;
// Удалить из очереди файлы, которых нет в новом выборе
var newNames = new Set(newFiles.map(function(f) { return f.name; }));
fileQueue = fileQueue.filter(function(f) {
return newNames.has(f.name);
});
renderTable();
// Разблокировать кнопку классификации — состав файлов изменился
showClassifyBtn();
// Сбросить прогресс пайплайна
resetStepper('stepUpload');
for (var i = 0; i < newFiles.length; i++) {
var f = newFiles[i];
var isZip = f.name.toLowerCase().endsWith('.zip');
if (isZip) {
// ZIP: показать временную строку, распаковать, убрать
var zipEntry = { name: f.name, lastModified: f.lastModified, size: f.size, status: '⏳ распаковка...' };
fileQueue.push(zipEntry);
var zipIdx = fileQueue.length - 1;
renderTable();
try {
var zipResp = await new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open('POST', UNZIP_URL);
xhr.responseType = 'json';
xhr.onload = function() { resolve(xhr.response); };
xhr.onerror = function() { reject(new Error('Сеть')); };
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
xhr.timeout = 60000;
xhr.send(f);
});
var zipData = zipResp;
if (!zipData.ok || !zipData.files) throw new Error('unzip failed');
// Убрать строку ZIP из таблицы
fileQueue.splice(zipIdx, 1);
renderTable();
// Добавить файлы из архива, пропуская дубликаты
for (var zi = 0; zi < zipData.files.length; zi++) {
var zf = zipData.files[zi];
if (zf.error) continue; // пропускаем ошибки
// Проверить дубликат по имени
var dup = -1;
for (var dj = 0; dj < fileQueue.length; dj++) {
if (fileQueue[dj].name === zf.filename) { dup = dj; break; }
}
if (dup >= 0) continue; // уже есть — не добавляем
var zEntry = {
name: zf.filename, size: zf.size,
doc_id: zf.doc_id, uploaded: true,
status: '⏳ парсинг...'
};
fileQueue.push(zEntry);
var zIdx = fileQueue.length - 1;
if (!contractId && zf.contract_id) contractId = zf.contract_id;
renderTable();
// Авто-парсинг: VM Python парсит ВСЁ
var zpr = zf.parsed;
if (zpr && zpr.status === 'parsed') {
fileQueue[zIdx].parsed = true;
fileQueue[zIdx].status = '✓ ' + zpr.element_count + ' эл.';
} else if (zpr && zpr.status === 'error') {
fileQueue[zIdx].status = '✗ ' + (zpr.error || 'ошибка парсинга') + '';
} else {
fileQueue[zIdx].status = '✗ Неизвестная ошибка';
}
renderTable();
}
} catch(ze) {
fileQueue[zipIdx].status = '✗ ZIP: ' + ze.message + '';
renderTable();
}
continue; // ZIP обработан, переходим к следующему файлу
}
// Обычный файл (не ZIP)
var entry = { name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: '↑ 0%' };
var existingIdx = -1;
for (var j = 0; j < fileQueue.length; j++) {
if (fileQueue[j].name === f.name) { existingIdx = j; break; }
}
var rowIdx;
if (existingIdx >= 0) {
fileQueue[existingIdx] = entry;
rowIdx = existingIdx;
} else {
fileQueue.push(entry);
rowIdx = fileQueue.length - 1;
}
renderTable();
try {
var resp = await uploadFile(f, function(pct) {
fileQueue[rowIdx].status = '↑ ' + pct + '%';
renderTable();
});
// Python API returns lowercase keys (doc_id, contract_id, parsed)
if (resp && resp.contract_id) contractId = resp.contract_id;
fileQueue[rowIdx].doc_id = resp.doc_id;
fileQueue[rowIdx].status = '✓';
fileQueue[rowIdx].uploaded = true;
// Авто-парсинг: VM Python парсит ВСЁ (PDF + DOCX + DOC)
var t0 = Date.now();
var pr = resp.parsed;
if (pr && pr.status === 'parsed') {
var elapsed = ((Date.now() - t0) / 1000).toFixed(1);
fileQueue[rowIdx].parsed = true;
fileQueue[rowIdx].parseInfo = pr;
fileQueue[rowIdx].status = '✓ ' + pr.element_count + ' эл. (' + elapsed + 'с)';
} else if (pr && pr.status === 'error') {
fileQueue[rowIdx].status = '✗ ' + (pr.error || 'ошибка парсинга') + '';
fileQueue[rowIdx].parseInfo = pr;
} else {
fileQueue[rowIdx].status = '✗ Неизвестная ошибка';
}
} catch(err) {
fileQueue[rowIdx].status = '✗ ' + err.message + '';
}
renderTable();
}
await refreshSupps();
stepDone('stepUpload');
stepActive('stepClassify');
// Синхронизировать БД с таблицей — удалить всё, чего нет в fileQueue
syncDB();
fileInput.value = '';
fileInput.disabled = false;
// Показать кнопки после авто-парсинга
var llmBtn = document.getElementById('llmBtn');
llmBtn.style.display = 'flex';
llmBtn.disabled = false;
showClassifyBtn();
lucide.createIcons();
});
// ── Классификация ──────────────────────────────────────────
function showClassifyBtn() {
var btn = document.getElementById('classifyBtn');
if (!btn) {
btn = document.createElement('button');
btn.id = 'classifyBtn';
btn.className = 'btn btn-primary';
btn.style.cssText = 'width:100%;justify-content:center;margin-top:8px;background:var(--brand-primary);border-color:var(--brand-primary);';
btn.innerHTML = ' Классифицировать';
btn.addEventListener('click', runClassify);
document.getElementById('llmBtn').parentNode.insertBefore(btn, document.getElementById('llmBtn'));
}
btn.style.display = 'flex';
btn.disabled = false;
}
async function runClassify() {
var btn = document.getElementById('classifyBtn');
btn.disabled = true;
btn.innerHTML = ' Классификация... 0с';
stepActive('stepClassify');
var start = Date.now();
var timer = setInterval(function() {
btn.innerHTML = ' Классификация... ' + Math.round((Date.now() - start) / 1000) + 'с';
}, 1000);
// Poll progress каждые 2с
var pollTimer = setInterval(async function() {
try {
var r = await fetch(VM_API + '/api/batch-progress?batch=' + batchId);
var d = await r.json();
if (d.ok && d.counts) {
var done = (d.counts.classified || 0) + (d.counts.failed || 0);
var total = d.total || 0;
btn.innerHTML = ' Классификация... ' + done + '/' + total + ' (' + Math.round((Date.now() - start) / 1000) + 'с)';
if (done >= total && total > 0) {
clearInterval(pollTimer);
}
}
} catch(e) {}
}, 2000);
try {
var resp = await fetch(VM_API + '/api/classify-batch', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({batch_id: batchId})
});
var data = await resp.json();
clearInterval(timer);
clearInterval(pollTimer);
if (data.ok) {
btn.innerHTML = '✓ ' + data.done + '/' + data.total + ' классифицировано (' + Math.round((Date.now() - start) / 1000) + 'с)';
stepDone('stepClassify');
stepActive('stepGroups');
loadGroups();
} else {
btn.innerHTML = '✗ ' + (data.error || 'ошибка');
btn.disabled = false;
}
} catch(e) {
clearInterval(timer);
clearInterval(pollTimer);
btn.innerHTML = '✗ ' + e.message;
btn.disabled = false;
}
}
async function loadGroups() {
var resp = await fetch(VM_API + '/api/groups?batch=' + batchId);
var data = await resp.json();
if (!data.ok || !data.groups) return;
var diffBody = document.getElementById('diffBody');
var diffCard = document.getElementById('diffCard');
diffCard.style.display = 'block';
var realGroups = data.groups.filter(function(g) { return g.contract_number !== '__unresolved__'; });
var unresolvedGroup = data.groups.find(function(g) { return g.contract_number === '__unresolved__'; });
diffBody.innerHTML = '