Files
jarca-ledger-mcp/dashboard/page_views/settlement_drafts.py
T
gitea-impl-agent ae4383ca2c fix: address settlement display and documentation review findings
Rework Issue 18 / PR 19, source review 76: C11-F01 and D02-F02/D03-F03/D04-F04. Preserve the approved contract and add named display, schema, policy, ownership and signature regressions.
2026-10-13 13:09:16 +00:00

257 lines
17 KiB
Python

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 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"}:
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.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("정책 저장")
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("저장 / 지정 / 포함·제외")
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(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=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("최종 마감", 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)