// 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;
var batchId = crypto.randomUUID(); // классификация: привязка всех файлов сессии
// Автоочистка старых записей без batch_id при загрузке страницы
(async function cleanup() {
try {
await fetch(VM_API + '/api/cleanup?batch=' + batchId, { method: 'POST' });
} catch(e) { /* ignore */ }
})();
// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
// вынесены в app_utils.js для облегчения анализа и отладки.
function renderTable() {
if (fileQueue.length === 0) {
fileTable.innerHTML = '
Нет файлов — выберите .docx / .pdf ';
} else {
fileTable.innerHTML = fileQueue.map(function(f, i) {
return '' +
'' + f.name + ' ' +
'' + formatDate(f.lastModified) + ' ' +
'' + formatSize(f.size) + ' ' +
'' + (f.status || '') + ' ' +
' ' +
' ' +
' ';
}).join('');
}
lucide.createIcons();
}
// 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;
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;
// Показать кнопки после авто-парсинга
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 = ' Классификация...';
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();
if (data.ok) {
btn.innerHTML = '✓ ' + data.done + '/' + data.total + ' классифицировано';
loadGroups();
} else {
btn.innerHTML = '✗ ' + (data.error || 'ошибка');
btn.disabled = false;
}
} catch(e) {
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';
diffBody.innerHTML = '📋 Найдено групп: ' + data.groups.length + '
';
data.groups.forEach(function(g, gi) {
var isUnresolved = g.contract_number === '__unresolved__';
var card = document.createElement('div');
card.style.cssText = 'border:1px solid ' + (isUnresolved ? 'var(--destructive)' : 'var(--brand-gray)') + ';border-radius:6px;padding:10px;margin-bottom:8px;';
card.innerHTML = '' +
(isUnresolved ? '❓ Не распознано' : '📄 ' + (g.contract_number || 'Без номера') + ' — ' + (g.counterparty || 'контрагент не определён')) +
'
' +
'' +
g.documents.map(function(d) {
return '
' +
'[' + (d.doc_type || '?') + '] ' +
d.filename +
(d.doc_date ? ' ' + d.doc_date + ' ' : '') +
'
';
}).join('') +
'
';
if (!isUnresolved) {
card.innerHTML += '▶ Сравнить ';
}
diffBody.appendChild(card);
});
lucide.createIcons();
}
window.runCompareForGroup = function(gi) {
// TODO: run /process-v2 for the specific contract
alert('Сравнение для группы ' + gi + ' — в разработке');
};
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);
fd.append('batch_id', batchId);
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 = '
';
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 = '+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0) + ' ?' + (s.unresolved||0) + '
';
html += 'Действие Услуга Цена Кол-во Сумма Дата ';
d.ops.forEach(function(op) {
var action = op.action;
var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : '';
var nr = op.new_row || {};
var name = nr.name || '';
var price = nr.price != null ? nr.price : '';
var qty = nr.qty != null ? nr.qty : '';
var sum = nr.sum != null ? nr.sum : '';
var ds = nr.date_start || '';
html += '' + action + ' ' + name + ' ' + price + ' ' + qty + ' ' + sum + ' ' + ds + ' ';
});
html += '
';
body.innerHTML = html;
body.style.display = 'block';
}
}
else if (d.type === 'extract_error' || d.type === 'apply_error') {
diffBody.innerHTML += '✗ ' + d.filename + ': ' + d.error + '
';
}
else if (d.type === 'done') {
clearInterval(timerInterval);
es.close();
diffStatus.textContent = '✓ ' + d.total_time_s + 'с';
btn.innerHTML = ' ✓ Готово';
if (d.total_unresolved > 0) {
diffBody.innerHTML += '⚠ ' + d.total_unresolved + ' неразрешённых строк —
разобрать ';
}
document.getElementById('chatCard').style.display = 'block';
lucide.createIcons();
}
else if (d.type === 'error') {
clearInterval(timerInterval);
es.close();
diffBody.innerHTML += '✗ ' + d.message + '
';
btn.innerHTML = ' Сравнить';
btn.disabled = false;
}
};
es.onerror = function() {
clearInterval(timerInterval);
es.close();
if (diffBody.innerHTML === '') {
diffBody.innerHTML = '✗ Ошибка соединения
';
}
btn.innerHTML = ' Сравнить';
btn.disabled = false;
lucide.createIcons();
};
});
// ── Обновить supplement_id/type для radio ──────────────────
async function refreshSupps() {
if (!contractId) { console.log('refreshSupps: no contractId'); return; }
try {
var resp = await fetch(VM_API + '/api/supplements?contract_id=' + contractId);
var data = await resp.json();
console.log('refreshSupps: data=', data);
if (data.ok && data.supplements) {
data.supplements.forEach(function(supp) {
console.log('refreshSupps: supp=', supp);
for (var i = 0; i < fileQueue.length; i++) {
if (fileQueue[i].doc_id === supp.document_id) {
fileQueue[i].supp_id = supp.id;
fileQueue[i].supp_type = supp.type;
}
}
});
renderTable();
}
} catch(e) { console.log('refreshSupps error:', e); }
}
// ── Чат ──────────────────────────────────────────────────────
document.getElementById('chatSend').addEventListener('click', function() {
var input = document.getElementById('chatInput');
var q = input.value.trim();
if (!q || !contractId) return;
var msgs = document.getElementById('chatMessages');
msgs.innerHTML += 'Вы: ' + q + '
';
input.value = '';
input.disabled = true;
document.getElementById('chatSend').disabled = true;
msgs.innerHTML += '⏳ Думаю...
';
var fd = new FormData();
fd.append('question', q);
fetch('/chat.cfm?contract_id=' + contractId, { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(d) {
msgs.lastChild.remove();
msgs.innerHTML += 'Ответ: ' + (d.OK ? d.ANSWER : (d.ERROR || '—')) + '
';
input.disabled = false;
document.getElementById('chatSend').disabled = false;
input.focus();
}).catch(function() {
msgs.lastChild.remove();
msgs.innerHTML += 'Ошибка сети
';
input.disabled = false;
document.getElementById('chatSend').disabled = false;
});
});
document.getElementById('chatInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') document.getElementById('chatSend').click();
});
function closeModal(e) {
if (e && e.target !== document.getElementById('modalOverlay')) return;
document.querySelector('.modal').classList.remove('wide');
document.getElementById('modalOverlay').classList.remove('open');
}
async function showText(i) {
var f = fileQueue[i];
var docId = f.doc_id;
document.getElementById('modalTitle').textContent = f.name;
document.getElementById('modalBody').innerHTML = 'Загрузка... ';
var modal = document.querySelector('.modal');
modal.classList.add('wide');
document.getElementById('modalOverlay').classList.add('open');
var leftHtml = 'Загрузка... ';
var rightHtml = '';
if (docId) {
try {
var qResp = await fetch(VM_API + '/api/documents/' + docId);
var qData = await qResp.json();
var elements = [];
if (qData.ok && qData.elements_json) {
var ej = qData.elements_json;
if (typeof ej === 'object' && ej.Value) ej = ej.Value;
if (typeof ej === 'string') elements = JSON.parse(ej);
else elements = ej;
}
// Общий textify для обеих панелей
var textLines = [];
elements.forEach(function(el) {
var etype = el.type || el.TYPE || '?';
if (etype === 'paragraph') {
textLines.push(el.text || el.TEXT || '');
} else if (etype === 'table') {
var rows = el.rows || el.ROWS || [];
if (rows.length > 0) {
var ncols = (rows[0]||[]).length;
textLines.push('--- Таблица ' + rows.length + '×' + ncols + ' ---');
rows.forEach(function(row) {
var cells = (row||[]).map(function(c){ return String(c||'').replace(/\n/g,' ').replace(/\|/g,'\\|'); });
textLines.push('| ' + cells.join(' | ') + ' |');
});
textLines.push('');
}
}
});
var textify = textLines.join('\n').replace(//g,'>');
// Левая: сырой текст (textify)
leftHtml = '' + textify + ' ';
// Правая: статистика + textify
rightHtml += 'Размер ' + formatSize(f.size) + '
';
rightHtml += 'Изменён ' + formatDate(f.lastModified) + '
';
if (f.parseInfo) {
var d = f.parseInfo;
rightHtml += 'Элементов ' + (d.element_count||0) + '
';
if (d.error) rightHtml += '' + d.error + '
';
}
rightHtml += 'Все элементы:
';
rightHtml += '' + textify + ' ';
if (f.supp_id) {
rightHtml += 'Тип ДС ' + (f.supp_type || '—') + '
';
}
} catch(e) {
leftHtml = 'Ошибка ';
}
} else {
leftHtml = '(ещё не загружен) ';
}
var html = '' +
'
Сырой текст
' + leftHtml + '
' +
'
Распарсено
' + rightHtml + '
' +
'
';
document.getElementById('modalBody').innerHTML = html;
}
window.closeModal = closeModal;
window.showText = showText;
// ── Промпт ───────────────────────────────────────────────────
var promptEditor = document.getElementById('promptEditor');
// ── Промпты (версионные) ─────────────────────────────────────
var promptRole = 'diff'; // текущая выбранная роль
var promptEditor = document.getElementById('promptEditor');
var promptStatus = document.getElementById('promptStatus');
var promptHistory = document.getElementById('promptHistory');
var promptModalOverlay = document.getElementById('promptModalOverlay');
var promptModalTitle = document.getElementById('promptModalTitle');
var promptName = document.getElementById('promptName');
var promptNotes = document.getElementById('promptNotes');
var activePromptId = {extract: '', diff: ''};
function openPromptModal(role) {
promptRole = role;
promptModalTitle.textContent = 'Промпт: ' + (role === 'extract' ? 'Извлечение' : 'Сравнение');
document.getElementById('promptTabExtract').style.background = role === 'extract' ? 'var(--brand-primary)' : '';
document.getElementById('promptTabExtract').style.color = role === 'extract' ? '#fff' : '';
document.getElementById('promptTabDiff').style.background = role === 'diff' ? 'var(--brand-primary)' : '';
document.getElementById('promptTabDiff').style.color = role === 'diff' ? '#fff' : '';
promptStatus.textContent = '';
promptName.value = '';
promptNotes.value = '';
loadPrompt(role);
promptModalOverlay.classList.add('open');
}
function closePromptModal(e) {
if (e && e.target !== promptModalOverlay) return;
promptModalOverlay.classList.remove('open');
}
function loadPrompt(role) {
fetch('/prompt.cfm?action=get_active&role=' + role)
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.OK) {
promptEditor.value = d.BODY || '';
activePromptId[role] = d.ID || '';
loadHistory(role);
}
})
.catch(function(e) { console.error('loadPrompt:', e); });
}
function loadHistory(role) {
fetch('/prompt.cfm?action=list&role=' + role)
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.OK || !d.VERSIONS || !d.VERSIONS.length) {
promptHistory.innerHTML = 'нет версий
';
return;
}
promptHistory.innerHTML = d.VERSIONS.map(function(v) {
var isActive = v.IS_ACTIVE ? '✓ ' : '';
var style = v.IS_ACTIVE ? 'font-weight:600;' : '';
return ''
+ '' + isActive + escHtml(v.NAME) + ' ' + fmtDate(v.CREATED_AT) + ' '
+ (!v.IS_ACTIVE ? '← ' : '')
+ (!v.IS_ACTIVE ? '✕ ' : '')
+ '
';
}).join('');
})
.catch(function(e) { console.error('loadHistory:', e); });
}
// escHtml/fmtDate — вынесены в app_utils.js
function activatePrompt(id) {
fetch('/prompt.cfm?action=activate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id})
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.OK) { loadPrompt(promptRole); promptStatus.textContent = '✓ активирован'; }
else { promptStatus.textContent = '✕ ' + (d.ERROR || 'ошибка'); }
});
}
function deletePrompt(id) {
if (!confirm('Удалить эту версию промпта?')) return;
fetch('/prompt.cfm?action=delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id})
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.OK) { loadHistory(promptRole); promptStatus.textContent = '✓ удалено'; }
else { promptStatus.textContent = '✕ ' + (d.ERROR || 'ошибка'); }
});
}
document.getElementById('promptSave').addEventListener('click', function() {
var body = promptEditor.value.trim();
if (!body) { promptStatus.textContent = '✕ тело промпта пустое'; return; }
var name = promptName.value.trim() || ('v' + new Date().toISOString().replace(/[:.]/g,'-').substring(0,16));
fetch('/prompt.cfm?action=save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
role: promptRole, name: name, body: body,
parent_id: activePromptId[promptRole] || '',
notes: promptNotes.value.trim(), is_active: true
})
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.OK) {
promptStatus.textContent = '✓ сохранено (новая версия)';
promptName.value = ''; promptNotes.value = '';
loadPrompt(promptRole);
} else {
promptStatus.textContent = '✕ ' + (d.ERROR || 'ошибка');
}
});
});
document.getElementById('promptTabExtract').addEventListener('click', function() { openPromptModal('extract'); });
document.getElementById('promptTabDiff').addEventListener('click', function() { openPromptModal('diff'); });
document.getElementById('promptBtnExtract').addEventListener('click', function() { openPromptModal('extract'); });
document.getElementById('promptBtnDiff').addEventListener('click', function() { openPromptModal('diff'); });
lucide.createIcons();