Files
contracts-flask/site/static/groups.js
T

394 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = '<div style="font-weight:600;margin-bottom:8px;">📋 Найдено групп: ' + realGroups.length +
(unresolvedGroup ? ' | ⚠ ' + unresolvedGroup.documents.length + ' файлов не распознано' : '') +
'</div>';
// Нераспознанная группа — всегда первая
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 = '<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-функция: карточка нераспознанных файлов.
*
* Вход: 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 '<div style="padding:2px 0;">' +
'<span style="color:var(--muted);">[' + escHtml(d.doc_type || '?') + ']</span> ' +
escHtml(d.filename) +
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
'<span style="color:var(--destructive);font-size:11px;">' + reason + '</span>' +
'</div>';
}).join('');
return '<div style="border:1px solid var(--destructive);border-radius:6px;padding:10px;margin-bottom:8px;">' +
'<div style="font-weight:600;margin-bottom:6px;">❓ Не распознано (' + group.documents.length + ' файлов)</div>' +
'<div style="font-size:12px;">' + docsHtml + '</div>' +
'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Загрузите недостающие базовые договоры для этих номеров.</div>' +
'</div>';
}
/**
* 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 = '<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:10px;margin-bottom:8px;">' +
'<div class="cmp-header" style="font-weight:600;margin-bottom:6px;">' +
'📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') +
'</div>' +
'<div style="font-size:12px;">' +
group.documents.map(function(d) {
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
return '<div style="padding:2px 0;">' +
'<span style="color:var(--muted);">' + escHtml(typeLabel || '?') + '</span> ' +
escHtml(d.filename) +
(d.zip_source ? ' <span style="color:var(--brand-gray);font-size:10px;">[' + escHtml(d.zip_source) + ']</span>' : '') +
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
'</div>';
}).join('') + '</div>' +
'<button class="btn btn-primary cmp-btn" style="margin-top:6px;font-size:12px;height:28px;" data-gi="' + gi + '"' +
(isRunning ? ' disabled' : '') + '>▶ Сравнить эту группу</button>' +
'</div>';
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 = '<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:10px;margin-bottom:8px;">' +
'<div class="cmp-header" style="font-weight:600;margin-bottom:6px;cursor:pointer;" data-gi="' + gi + '">' +
'<span class="cmp-arrow" id="cmpArrow_' + gi + '" style="font-size:10px;margin-right:4px;">▾</span>' +
'📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') +
' <span style="font-weight:400;color:var(--green);font-size:11px;">✓ Готово (' + time + ')</span>' +
'</div>' +
'<div style="font-size:12px;">' +
group.documents.map(function(d, di) {
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
return '<div style="padding:2px 0;cursor:pointer;" onclick="var b=document.querySelectorAll(\'#cmpBody_' + gi + ' .diff-section-body\');if(b[' + di + '])b[' + di + '].style.display=b[' + di + '].style.display===\'none\'?\'block\':\'none\';">' +
'<span style="color:var(--muted);">' + escHtml(typeLabel || '?') + '</span> ' +
escHtml(d.filename) +
(d.zip_source ? ' <span style="color:var(--brand-gray);font-size:10px;">[' + escHtml(d.zip_source) + ']</span>' : '') +
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
'</div>';
}).join('') + '</div>' +
'<div class="cmp-body" id="cmpBody_' + gi + '" style="margin-top:8px;">' + bodyHTML + '</div>' +
'</div>';
return html;
}