/** * 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 += ''; }); 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 = '
' + '
' + '📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') + '
' + '
' + 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 = '
' + '
' + '▾' + '📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') + ' ✓ Готово (' + time + ')' + '
' + '
' + 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; }