From cb7a729df79fa273788b4f1b243ea7d4c9e0d11b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sun, 14 Jun 2026 17:02:31 +0400 Subject: [PATCH] =?UTF-8?q?feat(api):=20api.py=20Blueprint=20=E2=80=94=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B3=D0=BE=D0=B2=D0=BE=D1=80=D1=8B,=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BF=D0=BD=D0=B8=D0=BA=D0=B8,=20=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/contracts — создать договор GET /api/contracts — список GET /api/contracts/ — договор + допники POST /api/contracts//supplements — загрузить допник (полный пайплайн) GET /api/contracts//history — история изменений Пайплайн допника: upload→parse→textify→extract→diff→spec_rows→spec_history --- site/api.py | 277 ++++++++++++++++++++++++++++++++++++++++++++++++++++ site/app.py | 3 +- 2 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 site/api.py diff --git a/site/api.py b/site/api.py new file mode 100644 index 0000000..7096d2a --- /dev/null +++ b/site/api.py @@ -0,0 +1,277 @@ +""" +api.py — API Blueprint для договоров, допников и истории. + +Точка входа для пользователя. Связывает все слои воедино: + parser → textify → upload → extractor → differ → БД. + +Эндпоинты: + POST /api/contracts — создать договор + GET /api/contracts — список договоров + GET /api/contracts/ — договор + допники + POST /api/contracts//supplements — загрузить допник + GET /api/contracts//history — история изменений +""" + +from flask import Blueprint, request, jsonify +import db +import parser as parser_mod +import textify +import extractor +import differ + +api_bp = Blueprint("api", __name__) + + +# ── Договоры ──────────────────────────────────────────────────── + +@api_bp.route("/api/contracts", methods=["POST"]) +def create_contract(): + """Создать новый договор.""" + data = request.get_json(silent=True) or {} + number = data.get("number", "") + client = data.get("client", "") + date_signed = data.get("date_signed") + + if not number: + return jsonify({"error": "number is required"}), 400 + + rowcount, err = db.execute( + "INSERT INTO contracts (number, client, date_signed) VALUES (%s, %s, %s)", + (number, client, date_signed), + ) + if err: + return jsonify({"error": err}), 500 + + return jsonify({"status": "created", "rowcount": rowcount}), 201 + + +@api_bp.route("/api/contracts", methods=["GET"]) +def list_contracts(): + """Список всех договоров.""" + result, err = db.query( + "SELECT id, number, client, date_signed, status, created_at " + "FROM contracts ORDER BY created_at DESC" + ) + if err: + return jsonify({"error": err}), 500 + return jsonify({"contracts": [dict(zip(result["columns"], r)) for r in result["rows"]]}) + + +@api_bp.route("/api/contracts/", methods=["GET"]) +def get_contract(contract_id): + """Договор со всеми допниками.""" + contract, err = db.query_one( + "SELECT id, number, client, date_signed, status, created_at " + "FROM contracts WHERE id = %s", + (contract_id,), + ) + if err: + return jsonify({"error": err}), 500 + if not contract: + return jsonify({"error": "not found"}), 404 + + # Допники + supps, err = db.query( + "SELECT id, number, date_signed, type, document_id, created_at " + "FROM supplements WHERE contract_id = %s ORDER BY date_signed", + (contract_id,), + ) + supp_list = [dict(zip(supps["columns"], r)) for r in supps["rows"]] if not err else [] + + contract["supplements"] = supp_list + return jsonify(contract) + + +# ── Допники (с полным пайплайном) ─────────────────────────────── + +@api_bp.route("/api/contracts//supplements", methods=["POST"]) +def add_supplement(contract_id): + """ + Загрузить допник к договору. + + Принимает multipart/form-data: file + поля number, date_signed, type. + Полный пайплайн: + 1. Сохранить файл в documents (upload) + 2. Парсинг → текст (parser + textify) + 3. LLM-извлечение строк (extractor) + 4. Сравнение с предыдущим допником (differ) + 5. Сохранить строки в spec_rows + 6. Сохранить изменения в spec_history + """ + # 1. Проверить файл + if "file" not in request.files: + return jsonify({"error": "no file"}), 400 + + file = request.files["file"] + if not file.filename: + return jsonify({"error": "empty filename"}), 400 + + filename = file.filename + mime_type = _guess_mime(filename) or file.content_type or "application/octet-stream" + file_bytes = file.read() + + # 2. Сохранить в documents + db.execute( + "INSERT INTO documents (filename, mime_type, original_bytes, status) " + "VALUES (%s, %s, %s, 'uploaded')", + (filename, mime_type, file_bytes), + ) + + # Получить ID документа + doc, err = db.query_one( + "SELECT id FROM documents WHERE filename = %s AND status = 'uploaded' " + "ORDER BY created_at DESC LIMIT 1", + (filename,), + ) + if err or not doc: + return jsonify({"error": f"document save failed: {err}"}), 500 + document_id = doc["id"] + + # 3. Парсинг + parse_result = parser_mod.parse(file_bytes, mime_type) + if parse_result.get("errors"): + db.execute( + "UPDATE documents SET status = 'error', error_message = %s WHERE id = %s", + ("; ".join(parse_result["errors"]), str(document_id)), + ) + return jsonify({"error": "parse failed", "details": parse_result["errors"]}), 422 + + elements = parse_result.get("elements", []) + parsed_text = textify.to_text(elements) + + # Обновить документ + db.execute( + "UPDATE documents SET parsed_text = %s, status = 'parsed' WHERE id = %s", + (parsed_text, str(document_id)), + ) + + # 4. Создать допник + number = request.form.get("number", "") + date_signed = request.form.get("date_signed") + supp_type = request.form.get("type", "amendment") + + db.execute( + "INSERT INTO supplements (contract_id, number, date_signed, type, document_id) " + "VALUES (%s, %s, %s, %s, %s)", + (contract_id, number, date_signed, supp_type, str(document_id)), + ) + + supp, err = db.query_one( + "SELECT id FROM supplements WHERE document_id = %s ORDER BY created_at DESC LIMIT 1", + (str(document_id),), + ) + if err or not supp: + return jsonify({"error": f"supplement save failed: {err}"}), 500 + supplement_id = supp["id"] + + # 5. LLM-извлечение строк + extract_result = extractor.extract(parsed_text) + if "error" in extract_result: + db.execute( + "UPDATE documents SET status = 'extract_error', error_message = %s WHERE id = %s", + (extract_result["error"], str(document_id)), + ) + return jsonify({"error": "extract failed", "details": extract_result}), 422 + + new_rows = extract_result.get("rows", []) + unresolved = extract_result.get("unresolved", []) + + # 6. Сохранить строки в spec_rows + saved_rows = [] + for row in new_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(supplement_id), + row.get("row_num"), + row.get("name"), + row.get("price"), + row.get("qty"), + row.get("sum"), + row.get("date_start"), + ), + ) + saved_rows.append(row) + + # 7. Сравнить с предыдущим допником + prev_rows_result, err = 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", + (contract_id, str(supplement_id)), + ) + prev_rows = [dict(zip(prev_rows_result["columns"], r)) for r in prev_rows_result["rows"]] if not err else [] + + diff_result = differ.diff(prev_rows, new_rows) + + # 8. Сохранить изменения в spec_history + for change in diff_result.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)", + ( + contract_id, + str(supplement_id), + change["row_num"], + change["change_type"], + _jsonb(change.get("old_values")), + _jsonb(change.get("new_values")), + ), + ) + + # Обновить статус документа + db.execute( + "UPDATE documents SET status = 'extracted' WHERE id = %s", + (str(document_id),), + ) + + return jsonify({ + "supplement_id": str(supplement_id), + "document_id": str(document_id), + "rows_extracted": len(saved_rows), + "unresolved": len(unresolved), + "diff_summary": diff_result.get("summary", {}), + }), 201 + + +# ── История ───────────────────────────────────────────────────── + +@api_bp.route("/api/contracts//history", methods=["GET"]) +def get_history(contract_id): + """Полная история изменений по договору.""" + result, err = db.query( + "SELECT sh.row_num, sh.change_type, sh.old_values, sh.new_values, " + "sh.created_at, s.number as supp_number, s.date_signed as supp_date " + "FROM spec_history sh " + "JOIN supplements s ON sh.supplement_id = s.id " + "WHERE sh.contract_id = %s " + "ORDER BY s.date_signed, sh.row_num", + (contract_id,), + ) + if err: + return jsonify({"error": err}), 500 + + history = [dict(zip(result["columns"], r)) for r in result["rows"]] + return jsonify({"contract_id": contract_id, "history": history}) + + +# ── Вспомогательные ───────────────────────────────────────────── + +def _guess_mime(filename: str) -> str: + """MIME-тип по расширению.""" + ext = filename.lower().rsplit(".", 1)[-1] if "." in filename else "" + return { + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "doc": "application/msword", + "pdf": "application/pdf", + "zip": "application/zip", + }.get(ext, "") + + +def _jsonb(val): + """Подготовить значение для JSONB-колонки.""" + import json + return json.dumps(val, ensure_ascii=False, default=str) if val is not None else None diff --git a/site/app.py b/site/app.py index f62f4ee..4370e3e 100644 --- a/site/app.py +++ b/site/app.py @@ -16,6 +16,7 @@ import db import schema from test_routes import test_bp from upload import upload_bp +from api import api_bp load_dotenv() @@ -45,7 +46,7 @@ class ContractsApp: # -- Blueprint-ы (каждый слой — отдельный Blueprint) -- self.app.register_blueprint(test_bp) self.app.register_blueprint(upload_bp) - # self.app.register_blueprint(api_bp) # ← будет добавлен позже + self.app.register_blueprint(api_bp) def _index(self): """Главная страница — статус сервиса."""