/** * 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); } } /** * reconcileSelection(files, newFiles) — Чистая функция (Фаза 1). * * Согласование состава: оставляет в массиве files только те, * имена которых есть в newFiles. Остальные удаляются. * * Вход: files — текущий state.files (массив) * newFiles — FileList от (массив File-объектов) * Выход: новый массив (НЕ мутирует входной) * * Вызывается из onFilesSelected() ПЕРЕД обработкой каждого файла. */ function reconcileSelection(files, newFiles) { var newNames = new Set(newFiles.map(function(f) { return f.name; })); return files.filter(function(f) { return newNames.has(f.name); }); } /** * applyParseResult(entry, parsed, elapsed) — Чистая функция (Фаза 1). * * Применяет результат парсинга к файлу. Унифицирует две ветки (ZIP и обычную), * которые раньше дублировали этот код. * * Вход: entry — элемент state.files (мутируется) * parsed — { status, element_count, error } от бэкенда * elapsed — время парсинга в секундах (только для обычных файлов) * Выход: нет (мутирует entry на месте) */ function applyParseResult(entry, parsed, elapsed) { if (parsed && parsed.status === 'parsed') { entry.parsed = true; entry.parseInfo = parsed; entry.status = { kind: 'parsed', count: parsed.element_count }; if (elapsed) entry.status.elapsed = elapsed; } else if (parsed && parsed.status === 'error') { entry.parseInfo = parsed; entry.status = { kind: 'error', text: parsed.error || 'ошибка парсинга' }; } else { entry.status = { kind: 'error', text: 'Неизвестная ошибка' }; } } /** * addZipFile(file) — Async-action: обработка ZIP-файла (Фаза 1). * * 1. Показывает временную строку «⏳ распаковка...» * 2. Отправляет ZIP на бэкенд (UNZIP_URL) * 3. Убирает временную строку * 4. Добавляет файлы из архива в state.files, пропуская дубликаты * 5. Для каждого: applyParseResult (результат парсинга от бэкенда) * * Мутирует state.files, вызывает render(state) после каждого изменения. */ async function addZipFile(file) { // Показать временную строку для ZIP var zipEntry = { name: file.name, lastModified: file.lastModified, size: file.size, status: { kind: 'unzipping' } }; state.files.push(zipEntry); var zipIdx = state.files.length - 1; render(state); try { // Отправить ZIP на бэкенд var zipResp = await new Promise(function(resolve, reject) { var xhr = new XMLHttpRequest(); xhr.open('POST', UNZIP_URL); xhr.responseType = 'json'; xhr.onload = function() { resolve(xhr.response); }; xhr.onerror = function() { reject(new Error('Сеть')); }; xhr.ontimeout = function() { reject(new Error('Таймаут')); }; xhr.timeout = 60000; xhr.send(file); }); if (!zipResp.ok || !zipResp.files) throw new Error('unzip failed'); // Убрать временную строку ZIP state.files.splice(zipIdx, 1); render(state); // Добавить файлы из архива (дубликаты перезаписываются) for (var zi = 0; zi < zipResp.files.length; zi++) { var zf = zipResp.files[zi]; if (zf.error) continue; // файл с ошибкой — пропускаем var zEntry = { name: zf.filename, size: zf.size, doc_id: zf.doc_id, uploaded: true, status: { kind: 'parsing' } }; // Проверить дубликат по имени var dup = -1; for (var dj = 0; dj < state.files.length; dj++) { if (state.files[dj].name === zf.filename) { dup = dj; break; } } if (dup >= 0) { // Заменить существующий (как addRegularFile — перезапись) state.files[dup] = zEntry; } else { state.files.push(zEntry); dup = state.files.length - 1; } // Если это первый файл — запомнить contract_id if (!state.contractId && zf.contract_id) state.contractId = zf.contract_id; render(state); // Применить результат парсинга от бэкенда (clean function) applyParseResult(state.files[dup], zf.parsed); render(state); } } catch(ze) { // Ошибка распаковки — показать в строке ZIP console.error('addZipFile error:', ze, 'UNZIP_URL=', UNZIP_URL); state.files[zipIdx].status = { kind: 'error', text: 'ZIP: ' + ze.message }; render(state); } } /** * addRegularFile(file) — Async-action: обработка обычного файла (Фаза 1). * * 1. Добавляет/заменяет файл в state.files * 2. Загружает через XHR (uploadFile) с индикатором прогресса * 3. После загрузки: проставляет doc_id, contractId * 4. applyParseResult — применяет результат парсинга * * Мутирует state.files, вызывает render(state) после каждого изменения. */ async function addRegularFile(file) { // Создать запись с начальным статусом var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'uploading', pct: 0 } }; // Заменить существующий файл с тем же именем или добавить новый var existingIdx = -1; for (var j = 0; j < state.files.length; j++) { if (state.files[j].name === file.name) { existingIdx = j; break; } } var rowIdx; if (existingIdx >= 0) { state.files[existingIdx] = entry; rowIdx = existingIdx; } else { state.files.push(entry); rowIdx = state.files.length - 1; } render(state); try { // XHR-загрузка с прогрессом var resp = await uploadFile(file, function(pct) { state.files[rowIdx].status = { kind: 'uploading', pct: pct }; render(state); }); // Бэкенд возвращает doc_id и contract_id (нижний регистр — Python keys) if (resp && resp.contract_id) state.contractId = resp.contract_id; state.files[rowIdx].doc_id = resp.doc_id; state.files[rowIdx].status = { kind: 'uploaded' }; state.files[rowIdx].uploaded = true; // Авто-парсинг: бэкенд парсит всё (PDF + DOCX + DOC) var t0 = Date.now(); var pr = resp.parsed; var elapsed = pr && pr.status === 'parsed' ? ((Date.now() - t0) / 1000).toFixed(1) : null; applyParseResult(state.files[rowIdx], pr, elapsed); } catch(err) { state.files[rowIdx].status = { kind: 'error', text: err.message }; } render(state); } /** * finalizeUpload() — Завершение цикла загрузки (Фаза 1). * * Вызывается ПОСЛЕ обработки всех файлов в onFilesSelected(). * 1. Обновляет supplement_id/type через refreshSupps() * 2. Отмечает шаг «Загрузка» как готовый * 3. Активирует шаг «Классификация» * 4. Синхронизирует БД * 5. Показывает кнопки (LLM, классификация) */ async function finalizeUpload() { await refreshSupps(); stepDone('stepUpload'); stepActive('stepClassify'); syncDB(); fileInput.value = ''; fileInput.disabled = false; var llmBtn = document.getElementById('llmBtn'); llmBtn.style.display = 'flex'; llmBtn.disabled = false; showClassifyBtn(); lucide.createIcons(); } /** * onFilesSelected(newFiles) — Оркестратор загрузки (Фаза 1). * * ПАТТЕРН (decoupling-final-plan.md): * 1. reconcileSelection — согласовать состав (чистая мутация) * 2. Для каждого файла: addZipFile (ZIP) или addRegularFile (обычный) * 3. finalizeUpload — завершить цикл * * Это ТОНКАЯ функция (~15 строк) — вся логика в шагах 1-3. * Вызывается из fileInput.addEventListener('change', ...). */ async function onFilesSelected(newFiles) { if (newFiles.length === 0) return; fileInput.disabled = true; // Шаг 1: согласовать состав — убрать файлы, которых нет в новом выборе state.files = reconcileSelection(state.files, newFiles); render(state); showClassifyBtn(); resetStepper('stepUpload'); // Шаг 2: обработать каждый файл for (var i = 0; i < newFiles.length; i++) { var f = newFiles[i]; if (f.name.toLowerCase().endsWith('.zip')) { await addZipFile(f); } else { await addRegularFile(f); } } // Шаг 3: финализация await finalizeUpload(); }