diff --git a/deploy/app.js b/deploy/app.js
index 54b2543..a175d3f 100644
--- a/deploy/app.js
+++ b/deploy/app.js
@@ -135,7 +135,7 @@ async function runClassify() {
btn.innerHTML = '✓ ' + data.done + '/' + data.total + ' классифицировано (' + Math.round((Date.now() - start) / 1000) + 'с)';
stepDone('stepClassify');
stepActive('stepGroups');
- loadGroups();
+ loadGroupsAction();
} else {
btn.innerHTML = '✗ ' + (data.error || 'ошибка');
btn.disabled = false;
@@ -148,118 +148,7 @@ async function runClassify() {
}
}
-async function loadGroups() {
- var resp = await fetch(VM_API + '/api/groups?batch=' + state.batchId);
- var data = await resp.json();
- if (!data.ok || !data.groups) return;
-
- var diffBody = document.getElementById('diffBody');
- var diffCard = document.getElementById('diffCard');
- diffCard.style.display = 'block';
- var realGroups = data.groups.filter(function(g) { return g.contract_number !== '__unresolved__'; });
- var unresolvedGroup = data.groups.find(function(g) { return g.contract_number === '__unresolved__'; });
- diffBody.innerHTML = '
📋 Найдено групп: ' + realGroups.length + (unresolvedGroup ? ' | ⚠ ' + unresolvedGroup.documents.length + ' файлов не распознано' : '') + '
';
- stepDone('stepGroups');
-
- // Сохраняем группы для compare
- state.groups = data.groups;
-
- data.groups.forEach(function(g, gi) {
- var isUnresolved = g.contract_number === '__unresolved__';
- var card = document.createElement('div');
- card.style.cssText = 'border:1px solid ' + (isUnresolved ? 'var(--destructive)' : 'var(--brand-gray)') + ';border-radius:6px;padding:10px;margin-bottom:8px;';
-
- if (isUnresolved) {
- var docsHtml = g.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('');
- card.innerHTML = '❓ Не распознано (' + g.documents.length + ' файлов)
' +
- '' + docsHtml + '
' +
- 'Загрузите недостающие базовые договоры для этих номеров.
';
- } else {
- buildGroupCard(g, gi, card);
- }
- diffBody.appendChild(card);
- });
- // Один обработчик на все кнопки сравнения
- diffBody.querySelectorAll('.cmp-btn').forEach(function(btn) {
- btn.addEventListener('click', function() {
- var gi = parseInt(this.getAttribute('data-gi'));
- runCompareForGroup(gi, this);
- });
- });
- lucide.createIcons();
-}
-
-// ── Карточка необработанной группы ──────────────────────────
-function buildGroupCard(g, gi, card) {
- card.innerHTML = '' +
- '' +
- g.documents.map(function(d) {
- var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
- return '
' +
- '' + escHtml(typeLabel || '?') + ' ' +
- escHtml(d.filename) +
- (d.doc_date ? ' ' + escHtml(d.doc_date) + ' ' : '') +
- '
';
- }).join('') + '
' +
- '▶ Сравнить эту группу ';
- // Необработанная: только кнопка, ничего больше
-}
-
-// ── Заменить карточку на обработанную ───────────────────────
-function markGroupDone(gi, time) {
- var bodyEl = document.getElementById('cmpBody_' + gi);
- var bodyHTML = bodyEl ? bodyEl.innerHTML : '';
- var card = bodyEl.parentNode;
- var g = state.groups[gi];
- card.innerHTML = '' +
- '' +
- g.documents.map(function(d, di) {
- var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
- return '
' +
- '' + escHtml(typeLabel || '?') + ' ' +
- escHtml(d.filename) +
- (d.doc_date ? ' ' + escHtml(d.doc_date) + ' ' : '') +
- '
';
- }).join('') + '
' +
- '' + bodyHTML + '
';
- // Клик на заголовок → свернуть/развернуть
- card.querySelector('.cmp-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 = '▸';
- }
- }
- });
-}
+// loadGroups, buildGroupCard, markGroupDone → groups.js (Фаза 2)
// Текущий активный EventSource (только один одновременно)
// state._activeCompare — теперь в state.js (Фаза 0: state + render)
@@ -268,6 +157,10 @@ window.runCompareForGroup = async function(gi, btn) {
var group = state.groups[gi];
if (!group || group.contract_number === '__unresolved__') return;
+ // Фаза 2: отметить группу как «в процессе»
+ // renderGroups НЕ вызываем — инкрементальные DOM-мутации внутри SSE
+ group.compare = { status: 'running' };
+
// Остановить предыдущее сравнение
if (state._activeCompare.es) { state._activeCompare.es.close(); state._activeCompare.es = null; }
if (state._activeCompare.timer) { clearInterval(state._activeCompare.timer); state._activeCompare.timer = null; }
@@ -363,8 +256,14 @@ window.runCompareForGroup = async function(gi, btn) {
es.close(); state._activeCompare.es = null;
statusEl.textContent = '✓ ' + d.total_time_s + 'с';
statusEl.style.color = 'var(--green)';
- // Заменить карточку на обработанную (другая вёрстка, нет кнопок)
- markGroupDone(gi, d.total_time_s + 'с');
+ // Фаза 2: вместо markGroupDone — флаг в state + пересборка карточек
+ // renderGroups перестроит карточку как «готовую» (renderGroupCardDone)
+ group.compare = {
+ status: 'done',
+ totalTime: d.total_time_s + 'с',
+ bodyHTML: cmpBody.innerHTML
+ };
+ renderGroups(state);
// Разблокировать остальные кнопки
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
stepDone('stepCompare');
@@ -372,6 +271,7 @@ window.runCompareForGroup = async function(gi, btn) {
else if (d.type === 'error') {
clearInterval(state._activeCompare.timer); state._activeCompare.timer = null;
es.close(); state._activeCompare.es = null;
+ group.compare = null; // Фаза 2: сбросить статус группы при ошибке
cmpBody.innerHTML += '✗ ' + d.message + '
';
btn.innerHTML = '✗'; btn.disabled = false;
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
@@ -380,6 +280,7 @@ window.runCompareForGroup = async function(gi, btn) {
es.onerror = function() {
clearInterval(state._activeCompare.timer); state._activeCompare.timer = null;
es.close(); state._activeCompare.es = null;
+ group.compare = null; // Фаза 2: сбросить статус группы при ошибке
btn.innerHTML = '✗ соединение'; btn.disabled = false;
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
};
diff --git a/deploy/groups.js b/deploy/groups.js
new file mode 100644
index 0000000..65061eb
--- /dev/null
+++ b/deploy/groups.js
@@ -0,0 +1,221 @@
+/**
+ * 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() {
+ 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();
+}
+
+/**
+ * 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.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.doc_date ? ' ' + escHtml(d.doc_date) + ' ' : '') +
+ '
';
+ }).join('') + '
' +
+ '
' + bodyHTML + '
' +
+ '
';
+
+ return html;
+}
diff --git a/index.cfm b/index.cfm
index 1d72210..e3e4f55 100644
--- a/index.cfm
+++ b/index.cfm
@@ -248,6 +248,7 @@
+