Files
contracts-flask/site/static/files.js
T

520 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 '<span class="status-ok">✓</span>';
case 'unzipping': return '⏳ распаковка...';
case 'parsing': return '⏳ парсинг...';
case 'parsed': return '<span class="status-ok">✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + '</span>';
case 'error': return '<span class="status-err">✗ ' + (st.text || 'Неизвестная ошибка') + '</span>';
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 = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
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(
'<tr class="zip-header" style="background:var(--brand-grey-light);">' +
'<td colspan="7" style="padding:6px 12px;font-size:12px;font-weight:600;">' +
'📦 ' + escHtml(g.zip) + ' — ' + g.files.length + ' файл.' +
(g.files.length === 1 ? '' : 'ов') +
'</td></tr>'
);
}
// Файлы внутри группы (с отступом если из 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(
'<tr id="row_' + i + '">' +
'<td class="name-cell" style="cursor:pointer;' + indent + '" onclick="toggleClassifyDetail(' + i + ')">' +
(f.doc_id ? '<span id="expand_' + i + '" style="font-size:10px;margin-right:4px;">▸</span>' : '') +
escHtml(f.name) + '</td>' +
'<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</td>' +
'<td class="num-cell" style="font-size:12px;color:var(--muted);">' + formatSize(f.size) + '</td>' +
'<td class="status-cell">' + statusToHTML(f.status) + '</td>' +
'<td><button class="info-btn' + (f.doc_id ? ' visible' : '') + '" onclick="showText(' + i + ')" title="Текст / Распарсено"><i data-lucide="file-text" style="width:16px;height:16px;"></i></button></td>' +
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить"><i data-lucide="trash-2" style="width:16px;height:16px;"></i></button></td>' +
'</tr>' +
'<tr id="detail_' + i + '" style="display:none;"><td colspan="7" style="padding:4px 12px;font-size:11px;background:var(--brand-grey-light);"><span style="color:var(--muted);">Загрузка...</span></td></tr>'
);
}
}
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 = '<span style="color:var(--destructive);">Ошибка загрузки</span>'; return; }
var html = '';
if (qData.classify_status === 'classified') {
html += '<div style="font-weight:600;margin-bottom:4px;">🏷️ Результат классификации</div>';
html += '<div style="display:flex;gap:16px;flex-wrap:wrap;">';
if (qData.doc_type) html += '<span><b>Тип:</b> ' + escHtml(qData.doc_type) + '</span>';
if (qData.own_number) html += '<span><b>Номер:</b> ' + escHtml(qData.own_number) + '</span>';
if (qData.parent_number) html += '<span><b>Родительский:</b> ' + escHtml(qData.parent_number) + '</span>';
if (qData.doc_date) html += '<span><b>Дата:</b> ' + escHtml(qData.doc_date) + '</span>';
if (qData.counterparty) html += '<span><b>Контрагент:</b> ' + escHtml(qData.counterparty) + '</span>';
html += '</div>';
if (qData.classify_input) {
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📤 Текст отправленный в LLM</summary>';
html += '<pre style="font-size:10px;max-height:150px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(qData.classify_input) + '</pre>';
html += '</details>';
}
if (qData.classify_raw) {
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📥 Сырой ответ LLM</summary>';
html += '<pre style="font-size:10px;max-height:150px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(qData.classify_raw) + '</pre>';
html += '</details>';
}
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 += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📄 Распарсенные элементы (JSON)</summary>';
html += '<pre style="font-size:10px;max-height:200px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(ejStr.substring(0, 5000)) + (ejStr.length > 5000 ? '\n... (обрезано)' : '') + '</pre>';
html += '</details>';
}
} else if (qData.classify_status === 'failed') {
html += '<span style="color:var(--destructive);">✗ Ошибка классификации: ' + escHtml(qData.error_message || '?') + '</span>';
} else {
html += '<span style="color:var(--muted);">Ещё не классифицирован</span>';
}
detailRow.cells[0].innerHTML = html;
} catch(e) {
detailRow.cells[0].innerHTML = '<span style="color:var(--destructive);">Ошибка: ' + escHtml(e.message) + '</span>';
}
};
/**
* 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, zipSource) {
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);
if (zipSource) fd.append('zip_source', zipSource);
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 от <input> (массив 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. Отправляет ZIP на бэкенд (только распаковка, без БД/парсинга)
* 2. Для каждого файла из архива: base64 → Blob → File → addRegularFile()
*
* addRegularFile делает ВСЁ: upload, parse, дубликаты, статусы, render.
* Никакого дублирования логики — единый путь для обычных и ZIP-файлов.
*/
async function addZipFile(file) {
var zipEntry = { name: file.name, lastModified: file.lastModified, size: file.size, status: { kind: 'unzipping' } };
state.files.push(zipEntry);
render(state);
try {
// Шаг 1: распаковать 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;
var fd = new FormData();
fd.append('files', file);
xhr.send(fd);
});
if (!zipResp.ok || !zipResp.files) throw new Error('unzip failed');
// Шаг 2: обработать каждый файл из архива
// Временная строка ZIP остаётся в таблице — обновляем её статус
var total = zipResp.files.length;
for (var zi = 0; zi < total; zi++) {
var zf = zipResp.files[zi];
zipEntry.status = { kind: 'unzipping' }; // обновляем статус
render(state);
if (zf.error) continue;
// base64 → File → addRegularFile (единый путь)
var byteStr = atob(zf.data_b64);
var bytes = new Uint8Array(byteStr.length);
for (var b = 0; b < byteStr.length; b++) bytes[b] = byteStr.charCodeAt(b);
var mime = {docx:'application/vnd.openxmlformats-officedocument.wordprocessingml.document',doc:'application/msword',pdf:'application/pdf'}[zf.ext] || 'application/octet-stream';
var extractedFile = new File([bytes], zf.filename, { type: mime, lastModified: Date.now() });
await addRegularFile(extractedFile, file.name);
}
// Шаг 3: убрать временную строку ZIP (только после успешной обработки всех файлов)
var zipPos = state.files.indexOf(zipEntry);
if (zipPos >= 0) state.files.splice(zipPos, 1);
render(state);
} catch(ze) {
// Ошибка — показать на строке ZIP (zipEntry всё ещё в state.files)
zipEntry.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, zipSource) {
// Создать запись с начальным статусом
var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'uploading', pct: 0 }, zip_source: zipSource || null };
// ДЕДУПЛИКАЦИЯ: ключ = (zip_source, name), чтобы одноимённые файлы из разных ZIP не затирались
var dupKey = (zipSource || '') + '/' + file.name;
var existingIdx = -1;
for (var j = 0; j < state.files.length; j++) {
var ejKey = (state.files[j].zip_source || '') + '/' + state.files[j].name;
if (ejKey === dupKey) { existingIdx = j; break; }
}
var rowIdx;
if (existingIdx >= 0) {
// Файл с таким именем уже есть — спросить пользователя
if (!confirm('Файл «' + file.name + '» уже есть в списке.\n\nOK — перезаписать\nОтмена — пропустить')) {
return; // пользователь выбрал «пропустить»
}
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);
}, zipSource);
// Бэкенд возвращает 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).
*
* ПАТТЕРН:
* 1. Для каждого файла: addZipFile (ZIP) или addRegularFile (обычный)
* 2. finalizeUpload — завершить цикл
*
* НЕ удаляет существующие файлы — только добавляет новые.
* Дубликаты обрабатываются через confirm() в addRegularFile.
* Удаление — только вручную (кнопка ✕).
*
* Вызывается из fileInput.addEventListener('change', ...).
*/
async function onFilesSelected(newFiles) {
if (newFiles.length === 0) return;
fileInput.disabled = true;
// Сбросить прогресс пайплайна при добавлении новых файлов
resetStepper('stepUpload');
// Обработать каждый файл
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);
}
}
// Завершить цикл
await finalizeUpload();
}