/** * files.js — Модуль работы с файлами. * * ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔⛔⛔ * * v2.0.3: честный счётчик ⏳ соединение... Nс вместо фейкового ↑N% * Проверено в бою 2026-07-16. Любое изменение = риск сломать загрузку. * * КЛЮЧЕВЫЕ ФУНКЦИИ (не трогать): * - uploadFile() — fetch-загрузка с имитацией прогресса * - onFilesSelected() — все строки в таблицу сразу, потом загрузка по одной * - statusToHTML() — рендер статуса (↑ N%, ✓) * - renderFiles() — рендер всей таблицы /** * statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1). * * Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега. * kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | '' | 'connecting' * Выход: безопасная HTML-строка (статусы не содержат пользовательских данных). * * ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления. * state хранит чистые данные, statusToHTML — чистая функция рендеринга. */ function statusToHTML(st) { if (!st || !st.kind) return ''; switch (st.kind) { case 'connecting': return '⏳ соединение... ' + (st.elapsed || 0) + 'с'; case 'converting': return '⏳ конвертация... ' + (st.elapsed || 0) + 'с'; case 'uploading': return '⏳ отправка... ' + (st.elapsed || 0) + 'с'; case 'uploaded': return ''; case 'unzipping': return '⏳ распаковка...'; case 'parsing': return '⏳ парсинг...'; case 'parsed': return '✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + ''; case 'error': return '✗ ' + escHtml(st.text || 'Неизвестная ошибка') + ''; case 'duplicate': return '⤿ дубликат'; case 'warning': return '⚠ ' + escHtml(st.text || 'пустой') + ''; case 'no_format': return '✗ формат не поддерживается'; 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 = 'Нет файлов — выберите .doc / .docx / .pdf'; lucide.createIcons(); return; } // Сгруппировать файлы по zip_source var groups = []; // [{zip: "name.zip"|null, files: [f, ...]}] var seen = {}; for (var i = 0; i < state.files.length; i++) { var f = state.files[i]; var zip = f.zip_source || null; var key = zip || '__naked__'; if (!seen[key]) { seen[key] = { zip: zip, files: [] }; groups.push(seen[key]); } // Сохраняем оригинальный индекс для id строк f._idx = i; seen[key].files.push(f); } // Рендер: заголовок ZIP → файлы с отступом var rows = []; for (var gi = 0; gi < groups.length; gi++) { var g = groups[gi]; if (g.zip) { // Заголовок-секция ZIP rows.push( '' + '' + '📦 ' + escHtml(g.zip) + ' — ' + g.files.length + ' файл.' + (g.files.length === 1 ? '' : 'ов') + '' ); } // Файлы внутри группы (с отступом если из ZIP) for (var fi = 0; fi < g.files.length; fi++) { var f = g.files[fi]; var i = f._idx; var indent = g.zip ? 'padding-left:24px;' : ''; rows.push( '' + '' + (f.doc_id ? '' : '') + escHtml(f.name) + '' + '' + formatDate(f.lastModified) + '' + '' + formatSize(f.size) + '' + '' + statusToHTML(f.status) + '' + '' + '' + '' + 'Загрузка...' ); } } fileTable.innerHTML = 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 — загрузка через ВМ-буфер (паттерн drhider). * * Фаза 1: PUT файла на ВМ (WebDAV /contracts-upload/, мимо шлюза кластера ~64КБ). * Фаза 2: POST /api/upload_refs {files:[{name,size,url}]} — бэк сам тянет файл с ВМ. * Честный счётчик времени: ⏳ соединение... Nс → ⏳ отправка... Nс (fetch не даёт progress). */ function uploadFile(file, onProgress, zipSource) { var startTime = Date.now(); var phase = 'connecting'; // connecting → uploading if (onProgress) onProgress({ kind: 'connecting', elapsed: 0 }); var token = crypto.randomUUID(); var vmUrl = VM_UPLOAD_URL + token + '_0'; // Честный счётчик: каждую секунду обновляем elapsed var timer = setInterval(function() { var elapsed = Math.floor((Date.now() - startTime) / 1000); if (onProgress) onProgress({ kind: phase, elapsed: elapsed }); }, 1000); // Фаза 1: PUT файла на ВМ-буфер return fetch(vmUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' } }) .then(function(r) { if (!r.ok) throw new Error('VM upload HTTP ' + r.status); phase = 'uploading'; // Фаза 2: refs на бэкенд → pull с ВМ var body = { batch_id: state.batchId, files: [{ name: file.name, size: file.size, url: vmUrl }] }; if (state.contractId) body.contract_id = state.contractId; if (zipSource) body.zip_source = zipSource; return fetch('/api/upload_refs?_=' + Date.now(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); }) .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function(data) { clearInterval(timer); if (data.ok && data.results && data.results.length === 1) { var res = data.results[0]; if (!res.ok) throw new Error(res.error || 'Неизвестная ошибка'); var elapsed = Math.floor((Date.now() - startTime) / 1000); if (onProgress) onProgress({ kind: 'uploading', elapsed: elapsed }); return res; // {ok, doc_id, contract_id, parsed} } throw new Error(data.error || 'Неизвестная ошибка'); }) .catch(function(e) { clearInterval(timer); if (e.message === 'Failed to fetch' || e.name === 'TypeError') { throw new Error('Сеть'); } throw e; }); } /** * 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: 'Неизвестная ошибка' }; } } /** * 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(); } /** * expandZipClient(f) — клиентское рекурсивное раскрытие ZIP (drhider listZipFiles). * Каждый документ из архива (включая вложенные zip) добавляется в state.files * с zip_source = имя архива. Fallback: если раскрыть не удалось — архив как есть. */ async function expandZipClient(f) { var nested = []; try { if (window.listZipFiles) { nested = await window.listZipFiles(f, ['.pdf', '.doc', '.docx']); } } catch (e) { nested = []; } if (!nested || nested.length === 0) { state.files.push({ name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'connecting', elapsed: 0 }, zip_source: null }); state.files[state.files.length - 1]._pendingFile = f; return; } for (var z = 0; z < nested.length; z++) { state.files.push({ name: nested[z].name, lastModified: nested[z].lastModified, size: nested[z].size, file: nested[z], status: { kind: 'connecting', elapsed: 0 }, zip_source: f.name }); state.files[state.files.length - 1]._pendingFile = nested[z]; } } /** * ⛔ НЕ МЕНЯТЬ ⛔ onFilesSelected — все строки сразу, потом загрузка. * * v2.0.2: Фаза 1 — все строки в таблицу. Фаза 2 — загрузка по одной. * Юзер видит таблицу целиком, каждая строка обновляется независимо. */ async function onFilesSelected(newFiles) { if (newFiles.length === 0) return; fileInput.disabled = true; resetStepper('stepUpload'); // Фаза 1: ВСЕ строки в таблицу сразу for (var i = 0; i < newFiles.length; i++) { var f = newFiles[i]; if (f.name.toLowerCase().endsWith('.zip')) { await expandZipClient(f); continue; } state.files.push({ name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'connecting', elapsed: 0 }, zip_source: null }); state.files[state.files.length - 1]._pendingFile = f; } render(state); // Фаза 2: загрузка по одному с обновлением строки for (var j = 0; j < state.files.length; j++) { var entry = state.files[j]; var pendingFile = entry._pendingFile; if (!pendingFile) continue; delete entry._pendingFile; var f = pendingFile; try { var resp = await uploadFile(f, function(st) { entry.status = st; render(state); }, entry.zip_source); if (resp && resp.contract_id) state.contractId = resp.contract_id; entry.doc_id = resp.doc_id; entry.status = { kind: 'uploaded' }; entry.uploaded = true; var pr = resp.parsed; var elapsed = pr && pr.status === 'parsed' ? '0.0' : null; applyParseResult(entry, pr, elapsed); } catch(err) { entry.status = { kind: 'error', text: err.message }; } render(state); } await finalizeUpload(); }