diff --git a/deploy/app.js b/deploy/app.js index a175d3f..97e1373 100644 --- a/deploy/app.js +++ b/deploy/app.js @@ -187,7 +187,8 @@ window.runCompareForGroup = async function(gi, btn) { } var cid = ad.contract_ids[0]; - // Создать тело для результатов под кнопкой + + // Создать контейнер для результатов сравнения var cmpBody = document.getElementById('cmpBody_' + gi); if (!cmpBody) { cmpBody = document.createElement('div'); @@ -197,93 +198,53 @@ window.runCompareForGroup = async function(gi, btn) { btn.parentNode.insertBefore(cmpBody, btn.nextSibling); } cmpBody.innerHTML = ''; - var cmpBody = document.getElementById('cmpBody_' + gi); cmpBody.style.display = 'block'; - cmpBody.innerHTML = ''; btn.innerHTML = ' Сравнение...'; stepActive('stepCompare'); - + var statusEl = document.createElement('div'); statusEl.style.cssText = 'font-size:11px;color:var(--muted);margin-bottom:4px;'; statusEl.textContent = '⏳ 0.0с'; cmpBody.appendChild(statusEl); - - var start = Date.now(); - state._activeCompare.timer = setInterval(function() { - statusEl.textContent = '⏳ ' + ((Date.now() - start) / 1000).toFixed(1) + 'с'; - }, 200); - - var es = new EventSource(VM_API + '/process-v2?contract_id=' + cid); - state._activeCompare.es = es; - var sections = {}; - - es.onmessage = function(e) { - var d = JSON.parse(e.data); - if (d.type === 'extract_start') { - var sec = document.createElement('div'); - sec.style.cssText = 'margin-top:8px;border:1px solid var(--brand-gray);border-radius:6px;overflow:hidden;'; - sec.innerHTML = '
⏳ ' + d.filename + '
'; - cmpBody.appendChild(sec); - sections[d.supplement_id] = sec; - } - else if (d.type === 'llm_done') { - var sec = sections[d.supplement_id]; - if (sec) { - sec.querySelector('.diff-section-header').innerHTML = '✓ ' + d.filename + ' — ' + d.ops_count + ' оп., ' + d.mode + ' (' + d.time_s + 'с)'; - sec.querySelector('.diff-section-header').style.color = 'var(--green)'; + // Фаза 3: унифицированный SSE через startCompareSSE (compare.js) + var result = startCompareSSE( + VM_API + '/process-v2?contract_id=' + cid, + cmpBody, + statusEl, + { + onDone: function(d, sections) { + state._activeCompare.timer = null; + state._activeCompare.es = null; + group.compare = { + status: 'done', + totalTime: d.total_time_s + 'с', + bodyHTML: cmpBody.innerHTML, + sections: sections + }; + renderGroups(state); + document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; }); + stepDone('stepCompare'); + }, + onError: function(d) { + state._activeCompare.timer = null; + state._activeCompare.es = null; + group.compare = null; + cmpBody.innerHTML += '
✗ ' + d.message + '
'; + btn.innerHTML = '✗'; btn.disabled = false; + document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; }); + }, + onConnectionError: function() { + state._activeCompare.timer = null; + state._activeCompare.es = null; + group.compare = null; + btn.innerHTML = '✗ соединение'; btn.disabled = false; + document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; }); } } - else if (d.type === 'applied') { - var sec = sections[d.supplement_id]; - if (sec && d.ops && d.ops.length > 0) { - var s = d.summary || {}; - var body = sec.querySelector('.diff-section-body'); - var html = '
+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0) + '
'; - html += '
'; - d.ops.forEach(function(op) { - var cls = op.action === 'ADD' ? 'diff-added' : op.action === 'DELETE' ? 'diff-deleted' : 'diff-changed'; - var nr = op.new_row || {}; - html += ''; - }); - html += '
ДействиеУслугаЦенаКол-воСуммаДата
' + op.action + '' + (nr.name||'') + '' + (nr.price!=null?nr.price:'') + '' + (nr.qty!=null?nr.qty:'') + '' + (nr.sum!=null?nr.sum:'') + '' + (nr.date_start||'') + '
'; - body.innerHTML = html; - body.style.display = 'block'; - } - } - else if (d.type === 'done') { - clearInterval(state._activeCompare.timer); state._activeCompare.timer = null; - es.close(); state._activeCompare.es = null; - statusEl.textContent = '✓ ' + d.total_time_s + 'с'; - statusEl.style.color = 'var(--green)'; - // Фаза 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'); - } - 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; }); - } - }; - 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; }); - }; + ); + state._activeCompare.es = result.es; + state._activeCompare.timer = result.timer; }; // uploadFile → files.js (Фаза 1) @@ -303,61 +264,14 @@ document.getElementById('llmBtn').addEventListener('click', function() { compareStatus.textContent = '⏳ 0.0с'; var llmStart = Date.now(); - var timerInterval = setInterval(function() { - compareStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с'; - }, 200); var order = state.files.map(function(f) { return f.supp_id; }).filter(Boolean).join(','); - var es = new EventSource('https://contracts.kube5s.ru/process-v2?contract_id=' + state.contractId + (order ? '&order=' + encodeURIComponent(order) : '')); - var sections = {}; // supplement_id → DOM element + var url = 'https://contracts.kube5s.ru/process-v2?contract_id=' + state.contractId + + (order ? '&order=' + encodeURIComponent(order) : ''); - es.onmessage = function(e) { - var d = JSON.parse(e.data); - - if (d.type === 'extract_start') { - var sec = document.createElement('div'); - sec.style.cssText = 'margin-top:8px;border:1px solid var(--brand-gray);border-radius:6px;overflow:hidden;'; - sec.innerHTML = '
⏳ ' + d.filename + '
'; - compareBody.appendChild(sec); - sections[d.supplement_id] = sec; - } - else if (d.type === 'llm_done') { - var sec = sections[d.supplement_id]; - if (sec) { - sec.querySelector('.diff-section-header').innerHTML = '✓ ' + d.filename + ' — ' + d.ops_count + ' оп., ' + d.mode + ' (' + d.time_s + 'с)'; - sec.querySelector('.diff-section-header').style.color = 'var(--green)'; - } - } - else if (d.type === 'applied') { - var sec = sections[d.supplement_id]; - if (sec && d.ops && d.ops.length > 0) { - var s = d.summary || {}; - var body = sec.querySelector('.diff-section-body'); - var html = '
+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0) + ' ?' + (s.unresolved||0) + '
'; - html += '
'; - d.ops.forEach(function(op) { - var action = op.action; - var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : ''; - var nr = op.new_row || {}; - var name = nr.name || ''; - var price = nr.price != null ? nr.price : ''; - var qty = nr.qty != null ? nr.qty : ''; - var sum = nr.sum != null ? nr.sum : ''; - var ds = nr.date_start || ''; - html += ''; - }); - html += '
ДействиеУслугаЦенаКол-воСуммаДата
' + action + '' + name + '' + price + '' + qty + '' + sum + '' + ds + '
'; - body.innerHTML = html; - body.style.display = 'block'; - } - } - else if (d.type === 'extract_error' || d.type === 'apply_error') { - compareBody.innerHTML += '
✗ ' + d.filename + ': ' + d.error + '
'; - } - else if (d.type === 'done') { - clearInterval(timerInterval); - es.close(); - compareStatus.textContent = '✓ ' + d.total_time_s + 'с'; + // Фаза 3: унифицированный SSE через startCompareSSE (compare.js) + startCompareSSE(url, compareBody, compareStatus, { + onDone: function(d) { btn.innerHTML = ' ✓ Готово'; stepDone('stepCompare'); if (d.total_unresolved > 0) { @@ -365,26 +279,21 @@ document.getElementById('llmBtn').addEventListener('click', function() { } document.getElementById('chatCard').style.display = 'block'; lucide.createIcons(); - } - else if (d.type === 'error') { - clearInterval(timerInterval); - es.close(); - compareBody.innerHTML += '
✗ ' + d.message + '
'; - btn.innerHTML = ' Общее сравнение'; + }, + onError: function(d) { + compareBody.innerHTML += '
✗ ' + d.message + '
'; + btn.innerHTML = ' Общее сравнение'; btn.disabled = false; + }, + onConnectionError: function() { + if (compareBody.innerHTML === '') { + compareBody.innerHTML = '
✗ Ошибка соединения
'; + } + btn.innerHTML = ' Общее сравнение'; + btn.disabled = false; + lucide.createIcons(); } - }; - - es.onerror = function() { - clearInterval(timerInterval); - es.close(); - if (compareBody.innerHTML === '') { - compareBody.innerHTML = '
✗ Ошибка соединения
'; - } - btn.innerHTML = ' Общее сравнение'; - btn.disabled = false; - lucide.createIcons(); - }; + }); }); // refreshSupps → files.js (Фаза 1) diff --git a/deploy/compare.js b/deploy/compare.js new file mode 100644 index 0000000..c712478 --- /dev/null +++ b/deploy/compare.js @@ -0,0 +1,243 @@ +/** + * compare.js — Модуль сравнения через SSE (Фаза 3, decoupling-final-plan.md). + * + * УНИФИЦИРУЕТ дублирование (~160 строк) между runCompareForGroup и llmBtn. + * + * ПАТТЕРН: + * applyCompareEvent(sections, event) — чистая мутация: SSE-событие → state + * startCompareSSE(url, container, statusEl, callbacks) — жизненный цикл SSE + * renderCompareOpsTable(ops) — общий рендер таблицы операций + * + * ЗАВИСИМОСТИ: + * state.js → state (для _activeCompare) + */ + +/** + * applyCompareEvent(sections, event) — Чистая функция (Фаза 3). + * + * Применяет ОДНО SSE-событие к состоянию sections. + * НЕ трогает DOM — только мутирует объект sections. + * + * Вход: sections — объект { [supplement_id]: sectionState } + * event — { type, supplement_id, filename, ops_count, mode, ... } + * Выход: нет (мутирует sections) + * + * Типы событий: + * extract_start → создаёт запись в sections + * llm_done → обновляет статус, ops_count, mode, time_s + * applied → сохраняет ops и summary + * extract_error/apply_error → сохраняет ошибку + */ +function applyCompareEvent(sections, event) { + var d = event; + var suppId = d.supplement_id; + + if (d.type === 'extract_start') { + // Создать новую секцию: извлечение началось + sections[suppId] = { + filename: d.filename, + status: 'extracting', + ops_count: 0, + mode: '', + time_s: 0, + summary: null, + ops: null, + error: null + }; + } + else if (d.type === 'llm_done') { + // LLM закончил — сохранить метаданные + var sec = sections[suppId]; + if (sec) { + sec.status = 'llm_done'; + sec.ops_count = d.ops_count || 0; + sec.mode = d.mode || ''; + sec.time_s = d.time_s || 0; + } + } + else if (d.type === 'applied') { + // Результаты сравнения применены — сохранить ops и summary + var sec = sections[suppId]; + if (sec) { + sec.status = 'applied'; + sec.summary = d.summary || {}; + sec.ops = d.ops || []; + } + } + else if (d.type === 'extract_error' || d.type === 'apply_error') { + // Ошибка извлечения или применения + var sec = sections[suppId]; + if (sec) { + sec.status = 'error'; + sec.error = d.error || 'неизвестная ошибка'; + } else { + // Ошибка для ещё не созданной секции + sections[suppId] = { + filename: d.filename || '?', + status: 'error', + error: d.error || 'неизвестная ошибка', + ops_count: 0, mode: '', time_s: 0, summary: null, ops: null + }; + } + } +} + +/** + * renderCompareSectionHeader(sec) — Чистая функция: заголовок секции (Фаза 3). + * + * Вход: sec — { filename, status, ops_count, mode, time_s, error } + * Выход: HTML-строка для div.diff-section-header + */ +function renderCompareSectionHeader(sec) { + if (sec.status === 'extracting') { + return '⏳ ' + sec.filename; + } + if (sec.status === 'llm_done' || sec.status === 'applied') { + return '✓ ' + sec.filename + ' — ' + sec.ops_count + ' оп., ' + sec.mode + ' (' + sec.time_s + 'с)'; + } + if (sec.status === 'error') { + return '✗ ' + sec.filename + ': ' + sec.error; + } + return sec.filename; +} + +/** + * renderCompareOpsTable(ops) — Чистая функция: таблица операций (Фаза 3). + * + * Используется ОБОИМИ обработчиками (runCompareForGroup и llmBtn). + * Раньше этот код был продублирован ~30 строк × 2. + * + * Вход: ops — [{ action, new_row: { name, price, qty, sum, date_start } }] + * Выход: HTML-строка + */ +function renderCompareOpsTable(ops) { + var html = '
'; + ops.forEach(function(op) { + var action = op.action; + // Цвет строки зависит от действия: ADD=зелёный, DELETE=красный, UPDATE=жёлтый + var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : ''; + var nr = op.new_row || {}; + html += ''; + }); + html += '
ДействиеУслугаЦенаКол-воСуммаДата
' + action + '' + (nr.name||'') + '' + (nr.price!=null?nr.price:'') + '' + (nr.qty!=null?nr.qty:'') + '' + (nr.sum!=null?nr.sum:'') + '' + (nr.date_start||'') + '
'; + return html; +} + +/** + * renderCompareSectionBody(sec) — Чистая функция: тело секции (Фаза 3). + * + * Рендерит summary (статистику) + таблицу ops. + * + * Вход: sec — { summary: { added, updated, deleted, unresolved }, ops } + * Выход: HTML-строка + */ +function renderCompareSectionBody(sec) { + if (!sec || sec.status !== 'applied' || !sec.ops || !sec.ops.length) return ''; + var s = sec.summary || {}; + var html = '
'; + html += '+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0); + if (s.unresolved) html += ' ?' + s.unresolved; + html += '
'; + html += renderCompareOpsTable(sec.ops); + return html; +} + +/** + * startCompareSSE(url, container, statusEl, callbacks) — Жизненный цикл SSE (Фаза 3). + * + * УНИФИЦИРОВАННЫЙ обработчик SSE для ОБОИХ режимов сравнения. + * Раньше ~80 строк дублировалось в runCompareForGroup и llmBtn. + * + * Параметры: + * url — URL для EventSource (/process-v2?contract_id=...) + * container — DOM-элемент, куда добавлять секции (cmpBody или compareBody) + * statusEl — DOM-элемент для отображения таймера/статуса + * callbacks — { onDone(d, sections), onError(d), onConnectionError() } + * Все колбэки опциональны. + * Таймер и EventSource УЖЕ остановлены к моменту вызова колбэков. + * + * Возвращает: { es, timer, sections } — для сохранения в state._activeCompare + */ +function startCompareSSE(url, container, statusEl, callbacks) { + callbacks = callbacks || {}; + + // Таймер: обновляет statusEl каждые 200мс + var start = Date.now(); + var timer = setInterval(function() { + statusEl.textContent = '⏳ ' + ((Date.now() - start) / 1000).toFixed(1) + 'с'; + }, 200); + + var es = new EventSource(url); + var sections = {}; // supplement_id → { filename, status, ops_count, mode, time_s, summary, ops, error, _el } + + es.onmessage = function(e) { + var d = JSON.parse(e.data); + + // ── extract_start: создать DOM-секцию ── + if (d.type === 'extract_start') { + applyCompareEvent(sections, d); + var sec = sections[d.supplement_id]; + var secEl = document.createElement('div'); + secEl.style.cssText = 'margin-top:8px;border:1px solid var(--brand-gray);border-radius:6px;overflow:hidden;'; + secEl.innerHTML = + '
' + + renderCompareSectionHeader(sec) + '
' + + ''; + container.appendChild(secEl); + sec._el = secEl; // ссылка на DOM для последующих обновлений + } + + // ── llm_done: обновить заголовок секции ── + else if (d.type === 'llm_done') { + applyCompareEvent(sections, d); + var sec = sections[d.supplement_id]; + if (sec && sec._el) { + sec._el.querySelector('.diff-section-header').innerHTML = renderCompareSectionHeader(sec); + sec._el.querySelector('.diff-section-header').style.color = 'var(--green)'; + } + } + + // ── applied: заполнить тело секции таблицей ops ── + else if (d.type === 'applied') { + applyCompareEvent(sections, d); + var sec = sections[d.supplement_id]; + if (sec && sec._el && sec.ops && sec.ops.length > 0) { + var body = sec._el.querySelector('.diff-section-body'); + body.innerHTML = renderCompareSectionBody(sec); + body.style.display = 'block'; + } + } + + // ── extract_error / apply_error: показать ошибку ── + else if (d.type === 'extract_error' || d.type === 'apply_error') { + applyCompareEvent(sections, d); + container.innerHTML += '
✗ ' + d.filename + ': ' + d.error + '
'; + } + + // ── done: завершение ── + else if (d.type === 'done') { + clearInterval(timer); + es.close(); + statusEl.textContent = '✓ ' + d.total_time_s + 'с'; + statusEl.style.color = 'var(--green)'; + if (callbacks.onDone) callbacks.onDone(d, sections); + } + + // ── error: фатальная ошибка ── + else if (d.type === 'error') { + clearInterval(timer); + es.close(); + if (callbacks.onError) callbacks.onError(d); + } + }; + + // ── Сетевая ошибка ── + es.onerror = function() { + clearInterval(timer); + es.close(); + if (callbacks.onConnectionError) callbacks.onConnectionError(); + }; + + return { es: es, timer: timer, sections: sections }; +} diff --git a/index.cfm b/index.cfm index e3e4f55..f4e061b 100644 --- a/index.cfm +++ b/index.cfm @@ -249,6 +249,7 @@ +