From e7917c8af5f8dea2e15ca12691ed1424485c9212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Thu, 25 Jun 2026 09:15:54 +0400 Subject: [PATCH] =?UTF-8?q?v1.0.178:=20=D0=A4=D0=B0=D0=B7=D0=B0=202=20?= =?UTF-8?q?=E2=80=94=20groups.js.=20loadGroupsAction=20+=20renderGroups=20?= =?UTF-8?q?+=20renderGroupCard=20+=20renderGroupCardDone.=20markGroupDone?= =?UTF-8?q?=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B0,=20=D0=B3=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=3D=20group.?= =?UTF-8?q?compare.status=20=3D=3D=3D=20'done'.=20renderGroups=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=81=D0=BE=D0=B1=D0=B8=D1=80=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D0=92=D0=A1=D0=95=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B8=20=D0=B8=D0=B7=20state.groups.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/app.js | 131 ++++------------------------ deploy/groups.js | 221 +++++++++++++++++++++++++++++++++++++++++++++++ index.cfm | 1 + 3 files changed, 238 insertions(+), 115 deletions(-) create mode 100644 deploy/groups.js 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 = '
' + - '📄 Договор №' + escHtml(g.contract_number || '?') + ' — ' + escHtml(g.counterparty || 'контрагент не определён') + - '
' + - '
' + - 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 = '
' + - '' + - '📄 Договор №' + escHtml(g.contract_number || '?') + ' — ' + escHtml(g.counterparty || 'контрагент не определён') + - ' ✓ Готово (' + time + ')' + - '
' + - '
' + - 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 = '
' + + '
' + + '📄 Договор №' + 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.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.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 @@ +