Files
contracts-app/site/api.py
T

275 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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
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/<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 = 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/<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})
# ── Вспомогательные ─────────────────────────────────────────────