diff --git a/deploy/convert_server.py b/deploy/convert_server.py index 72c0932..add6a77 100755 --- a/deploy/convert_server.py +++ b/deploy/convert_server.py @@ -13,6 +13,7 @@ db_prompts.seed_defaults() # Schema migration: classify_raw column (idempotent) execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_raw text") execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_input text") +execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS zip_source text") # ── DB modules ──────────────────────────────────────────────────────────── from db import supplements as db_supplements diff --git a/deploy/db/documents.py b/deploy/db/documents.py index c7d05a3..527b5fa 100644 --- a/deploy/db/documents.py +++ b/deploy/db/documents.py @@ -2,12 +2,12 @@ from .connection import query, execute, execute_returning -def insert(filename, mime_type, original_bytes, status="uploaded", batch_id=None): +def insert(filename, mime_type, original_bytes, status="uploaded", batch_id=None, zip_source=None): """Insert document, return row dict.""" return execute_returning( - """INSERT INTO documents (filename, mime_type, original_bytes, status, batch_id) - VALUES (%s, %s, %s, %s, %s) RETURNING *""", - (filename, mime_type, original_bytes, status, batch_id), + """INSERT INTO documents (filename, mime_type, original_bytes, status, batch_id, zip_source) + VALUES (%s, %s, %s, %s, %s, %s) RETURNING *""", + (filename, mime_type, original_bytes, status, batch_id, zip_source), ) @@ -73,7 +73,8 @@ def list_by_batch(batch_id): """All documents in a batch with classification fields.""" return query( """SELECT id, filename, status, doc_type, own_number, parent_number, - doc_date, counterparty, classify_status, error_message, classify_raw, classify_input + doc_date, counterparty, classify_status, error_message, classify_raw, classify_input, + zip_source FROM documents WHERE batch_id=%s ORDER BY created_at""", (batch_id,), ) diff --git a/deploy/files.js b/deploy/files.js index cb5f2a9..9b32903 100644 --- a/deploy/files.js +++ b/deploy/files.js @@ -170,7 +170,7 @@ window.toggleClassifyDetail = async function(i) { * - Возвращает Promise<ответ API> с полями doc_id, contract_id, parsed * - Таймаут 180с (большие PDF) */ -function uploadFile(file, onProgress) { +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'); @@ -183,6 +183,7 @@ function uploadFile(file, onProgress) { 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)); @@ -340,7 +341,7 @@ async function addZipFile(file) { 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); + await addRegularFile(extractedFile, file.name); } // Шаг 3: убрать временную строку ZIP (только после успешной обработки всех файлов) @@ -365,14 +366,16 @@ async function addZipFile(file) { * * Мутирует state.files, вызывает render(state) после каждого изменения. */ -async function addRegularFile(file) { +async function addRegularFile(file, zipSource) { // Создать запись с начальным статусом - var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'uploading', pct: 0 } }; + 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++) { - if (state.files[j].name === file.name) { existingIdx = j; break; } + var ejKey = (state.files[j].zip_source || '') + '/' + state.files[j].name; + if (ejKey === dupKey) { existingIdx = j; break; } } var rowIdx; if (existingIdx >= 0) { @@ -393,7 +396,7 @@ async function addRegularFile(file) { 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; diff --git a/deploy/services/upload.py b/deploy/services/upload.py index 4e77825..852dfb4 100644 --- a/deploy/services/upload.py +++ b/deploy/services/upload.py @@ -59,6 +59,13 @@ def handle_upload(rfile, content_type, content_length): except (ValueError, AttributeError): batch_id = None + # zip_source — имя родительского ZIP-архива (для визуальной группировки) + zip_source = None + if "zip_source" in fs: + raw_zip = fs.getfirst("zip_source", "") + if raw_zip: + zip_source = os.path.basename(raw_zip)[:255] # защита от path traversal + лимит + if not filename or not file_data: return {"ok": False, "error": "no file in request"} @@ -71,7 +78,7 @@ def handle_upload(rfile, content_type, content_length): except Exception: pass # old record may not exist or FK issue — proceed with insert - doc = documents.insert(filename, mime, b64, batch_id=batch_id) + doc = documents.insert(filename, mime, b64, batch_id=batch_id, zip_source=zip_source) if not contract_id: from datetime import datetime diff --git a/deploy/state.js b/deploy/state.js index 4905df5..e8c9509 100644 --- a/deploy/state.js +++ b/deploy/state.js @@ -16,7 +16,7 @@ * * state.files — бывший fileQueue, массив загруженных файлов. * Каждый элемент: { name, size, lastModified, file, doc_id, - * uploaded, parsed, parseInfo, supp_id, supp_type, status } + * uploaded, parsed, parseInfo, supp_id, supp_type, status, zip_source } * * state.contractId — бывший contractId, ID контракта в БД. * Приходит с бэкенда после первого успешного upload.