/** * 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 += '
✗ ' + escHtml(d.filename) + ': ' + escHtml(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 }; }