// Contracts App — весь JS на VM (contracts.kube5s.ru)
// Lucee: только домен + index.cfm-скелет
var VM_API = 'https://contracts.kube5s.ru';
var UPLOAD_URL = VM_API + '/upload';
var CONVERT_URL = VM_API + '/convert-doc';
var UNZIP_URL = VM_API + '/unzip-upload';
var UNZIP_URL = 'https://contracts.kube5s.ru/unzip-upload';
var SITE_URL = ''; // same origin for api calls
var fileInput = document.getElementById('fileInput');
var fileTable = document.getElementById('fileTable');
var fileQueue = [];
var contractId = null;
function formatSize(bytes) {
if (!bytes || bytes === 0) return '—';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
function formatDate(ts) {
if (!ts) return '—';
var d = new Date(ts);
return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
}
function renderTable() {
if (fileQueue.length === 0) {
fileTable.innerHTML = '
Нет файлов — выберите .docx / .pdf
';
} else {
fileTable.innerHTML = fileQueue.map(function(f, i) {
var isFirst = (i === 0);
var isLast = (i === fileQueue.length - 1);
var arrows = '' +
'';
return '
' +
'
' + f.name + '
' +
'
' + formatDate(f.lastModified) + '
' +
'
' + formatSize(f.size) + '
' +
'
' + (f.status || '') + '
' +
'
' + arrows + '
' +
'
' +
'
' +
'
';
}).join('');
}
lucide.createIcons();
}
function removeFile(i) {
fileQueue.splice(i, 1);
if (fileQueue.length === 0) contractId = null;
renderTable();
}
window.removeFile = removeFile;
function moveUp(i) {
if (i <= 0) return;
var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i-1]; fileQueue[i-1] = tmp;
renderTable();
}
function moveDown(i) {
if (i >= fileQueue.length - 1) return;
var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i+1]; fileQueue[i+1] = tmp;
renderTable();
}
window.moveUp = moveUp;
window.moveDown = moveDown;
// ── О сервисе ────────────────────────────────────────────────
function openAbout() {
document.getElementById('aboutModalOverlay').classList.add('open');
}
function closeAbout(e) {
if (e && e.target !== document.getElementById('aboutModalOverlay')) return;
document.getElementById('aboutModalOverlay').classList.remove('open');
}
window.openAbout = openAbout;
window.closeAbout = closeAbout;
// ── Автозагрузка при выборе файлов ──────────────────────────
fileInput.addEventListener('change', async function() {
var newFiles = Array.from(fileInput.files);
if (newFiles.length === 0) return;
fileInput.disabled = true;
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();
fileInput.value = '';
fileInput.disabled = false;
// Показать кнопки LLM после авто-парсинга
var llmBtn = document.getElementById('llmBtn');
llmBtn.style.display = 'flex';
llmBtn.disabled = false;
lucide.createIcons();
});
function uploadFile(file, onProgress) {
return new Promise(function(resolve, reject) {
// .doc → конвертация в docx
var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx');
var uploadFile = file;
var uploadName = file.name;
function doUpload() {
var xhr = new XMLHttpRequest();
var fd = new FormData();
fd.append('files', uploadFile, uploadName);
if (contractId) fd.append('contract_id', contractId);
xhr.open('POST', UPLOAD_URL);
xhr.upload.onprogress = function(e) {
if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100));
};
xhr.onload = function() {
try {
var r = JSON.parse(xhr.responseText);
if (r.ok) resolve(r);
else reject(new Error(r.error || 'Неизвестная ошибка'));
} catch(e) { reject(new Error('Некорректный ответ')); }
};
xhr.onerror = function() { reject(new Error('Сеть')); };
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
xhr.timeout = 180000;
xhr.send(fd);
}
if (isDoc) {
var xhr = new XMLHttpRequest();
xhr.open('POST', CONVERT_URL);
xhr.responseType = 'blob';
xhr.onload = function() {
if (xhr.status === 200 && xhr.response.size > 100) {
uploadFile = xhr.response;
uploadName = file.name.replace(/\.doc$/i, '.docx');
doUpload();
} else {
reject(new Error('Конвертация .doc'));
}
};
xhr.onerror = function() { reject(new Error('Конвертер')); };
xhr.send(file);
} else {
doUpload();
}
});
}
// ── LLM: Сравнить (Event Sourcing) ────────────────────────
document.getElementById('llmBtn').addEventListener('click', function() {
if (!contractId) return;
var btn = this;
btn.disabled = true;
btn.innerHTML = ' Сравнение...';
var diffCard = document.getElementById('diffCard');
var diffBody = document.getElementById('diffBody');
var diffStatus = document.getElementById('diffStatus');
diffCard.style.display = 'block';
diffBody.innerHTML = '';
diffStatus.textContent = '⏳ 0.0с';
var llmStart = Date.now();
var timerInterval = setInterval(function() {
diffStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с';
}, 200);
var order = fileQueue.map(function(f) { return f.supp_id; }).filter(Boolean).join(',');
var es = new EventSource('https://contracts.kube5s.ru/process-v2?contract_id=' + contractId + (order ? '&order=' + encodeURIComponent(order) : ''));
var sections = {}; // supplement_id → DOM element
es.onmessage = function(e) {
var d = JSON.parse(e.data);
if (d.type === 'extract_start') {
var sec = document.createElement('div');
sec.style.cssText = 'margin-top:8px;border:1px solid var(--brand-gray);border-radius:6px;overflow:hidden;';
sec.innerHTML = '
⏳ ' + d.filename + '
';
sec.querySelector('.diff-section-header').addEventListener('click', function() {
var body = sec.querySelector('.diff-section-body');
body.style.display = body.style.display === 'none' ? 'block' : 'none';
});
diffBody.appendChild(sec);
sections[d.supplement_id] = sec;
}
else if (d.type === 'llm_done') {
var sec = sections[d.supplement_id];
if (sec) {
sec.querySelector('.diff-section-header').innerHTML = '✓ ' + d.filename + ' — ' + d.ops_count + ' оп., ' + d.mode + ' (' + d.time_s + 'с)';
sec.querySelector('.diff-section-header').style.color = 'var(--green)';
}
}
else if (d.type === 'applied') {
var sec = sections[d.supplement_id];
if (sec && d.ops && d.ops.length > 0) {
var s = d.summary || {};
var body = sec.querySelector('.diff-section-body');
var html = '