275 lines
10 KiB
Python
275 lines
10 KiB
Python
"""
|
||
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})
|
||
|
||
|
||
# ── Вспомогательные ─────────────────────────────────────────────
|
||
|