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
+45
View File
@@ -166,6 +166,51 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
"""Preview balance reconciliation for manual_only/partial_import accounts without mutating the DB."""
return contracts.get_balance_reconciliation_report(account_id, baseline_balance, baseline_at, source_note, memo, tolerance)
@mcp.tool()
def list_review_decisions(target_type: str = "", target_id: str = "", status: str = "", limit: int = 100) -> dict:
"""List persisted review decisions for workbench targets."""
return contracts.list_review_decisions(target_type=target_type, target_id=target_id, status=status, limit=limit)
@mcp.tool()
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: dict | None = None, decision_id: str = "") -> dict:
"""Create or update a non-destructive persisted review decision."""
return contracts.upsert_review_decision(target_type, target_id, status, decision_type, reason_code, memo, operator, review_after, target_payload, decision_id)
@mcp.tool()
def hold_review_decision(target_type: str, target_id: str, reason_code: str = "held", memo: str = "", operator: str = "operator", review_after: str = "") -> dict:
"""Mark a review target as held without deleting evidence."""
return contracts.hold_review_decision(target_type, target_id, reason_code, memo, operator, review_after)
@mcp.tool()
def mark_review_recheck(target_type: str, target_id: str, reason_code: str = "needs_recheck", memo: str = "", operator: str = "operator", review_after: str = "") -> dict:
"""Mark a review target as needing recheck."""
return contracts.mark_review_recheck(target_type, target_id, reason_code, memo, operator, review_after)
@mcp.tool()
def release_review_decision(target_type: str, target_id: str, memo: str = "", operator: str = "operator") -> dict:
"""Release a held/recheck review target back to pending."""
return contracts.release_review_decision(target_type, target_id, memo, operator)
@mcp.tool()
def list_reconciliation_sessions(account_id: str = "", status: str = "", basis_date: str = "", limit: int = 100) -> dict:
"""List persisted reconciliation sessions."""
return contracts.list_reconciliation_sessions(account_id=account_id, status=status, basis_date=basis_date, limit=limit)
@mcp.tool()
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 = "") -> dict:
"""Create or update an account/basis-date reconciliation session."""
return contracts.upsert_reconciliation_session(account_id, basis_date, expected_balance, actual_balance, difference, status, source_note, memo, operator, session_id)
@mcp.tool()
def close_reconciliation_session(session_id: str = "", account_id: str = "", basis_date: str = "", memo: str = "", operator: str = "operator") -> dict:
"""Close an existing reconciliation session without destructive reset/delete."""
return contracts.close_reconciliation_session(session_id=session_id, account_id=account_id, basis_date=basis_date, memo=memo, operator=operator)
@mcp.tool()
def get_review_operating_indicators() -> dict:
"""Return workbench indicators: pending, held/recheck, unreconciled, last close, uncategorized backlog."""
return contracts.get_review_operating_indicators()
@mcp.tool()
def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: list[str] | str | None = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True) -> dict:
"""Create an income, expense, transfer, refund, or adjustment transaction. Amount is positive KRW minor unit; type determines direction."""
@@ -26,7 +26,16 @@ from ...service import (
get_ledger_db_status as get_ledger_db_status,
get_monthly_cashflow_report as get_monthly_cashflow_report,
get_net_worth_timeseries as get_net_worth_timeseries,
get_review_operating_indicators as get_review_operating_indicators,
get_uncategorized_merchant_summary as get_uncategorized_merchant_summary,
hold_review_decision as hold_review_decision,
list_reconciliation_sessions as list_reconciliation_sessions,
list_review_decisions as list_review_decisions,
mark_review_recheck as mark_review_recheck,
release_review_decision as release_review_decision,
upsert_reconciliation_session as upsert_reconciliation_session,
upsert_review_decision as upsert_review_decision,
close_reconciliation_session as close_reconciliation_session,
health_check as health_check,
import_kb_bank_pdf as import_kb_bank_pdf,
import_seoulpay_xlsx as import_seoulpay_xlsx,
@@ -86,7 +95,16 @@ __all__ = [
"get_ledger_db_status",
"get_monthly_cashflow_report",
"get_net_worth_timeseries",
"get_review_operating_indicators",
"get_uncategorized_merchant_summary",
"hold_review_decision",
"list_reconciliation_sessions",
"list_review_decisions",
"mark_review_recheck",
"release_review_decision",
"upsert_reconciliation_session",
"upsert_review_decision",
"close_reconciliation_session",
"health_check",
"import_kb_bank_pdf",
"import_seoulpay_xlsx",
@@ -10,7 +10,11 @@ from ...service import (
delete_ledger_category as delete_ledger_category,
get_uncategorized_merchant_summary as get_uncategorized_merchant_summary,
get_uncategorized_workbench as get_uncategorized_workbench,
hold_review_decision as hold_review_decision,
list_category_rules as list_category_rules,
mark_review_recheck as mark_review_recheck,
release_review_decision as release_review_decision,
upsert_review_decision as upsert_review_decision,
list_ledger_categories as list_ledger_categories,
list_uncategorized_transactions as list_uncategorized_transactions,
preview_categorization as preview_categorization,
@@ -29,7 +33,11 @@ __all__ = [
"delete_ledger_category",
"get_uncategorized_merchant_summary",
"get_uncategorized_workbench",
"hold_review_decision",
"list_category_rules",
"mark_review_recheck",
"release_review_decision",
"upsert_review_decision",
"list_ledger_categories",
"list_uncategorized_transactions",
"preview_categorization",
@@ -1,7 +1,25 @@
from __future__ import annotations
from ...service import get_balance_reconciliation_report as get_balance_reconciliation_report
from ...service import (
close_reconciliation_session as close_reconciliation_session,
get_balance_reconciliation_report as get_balance_reconciliation_report,
get_review_operating_indicators as get_review_operating_indicators,
hold_review_decision as hold_review_decision,
list_reconciliation_sessions as list_reconciliation_sessions,
mark_review_recheck as mark_review_recheck,
release_review_decision as release_review_decision,
upsert_reconciliation_session as upsert_reconciliation_session,
upsert_review_decision as upsert_review_decision,
)
__all__ = [
"close_reconciliation_session",
"get_balance_reconciliation_report",
"get_review_operating_indicators",
"hold_review_decision",
"list_reconciliation_sessions",
"mark_review_recheck",
"release_review_decision",
"upsert_reconciliation_session",
"upsert_review_decision",
]
@@ -3,9 +3,13 @@ from __future__ import annotations
from ...service import (
find_cancel_original_candidates as find_cancel_original_candidates,
find_self_transfer_candidates as find_self_transfer_candidates,
hold_review_decision as hold_review_decision,
link_cancel_to_original as link_cancel_to_original,
link_self_transfer_pair as link_self_transfer_pair,
list_cross_transfer_review_queue as list_cross_transfer_review_queue,
mark_review_recheck as mark_review_recheck,
release_review_decision as release_review_decision,
upsert_review_decision as upsert_review_decision,
preview_cross_account_transfers as preview_cross_account_transfers,
run_cross_account_transfer_match as run_cross_account_transfer_match,
)
@@ -13,9 +17,13 @@ from ...service import (
__all__ = [
"find_cancel_original_candidates",
"find_self_transfer_candidates",
"hold_review_decision",
"link_cancel_to_original",
"link_self_transfer_pair",
"list_cross_transfer_review_queue",
"mark_review_recheck",
"release_review_decision",
"upsert_review_decision",
"preview_cross_account_transfers",
"run_cross_account_transfer_match",
]
@@ -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}
@@ -102,6 +102,40 @@ def migrate_db(conn: sqlite3.Connection) -> None:
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_run ON asset_snapshots(basis_date, snapshot_run_id, created_at)")
conn.execute("""CREATE TABLE IF NOT EXISTS review_decisions (
id TEXT PRIMARY KEY,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
decision_type TEXT NOT NULL DEFAULT 'unknown',
reason_code TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
operator TEXT NOT NULL DEFAULT 'operator',
decided_at TEXT NOT NULL DEFAULT '',
review_after TEXT NOT NULL DEFAULT '',
target_payload TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_review_decisions_target ON review_decisions(target_type, target_id, updated_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_review_decisions_status ON review_decisions(status, review_after, updated_at)")
conn.execute("""CREATE TABLE IF NOT EXISTS reconciliation_sessions (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
basis_date TEXT NOT NULL,
expected_balance INTEGER,
actual_balance INTEGER,
difference INTEGER,
status TEXT NOT NULL DEFAULT 'open',
closed_at TEXT NOT NULL DEFAULT '',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
operator TEXT NOT NULL DEFAULT 'operator',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)""")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at)")
conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,))
@@ -1,6 +1,6 @@
from __future__ import annotations
SCHEMA_VERSION = "10"
SCHEMA_VERSION = "11"
SCHEMA_SQL = """
PRAGMA journal_mode=WAL;
@@ -168,7 +168,43 @@ CREATE TABLE IF NOT EXISTS asset_snapshots (
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date);
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id);
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '10');
CREATE TABLE IF NOT EXISTS review_decisions (
id TEXT PRIMARY KEY,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
decision_type TEXT NOT NULL DEFAULT 'unknown',
reason_code TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
operator TEXT NOT NULL DEFAULT 'operator',
decided_at TEXT NOT NULL DEFAULT '',
review_after TEXT NOT NULL DEFAULT '',
target_payload TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_review_decisions_target ON review_decisions(target_type, target_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_review_decisions_status ON review_decisions(status, review_after, updated_at);
CREATE TABLE IF NOT EXISTS reconciliation_sessions (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
basis_date TEXT NOT NULL,
expected_balance INTEGER,
actual_balance INTEGER,
difference INTEGER,
status TEXT NOT NULL DEFAULT 'open',
closed_at TEXT NOT NULL DEFAULT '',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
operator TEXT NOT NULL DEFAULT 'operator',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date);
CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at);
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '11');
"""
ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = {
+2
View File
@@ -13,6 +13,7 @@ from .application import cross_transfer_service as _cross_transfer_service
from .application import imports_service as _imports_service
from .application import import_transfer_services as _import_transfer_services
from .application import reconciliation_service as _reconciliation_service
from .application import review_decision_service as _review_decision_service
from .application import shared as _shared
from .application import system_services as _system_services
from .application import transactions_write_services as _transactions_write_services
@@ -32,6 +33,7 @@ _PROXY_MODULES = (
_category_rules_write_services,
_transaction_report_services,
_reconciliation_service,
_review_decision_service,
_imports_service,
_import_transfer_services,
_cross_transfer_service,