diff --git a/deploy/convert_server.py b/deploy/convert_server.py index ef62ecc..b7791a4 100755 --- a/deploy/convert_server.py +++ b/deploy/convert_server.py @@ -60,6 +60,8 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): if self.path == "/llm-ops": self._handle_llm_ops() + elif self.path == "/unzip-upload": + self._handle_unzip_upload() else: self._handle_doc_convert() @@ -275,6 +277,57 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(json.dumps({"error": str(e)}, ensure_ascii=False).encode()) + def _handle_unzip_upload(self): + """Распаковать ZIP и загрузить каждый файл в Lucee. Только файлы из корня архива.""" + import zipfile, io + length = int(self.headers.get("Content-Length", 0)) + data = self.rfile.read(length) + + results = [] + try: + with zipfile.ZipFile(io.BytesIO(data)) as zf: + for name in zf.namelist(): + # Только файлы из корня (без папок), пропускаем директории + if name.endswith('/') or '/' in name: + continue + content = zf.read(name) + ext = name.rsplit('.', 1)[-1].lower() if '.' in name else '' + if ext not in ('docx', 'doc', 'pdf'): + continue + + # Загрузить в Lucee + resp = httpx.post( + f"{LUCEE_URL}/upload.cfm", + data={"filename": name, "data": content}, + timeout=30 + ) + if resp.status_code == 200: + rj = resp.json() + if rj.get("OK"): + results.append({ + "filename": name, + "doc_id": rj.get("DOC_ID", ""), + "contract_id": rj.get("CONTRACT_ID", ""), + "size": len(content), + }) + else: + results.append({"filename": name, "error": rj.get("ERROR", "upload failed")}) + else: + results.append({"filename": name, "error": f"HTTP {resp.status_code}"}) + + except zipfile.BadZipFile: + self.send_error(400, "not a valid ZIP file") + return + except Exception as e: + self.send_error(500, str(e)) + return + + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(json.dumps({"ok": True, "files": results}, ensure_ascii=False).encode()) + def _handle_doc_convert(self): length = int(self.headers.get("Content-Length", 0)) data = self.rfile.read(length) diff --git a/deploy/nginx-contracts.conf b/deploy/nginx-contracts.conf index 93199d3..99291b1 100644 --- a/deploy/nginx-contracts.conf +++ b/deploy/nginx-contracts.conf @@ -43,6 +43,15 @@ server { add_header Access-Control-Allow-Methods "GET, OPTIONS" always; } + location /unzip-upload { + add_header Access-Control-Allow-Origin "*"; + add_header Access-Control-Allow-Methods "POST, OPTIONS"; + add_header Access-Control-Allow-Headers "*"; + if ($request_method = OPTIONS) { return 200; } + proxy_pass http://127.0.0.1:8766; + client_max_body_size 100m; + } + listen 443 ssl; # managed by Certbot ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot diff --git a/index.cfm b/index.cfm index 4d9bb8b..5791e9d 100644 --- a/index.cfm +++ b/index.cfm @@ -75,7 +75,7 @@
Nubes - Сверка договоров — LLM AI-driven Event Sourcing v1.0.117 — Lucee + Сверка договоров — LLM AI-driven Event Sourcing v1.0.118 — Lucee
@@ -85,7 +85,8 @@ Загрузка договоров/приложений/спецификаций
- + +
↕ первый в списке — основной договор, порядок меняется стрелками
@@ -121,7 +122,7 @@ '; + fileTable.innerHTML = ''; } else { fileTable.innerHTML = fileQueue.map(function(f, i) { var isFirst = (i === 0); @@ -247,8 +248,76 @@ fileInput.addEventListener('change', async function() { for (var i = 0; i < newFiles.length; i++) { var f = newFiles[i]; + var isZip = f.name.toLowerCase().endsWith('.zip'); + + if (isZip) { + // ZIP: показать временную строку, распаковать, убрать + var zipEntry = { name: f.name, lastModified: f.lastModified, size: f.size, status: '⏳ распаковка...' }; + fileQueue.push(zipEntry); + var zipIdx = fileQueue.length - 1; + renderTable(); + + try { + var zipResp = await fetch('/unzip-upload', { + method: 'POST', + body: f, + headers: {'Content-Type': 'application/zip'} + }); + var zipData = await zipResp.json(); + if (!zipData.ok || !zipData.files) throw new Error('unzip failed'); + + // Убрать строку ZIP из таблицы + fileQueue.splice(zipIdx, 1); + renderTable(); + + // Добавить файлы из архива, пропуская дубликаты + for (var zi = 0; zi < zipData.files.length; zi++) { + var zf = zipData.files[zi]; + if (zf.error) continue; // пропускаем ошибки + + // Проверить дубликат по имени + var dup = -1; + for (var dj = 0; dj < fileQueue.length; dj++) { + if (fileQueue[dj].name === zf.filename) { dup = dj; break; } + } + if (dup >= 0) continue; // уже есть — не добавляем + + var zEntry = { + name: zf.filename, size: zf.size, + doc_id: zf.doc_id, uploaded: true, + status: '⏳ парсинг...' + }; + fileQueue.push(zEntry); + var zIdx = fileQueue.length - 1; + if (!contractId && zf.contract_id) contractId = zf.contract_id; + renderTable(); + + // Авто-парсинг + var zt0 = Date.now(); + try { + var zpr = await fetch('/parser.cfm?doc_id=' + zf.doc_id); + var zpd = await zpr.json(); + var zelapsed = ((Date.now() - zt0) / 1000).toFixed(1); + if (zpd.OK && zpd.STATUS === 'parsed') { + fileQueue[zIdx].parsed = true; + fileQueue[zIdx].status = '✓ ' + zpd.ELEMENT_COUNT + ' эл. (' + zelapsed + 'с)'; + } else { + fileQueue[zIdx].status = '✗ ' + (zpd.ERROR_COUNT > 0 ? (zpd.ERRORS && zpd.ERRORS[0] || 'ошибка') : 'ошибка') + ''; + } + } catch(zpe) { + fileQueue[zIdx].status = '✗ парсинг: сеть'; + } + renderTable(); + } + } catch(ze) { + fileQueue[zipIdx].status = '✗ ZIP: ' + ze.message + ''; + renderTable(); + } + continue; // ZIP обработан, переходим к следующему файлу + } + + // Обычный файл (не ZIP) var entry = { name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: '↑ 0%' }; - // Заменить старый файл с тем же именем, если есть var existingIdx = -1; for (var j = 0; j < fileQueue.length; j++) { if (fileQueue[j].name === f.name) { existingIdx = j; break; }
Нет файлов — выберите .docx / .pdf / .zip
Нет файлов — выберите .docx / .pdf / .zip (файлы из корня архива)