fix(app): полная перезапись — все шаги пайплайна + ZIP-обработка

- ZIP: elements объединяются из всех файлов
- LLM: extractor.extract(text)
- diff: differ.diff(prev, rows)
- spec_rows + spec_history
- Без дубликатов, без replace_string_in_file
This commit is contained in:
2026-06-15 07:29:47 +04:00
parent 7095d1201f
commit 9899ede8c6
+33 -51
View File
@@ -1,15 +1,8 @@
""" """app.py — Точка входа и сборка слоёв. Бизнес-логики НОЛЬ."""
app.py — Точка входа и сборка слоёв.
Бизнес-логики НОЛЬ. Только:
1. Инициализация схемы БД (schema.ensure_schema)
2. Регистрация Blueprint-ов (test, api — будут добавляться)
Правило: если в app.py появляется if/for или бизнес-слово — это лишнее.
"""
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import Flask, render_template, request from flask import Flask, render_template, request
import json
import schema import schema
import db import db
@@ -26,44 +19,27 @@ load_dotenv()
class ContractsApp: class ContractsApp:
"""
Главный класс приложения.
Собирает все слои вместе, не содержит логики.
"""
def __init__(self): def __init__(self):
self.app = Flask(__name__) self.app = Flask(__name__)
# 1. Создать таблицы при старте (если их ещё нет)
schema.ensure_schema() schema.ensure_schema()
# 2. Собрать маршруты
self._add_routes() self._add_routes()
def _add_routes(self): def _add_routes(self):
"""Регистрация всех Blueprint-ов и системных маршрутов."""
# -- Системные маршруты --
self.app.add_url_rule("/", "index", self._upload_page, methods=["GET", "POST"]) self.app.add_url_rule("/", "index", self._upload_page, methods=["GET", "POST"])
self.app.add_url_rule("/health", "health", self._health) self.app.add_url_rule("/health", "health", self._health)
# -- Blueprint-ы (каждый слой — отдельный Blueprint) --
self.app.register_blueprint(test_bp) self.app.register_blueprint(test_bp)
self.app.register_blueprint(upload_bp) self.app.register_blueprint(upload_bp)
self.app.register_blueprint(api_bp) self.app.register_blueprint(api_bp)
def _health(self): def _health(self):
"""Health check для платформы."""
return "OK", 200, {"Content-Type": "text/plain"} return "OK", 200, {"Content-Type": "text/plain"}
def _upload_page(self): def _upload_page(self):
"""Страница загрузки: GET — форма, POST — обработка."""
if request.method == "POST": if request.method == "POST":
return self._process_upload() return self._process_upload()
return render_template("upload.html") return render_template("upload.html")
def _process_upload(self): def _process_upload(self):
"""Обработка загруженных файлов."""
files = request.files.getlist("files") files = request.files.getlist("files")
if not files or not any(f.filename for f in files): if not files or not any(f.filename for f in files):
return render_template("upload.html", error="Нет файлов") return render_template("upload.html", error="Нет файлов")
@@ -72,7 +48,6 @@ class ContractsApp:
conn, err = db.connect() conn, err = db.connect()
if err: if err:
return render_template("upload.html", error=f"БД: {err}") return render_template("upload.html", error=f"БД: {err}")
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
"INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id", "INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id",
@@ -89,7 +64,6 @@ class ContractsApp:
for f in files: for f in files:
if not f.filename: if not f.filename:
continue continue
filename = f.filename filename = f.filename
mime = mimeutil.guess_mime(filename) or f.content_type or "application/octet-stream" mime = mimeutil.guess_mime(filename) or f.content_type or "application/octet-stream"
file_bytes = f.read() file_bytes = f.read()
@@ -104,18 +78,34 @@ class ContractsApp:
(filename,), (filename,),
) )
doc_id = doc["id"] if doc else None doc_id = doc["id"] if doc else None
if not doc_id: if not doc_id:
results.append({"name": filename, "ok": False, "error": "doc save failed"}) results.append({"name": filename, "ok": False, "error": "doc save failed"})
continue continue
# Парсинг # Парсинг
pr = parser_mod.parse(file_bytes, mime) pr = parser_mod.parse(file_bytes, mime)
if pr.get("errors"):
results.append({"name": filename, "ok": False, "error": "; ".join(pr["errors"])}) # ZIP: объединяем элементы из всех файлов
if mime == "application/zip" and "files" in pr:
all_el = []
zip_errs = []
for zf in pr["files"]:
if zf.get("errors"):
zip_errs.extend(zf["errors"])
all_el.extend(zf.get("elements", []))
if zip_errs and not all_el:
results.append({"name": filename, "ok": False, "error": "; ".join(zip_errs)})
continue
elements = all_el
else:
elements = pr.get("elements", [])
if not elements:
errs = pr.get("errors", [])
results.append({"name": filename, "ok": False, "error": "; ".join(errs) if errs else "нет таблиц"})
continue continue
text = textify.to_text(pr.get("elements", [])) text = textify.to_text(elements)
db.execute("UPDATE documents SET parsed_text=%s, status='parsed' WHERE id=%s", (text, str(doc_id))) db.execute("UPDATE documents SET parsed_text=%s, status='parsed' WHERE id=%s", (text, str(doc_id)))
# Допник # Допник
@@ -128,7 +118,6 @@ class ContractsApp:
(str(doc_id),), (str(doc_id),),
) )
supp_id = supp["id"] if supp else None supp_id = supp["id"] if supp else None
if not supp_id: if not supp_id:
results.append({"name": filename, "ok": False, "error": "supplement save failed"}) results.append({"name": filename, "ok": False, "error": "supplement save failed"})
continue continue
@@ -142,23 +131,22 @@ class ContractsApp:
rows = ext.get("rows", []) rows = ext.get("rows", [])
total_rows += len(rows) total_rows += len(rows)
# Сохранить строки # Сохранить spec_rows
for row in rows: for row in rows:
db.execute( db.execute(
"INSERT INTO spec_rows (supplement_id, row_num, name, price, qty, sum, date_start) " "INSERT INTO spec_rows (supplement_id,row_num,name,price,qty,sum,date_start) VALUES (%s,%s,%s,%s,%s,%s,%s)",
"VALUES (%s,%s,%s,%s,%s,%s,%s)",
(str(supp_id), row.get("row_num"), row.get("name"), (str(supp_id), row.get("row_num"), row.get("name"),
row.get("price"), row.get("qty"), row.get("sum"), row.get("date_start")), row.get("price"), row.get("qty"), row.get("sum"), row.get("date_start")),
) )
# Diff с предыдущим # Diff с предыдущим
prev, _ = db.query( prev_res, _ = db.query(
"SELECT sr.row_num,sr.name,sr.price,sr.qty,sr.sum,sr.date_start " "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 " "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", "WHERE s.contract_id=%s AND s.id!=%s ORDER BY sr.row_num",
(str(contract_id), str(supp_id)), (str(contract_id), str(supp_id)),
) )
prev_rows = [dict(zip(prev["columns"], r)) for r in prev["rows"]] if prev else [] prev_rows = [dict(zip(prev_res["columns"], r)) for r in prev_res["rows"]] if prev_res else []
diff_r = differ.diff(prev_rows, rows) diff_r = differ.diff(prev_rows, rows)
for ch in diff_r.get("changes", []): for ch in diff_r.get("changes", []):
@@ -166,35 +154,29 @@ class ContractsApp:
"INSERT INTO spec_history (contract_id,supplement_id,row_num,change_type,old_values,new_values) " "INSERT INTO spec_history (contract_id,supplement_id,row_num,change_type,old_values,new_values) "
"VALUES (%s,%s,%s,%s,%s,%s)", "VALUES (%s,%s,%s,%s,%s,%s)",
(str(contract_id), str(supp_id), ch["row_num"], ch["change_type"], (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, 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), 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),)) 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", {})}) results.append({"name": filename, "ok": True, "rows": len(rows), "summary": diff_r.get("summary", {})})
# Получить финальные строки для отображения # Все строки для отображения
all_rows = [] all_rows = []
rows_result, _ = db.query( rows_res, _ = db.query(
"SELECT sr.row_num,sr.name,sr.price,sr.qty,sr.sum,sr.date_start " "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 " "FROM spec_rows sr JOIN supplements s ON sr.supplement_id=s.id "
"WHERE s.contract_id=%s ORDER BY sr.row_num", "WHERE s.contract_id=%s ORDER BY sr.row_num",
(str(contract_id),), (str(contract_id),),
) )
if rows_result: if rows_res:
all_rows = [dict(zip(rows_result["columns"], r)) for r in rows_result["rows"]] all_rows = [dict(zip(rows_res["columns"], r)) for r in rows_res["rows"]]
return render_template("upload.html", results=results, all_rows=all_rows, total_rows=total_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)."""
self.app.run(host="0.0.0.0", port=5000) self.app.run(host="0.0.0.0", port=5000)
# ── Точка входа ─────────────────────────────────────────────────
if __name__ == "__main__": if __name__ == "__main__":
app_instance = ContractsApp() ContractsApp().run()
app_instance.run()
app_instance = ContractsApp()
app_instance.run()