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: {