feat: render dossier timelines and exceptions
This commit is contained in:
+33
-2
@@ -143,8 +143,32 @@ async function runClassify() {
|
|||||||
// Poll progress каждые 2с — работает и для sync, и для async classify
|
// Poll progress каждые 2с — работает и для sync, и для async classify
|
||||||
var pollTimer = setInterval(async function() {
|
var pollTimer = setInterval(async function() {
|
||||||
try {
|
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();
|
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) {
|
if (d.ok && d.counts) {
|
||||||
var done = (d.counts.classified || 0)
|
var done = (d.counts.classified || 0)
|
||||||
+ (d.counts.failed || 0)
|
+ (d.counts.failed || 0)
|
||||||
@@ -172,13 +196,20 @@ async function runClassify() {
|
|||||||
body: JSON.stringify({batch_id: state.batchId})
|
body: JSON.stringify({batch_id: state.batchId})
|
||||||
});
|
});
|
||||||
var data = await resp.json();
|
var data = await resp.json();
|
||||||
|
state.v3Mode = Boolean(data.v3);
|
||||||
|
if (data.v3) state.v3Summary = data;
|
||||||
|
|
||||||
// sync classify: ответ уже содержит done
|
// sync classify: ответ уже содержит done
|
||||||
if (data.ok && data.done !== undefined) {
|
if (data.ok && data.done !== undefined) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
clearInterval(pollTimer);
|
clearInterval(pollTimer);
|
||||||
classifyDone = true;
|
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.garbage) summary += ' | 🗑️ ' + data.garbage + ' мусор';
|
||||||
if (data.types) summary += ' | ' + Object.entries(data.types).map(function(e){return e[1]+'×'+e[0];}).join(', ');
|
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) + 'с)';
|
btn.innerHTML = '✓ ' + summary + ' (' + Math.round((Date.now() - start) / 1000) + 'с)';
|
||||||
|
|||||||
@@ -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>';
|
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: завершение ──
|
// ── done: завершение ──
|
||||||
else if (d.type === 'done') {
|
else if (d.type === 'done') {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
|
|||||||
@@ -27,6 +27,16 @@
|
|||||||
* Вызывается из runClassify() после успешной классификации.
|
* Вызывается из runClassify() после успешной классификации.
|
||||||
*/
|
*/
|
||||||
async function loadGroupsAction() {
|
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 resp = await fetch(VM_API + '/api/groups?batch=' + state.batchId);
|
||||||
var data = await resp.json();
|
var data = await resp.json();
|
||||||
if (!data.ok || !data.groups) return;
|
if (!data.ok || !data.groups) return;
|
||||||
@@ -115,6 +125,166 @@ function renderGroups(state) {
|
|||||||
lucide.createIcons();
|
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-функция: карточка нераспознанных файлов.
|
* renderUnresolvedCard(group) — Чистая HTML-функция: карточка нераспознанных файлов.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ var state = {
|
|||||||
|
|
||||||
// ── Группы (бывший window._groupsData) ────────────────────
|
// ── Группы (бывший window._groupsData) ────────────────────
|
||||||
groups: null,
|
groups: null,
|
||||||
|
v3Mode: false,
|
||||||
|
v3Summary: null,
|
||||||
|
dossiers: null,
|
||||||
|
dossierTimelines: {},
|
||||||
|
|
||||||
// ── Активное сравнение ────────────────────────────────────
|
// ── Активное сравнение ────────────────────────────────────
|
||||||
_activeCompare: {
|
_activeCompare: {
|
||||||
|
|||||||
Reference in New Issue
Block a user