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:
+277
@@ -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
|
||||
Reference in New Issue
Block a user