""" 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 import mimeutil 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 # INSERT с RETURNING id conn, err = db.connect() if err: return jsonify({"error": err}), 500 try: cur = conn.cursor() cur.execute( "INSERT INTO contracts (number, client, date_signed) VALUES (%s, %s, %s) RETURNING id", (number, client, date_signed), ) contract_id = cur.fetchone()[0] conn.commit() cur.close() conn.close() return jsonify({"status": "created", "id": str(contract_id)}), 201 except Exception as e: conn.rollback() conn.close() return jsonify({"error": str(e)}), 500 @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 = mimeutil.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}) # ── Вспомогательные ─────────────────────────────────────────────