feat(api): api.py Blueprint — договоры, допники, история

POST /api/contracts          — создать договор
GET  /api/contracts          — список
GET  /api/contracts/<id>     — договор + допники
POST /api/contracts/<id>/supplements  — загрузить допник (полный пайплайн)
GET  /api/contracts/<id>/history      — история изменений

Пайплайн допника: upload→parse→textify→extract→diff→spec_rows→spec_history
This commit is contained in:
2026-06-14 17:02:31 +04:00
parent f40792c523
commit cb7a729df7
2 changed files with 279 additions and 1 deletions
+277
View File
@@ -0,0 +1,277 @@
"""
api.py — API Blueprint для договоров, допников и истории.
Точка входа для пользователя. Связывает все слои воедино:
parser → textify → upload → extractor → differ → БД.
Эндпоинты:
POST /api/contracts — создать договор
GET /api/contracts — список договоров
GET /api/contracts/<id> — договор + допники
POST /api/contracts/<id>/supplements — загрузить допник
GET /api/contracts/<id>/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/<contract_id>", 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/<contract_id>/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/<contract_id>/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