feat: wire v3 dossier API and reconciliation
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
"""API blueprint — groups, documents, supplements, sync, cleanup, spec-current, chat."""
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from datetime import date
|
||||
from flask import Blueprint, request, jsonify
|
||||
@@ -6,8 +8,11 @@ from db import documents, supplements, spec_current
|
||||
from db import dossiers as db_dossiers
|
||||
from db import exceptions as db_exceptions
|
||||
from db import timeline as db_timeline
|
||||
from db import extractions as db_extractions
|
||||
from db.connection import execute, query, transaction
|
||||
from services.grouping import group_documents, apply_groups
|
||||
from services.reconciliation import reconcile_dossier
|
||||
from services.timeline_builder import build_dossier_timeline
|
||||
from config import LLM_URL, LLM_KEY, LLM_MODEL
|
||||
import httpx
|
||||
|
||||
@@ -46,6 +51,28 @@ def api_dossier_timeline(dossier_id):
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/api/dossiers")
|
||||
def api_dossiers():
|
||||
"""Вернуть dossier cards batch вместе с alias и связанными документами."""
|
||||
batch_id = request.args.get("batch_id")
|
||||
if not batch_id:
|
||||
return jsonify(ok=False, error="batch_id required"), 400
|
||||
dossiers = db_dossiers.list_dossiers_by_batch(batch_id)
|
||||
extractions_by_document = {
|
||||
extraction["document_id"]: extraction
|
||||
for extraction in db_extractions.list_by_batch(batch_id)
|
||||
}
|
||||
for dossier in dossiers:
|
||||
for document in dossier.get("documents", []):
|
||||
extraction = extractions_by_document.get(document.get("document_id"))
|
||||
validation = (extraction or {}).get("math_validation") or {}
|
||||
document["math_status"] = validation.get("status", "UNKNOWN")
|
||||
return jsonify(
|
||||
ok=True,
|
||||
dossiers=dossiers,
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/api/exceptions")
|
||||
def api_exceptions():
|
||||
"""Вернуть исключения только по явно заданной области batch или dossier."""
|
||||
@@ -64,6 +91,93 @@ def api_exceptions():
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/api/reconcile", methods=["POST"])
|
||||
def api_reconcile():
|
||||
"""Сверить JSON или CSV строки внешнего биллинга с эталоном dossier."""
|
||||
content_type = request.mimetype or ""
|
||||
if content_type == "application/json":
|
||||
body = request.get_json(silent=True) or {}
|
||||
dossier_id = body.get("dossier_id")
|
||||
rows = body.get("external_rows")
|
||||
target = body.get("target_date_or_period")
|
||||
catalog_entries = body.get("catalog_entries", [])
|
||||
elif content_type == "multipart/form-data":
|
||||
dossier_id = request.form.get("dossier_id")
|
||||
upload = request.files.get("file")
|
||||
if upload is None:
|
||||
return jsonify(ok=False, error="CSV file required"), 400
|
||||
try:
|
||||
rows = list(csv.DictReader(io.StringIO(upload.stream.read().decode("utf-8-sig"))))
|
||||
except (UnicodeDecodeError, csv.Error):
|
||||
return jsonify(ok=False, error="CSV must be UTF-8 with a header row"), 400
|
||||
target = request.form.get("target_date_or_period")
|
||||
if target:
|
||||
try:
|
||||
target = json.loads(target)
|
||||
except json.JSONDecodeError:
|
||||
return jsonify(ok=False, error="target_date_or_period must be JSON"), 400
|
||||
catalog_entries = request.form.get("catalog_entries", "[]")
|
||||
try:
|
||||
catalog_entries = json.loads(catalog_entries)
|
||||
except json.JSONDecodeError:
|
||||
return jsonify(ok=False, error="catalog_entries must be JSON"), 400
|
||||
else:
|
||||
return jsonify(ok=False, error="use application/json or multipart/form-data"), 415
|
||||
|
||||
if not isinstance(dossier_id, str) or not dossier_id.strip():
|
||||
return jsonify(ok=False, error="dossier_id required"), 400
|
||||
if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
|
||||
return jsonify(ok=False, error="external_rows must be an array of objects"), 400
|
||||
if not isinstance(catalog_entries, list) or any(not isinstance(row, dict) for row in catalog_entries):
|
||||
return jsonify(ok=False, error="catalog_entries must be an array of objects"), 400
|
||||
try:
|
||||
result = reconcile_dossier(dossier_id, rows, target, catalog_entries)
|
||||
except ValueError as error:
|
||||
return jsonify(ok=False, error=str(error)), 400
|
||||
if not result.get("ok"):
|
||||
return jsonify(result), 404
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@api_bp.route("/api/exceptions/<exception_id>/resolve", methods=["POST"])
|
||||
def api_resolve_exception(exception_id):
|
||||
"""Сохранить выбор оператора и без LLM пересобрать dossier timeline."""
|
||||
issue = db_exceptions.get_exception(exception_id)
|
||||
if not issue:
|
||||
return jsonify(ok=False, error="exception not found"), 404
|
||||
if issue.get("status") != "OPEN":
|
||||
return jsonify(ok=False, error="exception is not open"), 409
|
||||
body = request.get_json(silent=True) or {}
|
||||
resolution = body.get("resolution")
|
||||
if not isinstance(resolution, dict) or not resolution:
|
||||
return jsonify(ok=False, error="resolution object required"), 400
|
||||
|
||||
options = issue.get("options") or []
|
||||
if "option_index" in resolution:
|
||||
option_index = resolution["option_index"]
|
||||
if type(option_index) is not int or not 0 <= option_index < len(options):
|
||||
return jsonify(ok=False, error="option_index is invalid"), 400
|
||||
resolution = {
|
||||
"option_index": option_index,
|
||||
"selected_option": options[option_index],
|
||||
}
|
||||
elif issue.get("stage") == "lineage_matching" and options:
|
||||
return jsonify(ok=False, error="choose an option_index"), 400
|
||||
|
||||
if issue.get("dossier_id") and not db_dossiers.get_dossier(issue["dossier_id"]):
|
||||
return jsonify(ok=False, error="dossier not found"), 404
|
||||
resolved = db_exceptions.resolve_exception(exception_id, resolution)
|
||||
if not resolved:
|
||||
return jsonify(ok=False, error="exception is not open"), 409
|
||||
|
||||
rebuild = None
|
||||
if issue.get("dossier_id"):
|
||||
rebuild = build_dossier_timeline(issue["dossier_id"])
|
||||
if not rebuild.get("ok"):
|
||||
return jsonify(ok=False, error="timeline rebuild failed", rebuild=rebuild), 500
|
||||
return jsonify(ok=True, exception=resolved, rebuild=rebuild)
|
||||
|
||||
|
||||
# ── Supplements ──────────────────────────────────────────────────
|
||||
|
||||
@api_bp.route("/api/supplements")
|
||||
|
||||
Reference in New Issue
Block a user