From 67658d0ef1b29088273824916a56f213a93bb84b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Thu, 25 Jun 2026 08:57:22 +0400 Subject: [PATCH] =?UTF-8?q?v1.0.178:=20=D0=A4=D0=B0=D0=B7=D0=B0=201.3=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=D1=8B=D0=BD=D0=BE=D1=81=20=D0=B2=20files.js.?= =?UTF-8?q?=20statusToHTML,=20renderFiles,=20syncDB,=20toggleClassifyDetai?= =?UTF-8?q?l,=20uploadFile,=20refreshSupps=20=E2=86=92=20files.js.=20app.j?= =?UTF-8?q?s=20=D0=BF=D0=BE=D1=85=D1=83=D0=B4=D0=B5=D0=BB=20=D0=BD=D0=B0?= =?UTF-8?q?=20~200=20=D1=81=D1=82=D1=80=D0=BE=D0=BA.=20index.cfm=20=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=B8=D1=82=20files.js.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/app.js | 194 ++----------------------------------- deploy/files.js | 250 ++++++++++++++++++++++++++++++++++++++++++++++++ index.cfm | 1 + 3 files changed, 258 insertions(+), 187 deletions(-) create mode 100644 deploy/files.js diff --git a/deploy/app.js b/deploy/app.js index 10a7ce2..9325f82 100644 --- a/deploy/app.js +++ b/deploy/app.js @@ -38,17 +38,9 @@ function render(state) { renderFiles(state); } -// Синхронизация БД с таблицей — удалить всё, чего нет в state.files -async function syncDB() { - var keepIds = state.files.map(function(f) { return f.doc_id; }).filter(Boolean); - try { - await fetch(VM_API + '/api/sync', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({keep_ids: keepIds}) - }); - } catch(e) { /* сервер недоступен — не критично */ } -} +// syncDB, statusToHTML, renderFiles, toggleClassifyDetail, uploadFile, refreshSupps +// → вынесены в files.js (Фаза 1) + // ── Pipeline stepper ───────────────────────────────────────── function stepDone(id) { var el = document.getElementById(id); @@ -71,113 +63,10 @@ function resetStepper(fromId) { } // Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) — // вынесены в app_utils.js для облегчения анализа и отладки. - -/** - * statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1). - * - * Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега. - * kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | '' - * Выход: безопасная HTML-строка (статусы не содержат пользовательских данных). - * - * ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления. - * state хранит чистые данные, statusToHTML — чистая функция рендеринга. - */ -function statusToHTML(st) { - if (!st || !st.kind) return ''; - switch (st.kind) { - case 'uploading': return '↑ ' + (st.pct || 0) + '%'; - case 'uploaded': return ''; - case 'unzipping': return '⏳ распаковка...'; - case 'parsing': return '⏳ парсинг...'; - case 'parsed': return '✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + ''; - case 'error': return '✗ ' + (st.text || 'Неизвестная ошибка') + ''; - default: return ''; - } -} - -function renderFiles(state) { - if (state.files.length === 0) { - fileTable.innerHTML = 'Нет файлов — выберите .docx / .pdf'; - } else { - fileTable.innerHTML = state.files.map(function(f, i) { - var rows = '' + - '' + - (f.doc_id ? '' : '') + - escHtml(f.name) + '' + - '' + formatDate(f.lastModified) + '' + - '' + formatSize(f.size) + '' + - '' + statusToHTML(f.status) + '' + - '' + - '' + - '' + - 'Загрузка...'; - return rows; - }).join(''); - } - lucide.createIcons(); -} +// statusToHTML, renderFiles → files.js (Фаза 1) // ── Раскрыть/скрыть результат классификации под строкой файла ── -window.toggleClassifyDetail = async function(i) { - var detailRow = document.getElementById('detail_' + i); - var expandIcon = document.getElementById('expand_' + i); - if (!detailRow) return; - - if (detailRow.style.display !== 'none') { - detailRow.style.display = 'none'; - if (expandIcon) expandIcon.textContent = '▸'; - return; - } - - var f = state.files[i]; - if (!f || !f.doc_id) return; - - detailRow.style.display = ''; - if (expandIcon) expandIcon.textContent = '▾'; - - try { - var resp = await fetch(VM_API + '/api/documents/' + f.doc_id); - var qData = await resp.json(); - if (!qData.ok) { detailRow.cells[0].innerHTML = 'Ошибка загрузки'; return; } - - var html = ''; - if (qData.classify_status === 'classified') { - html += '
🏷️ Результат классификации
'; - html += '
'; - if (qData.doc_type) html += 'Тип: ' + escHtml(qData.doc_type) + ''; - if (qData.own_number) html += 'Номер: ' + escHtml(qData.own_number) + ''; - if (qData.parent_number) html += 'Родительский: ' + escHtml(qData.parent_number) + ''; - if (qData.doc_date) html += 'Дата: ' + escHtml(qData.doc_date) + ''; - if (qData.counterparty) html += 'Контрагент: ' + escHtml(qData.counterparty) + ''; - html += '
'; - if (qData.classify_input) { - html += '
📤 Текст отправленный в LLM'; - html += '
' + escHtml(qData.classify_input) + '
'; - html += '
'; - } - if (qData.classify_raw) { - html += '
📥 Сырой ответ LLM'; - html += '
' + escHtml(qData.classify_raw) + '
'; - html += '
'; - } - if (qData.elements_json) { - var ej = qData.elements_json; - if (typeof ej === 'object' && ej.Value) ej = ej.Value; - var ejStr = typeof ej === 'string' ? ej : JSON.stringify(ej, null, 2); - html += '
📄 Распарсенные элементы (JSON)'; - html += '
' + escHtml(ejStr.substring(0, 5000)) + (ejStr.length > 5000 ? '\n... (обрезано)' : '') + '
'; - html += '
'; - } - } else if (qData.classify_status === 'failed') { - html += '✗ Ошибка классификации: ' + escHtml(qData.error_message || '?') + ''; - } else { - html += 'Ещё не классифицирован'; - } - detailRow.cells[0].innerHTML = html; - } catch(e) { - detailRow.cells[0].innerHTML = 'Ошибка: ' + escHtml(e.message) + ''; - } -}; +// toggleClassifyDetail → files.js (Фаза 1) // moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js @@ -639,56 +528,7 @@ window.runCompareForGroup = async function(gi, btn) { }; }; -function uploadFile(file, onProgress) { - return new Promise(function(resolve, reject) { - // .doc → конвертация в docx - var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx'); - var uploadFile = file; - var uploadName = file.name; - - function doUpload() { - var xhr = new XMLHttpRequest(); - var fd = new FormData(); - fd.append('files', uploadFile, uploadName); - if (state.contractId) fd.append('contract_id', state.contractId); - fd.append('batch_id', state.batchId); - xhr.open('POST', UPLOAD_URL); - xhr.upload.onprogress = function(e) { - if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100)); - }; - xhr.onload = function() { - try { - var r = JSON.parse(xhr.responseText); - if (r.ok) resolve(r); - else reject(new Error(r.error || 'Неизвестная ошибка')); - } catch(e) { reject(new Error('Некорректный ответ')); } - }; - xhr.onerror = function() { reject(new Error('Сеть')); }; - xhr.ontimeout = function() { reject(new Error('Таймаут')); }; - xhr.timeout = 180000; - xhr.send(fd); - } - - if (isDoc) { - var xhr = new XMLHttpRequest(); - xhr.open('POST', CONVERT_URL); - xhr.responseType = 'blob'; - xhr.onload = function() { - if (xhr.status === 200 && xhr.response.size > 100) { - uploadFile = xhr.response; - uploadName = file.name.replace(/\.doc$/i, '.docx'); - doUpload(); - } else { - reject(new Error('Конвертация .doc')); - } - }; - xhr.onerror = function() { reject(new Error('Конвертер')); }; - xhr.send(file); - } else { - doUpload(); - } - }); -} +// uploadFile → files.js (Фаза 1) // ── LLM: Общее сравнение (Event Sourcing) ──────────────────────── document.getElementById('llmBtn').addEventListener('click', function() { @@ -789,27 +629,7 @@ document.getElementById('llmBtn').addEventListener('click', function() { }; }); -// ── Обновить supplement_id/type для radio ────────────────── -async function refreshSupps() { - if (!state.contractId) { console.log('refreshSupps: no state.contractId'); return; } - try { - var resp = await fetch(VM_API + '/api/supplements?contract_id=' + state.contractId); - var data = await resp.json(); - console.log('refreshSupps: data=', data); - if (data.ok && data.supplements) { - data.supplements.forEach(function(supp) { - console.log('refreshSupps: supp=', supp); - for (var i = 0; i < state.files.length; i++) { - if (state.files[i].doc_id === supp.document_id) { - state.files[i].supp_id = supp.id; - state.files[i].supp_type = supp.type; - } - } - }); - render(state); - } - } catch(e) { console.log('refreshSupps error:', e); } -} +// refreshSupps → files.js (Фаза 1) // ── Чат ────────────────────────────────────────────────────── document.getElementById('chatSend').addEventListener('click', function() { diff --git a/deploy/files.js b/deploy/files.js new file mode 100644 index 0000000..1c9d41c --- /dev/null +++ b/deploy/files.js @@ -0,0 +1,250 @@ +/** + * files.js — Модуль работы с файлами (Фаза 1, decoupling-final-plan.md). + * + * ВЫНЕСЕНО из app.js: + * - statusToHTML(st) — чистая: структура → HTML + * - renderFiles(state) — рендер таблицы файлов + * - syncDB() — синхронизация БД с fileQueue + * - toggleClassifyDetail(i) — раскрыть результат классификации + * - uploadFile(file, cb) — XHR-загрузка одного файла (.doc/.docx/.pdf) + * - refreshSupps() — обновить supplement_id для файлов + * + * ЗАВИСИМОСТИ (глобальные, загружаются раньше): + * state.js → state (центральное состояние) + * app_utils.js → escHtml, formatSize, formatDate + * app.js → render(), stepDone, stepActive, resetStepper, showClassifyBtn + * + * ЗАГРУЖАЕТСЯ: после app_utils.js, перед app.js + */ + +/** + * statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1). + * + * Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега. + * kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | '' + * Выход: безопасная HTML-строка (статусы не содержат пользовательских данных). + * + * ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления. + * state хранит чистые данные, statusToHTML — чистая функция рендеринга. + */ +function statusToHTML(st) { + if (!st || !st.kind) return ''; + switch (st.kind) { + case 'uploading': return '↑ ' + (st.pct || 0) + '%'; + case 'uploaded': return ''; + case 'unzipping': return '⏳ распаковка...'; + case 'parsing': return '⏳ парсинг...'; + case 'parsed': return '✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + ''; + case 'error': return '✗ ' + (st.text || 'Неизвестная ошибка') + ''; + default: return ''; + } +} + +/** + * renderFiles(state) — Рендер таблицы файлов (бывший renderTable, Фаза 1). + * + * Читает state.files, генерирует HTML для #fileTable. + * Использует statusToHTML() для рендеринга статусов (чистые данные → HTML). + * Вызывает lucide.createIcons() для иконок. + * + * Вход: state (весь объект состояния, но читает только state.files). + * Выход: мутирует DOM (fileTable.innerHTML). + */ +function renderFiles(state) { + if (state.files.length === 0) { + fileTable.innerHTML = 'Нет файлов — выберите .docx / .pdf'; + } else { + fileTable.innerHTML = state.files.map(function(f, i) { + var rows = '' + + '' + + (f.doc_id ? '' : '') + + escHtml(f.name) + '' + + '' + formatDate(f.lastModified) + '' + + '' + formatSize(f.size) + '' + + '' + statusToHTML(f.status) + '' + + '' + + '' + + '' + + 'Загрузка...'; + return rows; + }).join(''); + } + lucide.createIcons(); +} + +/** + * syncDB() — Синхронизация БД с текущим состоянием файлов. + * + * Отправляет на бэкенд список doc_id, которые должны остаться в БД. + * Всё, чего нет в state.files, будет удалено из БД (cascade). + * Вызывается после каждого изменения состава файлов (removeFile, upload). + */ +async function syncDB() { + var keepIds = state.files.map(function(f) { return f.doc_id; }).filter(Boolean); + try { + await fetch(VM_API + '/api/sync', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({keep_ids: keepIds}) + }); + } catch(e) { /* сервер недоступен — не критично */ } +} + +/** + * toggleClassifyDetail(i) — Раскрыть/скрыть результат классификации под строкой файла. + * + * Вызывается по клику на имя файла в таблице. + * Загружает данные с бэкенда (/api/documents/:id) и показывает: + * - Тип, номер, родительский номер, дату, контрагента + * - Текст отправленный в LLM (📤) + * - Сырой ответ LLM (📥) + * - Распарсенные элементы JSON (📄) + */ +window.toggleClassifyDetail = async function(i) { + var detailRow = document.getElementById('detail_' + i); + var expandIcon = document.getElementById('expand_' + i); + if (!detailRow) return; + + if (detailRow.style.display !== 'none') { + detailRow.style.display = 'none'; + if (expandIcon) expandIcon.textContent = '▸'; + return; + } + + var f = state.files[i]; + if (!f || !f.doc_id) return; + + detailRow.style.display = ''; + if (expandIcon) expandIcon.textContent = '▾'; + + try { + var resp = await fetch(VM_API + '/api/documents/' + f.doc_id); + var qData = await resp.json(); + if (!qData.ok) { detailRow.cells[0].innerHTML = 'Ошибка загрузки'; return; } + + var html = ''; + if (qData.classify_status === 'classified') { + html += '
🏷️ Результат классификации
'; + html += '
'; + if (qData.doc_type) html += 'Тип: ' + escHtml(qData.doc_type) + ''; + if (qData.own_number) html += 'Номер: ' + escHtml(qData.own_number) + ''; + if (qData.parent_number) html += 'Родительский: ' + escHtml(qData.parent_number) + ''; + if (qData.doc_date) html += 'Дата: ' + escHtml(qData.doc_date) + ''; + if (qData.counterparty) html += 'Контрагент: ' + escHtml(qData.counterparty) + ''; + html += '
'; + if (qData.classify_input) { + html += '
📤 Текст отправленный в LLM'; + html += '
' + escHtml(qData.classify_input) + '
'; + html += '
'; + } + if (qData.classify_raw) { + html += '
📥 Сырой ответ LLM'; + html += '
' + escHtml(qData.classify_raw) + '
'; + html += '
'; + } + if (qData.elements_json) { + var ej = qData.elements_json; + if (typeof ej === 'object' && ej.Value) ej = ej.Value; + var ejStr = typeof ej === 'string' ? ej : JSON.stringify(ej, null, 2); + html += '
📄 Распарсенные элементы (JSON)'; + html += '
' + escHtml(ejStr.substring(0, 5000)) + (ejStr.length > 5000 ? '\n... (обрезано)' : '') + '
'; + html += '
'; + } + } else if (qData.classify_status === 'failed') { + html += '✗ Ошибка классификации: ' + escHtml(qData.error_message || '?') + ''; + } else { + html += 'Ещё не классифицирован'; + } + detailRow.cells[0].innerHTML = html; + } catch(e) { + detailRow.cells[0].innerHTML = 'Ошибка: ' + escHtml(e.message) + ''; + } +}; + +/** + * uploadFile(file, onProgress) — XHR-загрузка одного файла на бэкенд. + * + * Особенности: + * - .doc (не .docx!) конвертируется через CONVERT_URL перед загрузкой + * - onProgress(pct) — callback с процентом загрузки (0-100) + * - Возвращает Promise<ответ API> с полями doc_id, contract_id, parsed + * - Таймаут 180с (большие PDF) + */ +function uploadFile(file, onProgress) { + return new Promise(function(resolve, reject) { + // .doc → конвертация в docx (старый формат Word) + var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx'); + var uploadFile = file; + var uploadName = file.name; + + function doUpload() { + var xhr = new XMLHttpRequest(); + var fd = new FormData(); + fd.append('files', uploadFile, uploadName); + if (state.contractId) fd.append('contract_id', state.contractId); + fd.append('batch_id', state.batchId); + xhr.open('POST', UPLOAD_URL); + xhr.upload.onprogress = function(e) { + if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100)); + }; + xhr.onload = function() { + try { + var r = JSON.parse(xhr.responseText); + if (r.ok) resolve(r); + else reject(new Error(r.error || 'Неизвестная ошибка')); + } catch(e) { reject(new Error('Некорректный ответ')); } + }; + xhr.onerror = function() { reject(new Error('Сеть')); }; + xhr.ontimeout = function() { reject(new Error('Таймаут')); }; + xhr.timeout = 180000; + xhr.send(fd); + } + + if (isDoc) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', CONVERT_URL); + xhr.responseType = 'blob'; + xhr.onload = function() { + if (xhr.status === 200 && xhr.response.size > 100) { + uploadFile = xhr.response; + uploadName = file.name.replace(/\.doc$/i, '.docx'); + doUpload(); + } else { + reject(new Error('Конвертация .doc')); + } + }; + xhr.onerror = function() { reject(new Error('Конвертер')); }; + xhr.send(file); + } else { + doUpload(); + } + }); +} + +/** + * refreshSupps() — Обновить supplement_id/type для файлов в state.files. + * + * Запрашивает с бэкенда список supplement-записей для текущего contractId, + * сопоставляет их с файлами по doc_id → document_id и проставляет supp_id, supp_type. + * Вызывается после каждого upload и после apply-groups. + */ +async function refreshSupps() { + if (!state.contractId) { console.log('refreshSupps: no state.contractId'); return; } + try { + var resp = await fetch(VM_API + '/api/supplements?contract_id=' + state.contractId); + var data = await resp.json(); + console.log('refreshSupps: data=', data); + if (data.ok && data.supplements) { + data.supplements.forEach(function(supp) { + console.log('refreshSupps: supp=', supp); + for (var i = 0; i < state.files.length; i++) { + if (state.files[i].doc_id === supp.document_id) { + state.files[i].supp_id = supp.id; + state.files[i].supp_type = supp.type; + } + } + }); + render(state); + } + } catch(e) { console.log('refreshSupps error:', e); } +} diff --git a/index.cfm b/index.cfm index a099003..1d72210 100644 --- a/index.cfm +++ b/index.cfm @@ -247,6 +247,7 @@ +