394 lines
20 KiB
JavaScript
394 lines
20 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|