from __future__ import annotations import json import streamlit as st from jarca_ledger_mcp.adapters.streamlit import settlement_gateway as gateway def _safe_result(result: dict) -> None: if result.get("success"): st.success("요청을 처리했습니다.") else: # Never expose exception details, arbitrary evidence, or backend secrets. st.error(f"요청을 처리하지 못했습니다 ({result.get('error_code', 'operation_failed')}).") def _invalidate_and_rerun(draft_id: str, result: dict) -> None: st.session_state[f"settlement_mutation_feedback_{draft_id}"] = { "success": bool(result.get("success")), "error_code": result.get("error_code", "operation_failed"), "memo_saved": bool(result.get("memo_saved")), } st.session_state.pop(f"review_result_{draft_id}", None) st.session_state.pop(f"approved_{draft_id}", None) st.session_state.pop("settlement_bound_state", None) st.rerun() def _authenticated_actor() -> str | None: from dashboard.authentication import get_authenticated_actor return get_authenticated_actor() def _open_settlement_history(draft_id: str, run_id: str = "") -> None: """Open the read-only history panel and prefer the completed draft's run.""" st.session_state["settlement_history_open_draft"] = draft_id if run_id: st.session_state[f"settlement_history_run_{draft_id}"] = run_id def _render_settlement_history(db_path: str | None, month: str, draft_id: str) -> None: """List immutable runs and fetch the exact record selected by the operator.""" listed = gateway.list_monthly_settlement_history(month, db_path=db_path) if not listed.get("success"): st.error(f"마감 이력을 불러오지 못했습니다 ({listed.get('error_code', 'operation_failed')}).") return history = listed.get("history", []) if not isinstance(history, list): history = [] runs = [str(item.get("run_id")) for item in history if isinstance(item, dict) and item.get("run_id")] if not runs: st.info("저장된 불변 마감 이력이 없습니다.") return selection_key = f"settlement_history_run_{draft_id}" if st.session_state.get(selection_key) not in runs: st.session_state[selection_key] = runs[0] selected_run = st.selectbox("불변 마감 run 선택", runs, key=selection_key) st.dataframe(history, hide_index=True, use_container_width=True) record = gateway.get_monthly_settlement_history_record(selected_run, db_path=db_path) if not record.get("success"): st.error(f"마감 기록을 불러오지 못했습니다 ({record.get('error_code', 'operation_failed')}).") return st.caption("선택한 run의 저장 시점 불변 기록") st.json(record.get("history", {})) def _create_settlement_dialog(db_path: str | None, month: str, correction_run: str = "") -> None: @st.dialog("정산 진행") def dialog() -> None: target_month = st.text_input("대상 월", value=month, key="settlement_dialog_month") memo = st.text_area("메모 (선택)", key="settlement_dialog_memo") reason = st.text_area("정정 사유 (필수)", key="settlement_dialog_reason") if correction_run else "" if correction_run: st.caption(f"정정 연결 마감 run: {correction_run}") left, right = st.columns(2) if left.button("취소", key="settlement_dialog_cancel"): st.session_state.pop("settlement_dialog_open", None) st.rerun() if right.button("초안 만들기", type="primary", key="settlement_dialog_submit"): actor = _authenticated_actor() if not actor: st.error("인증된 작업자 identity를 확인할 수 없습니다. 다시 로그인하세요.") return if correction_run and not reason.strip(): st.error("정정 사유를 입력하세요.") return # Avoid creating a database when the read/list path is unavailable. listing_probe = gateway.list_settlement_drafts(target_month, db_path=db_path) if not listing_probe.get("success"): _safe_result(listing_probe) return # A cancelled draft must not reserve the next create operation's # idempotency key. Scope the key to this explicit dialog submit. import hashlib import uuid operation = "correction" if correction_run else "initial" nonce = uuid.uuid4().hex key = hashlib.sha256(f"dashboard:{operation}:{target_month}:{correction_run}:{nonce}".encode()).hexdigest() result = gateway.create_settlement_draft(target_month, actor, key, memo=memo, correction_of_run_id=correction_run, correction_reason=reason, db_path=db_path) if result.get("success"): # The selectbox is the authoritative selection on the next # rerun; seed its widget state, not a separate shadow key. st.query_params["page"] = "월정산" st.query_params["month"] = target_month st.query_params.pop("year", None) st.query_params.pop("status", None) st.rerun() _safe_result(result) dialog() def render_settlement_drafts(db_path: str | None = None, month: str = "") -> None: st.header("정산 진행") st.caption("월별 정산을 시작하고, 활성 초안을 이어서 작업합니다.") month_value = month or "2026-09" start = st.button("정산 시작", type="primary", key="settlement_start") if start: st.session_state["settlement_dialog_open"] = True if start or st.session_state.get("settlement_dialog_open"): _create_settlement_dialog(db_path, month_value) actor = _authenticated_actor() if not actor: st.warning("인증된 identity가 없으므로 조회만 가능합니다. 정산 작업을 시작하려면 다시 로그인하세요.") listed = gateway.list_settlement_drafts(month_value, db_path=db_path) drafts = listed.get("drafts", []) if listed.get("success") else [] if not listed.get("success"): st.error(f"초안 목록을 불러오지 못했습니다 ({listed.get('error_code', 'operation_failed')}).") if not isinstance(drafts, list): drafts = [] ids = [str(d.get("id", "")) for d in drafts if isinstance(d, dict) and d.get("id")] if ids: preferred = st.session_state.get("settlement_selected_draft") if preferred in ids: st.session_state["settlement_draft_id"] = preferred elif st.session_state.get("settlement_draft_id") not in ids: st.session_state["settlement_draft_id"] = ids[0] selected_id = st.selectbox("초안 선택", ids, key="settlement_draft_id") if ids else "" if not selected_id: if listed.get("success"): st.info("초안이 없습니다. 생성은 명시적 버튼으로 수행됩니다.") st.caption("목록은 활성·완료·취소 초안을 포함합니다. 평가자료 조회·등록·정정은 초안 변경과 별도 작업입니다.") st.button("정산시작", disabled=not actor, key="settlement_start_empty") st.caption("제외 사유는 항목별로 명시 입력합니다.") st.caption("거래 정산 상태(transaction readiness)는 자산 마감 준비와 별도입니다. 기존 lifecycle 작업: 검토·승인·최종·취소·평가자료.") return detail = gateway.get_settlement_draft(selected_id, db_path=db_path) feedback = st.session_state.pop(f"settlement_mutation_feedback_{selected_id}", None) if isinstance(feedback, dict): if feedback.get("memo_saved"): st.warning("메모는 저장되었지만 항목 변경은 실패했습니다") _safe_result(feedback) draft = detail.get("draft", {}) if detail.get("success") else {} if not isinstance(draft, dict): draft = {} version = draft.get("version") fingerprint = detail.get("review", {}).get("review_fingerprint", "") if isinstance(detail.get("review"), dict) else "" state_key = "settlement_bound_state" bound = (selected_id, version, fingerprint) previous = st.session_state.get(state_key) if previous != bound: st.session_state[state_key] = bound st.session_state.pop(f"review_result_{selected_id}", None) st.session_state.pop(f"approved_{selected_id}", None) st.caption(f"월 {draft.get('month', month_value)} · 기준일 {draft.get('basis_date', '-')} · 상태 {draft.get('status', '-')} · 버전 {version}") st.caption(f"draft_version={draft.get('draft_version', version)} · created_by={draft.get('created_by', '')} · created_at={draft.get('created_at', '')} · updated_at={draft.get('updated_at', '')}") st.caption(f"final_run_id={draft.get('final_run_id', '')} · correction_of_run_id={draft.get('correction_of_run_id', '')} · correction_reason={draft.get('correction_reason', '')}") st.caption(f"검토 상태: {'검토 완료' if fingerprint else '미검토'} · review_fingerprint={fingerprint} · 메모 {draft.get('memo', '')}") if draft.get("status") == "active": action_label = "정정이어서작업" if draft.get("correction_of_run_id") else "이어서작업" st.button(action_label, disabled=not actor, key=f"continue_{selected_id}") if draft.get("status") == "completed": st.success("완료 — 변경 불가, 정정은 새 초안으로 생성합니다.") if draft.get("status") in {"completed", "cancelled"}: final = detail.get("final_response") if isinstance(detail.get("final_response"), dict) else {} if final: st.markdown("### 기록된 종합 월정산 결과") st.caption(f"정책 버전: {final.get('aggregation_version', 'legacy')}") for key, label in (("income_total", "수입"), ("expense_total", "지출"), ("net_cashflow", "순현금흐름"), ("total_assets", "자산"), ("total_liabilities", "부채"), ("net_worth", "순자산")): st.write(label, final.get(key) if final.get(key) is not None else "미보존") rows = final.get("cashflow_items", []) if isinstance(rows, list): st.dataframe([{k: row.get(k) for k in ("cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency")} for row in rows if isinstance(row, dict)]) st.markdown("### 불변 이력") st.write("final_run_id", draft.get("final_run_id")) st.write("correction_of_run_id", draft.get("correction_of_run_id")) st.json(detail.get("items", [])) st.button( "마감내역보기", key=f"closed_history_{selected_id}", on_click=_open_settlement_history, args=(selected_id, str(draft.get("final_run_id") or "")), ) if st.session_state.get("settlement_history_open_draft") == selected_id: _render_settlement_history(db_path, str(draft.get("month") or month_value), selected_id) if draft.get("final_run_id") and st.button("정정시작", disabled=not actor, key=f"correction_start_{selected_id}"): _create_settlement_dialog(db_path, draft.get("month", month_value), str(draft["final_run_id"])) st.caption("종료된 초안의 증거는 수정하지 않습니다. 정정이 필요하면 이전 마감과 사유를 연결해 새 초안을 생성하세요.") return items = detail.get("items", []) if isinstance(detail.get("items"), list) else [] for item in items: if not isinstance(item, dict): continue st.write(f"{item.get('source_type', '')} {item.get('source_id', '')} · {item.get('status', '')} · 평가 {item.get('valuation_date', '-')}") basis, actual = item.get("basis_date", draft.get("basis_date", "")), item.get("valuation_date", "") if basis and actual: try: from datetime import date difference = (date.fromisoformat(actual) - date.fromisoformat(basis)).days st.caption(f"기준일 대비 날짜 차이: {difference:+}일 ({'이후 대체' if difference > 0 else '이전' if difference < 0 else '동일'})") except ValueError: st.caption("날짜 차이를 계산할 수 없습니다.") if item.get("status") == "missing": st.warning("누락 — 값은 0원이 아닙니다.") st.markdown("### 종합 월정산 정책") policy_now = draft.get("cashflow_policy") if isinstance(draft.get("cashflow_policy"), dict) else {"savings_categories": [], "category_decisions": []} with st.form(f"cashflow_policy_{selected_id}"): policy_text = st.text_area("정책 JSON (savings_categories 및 category_decisions)", value=json.dumps(policy_now, ensure_ascii=False, indent=2), height=220) policy_submit = st.form_submit_button("정책 저장", disabled=not actor) if policy_submit: try: policy = json.loads(policy_text) if not isinstance(policy, dict) or not isinstance(policy.get("savings_categories", []), list) or not isinstance(policy.get("category_decisions", []), list): raise ValueError for decision in policy.get("category_decisions", []): if not isinstance(decision, dict) or decision.get("cashflow_kind") not in {"income", "expense", "excluded"}: raise ValueError result = gateway.set_settlement_cashflow_policy(selected_id, version, actor, policy, db_path=db_path) if result.get("success"): _invalidate_and_rerun(selected_id, result) _safe_result(result) except (ValueError, TypeError): st.error("정책은 유효한 JSON이어야 하며 각 분류는 income, expense 또는 excluded로 지정하세요.") with st.form(f"settlement_edit_{selected_id}"): memo_edit = st.text_input("초안 메모 저장", value=draft.get("memo", "")) source_type = st.selectbox("대상 유형", ["account", "manual_asset"]) source_id = st.text_input("대상 ID") valuation_id = st.text_input("평가자료 ID (비우면 해제)") mode = st.selectbox("선택 방식", ["exact", "carried_before", "manual_substitute"]) selection_reason = st.text_input("선택 사유") included = st.checkbox("포함", value=True) exclusion_reason = st.text_input("제외 사유") save = st.form_submit_button("저장 / 지정 / 포함·제외", disabled=not actor) if save: result = gateway.update_settlement_draft(selected_id, version, actor, memo_edit, db_path=db_path) current_version = (result.get("draft") or {}).get("version", version) if result.get("success") else version memo_updated = bool(result.get("success")) if result.get("success") and source_id: result = gateway.set_settlement_draft_item(selected_id, source_type, source_id, current_version, actor, valuation_id=valuation_id, selection_mode=mode, selection_reason=selection_reason, included=included, exclusion_reason=exclusion_reason, db_path=db_path) if not result.get("success"): result = {**result, "memo_saved": True} if memo_updated or result.get("success"): _invalidate_and_rerun(selected_id, result) _safe_result(result) with st.form(f"valuation_{selected_id}"): account_id = st.text_input("평가 등록 계좌 ID") valuation_date = st.text_input("평가일 YYYY-MM-DD") amount = st.number_input("금액", value=0, step=1) currency = st.text_input("통화", value="KRW") valuation_kind = st.selectbox("평가 종류", ["balance", "total_value", "cash_balance", "loan", "loan_balance"]) liability_definition = st.selectbox("대표 부채 정의", ["", "principal_only", "principal_interest_fees"]) source_ref = st.text_input("증빙 참조 ID") verification = st.selectbox("확인 상태", ["unverified", "verified", "rejected"]) valuation_memo = st.text_input("평가 메모") registration_key = st.text_input("등록 idempotency key") register = st.form_submit_button("평가자료 등록", disabled=not actor) if register: result = gateway.register_account_valuation(account_id, valuation_date, int(amount), currency=currency, valuation_kind=valuation_kind, source_ref=source_ref, verification_status=verification, memo=valuation_memo, idempotency_key=registration_key, representative_liability_definition=liability_definition or None, db_path=db_path) if result.get("success") and any(item.get("source_id") == account_id for item in items if isinstance(item, dict)): _invalidate_and_rerun(selected_id, result) _safe_result(result) if st.button("평가자료 조회", disabled=not actor, key=f"valuation_list_{selected_id}") and source_id: vals = gateway.list_account_valuations(source_id, db_path=db_path) if vals.get("success"): st.json([{k: value.get(k) for k in ("id", "account_id", "basis_date", "recorded_at", "amount", "currency", "valuation_kind", "verification_status", "source_ref", "supersedes_id", "representative_liability_definition")} for value in vals.get("valuations", [])]) else: _safe_result(vals) with st.form(f"valuation_correct_{selected_id}"): valuation_id_correct = st.text_input("정정할 valuation ID") corrected_amount = st.number_input("정정 금액", value=0, step=1) correction_reason = st.text_input("평가 정정 사유") correction_key = st.text_input("평가 정정 키") correction_verification = st.selectbox("정정 확인 상태", ["unverified", "verified", "rejected"]) correction_source_ref = st.text_input("정정 증빙 참조 ID") correct = st.form_submit_button("평가자료 정정", disabled=not actor) if correct: result = gateway.correct_account_valuation(valuation_id_correct, int(corrected_amount), correction_reason, idempotency_key=correction_key, verification_status=correction_verification, source_ref=correction_source_ref, db_path=db_path) corrected_account_id = (result.get("valuation") or {}).get("account_id", "") if result.get("success") and any(item.get("source_id") == corrected_account_id for item in items if isinstance(item, dict)): _invalidate_and_rerun(selected_id, result) _safe_result(result) review_result = None if st.button("검토 / 새로고침 대상", disabled=not actor, key=f"review_{selected_id}"): review_result = gateway.review_settlement_draft(selected_id, db_path=db_path) st.session_state[f"review_result_{selected_id}"] = review_result if st.button("대상 명시적 갱신", disabled=not actor, key=f"refresh_{selected_id}"): result = gateway.refresh_settlement_draft_targets(selected_id, version, actor, db_path=db_path) if result.get("success"): _invalidate_and_rerun(selected_id, result) _safe_result(result) review_result = review_result or st.session_state.get(f"review_result_{selected_id}") if isinstance(review_result, dict) and review_result.get("success"): review = review_result.get("review", review_result) st.write("검토 합계 (잠정)", *[review.get(k) for k in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth")]) items_cf = review.get("cashflow_items", []) if isinstance(items_cf, list): st.dataframe([{k: row.get(k) for k in ("cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency")} for row in items_cf if isinstance(row, dict)]) blockers = review.get("cashflow_blockers", []) for blocker in blockers if isinstance(blockers, list) else []: if isinstance(blocker, dict): transaction_id = blocker.get("transaction_id") reason = blocker.get("reason") st.warning(f"거래 {transaction_id if isinstance(transaction_id, str) else '-'} · {reason if isinstance(reason, str) else '현금흐름 블로커'}") else: st.warning("현금흐름 블로커") st.write("누락", review.get("missing_items", [])) if review.get("contains_substitutions"): st.warning("대체 평가자료를 포함한 월정산") fingerprint = review.get("review_fingerprint", "") st.caption(f"현재 승인 대상 fingerprint: {fingerprint}") st.session_state[state_key] = (selected_id, review.get("version", version), fingerprint) with st.form(f"approve_{selected_id}"): approval_reason = st.text_input("승인 사유") approval_reference = st.text_input("승인 근거") allow_missing = st.checkbox("누락 승인", value=False) allow_carried = st.checkbox("이월 승인", value=False) allow_substitute = st.checkbox("대체자료 승인", value=False) approve = st.form_submit_button("검토 결과 승인", disabled=(not actor or bool(review.get("cashflow_blockers")))) if review.get("cashflow_blockers"): st.caption("현금흐름 블로커를 해소해야 승인할 수 있습니다.") if approve: if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint): st.error("선택한 초안/버전/검토 fingerprint가 변경되었습니다. 다시 검토하세요.") return _safe_result(gateway.approve_settlement_draft(selected_id, version, fingerprint, actor, approval_reason, approval_reference, allow_missing, allow_carried, allow_substitute, db_path=db_path)) if st.button("최종 마감", disabled=not actor, key=f"finalize_{selected_id}"): if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint): st.error("선택한 초안/버전/검토 fingerprint가 변경되었습니다. 다시 검토하세요.") return result = gateway.finalize_settlement_draft(selected_id, version, fingerprint, f"dashboard-finalize-{selected_id}-{version}", db_path=db_path) if result.get("success"): _invalidate_and_rerun(selected_id, result) _safe_result(result) with st.form(f"cancel_{selected_id}"): cancel_reason = st.text_input("취소 사유") cancel = st.form_submit_button("초안 취소", disabled=not actor) if cancel: result = gateway.cancel_settlement_draft(selected_id, version, actor, cancel_reason, db_path=db_path) if result.get("success"): _invalidate_and_rerun(selected_id, result) _safe_result(result)