757 lines
38 KiB
JavaScript
757 lines
38 KiB
JavaScript
// 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');
|
||
// state.files — теперь в state.js (Фаза 0: state + render)
|
||
// state.contractId — теперь в state.js (Фаза 0: state + render)
|
||
// state.batchId — теперь в state.js (Фаза 0: state + render)
|
||
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
|
||
|
||
// Автоочистка старых записей при загрузке страницы
|
||
(async function cleanup() {
|
||
try {
|
||
await fetch(VM_API + '/api/cleanup', { method: 'POST' });
|
||
} catch(e) { /* ignore */ }
|
||
})();
|
||
|
||
/**
|
||
* render(state) — Главная функция рендеринга (Фаза 0, decoupling-final-plan.md).
|
||
*
|
||
* ПАТТЕРН: action → мутация state → render(state)
|
||
*
|
||
* Фаза 0: вызывает существующий renderTable().
|
||
* renderTable читает state.files напрямую (state — глобальный объект).
|
||
* Фаза 1+: будет вызывать renderFiles(state), renderGroups(state), renderStepper(state) и т.д.
|
||
*
|
||
* ПРАВИЛО: любое изменение state ДОЛЖНО завершаться вызовом render(state).
|
||
* Никакой прямой манипуляции DOM в обход render().
|
||
* console.log(state) показывает ВСЁ состояние в любой момент.
|
||
*/
|
||
function render(state) {
|
||
renderFiles(state);
|
||
}
|
||
|
||
// syncDB, statusToHTML, renderFiles, toggleClassifyDetail, uploadFile, refreshSupps
|
||
// → вынесены в files.js (Фаза 1)
|
||
|
||
// ── Pipeline stepper ─────────────────────────────────────────
|
||
function stepDone(id) {
|
||
var el = document.getElementById(id);
|
||
if (el) { el.innerHTML = el.innerHTML.replace('○','✓'); el.style.color = 'var(--green)'; }
|
||
}
|
||
function stepActive(id) {
|
||
var el = document.getElementById(id);
|
||
if (el) { el.innerHTML = el.innerHTML.replace('○','⏳').replace('✓','⏳'); el.style.color = 'var(--brand-primary)'; }
|
||
}
|
||
function resetStepper(fromId) {
|
||
var steps = ['stepUpload','stepClassify','stepGroups','stepCompare'];
|
||
var reset = false;
|
||
steps.forEach(function(id) {
|
||
if (id === fromId) reset = true;
|
||
if (reset) {
|
||
var el = document.getElementById(id);
|
||
if (el) { el.innerHTML = el.innerHTML.replace('✓','○').replace('⏳','○'); el.style.color = 'var(--muted)'; }
|
||
}
|
||
});
|
||
}
|
||
// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
|
||
// вынесены в app_utils.js для облегчения анализа и отладки.
|
||
// statusToHTML, renderFiles → files.js (Фаза 1)
|
||
|
||
// ── Раскрыть/скрыть результат классификации под строкой файла ──
|
||
// toggleClassifyDetail → files.js (Фаза 1)
|
||
|
||
// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js
|
||
|
||
// ── Автозагрузка при выборе файлов ──────────────────────────
|
||
// Фаза 1 (decoupling-final-plan.md): fileInput handler разбит на чистые функции
|
||
// onFilesSelected → reconcileSelection → addZipFile/addRegularFile → finalizeUpload
|
||
// Вся логика — в files.js. Здесь только тонкая обёртка.
|
||
fileInput.addEventListener('change', async function() {
|
||
var newFiles = Array.from(fileInput.files);
|
||
onFilesSelected(newFiles);
|
||
});
|
||
|
||
// ── Классификация ──────────────────────────────────────────
|
||
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 = '<i data-lucide="tags" style="width:16px;height:16px;"></i> Классифицировать';
|
||
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 = '<span class="spinner"></span> Классификация... 0с';
|
||
stepActive('stepClassify');
|
||
|
||
var start = Date.now();
|
||
var timer = setInterval(function() {
|
||
btn.innerHTML = '<span class="spinner"></span> Классификация... ' + 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=' + state.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 = '<span class="spinner"></span> Классификация... ' + 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: state.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=' + state.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 = '<div style="font-weight:600;margin-bottom:8px;">📋 Найдено групп: ' + realGroups.length + (unresolvedGroup ? ' | ⚠ ' + unresolvedGroup.documents.length + ' файлов не распознано' : '') + '</div>';
|
||
stepDone('stepGroups');
|
||
|
||
// Сохраняем группы для compare
|
||
state.groups = data.groups;
|
||
|
||
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;';
|
||
|
||
if (isUnresolved) {
|
||
var docsHtml = g.documents.map(function(d) {
|
||
var reason = '';
|
||
if (d.parent_number) {
|
||
reason = ' — нет базового договора №' + escHtml(d.parent_number);
|
||
} else if (d.classify_status === 'failed') {
|
||
reason = ' — ошибка классификации: ' + escHtml(d.error_message || '?');
|
||
} else if (!d.doc_type || d.doc_type === 'other') {
|
||
reason = ' — тип не определён';
|
||
} else {
|
||
reason = ' — нет matching-договора';
|
||
}
|
||
return '<div style="padding:2px 0;">' +
|
||
'<span style="color:var(--muted);">[' + escHtml(d.doc_type || '?') + ']</span> ' +
|
||
escHtml(d.filename) +
|
||
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||
'<span style="color:var(--destructive);font-size:11px;">' + reason + '</span>' +
|
||
'</div>';
|
||
}).join('');
|
||
card.innerHTML = '<div style="font-weight:600;margin-bottom:6px;">❓ Не распознано (' + g.documents.length + ' файлов)</div>' +
|
||
'<div style="font-size:12px;">' + docsHtml + '</div>' +
|
||
'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Загрузите недостающие базовые договоры для этих номеров.</div>';
|
||
} else {
|
||
buildGroupCard(g, gi, card);
|
||
}
|
||
diffBody.appendChild(card);
|
||
});
|
||
// Один обработчик на все кнопки сравнения
|
||
diffBody.querySelectorAll('.cmp-btn').forEach(function(btn) {
|
||
btn.addEventListener('click', function() {
|
||
var gi = parseInt(this.getAttribute('data-gi'));
|
||
runCompareForGroup(gi, this);
|
||
});
|
||
});
|
||
lucide.createIcons();
|
||
}
|
||
|
||
// ── Карточка необработанной группы ──────────────────────────
|
||
function buildGroupCard(g, gi, card) {
|
||
card.innerHTML = '<div class="cmp-header" style="font-weight:600;margin-bottom:6px;">' +
|
||
'📄 Договор №' + escHtml(g.contract_number || '?') + ' — ' + escHtml(g.counterparty || 'контрагент не определён') +
|
||
'</div>' +
|
||
'<div style="font-size:12px;">' +
|
||
g.documents.map(function(d) {
|
||
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
|
||
return '<div style="padding:2px 0;">' +
|
||
'<span style="color:var(--muted);">' + escHtml(typeLabel || '?') + '</span> ' +
|
||
escHtml(d.filename) +
|
||
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||
'</div>';
|
||
}).join('') + '</div>' +
|
||
'<button class="btn btn-primary cmp-btn" style="margin-top:6px;font-size:12px;height:28px;" data-gi="' + gi + '">▶ Сравнить эту группу</button>';
|
||
// Необработанная: только кнопка, ничего больше
|
||
}
|
||
|
||
// ── Заменить карточку на обработанную ───────────────────────
|
||
function markGroupDone(gi, time) {
|
||
var bodyEl = document.getElementById('cmpBody_' + gi);
|
||
var bodyHTML = bodyEl ? bodyEl.innerHTML : '';
|
||
var card = bodyEl.parentNode;
|
||
var g = state.groups[gi];
|
||
card.innerHTML = '<div class="cmp-header" style="font-weight:600;margin-bottom:6px;cursor:pointer;">' +
|
||
'<span class="cmp-arrow" id="cmpArrow_' + gi + '" style="font-size:10px;margin-right:4px;">▾</span>' +
|
||
'📄 Договор №' + escHtml(g.contract_number || '?') + ' — ' + escHtml(g.counterparty || 'контрагент не определён') +
|
||
' <span style="font-weight:400;color:var(--green);font-size:11px;">✓ Готово (' + time + ')</span>' +
|
||
'</div>' +
|
||
'<div style="font-size:12px;">' +
|
||
g.documents.map(function(d, di) {
|
||
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
|
||
return '<div style="padding:2px 0;cursor:pointer;" onclick="var b=document.querySelectorAll(\'#cmpBody_' + gi + ' .diff-section-body\');if(b[' + di + '])b[' + di + '].style.display=b[' + di + '].style.display===\'none\'?\'block\':\'none\';">' +
|
||
'<span style="color:var(--muted);">' + escHtml(typeLabel || '?') + '</span> ' +
|
||
escHtml(d.filename) +
|
||
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||
'</div>';
|
||
}).join('') + '</div>' +
|
||
'<div class="cmp-body" id="cmpBody_' + gi + '" style="margin-top:8px;">' + bodyHTML + '</div>';
|
||
// Клик на заголовок → свернуть/развернуть
|
||
card.querySelector('.cmp-header').addEventListener('click', function() {
|
||
var body = document.getElementById('cmpBody_' + gi);
|
||
var arrow = document.getElementById('cmpArrow_' + gi);
|
||
if (body) {
|
||
if (body.style.display === 'none') {
|
||
body.style.display = 'block';
|
||
if (arrow) arrow.textContent = '▾';
|
||
} else {
|
||
body.style.display = 'none';
|
||
if (arrow) arrow.textContent = '▸';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Текущий активный EventSource (только один одновременно)
|
||
// state._activeCompare — теперь в state.js (Фаза 0: state + render)
|
||
|
||
window.runCompareForGroup = async function(gi, btn) {
|
||
var group = state.groups[gi];
|
||
if (!group || group.contract_number === '__unresolved__') return;
|
||
|
||
// Остановить предыдущее сравнение
|
||
if (state._activeCompare.es) { state._activeCompare.es.close(); state._activeCompare.es = null; }
|
||
if (state._activeCompare.timer) { clearInterval(state._activeCompare.timer); state._activeCompare.timer = null; }
|
||
|
||
// Свернуть ТОЛЬКО тело текущей группы
|
||
var curBody = document.getElementById('cmpBody_' + gi);
|
||
if (curBody) curBody.style.display = 'none';
|
||
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner"></span> Применяю...';
|
||
// Заблокировать ВСЕ кнопки сравнения пока идёт процесс
|
||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = true; });
|
||
|
||
var ar = await fetch(VM_API + '/api/apply-groups', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({batch_id: state.batchId, groups: [group]})
|
||
});
|
||
var ad = await ar.json();
|
||
if (!ad.ok || !ad.contract_ids || !ad.contract_ids.length) {
|
||
btn.innerHTML = '✗ ошибка'; btn.disabled = false;
|
||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||
return;
|
||
}
|
||
|
||
var cid = ad.contract_ids[0];
|
||
// Создать тело для результатов под кнопкой
|
||
var cmpBody = document.getElementById('cmpBody_' + gi);
|
||
if (!cmpBody) {
|
||
cmpBody = document.createElement('div');
|
||
cmpBody.className = 'cmp-body';
|
||
cmpBody.id = 'cmpBody_' + gi;
|
||
cmpBody.style.cssText = 'margin-top:8px;';
|
||
btn.parentNode.insertBefore(cmpBody, btn.nextSibling);
|
||
}
|
||
cmpBody.innerHTML = '';
|
||
var cmpBody = document.getElementById('cmpBody_' + gi);
|
||
cmpBody.style.display = 'block';
|
||
cmpBody.innerHTML = '';
|
||
btn.innerHTML = '<span class="spinner"></span> Сравнение...';
|
||
stepActive('stepCompare');
|
||
|
||
var statusEl = document.createElement('div');
|
||
statusEl.style.cssText = 'font-size:11px;color:var(--muted);margin-bottom:4px;';
|
||
statusEl.textContent = '⏳ 0.0с';
|
||
cmpBody.appendChild(statusEl);
|
||
|
||
var start = Date.now();
|
||
state._activeCompare.timer = setInterval(function() {
|
||
statusEl.textContent = '⏳ ' + ((Date.now() - start) / 1000).toFixed(1) + 'с';
|
||
}, 200);
|
||
|
||
var es = new EventSource(VM_API + '/process-v2?contract_id=' + cid);
|
||
state._activeCompare.es = es;
|
||
var sections = {};
|
||
|
||
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 = '<div class="diff-section-header" style="padding:8px 12px;background:var(--brand-grey-light);cursor:pointer;font-size:13px;font-weight:600;" onclick="var b=this.nextElementSibling;b.style.display=b.style.display===\'none\'?\'block\':\'none\';">⏳ ' + d.filename + '</div><div class="diff-section-body" style="display:none;padding:8px 12px;"></div>';
|
||
cmpBody.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 = '<div style="font-size:12px;margin-bottom:6px;">+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0) + '</div>';
|
||
html += '<div class="table-wrap"><table style="font-size:11px;"><thead><tr><th>Действие</th><th>Услуга</th><th>Цена</th><th>Кол-во</th><th>Сумма</th><th>Дата</th></tr></thead><tbody>';
|
||
d.ops.forEach(function(op) {
|
||
var cls = op.action === 'ADD' ? 'diff-added' : op.action === 'DELETE' ? 'diff-deleted' : 'diff-changed';
|
||
var nr = op.new_row || {};
|
||
html += '<tr class="' + cls + '"><td>' + op.action + '</td><td>' + (nr.name||'') + '</td><td class="num-cell">' + (nr.price!=null?nr.price:'') + '</td><td class="num-cell">' + (nr.qty!=null?nr.qty:'') + '</td><td class="num-cell">' + (nr.sum!=null?nr.sum:'') + '</td><td>' + (nr.date_start||'') + '</td></tr>';
|
||
});
|
||
html += '</tbody></table></div>';
|
||
body.innerHTML = html;
|
||
body.style.display = 'block';
|
||
}
|
||
}
|
||
else if (d.type === 'done') {
|
||
clearInterval(state._activeCompare.timer); state._activeCompare.timer = null;
|
||
es.close(); state._activeCompare.es = null;
|
||
statusEl.textContent = '✓ ' + d.total_time_s + 'с';
|
||
statusEl.style.color = 'var(--green)';
|
||
// Заменить карточку на обработанную (другая вёрстка, нет кнопок)
|
||
markGroupDone(gi, d.total_time_s + 'с');
|
||
// Разблокировать остальные кнопки
|
||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||
stepDone('stepCompare');
|
||
}
|
||
else if (d.type === 'error') {
|
||
clearInterval(state._activeCompare.timer); state._activeCompare.timer = null;
|
||
es.close(); state._activeCompare.es = null;
|
||
cmpBody.innerHTML += '<div style="color:var(--destructive);">✗ ' + d.message + '</div>';
|
||
btn.innerHTML = '✗'; btn.disabled = false;
|
||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||
}
|
||
};
|
||
es.onerror = function() {
|
||
clearInterval(state._activeCompare.timer); state._activeCompare.timer = null;
|
||
es.close(); state._activeCompare.es = null;
|
||
btn.innerHTML = '✗ соединение'; btn.disabled = false;
|
||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||
};
|
||
};
|
||
|
||
// uploadFile → files.js (Фаза 1)
|
||
|
||
// ── LLM: Общее сравнение (Event Sourcing) ────────────────────────
|
||
document.getElementById('llmBtn').addEventListener('click', function() {
|
||
if (!state.contractId) return;
|
||
var btn = this;
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner"></span> Сравнение...';
|
||
|
||
var compareCard = document.getElementById('compareCard');
|
||
var compareBody = document.getElementById('compareBody');
|
||
var compareStatus = document.getElementById('compareStatus');
|
||
compareCard.style.display = 'block';
|
||
compareBody.innerHTML = '';
|
||
compareStatus.textContent = '⏳ 0.0с';
|
||
|
||
var llmStart = Date.now();
|
||
var timerInterval = setInterval(function() {
|
||
compareStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с';
|
||
}, 200);
|
||
|
||
var order = state.files.map(function(f) { return f.supp_id; }).filter(Boolean).join(',');
|
||
var es = new EventSource('https://contracts.kube5s.ru/process-v2?contract_id=' + state.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 = '<div class="diff-section-header" style="padding:8px 12px;background:var(--brand-grey-light);cursor:pointer;font-size:13px;font-weight:600;" onclick="var b=this.nextElementSibling;b.style.display=b.style.display===\'none\'?\'block\':\'none\';">⏳ ' + d.filename + '</div><div class="diff-section-body" style="display:none;padding:8px 12px;"></div>';
|
||
compareBody.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 = '<div style="font-size:12px;margin-bottom:6px;">+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0) + ' ?' + (s.unresolved||0) + '</div>';
|
||
html += '<div class="table-wrap"><table style="font-size:11px;"><thead><tr><th>Действие</th><th>Услуга</th><th>Цена</th><th>Кол-во</th><th>Сумма</th><th>Дата</th></tr></thead><tbody>';
|
||
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 += '<tr class="' + cls + '"><td>' + action + '</td><td>' + name + '</td><td class="num-cell">' + price + '</td><td class="num-cell">' + qty + '</td><td class="num-cell">' + sum + '</td><td>' + ds + '</td></tr>';
|
||
});
|
||
html += '</tbody></table></div>';
|
||
body.innerHTML = html;
|
||
body.style.display = 'block';
|
||
}
|
||
}
|
||
else if (d.type === 'extract_error' || d.type === 'apply_error') {
|
||
compareBody.innerHTML += '<div style="font-size:12px;color:var(--destructive);">✗ ' + d.filename + ': ' + d.error + '</div>';
|
||
}
|
||
else if (d.type === 'done') {
|
||
clearInterval(timerInterval);
|
||
es.close();
|
||
compareStatus.textContent = '✓ ' + d.total_time_s + 'с';
|
||
btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
|
||
stepDone('stepCompare');
|
||
if (d.total_unresolved > 0) {
|
||
compareBody.innerHTML += '<div style="margin-top:8px;color:#eab308;">⚠ ' + d.total_unresolved + ' неразрешённых строк — <a href=\"/resolve.cfm?contract_id=' + state.contractId + '\">разобрать</a></div>';
|
||
}
|
||
document.getElementById('chatCard').style.display = 'block';
|
||
lucide.createIcons();
|
||
}
|
||
else if (d.type === 'error') {
|
||
clearInterval(timerInterval);
|
||
es.close();
|
||
compareBody.innerHTML += '<div style=\"color:var(--destructive);margin-top:8px;\">✗ ' + d.message + '</div>';
|
||
btn.innerHTML = '<i data-lucide=\"scale\" style=\"width:16px;height:16px;\"></i> Общее сравнение';
|
||
btn.disabled = false;
|
||
}
|
||
};
|
||
|
||
es.onerror = function() {
|
||
clearInterval(timerInterval);
|
||
es.close();
|
||
if (compareBody.innerHTML === '') {
|
||
compareBody.innerHTML = '<div style=\"color:var(--destructive);\">✗ Ошибка соединения</div>';
|
||
}
|
||
btn.innerHTML = '<i data-lucide=\"scale\" style=\"width:16px;height:16px;\"></i> Общее сравнение';
|
||
btn.disabled = false;
|
||
lucide.createIcons();
|
||
};
|
||
});
|
||
|
||
// refreshSupps → files.js (Фаза 1)
|
||
|
||
// ── Чат ──────────────────────────────────────────────────────
|
||
document.getElementById('chatSend').addEventListener('click', function() {
|
||
var input = document.getElementById('chatInput');
|
||
var q = input.value.trim();
|
||
if (!q || !state.contractId) return;
|
||
var msgs = document.getElementById('chatMessages');
|
||
msgs.innerHTML += '<div style="color:var(--brand-primary);margin-top:4px;"><strong>Вы:</strong> ' + q + '</div>';
|
||
input.value = '';
|
||
input.disabled = true;
|
||
document.getElementById('chatSend').disabled = true;
|
||
msgs.innerHTML += '<div style="color:var(--muted);margin-top:2px;">⏳ Думаю...</div>';
|
||
|
||
var fd = new FormData();
|
||
fd.append('question', q);
|
||
fetch('/chat.cfm?contract_id=' + state.contractId, { method: 'POST', body: fd })
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(d) {
|
||
msgs.lastChild.remove();
|
||
msgs.innerHTML += '<div style="margin-top:2px;"><strong>Ответ:</strong> ' + (d.OK ? d.ANSWER : (d.ERROR || '—')) + '</div>';
|
||
input.disabled = false;
|
||
document.getElementById('chatSend').disabled = false;
|
||
input.focus();
|
||
}).catch(function() {
|
||
msgs.lastChild.remove();
|
||
msgs.innerHTML += '<div style="color:var(--destructive);margin-top:2px;">Ошибка сети</div>';
|
||
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 = state.files[i];
|
||
var docId = f.doc_id;
|
||
document.getElementById('modalTitle').textContent = f.name;
|
||
document.getElementById('modalBody').innerHTML = '<span style="color:var(--muted);">Загрузка...</span>';
|
||
var modal = document.querySelector('.modal');
|
||
modal.classList.add('wide');
|
||
document.getElementById('modalOverlay').classList.add('open');
|
||
|
||
var leftHtml = '<span style="color:var(--muted);">Загрузка...</span>';
|
||
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,'<').replace(/>/g,'>');
|
||
|
||
// Левая: сырой текст (textify)
|
||
leftHtml = '<pre>' + textify + '</pre>';
|
||
|
||
// Правая: статистика + textify
|
||
rightHtml += '<div class="kv"><span class="k">Размер</span><span class="v">' + formatSize(f.size) + '</span></div>';
|
||
rightHtml += '<div class="kv"><span class="k">Изменён</span><span class="v">' + formatDate(f.lastModified) + '</span></div>';
|
||
if (f.parseInfo) {
|
||
var d = f.parseInfo;
|
||
rightHtml += '<div class="kv"><span class="k">Элементов</span><span class="v">' + (d.element_count||0) + '</span></div>';
|
||
if (d.error) rightHtml += '<div style="margin-top:8px;color:var(--destructive);">' + d.error + '</div>';
|
||
}
|
||
rightHtml += '<div style="margin-top:10px;font-weight:600;font-size:12px;">Все элементы:</div>';
|
||
rightHtml += '<pre>' + textify + '</pre>';
|
||
if (f.supp_id) {
|
||
rightHtml += '<div class="kv"><span class="k">Тип ДС</span><span class="v">' + (f.supp_type || '—') + '</span></div>';
|
||
}
|
||
|
||
// ── Результаты классификации ──────────────────────────
|
||
if (qData.classify_status === 'classified' || qData.classify_raw) {
|
||
rightHtml += '<div style="margin-top:12px;font-weight:600;font-size:12px;border-top:1px solid var(--brand-gray);padding-top:8px;">🏷️ Классификация LLM</div>';
|
||
if (qData.doc_type) rightHtml += '<div class="kv"><span class="k">Тип</span><span class="v">' + escHtml(qData.doc_type) + '</span></div>';
|
||
if (qData.own_number) rightHtml += '<div class="kv"><span class="k">Номер</span><span class="v">' + escHtml(qData.own_number) + '</span></div>';
|
||
if (qData.parent_number) rightHtml += '<div class="kv"><span class="k">Родительский</span><span class="v">' + escHtml(qData.parent_number) + '</span></div>';
|
||
if (qData.doc_date) rightHtml += '<div class="kv"><span class="k">Дата</span><span class="v">' + escHtml(qData.doc_date) + '</span></div>';
|
||
if (qData.counterparty) rightHtml += '<div class="kv"><span class="k">Контрагент</span><span class="v">' + escHtml(qData.counterparty) + '</span></div>';
|
||
if (qData.classify_raw) {
|
||
var rawId = 'raw_' + docId.replace(/-/g,'');
|
||
rightHtml += '<details style="margin-top:8px;"><summary style="cursor:pointer;font-size:11px;color:var(--brand-primary);">Сырой ответ LLM</summary>';
|
||
rightHtml += '<pre style="font-size:10px;max-height:200px;overflow:auto;background:var(--brand-grey-light);padding:6px;border-radius:4px;margin-top:4px;">' + escHtml(qData.classify_raw) + '</pre>';
|
||
rightHtml += '</details>';
|
||
}
|
||
}
|
||
} catch(e) {
|
||
leftHtml = '<span style="color:var(--destructive);">Ошибка</span>';
|
||
}
|
||
} else {
|
||
leftHtml = '<span style="color:var(--muted);">(ещё не загружен)</span>';
|
||
}
|
||
|
||
var html = '<div class="text-panes">' +
|
||
'<div class="text-pane"><div class="pane-title">Сырой текст</div>' + leftHtml + '</div>' +
|
||
'<div class="text-pane"><div class="pane-title">Распарсено</div>' + rightHtml + '</div>' +
|
||
'</div>';
|
||
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');
|
||
// Три роли промптов:
|
||
// extract — извлечение строк из первого документа (базовый договор)
|
||
// diff — сравнение допсоглашений (ADD/UPDATE/DELETE)
|
||
// classify — классификация документа (тип, номер, контрагент, дата)
|
||
var activePromptId = {extract: '', diff: '', classify: ''};
|
||
|
||
function openPromptModal(role) {
|
||
promptRole = role;
|
||
var labels = {extract: 'Извлечение', diff: 'Сравнение', classify: 'Классификация'};
|
||
promptModalTitle.textContent = 'Промпт: ' + (labels[role] || role);
|
||
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' : '';
|
||
document.getElementById('promptTabClassify').style.background = role === 'classify' ? 'var(--brand-primary)' : '';
|
||
document.getElementById('promptTabClassify').style.color = role === 'classify' ? '#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(VM_API + '/api/prompts?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(VM_API + '/api/prompts/list?role=' + role)
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(d) {
|
||
if (!d.ok || !d.versions || !d.versions.length) {
|
||
promptHistory.innerHTML = '<div style="color:var(--muted);">нет версий</div>';
|
||
return;
|
||
}
|
||
promptHistory.innerHTML = d.versions.map(function(v) {
|
||
var isActive = v.is_active ? '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--green);margin-right:4px;" title="активная"></span> ' : '';
|
||
var style = v.is_active ? 'font-weight:600;' : '';
|
||
return '<div style="display:flex;align-items:center;padding:4px 0;border-bottom:1px solid var(--brand-gray);font-size:11px;' + style + '">'
|
||
+ '<span style="flex:1;">' + isActive + escHtml(v.name) + ' <span style="color:var(--muted);">' + fmtDate(v.created_at) + '</span>'
|
||
+ (v.notes ? ' <span style="color:var(--muted);font-style:italic;">— ' + escHtml(v.notes) + '</span>' : '')
|
||
+ '</span>'
|
||
+ (!v.is_active ? '<button class="btn" onclick="activatePrompt(\'' + v.id + '\')" style="font-size:11px;height:22px;padding:0 8px;margin-left:4px;">активировать</button>' : '')
|
||
+ (!v.is_active ? '<button class="btn" onclick="deletePrompt(\'' + v.id + '\')" style="font-size:11px;height:22px;padding:0 8px;margin-left:2px;color:var(--destructive);">удалить</button>' : '')
|
||
+ '</div>';
|
||
}).join('');
|
||
})
|
||
.catch(function(e) { console.error('loadHistory:', e); });
|
||
}
|
||
|
||
function activatePrompt(id) {
|
||
fetch(VM_API + '/api/prompts/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(VM_API + '/api/prompts/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(VM_API + '/api/prompts/save', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
role: promptRole, name: name, body: body,
|
||
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('promptTabClassify').addEventListener('click', function() { openPromptModal('classify'); });
|
||
document.getElementById('promptBtnExtract').addEventListener('click', function() { openPromptModal('extract'); });
|
||
document.getElementById('promptBtnDiff').addEventListener('click', function() { openPromptModal('diff'); });
|
||
document.getElementById('promptBtnClassify').addEventListener('click', function() { openPromptModal('classify'); });
|
||
lucide.createIcons();
|