/** * files.js — Модуль работы с файлами. * * ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔⛔⛔ * * FilePicker owns selection/tree rendering; this module connects it to the * Contracts batch, upload sink, and document pipeline. * * Состав и обработка документов синхронизируются с FilePicker в app.js. */ var filePicker = null; var uploadQueue = Promise.resolve(); var uploadTasks = new Map(); var queuedUploads = new Set(); var pendingUploads = 0; var FILE_PICKER_LIMITS = { maxEntries: 1000, maxTotalBytes: 500 * 1024 * 1024, maxEntryBytes: 50 * 1024 * 1024, maxDepth: 20, }; /** * renderFiles(state) — обновить отображение дерева и статусов picker. */ function renderFiles(state) { if (filePicker) filePicker.render(); } /** * syncDB() — Синхронизация БД с текущим состоянием файлов. * * Синхронизирует keep_ids только для текущего batch_id. */ async function syncDB() { var keepIds = Array.from(new Set(state.files.map(function(f) { return f.doc_id; }).filter(Boolean))); var response = await fetch(VM_API + '/api/sync', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({batch_id: state.batchId, keep_ids: keepIds}) }); var data = await response.json(); if (!response.ok || !data.ok) { throw new Error(data.error || 'Не удалось синхронизировать документы'); } return data; } /** * 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) { var file = state.files[i]; if (!file || !file.doc_id) return; document.getElementById('modalTitle').textContent = 'Классификация — ' + file.name; document.getElementById('modalBody').innerHTML = 'Загрузка...'; document.getElementById('modalOverlay').classList.add('open'); try { var detailResponse = await fetch(VM_API + '/api/documents/' + encodeURIComponent(file.doc_id)); var detailData = await detailResponse.json(); if (!detailResponse.ok || !detailData.ok) { throw new Error(detailData.error || 'Ошибка загрузки документа'); } var detailHtml = '
Результат классификации
'; [['Тип', detailData.doc_type], ['Номер', detailData.own_number], ['Родительский', detailData.parent_number], ['Дата', detailData.doc_date], ['Контрагент', detailData.counterparty]].forEach(function(pair) { if (pair[1]) detailHtml += '
' + pair[0] + '' + escHtml(pair[1]) + '
'; }); if (detailData.classify_input) { detailHtml += '
Текст, отправленный в LLM
'
          + escHtml(detailData.classify_input) + '
'; } if (detailData.classify_raw) { detailHtml += '
Сырой ответ LLM
'
          + escHtml(detailData.classify_raw) + '
'; } if (detailData.elements_json) { var parsedElements = typeof detailData.elements_json === 'string' ? detailData.elements_json : JSON.stringify(detailData.elements_json, null, 2); detailHtml += '
Распарсенные элементы
'
          + escHtml(parsedElements.substring(0, 5000))
          + (parsedElements.length > 5000 ? '\n... (обрезано)' : '')
          + '
'; } document.getElementById('modalBody').innerHTML = detailHtml; } catch (error) { document.getElementById('modalBody').textContent = 'Ошибка: ' + error.message; } 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) + ''; } }; /** * 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); } } /** * 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). * * Вызывается после завершения очереди выбранных файлов. * 1. Обновляет supplement_id/type через refreshSupps() * 2. Отмечает шаг «Загрузка» как готовый * 3. Активирует шаг «Классификация» * 4. Синхронизирует БД * 5. Показывает кнопки (LLM, классификация) */ async function finalizeUpload() { if (!state.files.some(function(file) { return file.doc_id; })) { var llmButton = document.getElementById('llmBtn'); var classifyButton = document.getElementById('classifyBtn'); if (llmButton) llmButton.style.display = 'none'; if (classifyButton) classifyButton.style.display = 'none'; return; } await refreshSupps(); await syncDB(); stepDone('stepUpload'); stepActive('stepClassify'); var llmBtn = document.getElementById('llmBtn'); llmBtn.style.display = 'flex'; llmBtn.disabled = false; showClassifyBtn(); lucide.createIcons(); } function zipSourceFromPath(path) { var parts = String(path || '').split('/'); var lastZip = -1; parts.forEach(function(part, index) { if (part.toLowerCase().endsWith('.zip')) lastZip = index; }); return lastZip < 0 ? null : parts.slice(0, lastZip + 1).join('/'); } function isPickerEntrySelected(entry) { return state.files.some(function(file) { return file.pickerId === entry.pickerId; }); } function setPickerFileStatus(entry, kind, text) { entry.status = { kind: kind, text: text }; if (filePicker) filePicker.setStatus(entry.pickerId, entry.status); } function showPickerError(error) { if (filePicker) filePicker.setMessage(error.message || String(error)); } async function uploadPickerEntry(entry) { if (!isPickerEntrySelected(entry) || entry.uploadBlocked) return; var task = { controller: new AbortController(), phase: 'put' }; uploadTasks.set(entry.pickerId, task); setPickerFileStatus(entry, 'uploading', '⏳ Подготовка к загрузке'); var sinkResult; try { var response = await window.FilePicker.uploadViaVM([{ name: entry.file.name, size: entry.file.size, file: entry.file, path: entry.pickerPath }], { vmUploadUrl: VM_UPLOAD_URL, backendUploadUrl: VM_API + '/api/upload_refs', signal: task.controller.signal, getExtraPayload: function() { var payload = { batch_id: state.batchId }; if (state.contractId) payload.contract_id = state.contractId; if (entry.zip_source) payload.zip_source = entry.zip_source; return payload; }, onFileStatus: function(index, message) { if (message.indexOf('⚡') === 0) task.phase = 'backend'; var kind = message.indexOf('✗') === 0 ? 'error' : message.indexOf('✓') === 0 ? 'parsing' : task.phase === 'backend' ? 'parsing' : 'uploading'; if (isPickerEntrySelected(entry)) setPickerFileStatus(entry, kind, message); }, onFileComplete: function(fileResult) { sinkResult = fileResult.result; } }); if (!response.ok) throw new Error(response.error || 'Ошибка загрузки файла'); if (!sinkResult) throw new Error('Сервис не вернул результат обработки файла'); if (sinkResult.ok && !sinkResult.doc_id) { throw new Error('Сервис обработал файл без идентификатора документа'); } if (sinkResult.error === 'duplicate' && sinkResult.doc_id) { entry.doc_id = sinkResult.doc_id; entry.uploaded = true; entry.status = { kind: 'duplicate', text: '⤿ дубликат' }; } else if (sinkResult.ok) { entry.doc_id = sinkResult.doc_id; entry.uploaded = true; if (sinkResult.contract_id) state.contractId = sinkResult.contract_id; applyParseResult(entry, sinkResult.parsed); } else { throw new Error(sinkResult.error || 'Не удалось обработать файл'); } if (isPickerEntrySelected(entry)) { filePicker.setDocument(entry.pickerId, entry.doc_id); setPickerFileStatus(entry, entry.status.kind, entry.status.text || (entry.status.kind === 'parsed' ? '✓ ' + (entry.status.count || 0) + ' эл.' : entry.status.text || 'Ошибка обработки')); } } catch (error) { if (!(task.controller.signal.aborted && !isPickerEntrySelected(entry))) { setPickerFileStatus(entry, 'error', '✗ ' + error.message); } } finally { uploadTasks.delete(entry.pickerId); } } function enqueuePickerUpload(entry) { if (queuedUploads.has(entry.pickerId) || uploadTasks.has(entry.pickerId)) return; queuedUploads.add(entry.pickerId); pendingUploads += 1; var run = function() { return uploadPickerEntry(entry); }; uploadQueue = uploadQueue.then(run, run).finally(async function() { queuedUploads.delete(entry.pickerId); pendingUploads -= 1; if (pendingUploads === 0) { try { await finalizeUpload(); } catch (error) { showPickerError(error); } } }); } function scheduleBatchSync() { var sync = async function() { await syncDB(); if (state.files.some(function(file) { return file.doc_id; })) { showClassifyBtn(); var compareButton = document.getElementById('llmBtn'); if (compareButton) compareButton.style.display = 'flex'; } }; uploadQueue = uploadQueue.then(sync, sync).catch(showPickerError); } function onPickerFilesChange(pickerFiles) { var previous = new Map(state.files.map(function(file) { return [file.pickerId, file]; })); var incomingIds = new Set(pickerFiles.map(function(file) { return file.id; })); var added = []; var nextFiles = []; var toQueue = []; var selectedBytes = 0; pickerFiles.forEach(function(pickerFile) { selectedBytes += pickerFile.size; var entry = previous.get(pickerFile.id); if (!entry) { entry = { pickerId: pickerFile.id, pickerPath: pickerFile.path, name: pickerFile.name, size: pickerFile.size, lastModified: pickerFile.file.lastModified, file: pickerFile.file, zip_source: zipSourceFromPath(pickerFile.path), status: { kind: 'connecting' } }; added.push(entry); } if (!entry.doc_id && !uploadTasks.has(entry.pickerId)) { var limitError = entry.size > FILE_PICKER_LIMITS.maxEntryBytes ? 'Файл превышает лимит 50 МБ' : selectedBytes > FILE_PICKER_LIMITS.maxTotalBytes ? 'Превышен общий лимит 500 МБ' : ''; if (limitError) { entry.uploadBlocked = true; entry.status = { kind: 'error', text: limitError }; filePicker.setStatus(entry.pickerId, entry.status); } else if (entry.uploadBlocked) { delete entry.uploadBlocked; entry.status = { kind: 'connecting' }; if (!queuedUploads.has(entry.pickerId)) toQueue.push(entry); } else if (added.indexOf(entry) !== -1) { toQueue.push(entry); } } nextFiles.push(entry); }); var removed = state.files.filter(function(file) { return !incomingIds.has(file.pickerId); }); removed.forEach(function(file) { var task = uploadTasks.get(file.pickerId); if (task && task.phase === 'put') task.controller.abort(); }); var changed = added.length > 0 || removed.length > 0 || toQueue.length > 0; state.files = nextFiles; if (!state.files.some(function(file) { return file.doc_id; })) state.contractId = null; if (changed) { resetStepper('stepUpload'); var classifyButton = document.getElementById('classifyBtn'); if (classifyButton) classifyButton.style.display = 'none'; var compareButton = document.getElementById('llmBtn'); if (compareButton) compareButton.style.display = 'none'; } render(state); toQueue.forEach(enqueuePickerUpload); if (removed.length) scheduleBatchSync(); }