from __future__ import annotations 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 render_settlement_drafts(db_path: str | None = None, month: str = "") -> None: st.header("월정산 초안 lifecycle") st.caption("자산 마감과 거래 정산 상태는 별도입니다. 조회/렌더링은 쓰기를 수행하지 않습니다.") month_value = month or st.text_input("초안 월", value="2026-09", key="settlement_month") actor = st.text_input("작업자", value="operator", key="settlement_actor") with st.form("settlement_create"): memo = st.text_input("메모") idem = st.text_input("생성 키", value="dashboard-create") correction_run = st.text_input("정정 대상 run ID (선택)") correction_reason = st.text_input("정정 사유") create = st.form_submit_button("초안 생성 / 정정 초안") if create: _safe_result(gateway.create_settlement_draft(month_value, actor, idem, memo=memo, correction_of_run_id=correction_run, correction_reason=correction_reason, db_path=db_path)) 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")] selected_id = st.selectbox("초안 선택", ids, key="settlement_draft_id") if ids else "" if not selected_id: if listed.get("success"): st.info("초안이 없습니다. 생성은 명시적 버튼으로 수행됩니다.") st.caption("완료 초안은 이력에서 불변으로 조회하고, 정정은 이전 마감 ID와 사유를 지정해 새 초안으로 만듭니다.") st.caption("목록은 활성·완료·취소 이력을 포함합니다. 평가자료 조회·등록·정정은 초안 변경과 별도 작업입니다.") st.button("검토", disabled=True, key="settlement_review_disabled") st.button("승인", disabled=True, key="settlement_approve_disabled") st.button("최종 마감", disabled=True, key="settlement_finalize_disabled") st.button("초안 취소", disabled=True, key="settlement_cancel_disabled") st.button("평가자료 조회 / 등록 / 정정", disabled=True, key="settlement_valuation_disabled") st.caption("제외 사유는 항목별로 명시 입력합니다.") st.caption("거래 정산 상태(transaction readiness)는 자산 마감 준비와 별도입니다.") 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") draft_version = 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"ID {draft.get('id', selected_id)} · 월 {draft.get('month', month_value)} · 기준일 {draft.get('basis_date', '-')} · 상태 {draft.get('status', '-')} · 버전 {version} · draft_version={draft_version}") st.caption(f"review_fingerprint={fingerprint or '미검토'}") st.caption(f"생성자 {draft.get('created_by', '-')} · 생성 {draft.get('created_at', '-')} · 변경 {draft.get('updated_at', '-')} · 메모 {draft.get('memo', '')}") if draft.get("status") == "completed": st.success(f"완료 — run {draft.get('final_run_id', '-')}; 변경 불가, 정정은 새 초안으로 생성합니다.") if draft.get("status") in {"completed", "cancelled"}: 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.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원이 아닙니다.") 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("저장 / 지정 / 포함·제외") 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("평가자료 등록") 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("평가자료 조회", 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("평가자료 정정") 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("검토 / 새로고침 대상", 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("대상 명시적 갱신", 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("total_assets"), review.get("total_liabilities"), review.get("net_worth")) 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("검토 결과 승인") 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("최종 마감", 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("초안 취소") 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)