diff --git a/site/static/app.js b/site/static/app.js index c2c2c9a..82899fe 100644 --- a/site/static/app.js +++ b/site/static/app.js @@ -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 = ' Извлечение... ' + 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) + 'с)'; diff --git a/site/static/compare.js b/site/static/compare.js index 692aa02..ac54149 100644 --- a/site/static/compare.js +++ b/site/static/compare.js @@ -215,6 +215,11 @@ function startCompareSSE(url, container, statusEl, callbacks) { container.innerHTML += '
✗ ' + escHtml(d.filename) + ': ' + escHtml(d.error) + '
'; } + // ── done: завершение ── + else if (d.type === 'timeline') { + if (callbacks.onTimeline) callbacks.onTimeline(d); + } + // ── done: завершение ── else if (d.type === 'done') { clearInterval(timer); diff --git a/site/static/groups.js b/site/static/groups.js index bc164ab..168e22d 100644 --- a/site/static/groups.js +++ b/site/static/groups.js @@ -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 = '
Досье: ' + appState.dossiers.length + + ' | требуется связать: ' + (summary.ambiguous || 0) + '
'; + + appState.dossiers.forEach(function(dossier) { + var aliases = (dossier.alias_numbers || []).map(escHtml).join(', '); + var docs = (dossier.documents || []).map(function(doc) { + return '
' + + '' + escHtml(doc.role || 'document') + ' ' + + escHtml(doc.filename || '') + + ' ' + + (doc.math_status === 'VERIFIED' ? '✓ арифметика' : '⚠ ' + escHtml(doc.math_status || 'UNKNOWN')) + '' + + (doc.doc_date ? ' ' + escHtml(doc.doc_date) + '' : '') + + '
'; + }).join(''); + html += '
' + + '
Договор №' + escHtml(dossier.canonical_number || '—') + + ' — ' + escHtml(dossier.counterparty_name || 'контрагент не определён') + '
' + + (aliases ? '
Алиасы: ' + aliases + '
' : '') + + (!dossier.has_frame_contract ? '
Рамочный договор не загружен
' : '') + + docs + + '' + + '
' + + '
'; + }); + if (summary.unlinked && summary.unlinked.length) { + html += '
Не привязано документов: ' + + summary.unlinked.length + '
'; + } + 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 = ' Обновить таймлайн'; + status.textContent = 'Таймлайн обновлён'; + lucide.createIcons(); + }, + onError: function(data) { + button.disabled = false; + status.textContent = 'Ошибка построения'; + container.innerHTML = '
' + escHtml(data.message) + '
'; + }, + onConnectionError: function() { + button.disabled = false; + status.textContent = 'Ошибка соединения'; + container.innerHTML = '
Ошибка соединения
'; + } + }, + ); + 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 = '' + + '
Активно: ' + active.length + ' интервалов
'; + 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 += '
' + + '
' + escHtml(intervals[0].raw_name || 'Услуга') + '
'; + intervals.forEach(function(interval) { + var isActive = interval.valid_from && interval.valid_from <= asOf && + (!interval.valid_to || interval.valid_to > asOf); + html += '
[' + escHtml(interval.valid_from || '—') + ' — ' + + escHtml(interval.valid_to || '∞') + ') · ' + escHtml(interval.charge_type || 'unknown') + + ' · цена ' + escHtml(interval.price || '—') + ' · кол-во ' + escHtml(interval.qty || '—') + + ' · сумма ' + escHtml(interval.sum || '—') + (isActive ? ' · действует' : '') + '
'; + }); + html += '
'; + }); + if (data.exceptions && data.exceptions.length) { + html += '
' + + '
Очередь исключений (' + data.exceptions.length + ')
'; + data.exceptions.forEach(function(issue) { + html += '
' + + '
' + escHtml(issue.title || issue.stage) + '
' + + (issue.source_quote ? '
' + escHtml(issue.source_quote) + '
' : ''); + (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 += ''; + }); + html += '
'; + }); + html += '
'; + } + 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-функция: карточка нераспознанных файлов. * diff --git a/site/static/state.js b/site/static/state.js index e8c9509..9a9e436 100644 --- a/site/static/state.js +++ b/site/static/state.js @@ -54,6 +54,10 @@ var state = { // ── Группы (бывший window._groupsData) ──────────────────── groups: null, + v3Mode: false, + v3Summary: null, + dossiers: null, + dossierTimelines: {}, // ── Активное сравнение ──────────────────────────────────── _activeCompare: {