feat: render dossier timelines and exceptions

This commit is contained in:
Repinoid
2026-10-31 10:34:02 +03:00
parent 18730bf93e
commit 5d8d57797e
4 changed files with 212 additions and 2 deletions
+33 -2
View File
@@ -143,8 +143,32 @@ async function runClassify() {
// Poll progress каждые 2с — работает и для sync, и для async classify
var pollTimer = setInterval(async function() {
try {
var r = await fetch(VM_API + '/api/batch-progress?batch=' + state.batchId);
var progressUrl = state.v3Mode
? '/api/classify-progress?batch=' + encodeURIComponent(state.batchId)
: VM_API + '/api/batch-progress?batch=' + encodeURIComponent(state.batchId);
var r = await fetch(progressUrl);
var d = await r.json();
if (state.v3Mode && d.v3) {
var completed = d.done || 0;
var total = d.total || 0;
btn.innerHTML = '<span class="spinner"></span> Извлечение... ' + completed + '/' + total +
' (' + Math.round((Date.now() - start) / 1000) + 'с)';
if (d.finished) {
clearInterval(pollTimer);
clearInterval(timer);
classifyDone = true;
if (d.state === 'failed') {
btn.innerHTML = '✗ ' + escHtml(d.error || 'ошибка извлечения');
btn.disabled = false;
return;
}
btn.innerHTML = '✓ ' + completed + '/' + total + ' документов извлечено';
stepDone('stepClassify');
stepActive('stepGroups');
loadGroupsAction();
}
return;
}
if (d.ok && d.counts) {
var done = (d.counts.classified || 0)
+ (d.counts.failed || 0)
@@ -172,13 +196,20 @@ async function runClassify() {
body: JSON.stringify({batch_id: state.batchId})
});
var data = await resp.json();
state.v3Mode = Boolean(data.v3);
if (data.v3) state.v3Summary = data;
// sync classify: ответ уже содержит done
if (data.ok && data.done !== undefined) {
clearInterval(timer);
clearInterval(pollTimer);
classifyDone = true;
var summary = data.summary || (data.done + '/' + data.total + ' классифицировано');
var summary = data.v3
? data.done + '/' + data.total + ' извлечено; ' + (data.dossier_count || 0) + ' досье'
: data.summary || (data.done + '/' + data.total + ' классифицировано');
if (data.v3 && data.ignored_accounting) summary += ' | отсеяно: ' + data.ignored_accounting;
if (data.v3) summary += ' | арифметика: ' + data.math_checks_passed;
if (data.v3 && data.failed) summary += ' | ошибок: ' + data.failed;
if (data.garbage) summary += ' | 🗑️ ' + data.garbage + ' мусор';
if (data.types) summary += ' | ' + Object.entries(data.types).map(function(e){return e[1]+'×'+e[0];}).join(', ');
btn.innerHTML = '✓ ' + summary + ' (' + Math.round((Date.now() - start) / 1000) + 'с)';
+5
View File
@@ -215,6 +215,11 @@ function startCompareSSE(url, container, statusEl, callbacks) {
container.innerHTML += '<div style="font-size:12px;color:var(--destructive);">✗ ' + escHtml(d.filename) + ': ' + escHtml(d.error) + '</div>';
}
// ── done: завершение ──
else if (d.type === 'timeline') {
if (callbacks.onTimeline) callbacks.onTimeline(d);
}
// ── done: завершение ──
else if (d.type === 'done') {
clearInterval(timer);
+170
View File
@@ -27,6 +27,16 @@
* Вызывается из runClassify() после успешной классификации.
*/
async function loadGroupsAction() {
if (state.v3Mode) {
var v3Response = await fetch('/api/dossiers?batch_id=' + encodeURIComponent(state.batchId));
var v3Data = await v3Response.json();
if (!v3Data.ok) return;
state.dossiers = v3Data.dossiers || [];
renderDossiers(state);
stepDone('stepGroups');
return;
}
var resp = await fetch(VM_API + '/api/groups?batch=' + state.batchId);
var data = await resp.json();
if (!data.ok || !data.groups) return;
@@ -115,6 +125,166 @@ function renderGroups(state) {
lucide.createIcons();
}
function renderDossiers(appState) {
var diffBody = document.getElementById('diffBody');
var diffCard = document.getElementById('diffCard');
diffCard.style.display = 'block';
var summary = appState.v3Summary || {};
var html = '<div style="font-weight:600;margin-bottom:8px;">Досье: ' + appState.dossiers.length +
' | требуется связать: ' + (summary.ambiguous || 0) + '</div>';
appState.dossiers.forEach(function(dossier) {
var aliases = (dossier.alias_numbers || []).map(escHtml).join(', ');
var docs = (dossier.documents || []).map(function(doc) {
return '<div style="padding:3px 0;font-size:12px;">' +
'<span style="color:var(--muted);">' + escHtml(doc.role || 'document') + '</span> ' +
escHtml(doc.filename || '') +
' <span style="color:' + (doc.math_status === 'VERIFIED' ? 'var(--green)' : 'var(--muted)') + ';">' +
(doc.math_status === 'VERIFIED' ? '✓ арифметика' : '⚠ ' + escHtml(doc.math_status || 'UNKNOWN')) + '</span>' +
(doc.doc_date ? ' <span style="color:var(--muted);">' + escHtml(doc.doc_date) + '</span>' : '') +
'</div>';
}).join('');
html += '<section style="border-top:1px solid var(--brand-gray);padding:10px 0;">' +
'<div style="font-weight:600;">Договор №' + escHtml(dossier.canonical_number || '—') +
' — ' + escHtml(dossier.counterparty_name || 'контрагент не определён') + '</div>' +
(aliases ? '<div style="font-size:12px;color:var(--muted);">Алиасы: ' + aliases + '</div>' : '') +
(!dossier.has_frame_contract ? '<div style="font-size:12px;color:var(--muted);">Рамочный договор не загружен</div>' : '') +
docs +
'<button class="btn btn-primary timeline-btn" type="button" data-dossier-id="' + escHtml(dossier.id) + '" style="margin-top:8px;">' +
'<i data-lucide="list-tree" style="width:16px;height:16px;"></i> Построить таймлайн</button>' +
'<div class="dossier-timeline" id="dossierTimeline_' + escHtml(dossier.id) + '" style="margin-top:8px;"></div>' +
'</section>';
});
if (summary.unlinked && summary.unlinked.length) {
html += '<div style="margin-top:8px;color:var(--destructive);">Не привязано документов: ' +
summary.unlinked.length + '</div>';
}
diffBody.innerHTML = html;
diffBody.querySelectorAll('.timeline-btn').forEach(function(button) {
button.addEventListener('click', function() {
loadDossierTimelineAction(this.getAttribute('data-dossier-id'), this);
});
});
lucide.createIcons();
}
function loadDossierTimelineAction(dossierId, button) {
var container = document.getElementById('dossierTimeline_' + dossierId);
var oldStatus = container.previousElementSibling;
if (oldStatus && oldStatus.classList.contains('dossier-status')) oldStatus.remove();
var status = document.createElement('div');
status.className = 'dossier-status';
status.style.cssText = 'font-size:12px;color:var(--muted);margin:6px 0;';
status.textContent = 'Построение таймлайна...';
container.parentNode.insertBefore(status, container);
container.innerHTML = '';
button.disabled = true;
var stream = startCompareSSE(
'/process-v2?dossier_id=' + encodeURIComponent(dossierId),
container,
status,
{
onTimeline: function(data) {
state.dossierTimelines[dossierId] = data;
renderDossierTimeline(dossierId, container, data);
},
onDone: function() {
button.disabled = false;
button.innerHTML = '<i data-lucide="list-tree" style="width:16px;height:16px;"></i> Обновить таймлайн';
status.textContent = 'Таймлайн обновлён';
lucide.createIcons();
},
onError: function(data) {
button.disabled = false;
status.textContent = 'Ошибка построения';
container.innerHTML = '<div style="color:var(--destructive);">' + escHtml(data.message) + '</div>';
},
onConnectionError: function() {
button.disabled = false;
status.textContent = 'Ошибка соединения';
container.innerHTML = '<div style="color:var(--destructive);">Ошибка соединения</div>';
}
},
);
state._activeCompare.es = stream.es;
state._activeCompare.timer = stream.timer;
}
function renderDossierTimeline(dossierId, container, data, asOf) {
asOf = asOf || new Date().toISOString().slice(0, 10);
var active = (data.timeline || []).filter(function(interval) {
return interval.valid_from && interval.valid_from <= asOf &&
(!interval.valid_to || interval.valid_to > asOf);
});
var byService = {};
(data.timeline || []).forEach(function(interval) {
var key = interval.service_item_id || interval.id;
(byService[key] || (byService[key] = [])).push(interval);
});
var html = '<label style="display:flex;align-items:center;gap:8px;font-size:12px;margin:8px 0;">' +
'Действующие услуги на дату <input class="timeline-date" type="date" value="' + escHtml(asOf) + '" style="height:30px;border:1px solid var(--brand-gray);border-radius:4px;padding:2px 6px;"></label>' +
'<div style="font-size:12px;color:var(--muted);margin-bottom:8px;">Активно: ' + active.length + ' интервалов</div>';
Object.keys(byService).forEach(function(serviceId) {
var intervals = byService[serviceId].slice().sort(function(left, right) {
return String(left.valid_from || '').localeCompare(String(right.valid_from || ''));
});
html += '<section style="border-top:1px solid var(--brand-gray);padding:8px 0;">' +
'<div style="font-weight:600;font-size:13px;">' + escHtml(intervals[0].raw_name || 'Услуга') + '</div>';
intervals.forEach(function(interval) {
var isActive = interval.valid_from && interval.valid_from <= asOf &&
(!interval.valid_to || interval.valid_to > asOf);
html += '<div style="font-size:12px;padding:3px 0;' + (isActive ? 'color:var(--green);font-weight:600;' : '') + '">[' + escHtml(interval.valid_from || '—') + ' — ' +
escHtml(interval.valid_to || '∞') + ') · ' + escHtml(interval.charge_type || 'unknown') +
' · цена ' + escHtml(interval.price || '—') + ' · кол-во ' + escHtml(interval.qty || '—') +
' · сумма ' + escHtml(interval.sum || '—') + (isActive ? ' · действует' : '') + '</div>';
});
html += '</section>';
});
if (data.exceptions && data.exceptions.length) {
html += '<section style="border-top:1px solid var(--brand-gray);padding:8px 0;">' +
'<div style="font-weight:600;color:var(--destructive);">Очередь исключений (' + data.exceptions.length + ')</div>';
data.exceptions.forEach(function(issue) {
html += '<div style="padding:6px 0;border-bottom:1px solid var(--brand-gray);">' +
'<div>' + escHtml(issue.title || issue.stage) + '</div>' +
(issue.source_quote ? '<div style="font-size:12px;color:var(--muted);">' + escHtml(issue.source_quote) + '</div>' : '');
(issue.options || []).forEach(function(option, index) {
var label = option.canonical_name || option.service_family || ('Вариант ' + (index + 1));
if (option.instance_anchor) label += ' · ' + option.instance_anchor;
html += '<button class="btn exception-choice" type="button" data-exception-id="' + escHtml(issue.id) +
'" data-option-index="' + index + '" style="margin:4px 4px 0 0;font-size:12px;min-height:32px;">' +
escHtml(label) + '</button>';
});
html += '</div>';
});
html += '</section>';
}
container.innerHTML = html;
container.querySelector('.timeline-date').addEventListener('change', function() {
renderDossierTimeline(dossierId, container, data, this.value);
});
container.querySelectorAll('.exception-choice').forEach(function(choice) {
choice.addEventListener('click', async function() {
var response = await fetch('/api/exceptions/' + encodeURIComponent(this.dataset.exceptionId) + '/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resolution: { option_index: Number(this.dataset.optionIndex) } })
});
var result = await response.json();
if (!result.ok) {
this.disabled = false;
this.title = result.error || 'Не удалось сохранить решение';
return;
}
var button = document.querySelector('.timeline-btn[data-dossier-id="' + dossierId + '"]');
loadDossierTimelineAction(dossierId, button);
});
});
}
/**
* renderUnresolvedCard(group) — Чистая HTML-функция: карточка нераспознанных файлов.
*
+4
View File
@@ -54,6 +54,10 @@ var state = {
// ── Группы (бывший window._groupsData) ────────────────────
groups: null,
v3Mode: false,
v3Summary: null,
dossiers: null,
dossierTimelines: {},
// ── Активное сравнение ────────────────────────────────────
_activeCompare: {