feat: persist review workbench decisions (#1)
Merge dcy-012 / REQ-020 / BAT-016 implementation after green CI.
This commit is contained in:
+65
-6
@@ -649,6 +649,29 @@ def cancel_original_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
|
||||
st.divider()
|
||||
|
||||
|
||||
def render_review_decision_actions(gateway: Any, target_type: str, target_id: str, key_prefix: str, db_path: str, target_payload: dict[str, Any] | None = None) -> None:
|
||||
decision_type = st.selectbox(
|
||||
"판정",
|
||||
["unknown", "account_transfer", "real_expense", "real_income", "investment_transfer", "excluded_from_analysis", "duplicate_or_cancel"],
|
||||
key=f"{key_prefix}_decision_type",
|
||||
)
|
||||
reason_code = st.text_input("사유 코드", value="operator_review", key=f"{key_prefix}_reason")
|
||||
memo = st.text_input("결정 메모", value="", key=f"{key_prefix}_memo")
|
||||
cols = st.columns(4)
|
||||
if cols[0].button("결정 저장", key=f"{key_prefix}_decided", use_container_width=True, type="primary"):
|
||||
res = gateway.upsert_review_decision(target_type, target_id, status="decided", decision_type=decision_type, reason_code=reason_code, memo=memo, target_payload=target_payload or {}, db_path=db_path)
|
||||
clear_cache(); st.success("저장했습니다." if res.get("success") else res.get("error", res)); st.rerun()
|
||||
if cols[1].button("보류", key=f"{key_prefix}_held", use_container_width=True):
|
||||
res = gateway.hold_review_decision(target_type, target_id, reason_code=reason_code or "held", memo=memo, db_path=db_path)
|
||||
clear_cache(); st.success("보류했습니다." if res.get("success") else res.get("error", res)); st.rerun()
|
||||
if cols[2].button("재검토", key=f"{key_prefix}_recheck", use_container_width=True):
|
||||
res = gateway.mark_review_recheck(target_type, target_id, reason_code=reason_code or "needs_recheck", memo=memo, db_path=db_path)
|
||||
clear_cache(); st.success("재검토로 표시했습니다." if res.get("success") else res.get("error", res)); st.rerun()
|
||||
if cols[3].button("해제", key=f"{key_prefix}_release", use_container_width=True):
|
||||
res = gateway.release_review_decision(target_type, target_id, memo=memo, db_path=db_path)
|
||||
clear_cache(); st.success("pending으로 해제했습니다." if res.get("success") else res.get("error", res)); st.rerun()
|
||||
|
||||
|
||||
RULE_MATCH_FIELDS = ["merchant", "raw_summary", "counterparty", "payment_method", "branch", "description", "memo"]
|
||||
RULE_MATCH_TYPES = ["contains", "equals", "starts_with", "ends_with", "regex"]
|
||||
RULE_TX_TYPES = ["", "income", "expense", "transfer", "refund", "adjustment"]
|
||||
@@ -888,6 +911,8 @@ def render_uncategorized_work_items(rows: list[dict[str, Any]], db_path: str, re
|
||||
linked_transfer_candidates_dialog(row, db_path)
|
||||
if action_cols[4].button("원거래찾기", key=f"uncat_cancel_{row['id']}_{idx}", use_container_width=True):
|
||||
cancel_original_candidates_dialog(row, db_path)
|
||||
with st.expander(f"Review decision · 현재 {row.get('review_status', 'pending')}", expanded=False):
|
||||
render_review_decision_actions(categorization_gateway, "uncategorized_transaction", row["id"], f"uncat_review_decision_{row['id']}_{idx}", db_path, target_payload=row)
|
||||
|
||||
|
||||
|
||||
@@ -944,6 +969,8 @@ def page_overview(db_path: str) -> None:
|
||||
uncategorized_res = overview_gateway.list_uncategorized_transactions(limit=1000, db_path=db_path)
|
||||
transfer_queue_res = overview_gateway.list_cross_transfer_review_queue(limit=300, db_path=db_path)
|
||||
reconciliation_res = reconciliation_gateway.get_balance_reconciliation_report(db_path=db_path)
|
||||
review_indicators_res = reconciliation_gateway.get_review_operating_indicators(db_path=db_path)
|
||||
review_indicators = review_indicators_res.get("indicators", {}) if review_indicators_res.get("success") else {}
|
||||
backups_res = overview_gateway.list_ledger_backups(limit=1)
|
||||
imports_res = overview_gateway.list_ledger_import_batches(limit=1, db_path=db_path)
|
||||
|
||||
@@ -1008,11 +1035,12 @@ def page_overview(db_path: str) -> None:
|
||||
reconciliation_items = reconciliation_res.get("items", []) if reconciliation_res.get("success") else []
|
||||
reconciliation_review = [item for item in reconciliation_items if item.get("status") == "REVIEW_NEEDED"]
|
||||
missing_balance = asset_report.get("missing_balance_accounts", [])
|
||||
w1, w2, w3, w4 = st.columns(4)
|
||||
w1.metric("미분류 거래", f"{uncategorized_res.get('count', 0):,}건")
|
||||
w2.metric("확인필요 큐", f"{len(review_items):,}건")
|
||||
w3.metric("잔액대조 필요", f"{len(reconciliation_review):,}건")
|
||||
w4.metric("잔액 미입력", f"{len(missing_balance):,}건")
|
||||
w1, w2, w3, w4, w5 = st.columns(5)
|
||||
w1.metric("미처리 review", f"{review_indicators.get('pending_review_count', len(review_items)):,}건")
|
||||
w2.metric("보류/재검토", f"{review_indicators.get('held_recheck_count', 0):,}건")
|
||||
w3.metric("미대조 계좌", f"{review_indicators.get('unreconciled_account_count', len(reconciliation_review)):,}개")
|
||||
w4.metric("최근 대조 마감", review_indicators.get("last_reconciliation_closed_at") or "-")
|
||||
w5.metric("미분류 추천 대기", f"{review_indicators.get('uncategorized_recommendation_backlog', uncategorized_res.get('count', 0)):,}건")
|
||||
latest_backup = (backups_res.get("backups") or [{}])[0] if backups_res.get("success") else {}
|
||||
latest_import = (imports_res.get("import_batches") or [{}])[0] if imports_res.get("success") else {}
|
||||
st.caption(
|
||||
@@ -1231,7 +1259,11 @@ def page_cross_transfer_review(db_path: str) -> None:
|
||||
for r in rows
|
||||
}
|
||||
selected = st.selectbox("수동 수정할 거래", list(options.keys()))
|
||||
update_transaction_form(options[selected], "transfer_review", db_path)
|
||||
selected_row = options[selected]
|
||||
st.caption(f"현재 review_status: {selected_row.get('review_status', 'pending')}")
|
||||
with st.expander("Review decision 저장/보류/재검토/해제", expanded=False):
|
||||
render_review_decision_actions(transfers_gateway, "cross_transfer", selected_row["id"], f"transfer_review_decision_{selected_row['id']}", db_path, target_payload=selected_row)
|
||||
update_transaction_form(selected_row, "transfer_review", db_path)
|
||||
|
||||
|
||||
def page_uncategorized(db_path: str) -> None:
|
||||
@@ -1679,6 +1711,33 @@ def page_accounts(db_path: str) -> None:
|
||||
st.dataframe(items[show_cols], width="stretch")
|
||||
review_count = int((items["status"] == "REVIEW_NEEDED").sum()) if "status" in items else 0
|
||||
st.metric("확인필요", f"{review_count}건")
|
||||
selectable = report.get("items", [])
|
||||
if selectable:
|
||||
labels = {f"{item.get('account_name')} · {item.get('status')} · {format_krw(item.get('difference'))}": item for item in selectable}
|
||||
chosen_label = st.selectbox("대조 세션/판정 대상", list(labels.keys()))
|
||||
chosen = labels[chosen_label]
|
||||
with st.expander("Reconciliation session 기록/마감", expanded=False):
|
||||
status = st.selectbox("Session status", ["open", "closed", "needs_recheck", "ignored"], key="recon_session_status")
|
||||
memo = st.text_input("Session memo", value="", key="recon_session_memo")
|
||||
if st.button("Session 저장/갱신", key="recon_session_save", type="primary"):
|
||||
res = reconciliation_gateway.upsert_reconciliation_session(
|
||||
chosen["account_id"],
|
||||
str(chosen.get("baseline_at", ""))[:10],
|
||||
expected_balance=chosen.get("baseline_balance"),
|
||||
actual_balance=chosen.get("calculated_balance"),
|
||||
difference=chosen.get("difference"),
|
||||
status=status,
|
||||
source_note=source_note,
|
||||
memo=memo,
|
||||
db_path=db_path,
|
||||
)
|
||||
clear_cache(); st.success("저장했습니다." if res.get("success") else res.get("error", res)); st.rerun()
|
||||
existing_session = chosen.get("reconciliation_session") or {}
|
||||
if existing_session.get("id") and st.button("기존 Session 마감", key="recon_session_close"):
|
||||
res = reconciliation_gateway.close_reconciliation_session(session_id=existing_session["id"], memo=memo, db_path=db_path)
|
||||
clear_cache(); st.success("마감했습니다." if res.get("success") else res.get("error", res)); st.rerun()
|
||||
with st.expander(f"Review decision · 현재 {chosen.get('review_status', 'pending')}", expanded=False):
|
||||
render_review_decision_actions(reconciliation_gateway, "reconciliation_account", chosen["account_id"], f"recon_review_{chosen['account_id']}", db_path, target_payload=chosen)
|
||||
else:
|
||||
st.info("잔액대조 대상 계좌가 없습니다.")
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user