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
|
||||||
+2
-1
@@ -16,6 +16,7 @@ import db
|
|||||||
import schema
|
import schema
|
||||||
from test_routes import test_bp
|
from test_routes import test_bp
|
||||||
from upload import upload_bp
|
from upload import upload_bp
|
||||||
|
from api import api_bp
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ class ContractsApp:
|
|||||||
# -- Blueprint-ы (каждый слой — отдельный Blueprint) --
|
# -- Blueprint-ы (каждый слой — отдельный Blueprint) --
|
||||||
self.app.register_blueprint(test_bp)
|
self.app.register_blueprint(test_bp)
|
||||||
self.app.register_blueprint(upload_bp)
|
self.app.register_blueprint(upload_bp)
|
||||||
# self.app.register_blueprint(api_bp) # ← будет добавлен позже
|
self.app.register_blueprint(api_bp)
|
||||||
|
|
||||||
def _index(self):
|
def _index(self):
|
||||||
"""Главная страница — статус сервиса."""
|
"""Главная страница — статус сервиса."""
|
||||||
|
|||||||
Reference in New Issue
Block a user