fix: форма без AJAX — обычный POST, сервер всё делает

Убран JS fetch. Форма отправляется напрямую на сервер.
Сервер обрабатывает файлы и возвращает страницу с результатами.
Нет Failed to fetch.
This commit is contained in:
2026-06-15 07:00:44 +04:00
parent 46b6bee913
commit 4e714f5680
2 changed files with 189 additions and 196 deletions
+134 -2
View File
@@ -9,9 +9,15 @@ app.py — Точка входа и сборка слоёв.
""" """
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import Flask, render_template from flask import Flask, render_template, request
import schema import schema
import db
import parser as parser_mod
import textify
import extractor
import differ
import mimeutil
from test_routes import test_bp from test_routes import test_bp
from upload import upload_bp from upload import upload_bp
from api import api_bp from api import api_bp
@@ -51,9 +57,135 @@ class ContractsApp:
return "OK", 200, {"Content-Type": "text/plain"} return "OK", 200, {"Content-Type": "text/plain"}
def _upload_page(self): def _upload_page(self):
"""Страница загрузки договора (UI).""" """Страница загрузки: GET — форма, POST — обработка."""
if request.method == "POST":
return self._process_upload()
return render_template("upload.html") return render_template("upload.html")
def _process_upload(self):
"""Обработка загруженных файлов."""
files = request.files.getlist("files")
if not files or not any(f.filename for f in files):
return render_template("upload.html", error="Нет файлов")
# Создать договор
conn, err = db.connect()
if err:
return render_template("upload.html", error=f"БД: {err}")
cur = conn.cursor()
cur.execute(
"INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id",
("б" + __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M"), ""),
)
contract_id = cur.fetchone()[0]
conn.commit()
cur.close()
conn.close()
results = []
total_rows = 0
for f in files:
if not f.filename:
continue
filename = f.filename
mime = mimeutil.guess_mime(filename) or f.content_type or "application/octet-stream"
file_bytes = f.read()
# Сохранить документ
db.execute(
"INSERT INTO documents (filename, mime_type, original_bytes, status) VALUES (%s, %s, %s, 'uploaded')",
(filename, mime, file_bytes),
)
doc, _ = db.query_one(
"SELECT id FROM documents WHERE filename=%s AND status='uploaded' ORDER BY created_at DESC LIMIT 1",
(filename,),
)
doc_id = doc["id"] if doc else None
if not doc_id:
results.append({"name": filename, "ok": False, "error": "doc save failed"})
continue
# Парсинг
pr = parser_mod.parse(file_bytes, mime)
if pr.get("errors"):
results.append({"name": filename, "ok": False, "error": "; ".join(pr["errors"])})
continue
text = textify.to_text(pr.get("elements", []))
db.execute("UPDATE documents SET parsed_text=%s, status='parsed' WHERE id=%s", (text, str(doc_id)))
# Допник
db.execute(
"INSERT INTO supplements (contract_id, document_id, type) VALUES (%s, %s, 'initial')",
(str(contract_id), str(doc_id)),
)
supp, _ = db.query_one(
"SELECT id FROM supplements WHERE document_id=%s ORDER BY created_at DESC LIMIT 1",
(str(doc_id),),
)
supp_id = supp["id"] if supp else None
if not supp_id:
results.append({"name": filename, "ok": False, "error": "supplement save failed"})
continue
# LLM-извлечение
ext = extractor.extract(text)
if "error" in ext:
results.append({"name": filename, "ok": False, "error": ext["error"]})
continue
rows = ext.get("rows", [])
total_rows += len(rows)
# Сохранить строки
for row in rows:
db.execute(
"INSERT INTO spec_rows (supplement_id, row_num, name, price, qty, sum, date_start) "
"VALUES (%s,%s,%s,%s,%s,%s,%s)",
(str(supp_id), row.get("row_num"), row.get("name"),
row.get("price"), row.get("qty"), row.get("sum"), row.get("date_start")),
)
# Diff с предыдущим
prev, _ = db.query(
"SELECT sr.row_num, sr.name, sr.price, sr.qty, sr.sum, sr.date_start "
"FROM spec_rows sr JOIN supplements s ON sr.supplement_id=s.id "
"WHERE s.contract_id=%s AND s.id!=%s ORDER BY sr.row_num",
(str(contract_id), str(supp_id)),
)
prev_rows = [dict(zip(prev["columns"], r)) for r in prev["rows"]] if prev else []
diff_r = differ.diff(prev_rows, rows)
for ch in diff_r.get("changes", []):
db.execute(
"INSERT INTO spec_history (contract_id, supplement_id, row_num, change_type, old_values, new_values) "
"VALUES (%s,%s,%s,%s,%s,%s)",
(str(contract_id), str(supp_id), ch["row_num"], ch["change_type"],
__import__("json").dumps(ch.get("old_values"), ensure_ascii=False, default=str) if ch.get("old_values") else None,
__import__("json").dumps(ch.get("new_values"), ensure_ascii=False, default=str) if ch.get("new_values") else None),
)
db.execute("UPDATE documents SET status='extracted' WHERE id=%s", (str(doc_id),))
results.append({"name": filename, "ok": True, "rows": len(rows), "summary": diff_r.get("summary", {})})
# Получить финальные строки для отображения
all_rows = []
rows_result, _ = db.query(
"SELECT sr.row_num, sr.name, sr.price, sr.qty, sr.sum, sr.date_start "
"FROM spec_rows sr JOIN supplements s ON sr.supplement_id=s.id "
"WHERE s.contract_id=%s ORDER BY sr.row_num",
(str(contract_id),),
)
if rows_result:
all_rows = [dict(zip(rows_result["columns"], r)) for r in rows_result["rows"]]
return render_template("upload.html", results=results, all_rows=all_rows, total_rows=total_rows)
def run(self): def run(self):
"""Запуск Flask (только для разработки, в production — gunicorn).""" """Запуск Flask (только для разработки, в production — gunicorn)."""
self.app.run(host="0.0.0.0", port=5000) self.app.run(host="0.0.0.0", port=5000)
+55 -194
View File
@@ -8,34 +8,22 @@
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<style> <style>
:root { :root {
--brand-primary: #2563eb; --brand-primary: #2563eb; --brand-primary-dark: #1d4ed8;
--brand-primary-dark: #1d4ed8; --brand-gray: #d1d5db; --brand-grey-light: #f3f4f6;
--brand-gray: #d1d5db; --background: #ffffff; --foreground: #1a1a1a;
--brand-grey-light: #f3f4f6; --muted: #6b7280; --destructive: #ef4444; --green: #22c55e;
--background: #ffffff;
--foreground: #1a1a1a;
--muted: #6b7280;
--destructive: #ef4444;
--green: #22c55e;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-family: system-ui, sans-serif; font-size: 14px; color: var(--foreground);
font-size: 14px; color: var(--foreground); background: var(--brand-grey-light); min-height: 100vh;
background: var(--brand-grey-light);
min-height: 100vh;
} }
/* Верхняя панель */
.topbar { .topbar {
background: var(--background); border-bottom: 1px solid var(--brand-gray); background: var(--background); border-bottom: 1px solid var(--brand-gray);
padding: 0 24px; height: 56px; padding: 0 24px; height: 56px; display: flex; align-items: center; gap: 16px;
display: flex; align-items: center; gap: 16px;
} }
.topbar img { height: 28px; } .topbar img { height: 28px; }
.topbar .title { font-weight: 600; font-size: 16px; } .topbar .title { font-weight: 600; font-size: 16px; }
/* Контент */
.content { max-width: 800px; margin: 32px auto; padding: 0 16px; } .content { max-width: 800px; margin: 32px auto; padding: 0 16px; }
.card { .card {
background: var(--background); border-radius: 12px; background: var(--background); border-radius: 12px;
@@ -44,11 +32,9 @@
} }
.card-header { .card-header {
background: var(--brand-grey-light); padding: 12px 16px; background: var(--brand-grey-light); padding: 12px 16px;
font-weight: 600; font-size: 16px; font-weight: 600; font-size: 16px; display: flex; align-items: center; gap: 8px;
display: flex; align-items: center; gap: 8px;
} }
.card-body { padding: 16px; } .card-body { padding: 16px; }
.form-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
.btn { .btn {
height: 36px; border-radius: 6px; gap: 6px; padding: 0 14px; height: 36px; border-radius: 6px; gap: 6px; padding: 0 14px;
font-size: 14px; font-family: inherit; cursor: pointer; font-size: 14px; font-family: inherit; cursor: pointer;
@@ -57,32 +43,21 @@
} }
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; } .btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
.btn-primary:hover { background: var(--brand-primary-dark); } .btn-primary:hover { background: var(--brand-primary-dark); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.queue { margin-top: 12px; } .queue { margin-top: 12px; }
.queue-item { .queue-item {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-radius: 6px; padding: 8px 12px; border-radius: 6px; border: 1px solid var(--brand-gray);
border: 1px solid var(--brand-gray);
background: var(--brand-grey-light); margin-bottom: 4px; background: var(--brand-grey-light); margin-bottom: 4px;
} }
.queue-item .name { flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .queue-item .name { flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.queue-item .size { color: var(--muted); font-size: 12px; } .queue-item .size { color: var(--muted); font-size: 12px; }
.queue-item .remove { cursor: pointer; color: var(--muted); padding: 4px; } .queue-item .remove { cursor: pointer; color: var(--muted); padding: 4px; }
.queue-item .remove:hover { color: var(--destructive); } .queue-item .remove:hover { color: var(--destructive); }
.queue-empty { color: var(--muted); padding: 12px; text-align: center; }
.progress-bar {
height: 4px; background: var(--brand-grey-light);
border-radius: 2px; margin-top: 12px; overflow: hidden;
}
.progress-fill { height: 100%; background: var(--brand-primary); border-radius: 2px; transition: width .3s; }
.table-wrap { border-radius: 6px; border: 1px solid var(--brand-gray); overflow: hidden; margin-top: 12px; } .table-wrap { border-radius: 6px; border: 1px solid var(--brand-gray); overflow: hidden; margin-top: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; } table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { background: var(--brand-grey-light); text-transform: uppercase; padding: 4px 8px; border-right: 1px solid var(--brand-gray); text-align: left; font-weight: 600; } th { background: var(--brand-grey-light); text-transform: uppercase; padding: 4px 8px; border-right: 1px solid var(--brand-gray); text-align: left; font-weight: 600; }
td { padding: 4px 8px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); } td { padding: 4px 8px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); }
tr:hover td { background: rgba(243,244,246,.5); } tr:hover td { background: rgba(243,244,246,.5); }
.summary { margin-top: 12px; font-size: 14px; display: flex; gap: 16px; flex-wrap: wrap; }
.loader { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: spin .6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.error-box { margin-top: 12px; padding: 12px; border-radius: 6px; border: 1px solid var(--destructive); background: rgba(239,68,68,.05); color: var(--destructive); font-size: 14px; } .error-box { margin-top: 12px; padding: 12px; border-radius: 6px; border: 1px solid var(--destructive); background: rgba(239,68,68,.05); color: var(--destructive); font-size: 14px; }
.badge { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; padding: 2px 8px; border-radius: 4px; } .badge { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; padding: 2px 8px; border-radius: 4px; }
.badge-ok { background: rgba(34,197,94,.1); color: var(--green); } .badge-ok { background: rgba(34,197,94,.1); color: var(--green); }
@@ -90,186 +65,72 @@
</style> </style>
</head> </head>
<body> <body>
<!-- Верхняя панель -->
<div class="topbar"> <div class="topbar">
<img src="{{ url_for('static', filename='nubes-logo.svg') }}" alt="Nubes"> <img src="{{ url_for('static', filename='nubes-logo.svg') }}" alt="Nubes">
<span class="title">Сверка договоров</span> <span class="title">Сверка договоров</span>
</div> </div>
<div class="content"> <div class="content">
<!-- Карточка: Добавление -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<i data-lucide="folder-open" style="width:18px;height:18px;"></i> <i data-lucide="folder-open" style="width:18px;height:18px;"></i>
Загрузка договоров / допников Загрузка договоров / допников
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="form-row"> <form method="POST" enctype="multipart/form-data">
<input type="file" id="fileInput" accept=".docx,.doc,.pdf,.zip" multiple style="height:36px;" onchange="addFiles()"> <input type="file" name="files" accept=".docx,.doc,.pdf,.zip" multiple style="margin-bottom:12px;">
</div> <button type="submit" class="btn btn-primary" style="width:100%;justify-content:center;">
<div class="queue" id="queue"></div> <i data-lucide="play" style="width:16px;height:16px;"></i> Обработать
<div class="queue-empty" id="queueEmpty">Нет добавленных файлов</div>
<div style="margin-top:12px;">
<button class="btn btn-primary" id="processBtn" onclick="processAll()" style="width:100%;justify-content:center;">
<i data-lucide="play" style="width:16px;height:16px;"></i> Обработать все файлы
</button> </button>
</div> </form>
<div class="progress-bar" id="progressBar" style="display:none;"> {% if error %}
<div class="progress-fill" id="progressFill" style="width:0%;"></div> <div class="error-box" style="margin-top:12px;">❌ {{ error }}</div>
</div> {% endif %}
</div> </div>
</div> </div>
<!-- Карточка: Результаты --> {% if results %}
<div class="card" id="resultsCard" style="display:none;"> <div class="card">
<div class="card-header"> <div class="card-header">
<i data-lucide="file-text" style="width:18px;height:18px;"></i> <i data-lucide="file-text" style="width:18px;height:18px;"></i>
Результаты обработки Результаты (строк извлечено: {{ total_rows }})
</div>
<div class="card-body">
{% for r in results %}
<div class="queue-item">
<span class="badge {{ 'badge-ok' if r.ok else 'badge-err' }}">{{ '✅' if r.ok else '❌' }} {{ 'OK' if r.ok else 'Ошибка' }}</span>
<span class="name">{{ r.name }}</span>
{% if r.ok %}
<span class="size">строк: {{ r.rows }}, +{{ r.summary.added }} ~{{ r.summary.changed }} -{{ r.summary.deleted }}</span>
{% else %}
<span class="size" style="color:var(--destructive);">{{ r.error }}</span>
{% endif %}
</div>
{% endfor %}
{% if all_rows %}
<div class="table-wrap" style="margin-top:12px;">
<table>
<thead><tr><th></th><th>Услуга</th><th>Цена, руб</th><th>Кол-во</th><th>Сумма, руб</th><th>Дата</th></tr></thead>
<tbody>
{% for row in all_rows %}
<tr>
<td>{{ row.row_num }}</td>
<td>{{ row.name[:80] if row.name else '' }}</td>
<td style="text-align:right">{{ '{:,.2f}'.format(row.price) if row.price else '' }}</td>
<td style="text-align:center">{{ row.qty if row.qty else '' }}</td>
<td style="text-align:right">{{ '{:,.2f}'.format(row.sum) if row.sum else '' }}</td>
<td>{{ row.date_start if row.date_start else '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div> </div>
<div class="card-body" id="resultsBody"></div>
</div> </div>
{% endif %}
</div> </div>
<script>lucide.createIcons();</script>
<script>
lucide.createIcons();
let fileQueue = [];
function addFiles() {
const input = document.getElementById('fileInput');
for (const f of input.files) {
if (!fileQueue.find(q => q.name === f.name && q.size === f.size)) {
fileQueue.push(f);
}
}
input.value = '';
renderQueue();
}
function removeFile(index) { fileQueue.splice(index, 1); renderQueue(); }
function renderQueue() {
const q = document.getElementById('queue');
const empty = document.getElementById('queueEmpty');
if (fileQueue.length === 0) {
q.innerHTML = ''; empty.style.display = 'block';
} else {
empty.style.display = 'none';
q.innerHTML = fileQueue.map((f, i) =>
'<div class="queue-item"><i data-lucide="file-text" style="width:16px;height:16px;color:var(--muted);"></i>' +
'<span class="name">' + f.name + '</span>' +
'<span class="size">' + formatSize(f.size) + '</span>' +
'<span class="remove" onclick="removeFile('+i+')"><i data-lucide="x" style="width:14px;height:14px;"></i></span></div>'
).join('');
lucide.createIcons();
}
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
async function processAll() {
if (fileQueue.length === 0) return;
const processBtn = document.getElementById('processBtn');
const progressBar = document.getElementById('progressBar');
const progressFill = document.getElementById('progressFill');
const resultsCard = document.getElementById('resultsCard');
const resultsBody = document.getElementById('resultsBody');
processBtn.disabled = true;
processBtn.innerHTML = '<span class="loader"></span> Обработка...';
progressBar.style.display = 'block';
resultsCard.style.display = 'block';
resultsBody.innerHTML = '';
// Создаём договор
let contractId;
try {
const resp = await fetch('/api/contracts', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({number: 'б/н ' + new Date().toISOString().slice(0,10)}),
});
const data = await resp.json();
if (!resp.ok || !data.id) throw new Error(data.error || 'no id');
contractId = data.id;
} catch (e) {
resultsBody.innerHTML = '<div class="error-box">❌ ' + e.message + '</div>';
resetBtn(); return;
}
let totalRows = 0;
let results = [];
for (let i = 0; i < fileQueue.length; i++) {
const file = fileQueue[i];
progressFill.style.width = Math.round((i / fileQueue.length) * 100) + '%';
const formData = new FormData();
formData.append('file', file);
formData.append('type', 'initial');
try {
const resp = await fetch('/api/contracts/' + contractId + '/supplements', { method: 'POST', body: formData });
const data = await resp.json();
if (resp.ok) {
totalRows += data.rows_extracted || 0;
results.push({name: file.name, status: 'ok', rows: data.rows_extracted || 0, summary: data.diff_summary || {}});
} else {
results.push({name: file.name, status: 'error', error: data.error || data.details?.error || 'Ошибка'});
}
} catch (e) {
results.push({name: file.name, status: 'error', error: e.message});
}
}
progressFill.style.width = '100%';
resultsBody.innerHTML =
'<div class="summary" style="margin-bottom:12px;">' +
'<span>Файлов: ' + fileQueue.length + '</span>' +
'<span>Строк извлечено: ' + totalRows + '</span></div>' +
results.map(r =>
'<div class="queue-item">' +
'<span class="badge ' + (r.status === 'ok' ? 'badge-ok' : 'badge-err') + '">' +
(r.status === 'ok' ? '✅ OK' : '❌ Ошибка') + '</span>' +
'<span class="name">' + r.name + '</span>' +
(r.status === 'ok'
? '<span class="size">строк: ' + r.rows + ', +' + (r.summary.added||0) + ' ~' + (r.summary.changed||0) + ' -' + (r.summary.deleted||0) + '</span>'
: '<span class="size" style="color:var(--destructive);">' + r.error + '</span>') +
'</div>'
).join('');
// История
try {
const histResp = await fetch('/api/contracts/' + contractId + '/history');
const hist = await histResp.json();
const rows = (hist.history || []).slice(-totalRows);
if (rows.length > 0) {
resultsBody.innerHTML +=
'<div class="table-wrap" style="margin-top:12px;"><table><thead><tr>' +
'<th>№</th><th>Услуга</th><th>Цена, руб</th><th>Кол-во</th><th>Сумма, руб</th><th>Дата</th></tr></thead><tbody>' +
rows.map(r => {
const v = r.new_values || {};
return '<tr><td>' + (r.row_num||'') + '</td><td>' + (v.name||'').substring(0,80) + '</td>' +
'<td style="text-align:right">' + (v.price ? Number(v.price).toLocaleString() : '') + '</td>' +
'<td style="text-align:center">' + (v.qty||'') + '</td>' +
'<td style="text-align:right">' + (v.sum ? Number(v.sum).toLocaleString() : '') + '</td>' +
'<td>' + (v.date_start||'') + '</td></tr>';
}).join('') + '</tbody></table></div>';
}
} catch(e) {}
resetBtn(); lucide.createIcons();
}
function resetBtn() {
const btn = document.getElementById('processBtn');
btn.disabled = false;
btn.innerHTML = '<i data-lucide="play" style="width:16px;height:16px;"></i> Обработать все файлы';
lucide.createIcons();
}
</script>
</body> </body>
</html> </html>