219 lines
14 KiB
Python
219 lines
14 KiB
Python
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)
|