feat: persist review workbench decisions (#1)

Merge dcy-012 / REQ-020 / BAT-016 implementation after green CI.
This commit is contained in:
2026-07-08 10:22:53 +09:00
committed by GitHub
parent 2fc972a7bf
commit 698dd9e62e
28 changed files with 989 additions and 81 deletions
@@ -19,6 +19,7 @@ from .shared import (
re,
row_to_dict,
)
from .review_decision_service import attach_review_decisions
def _build_cross_transfer_index(transactions: list[dict[str, Any]]) -> dict[tuple[str, int, str], list[dict[str, Any]]]:
index: dict[tuple[str, int, str], list[dict[str, Any]]] = {}
@@ -607,4 +608,5 @@ def list_cross_transfer_review_queue(limit: int = 300, db_path: str | None = Non
f"SELECT * FROM transactions t{where} ORDER BY t.transaction_date DESC, t.created_at DESC LIMIT ?",
(max(1, min(int(limit), 1000)),),
).fetchall()
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows), source="cross_transfer_review_queue")
transactions = attach_review_decisions(conn, [row_to_dict(r) for r in rows], "cross_transfer", "id")
return ok(transactions=transactions, count=len(transactions), source="cross_transfer_review_queue")
@@ -11,8 +11,10 @@ from .shared import (
normalize_snapshot_at,
now_iso,
ok,
row_to_dict,
sqlite3,
)
from .review_decision_service import _fetch_latest_decisions
def _transaction_signed_amount(tx_type: str, amount: int) -> int:
@@ -72,6 +74,13 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
clauses.append(f"statement_status IN ({placeholders})")
params.extend(sorted(RECONCILIATION_TARGET_STATUSES))
accounts = conn.execute(f"SELECT * FROM accounts WHERE {' AND '.join(clauses)} ORDER BY name", params).fetchall()
account_targets = [("reconciliation_account", account["id"]) for account in accounts]
latest_decisions = _fetch_latest_decisions(conn, account_targets)
session_rows = conn.execute("SELECT * FROM reconciliation_sessions ORDER BY basis_date DESC, updated_at DESC").fetchall()
latest_sessions: dict[str, dict[str, Any]] = {}
for row in session_rows:
session = row_to_dict(row)
latest_sessions.setdefault(session["account_id"], session)
items: list[dict[str, Any]] = []
for account in accounts:
effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"]
@@ -99,6 +108,9 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
"difference_ratio": ratio,
"status": status,
"recommended_decisions": ["계좌이동", "실지출", "실수입", "투자이동", "분석제외", "확인필요"] if status == "REVIEW_NEEDED" else [],
"reconciliation_session": latest_sessions.get(account["id"]),
"review_status": latest_decisions.get(("reconciliation_account", account["id"]), {}).get("status", "pending"),
"review_decision": latest_decisions.get(("reconciliation_account", account["id"])),
"source_note": source_note,
"memo": memo,
**calc,
@@ -0,0 +1,359 @@
from __future__ import annotations
from .shared import (
Any,
db,
fail,
json,
new_id,
normalize_date,
now_iso,
ok,
row_to_dict,
)
VALID_REVIEW_STATUSES = {"pending", "decided", "held", "needs_recheck", "ignored"}
VALID_DECISION_TYPES = {
"account_transfer",
"real_expense",
"real_income",
"investment_transfer",
"excluded_from_analysis",
"duplicate_or_cancel",
"unknown",
}
VALID_RECONCILIATION_STATUSES = {"open", "closed", "needs_recheck", "ignored"}
SENSITIVE_PAYLOAD_KEYS = {"raw_payload", "password", "account_password", "secret", "token", "file_path"}
def _normalize_review_status(value: str | None) -> str:
status = (value or "pending").strip() or "pending"
if status not in VALID_REVIEW_STATUSES:
raise ValueError(f"status must be one of {sorted(VALID_REVIEW_STATUSES)}")
return status
def _normalize_decision_type(value: str | None) -> str:
decision_type = (value or "unknown").strip() or "unknown"
if decision_type not in VALID_DECISION_TYPES:
raise ValueError(f"decision_type must be one of {sorted(VALID_DECISION_TYPES)}")
return decision_type
def _normalize_reconciliation_status(value: str | None) -> str:
status = (value or "open").strip() or "open"
if status not in VALID_RECONCILIATION_STATUSES:
raise ValueError(f"status must be one of {sorted(VALID_RECONCILIATION_STATUSES)}")
return status
def _safe_payload(target_payload: Any) -> str:
if target_payload in (None, ""):
return "{}"
if isinstance(target_payload, str):
try:
parsed = json.loads(target_payload)
except json.JSONDecodeError:
return json.dumps({"summary": target_payload[:500]}, ensure_ascii=False)
elif isinstance(target_payload, dict):
parsed = target_payload
else:
parsed = {"value": str(target_payload)[:500]}
safe: dict[str, Any] = {}
for key, value in parsed.items():
key_text = str(key)
if key_text.lower() in SENSITIVE_PAYLOAD_KEYS:
continue
if isinstance(value, (str, int, float, bool)) or value is None:
safe[key_text] = str(value)[:500] if isinstance(value, str) else value
elif isinstance(value, (list, tuple)):
safe[key_text] = [str(item)[:200] for item in value[:20]]
else:
safe[key_text] = str(value)[:500]
return json.dumps(safe, ensure_ascii=False, sort_keys=True)
def _decode_payload(row: dict[str, Any]) -> dict[str, Any]:
raw = row.get("target_payload") or "{}"
try:
row["target_payload"] = json.loads(raw)
except json.JSONDecodeError:
row["target_payload"] = {}
return row
def _fetch_latest_decisions(conn: Any, targets: list[tuple[str, str]]) -> dict[tuple[str, str], dict[str, Any]]:
if not targets:
return {}
by_target: dict[tuple[str, str], dict[str, Any]] = {}
for target_type, target_id in targets:
row = conn.execute(
"""
SELECT * FROM review_decisions
WHERE target_type=? AND target_id=?
ORDER BY decided_at DESC, updated_at DESC, created_at DESC
LIMIT 1
""",
(target_type, target_id),
).fetchone()
if row:
by_target[(target_type, target_id)] = _decode_payload(row_to_dict(row))
return by_target
def attach_review_decisions(db_conn: Any, items: list[dict[str, Any]], target_type: str, id_field: str = "id") -> list[dict[str, Any]]:
targets = [(target_type, str(item.get(id_field) or "")) for item in items if item.get(id_field)]
decisions = _fetch_latest_decisions(db_conn, targets)
enriched: list[dict[str, Any]] = []
for item in items:
target_id = str(item.get(id_field) or "")
decision = decisions.get((target_type, target_id))
state = decision.get("status") if decision else "pending"
enriched.append({**item, "review_status": state, "review_decision": decision})
return enriched
def list_review_decisions(target_type: str = "", target_id: str = "", status: str = "", limit: int = 100, db_path: str | None = None) -> dict[str, Any]:
clauses: list[str] = []
params: list[Any] = []
if target_type:
clauses.append("target_type=?")
params.append(target_type)
if target_id:
clauses.append("target_id=?")
params.append(target_id)
if status:
try:
status = _normalize_review_status(status)
except ValueError as exc:
return fail("validation_error", str(exc))
clauses.append("status=?")
params.append(status)
where = " WHERE " + " AND ".join(clauses) if clauses else ""
max_limit = max(1, min(int(limit), 1000))
with db(db_path) as conn:
rows = conn.execute(
f"SELECT * FROM review_decisions{where} ORDER BY decided_at DESC, updated_at DESC LIMIT ?",
(*params, max_limit),
).fetchall()
decisions = [_decode_payload(row_to_dict(row)) for row in rows]
return ok(review_decisions=decisions, decisions=decisions, count=len(decisions))
def upsert_review_decision(
target_type: str,
target_id: str,
status: str = "decided",
decision_type: str = "unknown",
reason_code: str = "",
memo: str = "",
operator: str = "operator",
review_after: str = "",
target_payload: Any = None,
decision_id: str = "",
db_path: str | None = None,
) -> dict[str, Any]:
target_type = (target_type or "").strip()
target_id = (target_id or "").strip()
if not target_type or not target_id:
return fail("validation_error", "target_type and target_id are required")
try:
normalized_status = _normalize_review_status(status)
normalized_decision_type = _normalize_decision_type(decision_type)
normalized_review_after = normalize_date(review_after, default_today=False) if review_after else ""
except Exception as exc:
return fail("validation_error", str(exc))
ts = now_iso()
payload_text = _safe_payload(target_payload)
with db(db_path) as conn:
existing = None
if decision_id:
existing = conn.execute("SELECT * FROM review_decisions WHERE id=?", (decision_id,)).fetchone()
if not existing:
return fail("not_found", f"review decision not found: {decision_id}")
else:
existing = conn.execute(
"SELECT * FROM review_decisions WHERE target_type=? AND target_id=? ORDER BY decided_at DESC, updated_at DESC LIMIT 1",
(target_type, target_id),
).fetchone()
row_id = decision_id or (existing["id"] if existing else new_id("rd"))
created_at = existing["created_at"] if existing else ts
decided_at = ts if normalized_status in {"decided", "held", "needs_recheck", "ignored"} else ""
conn.execute(
"""
INSERT INTO review_decisions(id, target_type, target_id, status, decision_type, reason_code, memo, operator, decided_at, review_after, target_payload, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
target_type=excluded.target_type,
target_id=excluded.target_id,
status=excluded.status,
decision_type=excluded.decision_type,
reason_code=excluded.reason_code,
memo=excluded.memo,
operator=excluded.operator,
decided_at=excluded.decided_at,
review_after=excluded.review_after,
target_payload=excluded.target_payload,
updated_at=excluded.updated_at
""",
(row_id, target_type, target_id, normalized_status, normalized_decision_type, reason_code or "", memo or "", operator or "operator", decided_at, normalized_review_after, payload_text, created_at, ts),
)
row = conn.execute("SELECT * FROM review_decisions WHERE id=?", (row_id,)).fetchone()
return ok(review_decision=_decode_payload(row_to_dict(row)), decision_id=row_id)
def hold_review_decision(target_type: str, target_id: str, reason_code: str = "held", memo: str = "", operator: str = "operator", review_after: str = "", db_path: str | None = None) -> dict[str, Any]:
return upsert_review_decision(target_type, target_id, status="held", decision_type="unknown", reason_code=reason_code, memo=memo, operator=operator, review_after=review_after, db_path=db_path)
def mark_review_recheck(target_type: str, target_id: str, reason_code: str = "needs_recheck", memo: str = "", operator: str = "operator", review_after: str = "", db_path: str | None = None) -> dict[str, Any]:
return upsert_review_decision(target_type, target_id, status="needs_recheck", decision_type="unknown", reason_code=reason_code, memo=memo, operator=operator, review_after=review_after, db_path=db_path)
def release_review_decision(target_type: str, target_id: str, memo: str = "", operator: str = "operator", db_path: str | None = None) -> dict[str, Any]:
return upsert_review_decision(target_type, target_id, status="pending", decision_type="unknown", reason_code="released", memo=memo, operator=operator, review_after="", db_path=db_path)
def list_reconciliation_sessions(account_id: str = "", status: str = "", basis_date: str = "", limit: int = 100, db_path: str | None = None) -> dict[str, Any]:
clauses: list[str] = []
params: list[Any] = []
if account_id:
clauses.append("account_id=?")
params.append(account_id)
if status:
try:
status = _normalize_reconciliation_status(status)
except ValueError as exc:
return fail("validation_error", str(exc))
clauses.append("status=?")
params.append(status)
if basis_date:
try:
basis_date = normalize_date(basis_date)
except Exception as exc:
return fail("validation_error", str(exc))
clauses.append("basis_date=?")
params.append(basis_date)
where = " WHERE " + " AND ".join(clauses) if clauses else ""
with db(db_path) as conn:
rows = conn.execute(
f"SELECT * FROM reconciliation_sessions{where} ORDER BY basis_date DESC, updated_at DESC LIMIT ?",
(*params, max(1, min(int(limit), 1000))),
).fetchall()
sessions = [row_to_dict(row) for row in rows]
return ok(reconciliation_sessions=sessions, sessions=sessions, count=len(sessions))
def upsert_reconciliation_session(
account_id: str,
basis_date: str,
expected_balance: int | None = None,
actual_balance: int | None = None,
difference: int | None = None,
status: str = "open",
source_note: str = "",
memo: str = "",
operator: str = "operator",
session_id: str = "",
db_path: str | None = None,
) -> dict[str, Any]:
account_id = (account_id or "").strip()
if not account_id:
return fail("validation_error", "account_id is required")
try:
basis = normalize_date(basis_date, default_today=False)
normalized_status = _normalize_reconciliation_status(status)
expected = int(expected_balance) if expected_balance is not None else None
actual = int(actual_balance) if actual_balance is not None else None
diff = int(difference) if difference is not None else ((expected - actual) if expected is not None and actual is not None else None)
except Exception as exc:
return fail("validation_error", str(exc))
ts = now_iso()
closed_at = ts if normalized_status == "closed" else ""
with db(db_path) as conn:
account = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone()
if not account:
return fail("not_found", f"account not found: {account_id}")
existing = None
if session_id:
existing = conn.execute("SELECT * FROM reconciliation_sessions WHERE id=?", (session_id,)).fetchone()
if not existing:
return fail("not_found", f"reconciliation session not found: {session_id}")
else:
existing = conn.execute("SELECT * FROM reconciliation_sessions WHERE account_id=? AND basis_date=?", (account_id, basis)).fetchone()
row_id = session_id or (existing["id"] if existing else new_id("rs"))
created_at = existing["created_at"] if existing else ts
if normalized_status != "closed" and existing:
closed_at = existing["closed_at"] if existing["status"] == "closed" and normalized_status == "closed" else ""
conn.execute(
"""
INSERT INTO reconciliation_sessions(id, account_id, basis_date, expected_balance, actual_balance, difference, status, closed_at, source_note, memo, operator, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
account_id=excluded.account_id,
basis_date=excluded.basis_date,
expected_balance=excluded.expected_balance,
actual_balance=excluded.actual_balance,
difference=excluded.difference,
status=excluded.status,
closed_at=excluded.closed_at,
source_note=excluded.source_note,
memo=excluded.memo,
operator=excluded.operator,
updated_at=excluded.updated_at
""",
(row_id, account_id, basis, expected, actual, diff, normalized_status, closed_at, source_note or "", memo or "", operator or "operator", created_at, ts),
)
row = conn.execute("SELECT * FROM reconciliation_sessions WHERE id=?", (row_id,)).fetchone()
return ok(reconciliation_session=row_to_dict(row), session_id=row_id)
def close_reconciliation_session(session_id: str = "", account_id: str = "", basis_date: str = "", memo: str = "", operator: str = "operator", db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn:
if session_id:
row = conn.execute("SELECT * FROM reconciliation_sessions WHERE id=?", (session_id,)).fetchone()
elif account_id and basis_date:
try:
basis = normalize_date(basis_date, default_today=False)
except Exception as exc:
return fail("validation_error", str(exc))
row = conn.execute("SELECT * FROM reconciliation_sessions WHERE account_id=? AND basis_date=?", (account_id, basis)).fetchone()
else:
return fail("validation_error", "session_id or account_id+basis_date is required")
if not row:
return fail("not_found", "reconciliation session not found")
existing = row_to_dict(row)
return upsert_reconciliation_session(
account_id=existing["account_id"],
basis_date=existing["basis_date"],
expected_balance=existing.get("expected_balance"),
actual_balance=existing.get("actual_balance"),
difference=existing.get("difference"),
status="closed",
source_note=existing.get("source_note", ""),
memo=memo or existing.get("memo", ""),
operator=operator,
session_id=existing["id"],
db_path=db_path,
)
def get_review_operating_indicators(db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn:
review_rows = conn.execute("SELECT status, COUNT(*) AS count FROM review_decisions GROUP BY status").fetchall()
pending_count = int(conn.execute("SELECT COUNT(*) FROM review_decisions WHERE status='pending'").fetchone()[0])
held_recheck_count = int(conn.execute("SELECT COUNT(*) FROM review_decisions WHERE status IN ('held','needs_recheck')").fetchone()[0])
unreconciled_accounts = int(conn.execute("SELECT COUNT(DISTINCT account_id) FROM reconciliation_sessions WHERE status<>'closed'").fetchone()[0])
last_closed = conn.execute("SELECT MAX(closed_at) AS closed_at FROM reconciliation_sessions WHERE status='closed' AND closed_at<>''").fetchone()["closed_at"]
uncategorized_pending = int(conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active' AND COALESCE(category,'')='' ").fetchone()[0])
by_status = {row["status"]: int(row["count"] or 0) for row in review_rows}
return ok(
indicators={
"pending_review_count": pending_count,
"held_recheck_count": held_recheck_count,
"unreconciled_account_count": unreconciled_accounts,
"last_reconciliation_closed_at": last_closed or "",
"uncategorized_recommendation_backlog": uncategorized_pending,
"review_decisions_by_status": by_status,
}
)
@@ -57,7 +57,7 @@ def _readonly_sqlite_connection(path: Path) -> sqlite3.Connection:
def _safe_table_counts(conn: sqlite3.Connection) -> dict[str, int]:
counts: dict[str, int] = {}
for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories", "manual_assets", "asset_snapshots"):
for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories", "manual_assets", "asset_snapshots", "review_decisions", "reconciliation_sessions"):
exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
if exists:
counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
@@ -10,6 +10,7 @@ from .shared import (
re,
row_to_dict,
)
from .review_decision_service import attach_review_decisions
def _rule_matches(rule: dict[str, Any], tx: dict[str, Any]) -> bool:
if rule.get("transaction_type") and rule.get("transaction_type") != tx.get("type", ""):
@@ -171,6 +172,8 @@ def get_uncategorized_workbench(
group["recommendation"] = rec
group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True)
with db(db_path) as conn:
txs = attach_review_decisions(conn, txs, "uncategorized_transaction", "id")
recommendable_count = sum(1 for tx in txs if tx.get("recommended_category"))
repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2)
total = row_to_dict(total_row) if total_row else {"count": 0, "total_amount": 0, "merchant_group_count": 0}