""" app.py — Точка входа и сборка слоёв. Бизнес-логики НОЛЬ. Только: 1. Инициализация схемы БД (schema.ensure_schema) 2. Регистрация Blueprint-ов (test, api — будут добавляться) Правило: если в app.py появляется if/for или бизнес-слово — это лишнее. """ from dotenv import load_dotenv from flask import Flask, render_template, request import schema import db import parser as parser_mod import textify import extractor import differ import mimeutil from test_routes import test_bp from upload import upload_bp from api import api_bp load_dotenv() class ContractsApp: """ Главный класс приложения. Собирает все слои вместе, не содержит логики. """ def __init__(self): self.app = Flask(__name__) # 1. Создать таблицы при старте (если их ещё нет) schema.ensure_schema() # 2. Собрать маршруты self._add_routes() def _add_routes(self): """Регистрация всех Blueprint-ов и системных маршрутов.""" # -- Системные маршруты -- self.app.add_url_rule("/", "index", self._upload_page) self.app.add_url_rule("/health", "health", self._health) # -- Blueprint-ы (каждый слой — отдельный Blueprint) -- self.app.register_blueprint(test_bp) self.app.register_blueprint(upload_bp) self.app.register_blueprint(api_bp) def _health(self): """Health check для платформы.""" return "OK", 200, {"Content-Type": "text/plain"} def _upload_page(self): """Страница загрузки: GET — форма, POST — обработка.""" if request.method == "POST": return self._process_upload() 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): """Запуск Flask (только для разработки, в production — gunicorn).""" self.app.run(host="0.0.0.0", port=5000) # ── Точка входа ───────────────────────────────────────────────── if __name__ == "__main__": app_instance = ContractsApp() app_instance.run() app_instance = ContractsApp() app_instance.run()