/**
* groups.js — Модуль групп и карточек договоров (Фаза 2, decoupling-final-plan.md).
*
* ВЫНЕСЕНО из app.js:
* - loadGroupsAction() — fetch групп, сохранить в state.groups, renderGroups
* - renderGroups(state) — пересобрать ВСЕ карточки групп в diffBody
* - renderGroupCard(g, gi) — чистая HTML-функция: карточка необработанной группы
* - renderGroupCardDone(g, gi)— чистая HTML-функция: карточка обработанной группы
*
* ПАТТЕРН (decoupling-final-plan.md):
* renderGroups пересобирает ВСЕ карточки целиком из state.groups.
* Готовность группы: group.compare.status === 'done' (вместо markGroupDone).
* Никакого window._groupsData — всё в state.groups.
*
* ЗАВИСИМОСТИ:
* state.js → state (центральное состояние)
* app_utils.js → escHtml
*/
/**
* loadGroupsAction() — Загрузить группы с бэкенда (Фаза 2).
*
* 1. fetch /api/groups?batch=...
* 2. Сохранить в state.groups (бывший window._groupsData)
* 3. Вызвать renderGroups(state) — пересобрать карточки
*
* Вызывается из 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;
// Сохраняем в центральное состояние (вместо window._groupsData)
state.groups = data.groups;
// Пересобрать ВСЕ карточки
renderGroups(state);
stepDone('stepGroups');
}
/**
* renderGroups(state) — Пересобрать ВСЕ карточки групп (Фаза 2).
*
* ПЕРЕСОБИРАЕТ весь diffBody.innerHTML заново из state.groups.
* Никакого инкрементального обновления — всегда полная перестройка.
*
* Для каждой группы:
* - Необработанная (group.compare.status !== 'done') → renderGroupCard()
* - Обработанная (group.compare.status === 'done') → renderGroupCardDone()
* - Нераспознанная (contract_number === '__unresolved__') → отдельный рендер
*/
function renderGroups(state) {
var groups = state.groups;
if (!groups) return;
var diffBody = document.getElementById('diffBody');
var diffCard = document.getElementById('diffCard');
diffCard.style.display = 'block';
var realGroups = groups.filter(function(g) { return g.contract_number !== '__unresolved__'; });
var unresolvedGroup = groups.find(function(g) { return g.contract_number === '__unresolved__'; });
// Заголовок: количество групп + нераспознанные
var html = '
📋 Найдено групп: ' + realGroups.length +
(unresolvedGroup ? ' | ⚠ ' + unresolvedGroup.documents.length + ' файлов не распознано' : '') +
'
';
// Нераспознанная группа — всегда первая
if (unresolvedGroup) {
html += renderUnresolvedCard(unresolvedGroup);
}
// Карточки обычных групп
groups.forEach(function(g, gi) {
if (g.contract_number === '__unresolved__') return;
if (g.compare && g.compare.status === 'done') {
html += renderGroupCardDone(g, gi);
} else {
html += renderGroupCard(g, gi);
}
});
diffBody.innerHTML = html;
// Один обработчик на все кнопки сравнения (event delegation не используется —
// кнопки пересоздаются при каждом renderGroups)
diffBody.querySelectorAll('.cmp-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var gi = parseInt(this.getAttribute('data-gi'));
runCompareForGroup(gi, this);
});
});
// Обработчики сворачивания для готовых карточек
diffBody.querySelectorAll('.cmp-header').forEach(function(header) {
var gi = parseInt(header.getAttribute('data-gi'));
if (isNaN(gi)) return;
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 = '▸';
}
}
});
});
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 += '
' +
escHtml(label) + ' ';
});
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-функция: карточка нераспознанных файлов.
*
* Вход: group — { contract_number: '__unresolved__', documents: [...] }
* Выход: HTML-строка
*/
function renderUnresolvedCard(group) {
var docsHtml = group.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 '' +
'[' + escHtml(d.doc_type || '?') + '] ' +
escHtml(d.filename) +
(d.doc_date ? ' ' + escHtml(d.doc_date) + ' ' : '') +
'' + reason + ' ' +
'
';
}).join('');
return '' +
'
❓ Не распознано (' + group.documents.length + ' файлов)
' +
'
' + docsHtml + '
' +
'
Загрузите недостающие базовые договоры для этих номеров.
' +
'
';
}
/**
* renderGroupCard(group, gi) — Чистая HTML-функция: необработанная группа (Фаза 2).
*
* Показывает: номер договора, контрагента, список документов, кнопку «Сравнить».
* Если группа в процессе сравнения (group.compare.status === 'running') — кнопка disabled.
*
* Вход: group — элемент state.groups
* gi — индекс группы (для data-gi атрибута кнопки)
* Выход: HTML-строка
*/
function renderGroupCard(group, gi) {
var isRunning = group.compare && group.compare.status === 'running';
var html = '' +
'' +
'
' +
group.documents.map(function(d) {
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
return '
' +
'' + escHtml(typeLabel || '?') + ' ' +
escHtml(d.filename) +
(d.zip_source ? ' [' + escHtml(d.zip_source) + '] ' : '') +
(d.doc_date ? ' ' + escHtml(d.doc_date) + ' ' : '') +
'
';
}).join('') + '
' +
'
▶ Сравнить эту группу ' +
'
';
return html;
}
/**
* renderGroupCardDone(group, gi) — Чистая HTML-функция: обработанная группа (Фаза 2).
*
* Показывает: ✓ Готово (время), список документов (кликабельные — раскрывают секции),
* тело сравнения (cmpBody) — сворачивается по клику на заголовок.
*
* Вход: group — элемент state.groups (group.compare.status === 'done')
* gi — индекс группы
* Выход: HTML-строка
*
* ЗАМЕНЯЕТ удалённую markGroupDone().
* Готовность определяется по group.compare.status, а не по наличию кнопки.
*/
function renderGroupCardDone(group, gi) {
var time = group.compare.totalTime || '?с';
var bodyHTML = group.compare.bodyHTML || '';
var html = '' +
'' +
'
' +
group.documents.map(function(d, di) {
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
return '
' +
'' + escHtml(typeLabel || '?') + ' ' +
escHtml(d.filename) +
(d.zip_source ? ' [' + escHtml(d.zip_source) + '] ' : '') +
(d.doc_date ? ' ' + escHtml(d.doc_date) + ' ' : '') +
'
';
}).join('') + '
' +
'
' + bodyHTML + '
' +
'
';
return html;
}