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 = '
';
- 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 += '| ' + op.action + ' | ' + (nr.name||'') + ' | ' + (nr.price!=null?nr.price:'') + ' | ' + (nr.qty!=null?nr.qty:'') + ' | ' + (nr.sum!=null?nr.sum:'') + ' | ' + (nr.date_start||'') + ' |
';
- });
- html += '
';
- 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 = '';
- 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 += '| ' + action + ' | ' + name + ' | ' + price + ' | ' + qty + ' | ' + sum + ' | ' + ds + ' |
';
- });
- html += '
';
- 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 += '| ' + action + ' | ' + (nr.name||'') + ' | ' + (nr.price!=null?nr.price:'') + ' | ' + (nr.qty!=null?nr.qty:'') + ' | ' + (nr.sum!=null?nr.sum:'') + ' | ' + (nr.date_start||'') + ' |
';
+ });
+ html += '
';
+ 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 =
+ '' +
+ '';
+ 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 @@
+