@@ -2,301 +2,670 @@ from __future__ import annotations
# ruff: noqa: F405
from datetime import datetime
from datetime import date , datetime
import pandas as pd
import streamlit as st
from jarca_ledger_mcp . adapters . streamlit import overview_gateway
from jarca_ledger_mcp . adapters . streamlit import reconciliation_gateway
from jarca_ledger_mcp . adapters . streamlit import settlement_gateway
from dashboard . page_views . settlement_drafts import render_settlement_drafts
from dashboard . common import * # noqa: F403, F405
def _render_asset_evidence ( report : dict ) - > None :
warnings = report . get ( " warnings " ) or [ ]
exclusions = report . get ( " excluded_items " ) or report . get ( " exclusions " ) or [ ]
carried = report . get ( " carried_items " ) or [ ]
provenance = report . get ( " provenance " ) or { }
approval = report . get ( " approval " ) or provenance . get ( " approval " ) or { }
correction = report . get ( " correction " ) or provenance . get ( " correction " ) or { }
st . markdown ( " **근거 상세 (코드 / 출처 식별자)** " )
for item in warnings :
st . markdown ( f " - 경고 ` { item . get ( ' code ' , ' - ' ) } ` · 출처 { item . get ( ' source_type ' , ' - ' ) } / { item . get ( ' source_id ' , ' - ' ) } · 대상 { item . get ( ' name ' , ' - ' ) } " )
if exclusions :
st . markdown ( " **제외 상세** " )
for item in exclusions :
st . markdown ( f " - 제외 코드 ` { item . get ( ' reason ' , ' - ' ) } ` · 출처 { item . get ( ' source_type ' , ' - ' ) } / { item . get ( ' source_id ' , ' - ' ) } · 대상 { item . get ( ' name ' , ' - ' ) } " )
if carried :
st . markdown ( " **이월 항목** " )
for item in carried :
st . markdown ( f " - 이월 · 출처 { item . get ( ' source_type ' , ' - ' ) } / { item . get ( ' source_id ' , ' - ' ) } · 평가일 { item . get ( ' valuation_date ' , ' - ' ) } " )
if approval :
st . markdown ( f " **조건부 승인** · 승인자 { approval . get ( ' approved_by ' , ' - ' ) } · 사유 { approval . get ( ' approval_reason ' , ' - ' ) } · 참조 { approval . get ( ' approval_reference ' , ' - ' ) } " )
if correction :
st . markdown ( f " **정정** · 원본 run { correction . get ( ' correction_of_run_id ' , ' - ' ) } · 사유 { correction . get ( ' correction_reason ' , ' - ' ) } " )
st . caption ( f " canonical provenance · 평가일 { provenance . get ( ' valuation_date ' , ' - ' ) } · 검증 { provenance . get ( ' verification_status ' , ' - ' ) } · 유형 { provenance . get ( ' evidence_kind ' , ' - ' ) } · run { provenance . get ( ' snapshot_run_id ' , ' - ' ) } " )
def _query_value ( name : str , default : str = " " ) - > str :
value = st . query_params . get ( name , default )
if isinstance ( value , list ) :
value = value [ 0 ] if value else default
return str ( value or default )
def _valid_month ( value : str ) - > bool :
try :
datetime . strptime ( value , " % Y- % m " )
return len ( value ) == 7
except ( TypeError , ValueError ) :
return False
def _monthly_list_url ( month : str | None , year : str , status : str ) - > str :
params = [ " page=월정산 " ]
if month :
params . append ( f " month= { month } " )
if year and year != " 전체 " :
params . append ( f " year= { year } " )
if status and status != " 전체 " :
params . append ( f " status= { status } " )
return " ? " + " & " . join ( params )
def _set_monthly_detail ( settlement_id : str , year : str , status : str ) - > None :
# The sidebar radio owns this widget key and may already have been
# instantiated in the current run. Mutating it here raises
# StreamlitAPIException during a real sidebar-backed navigation event.
if " dashboard_page " not in st . session_state :
st . session_state [ " dashboard_page " ] = " 월정산 "
st . query_params [ " page " ] = " monthly_settlement "
st . query_params [ " id " ] = settlement_id
st . query_params . pop ( " month " , None )
if year and year != " 전체 " :
st . query_params [ " year " ] = year
else :
st . query_params . pop ( " year " , None )
if status and status != " 전체 " :
st . query_params [ " status " ] = status
else :
st . query_params . pop ( " status " , None )
def _clear_monthly_detail ( year : str , status : str ) - > None :
# Keep the sidebar-owned widget state untouched; query params drive the
# rerun and the existing radio value is already 월정산.
if " dashboard_page " not in st . session_state :
st . session_state [ " dashboard_page " ] = " 월정산 "
# Clear any detail selection before rerunning; otherwise the app's
# query-to-session restore can resurrect the stale detail on this run.
st . session_state . pop ( " monthly_settlement_detail_id " , None )
st . session_state . pop ( " monthly_settlement_detail_month " , None )
# Do not let the previous overview selection fire again when the list
# widget is recreated after returning from detail.
st . session_state . pop ( " monthly_settlement_overview_table " , None )
st . query_params [ " page " ] = " monthly_settlement "
st . query_params . pop ( " id " , None )
st . query_params . pop ( " month " , None )
if year and year != " 전체 " :
st . query_params [ " year " ] = year
else :
st . query_params . pop ( " year " , None )
if status and status != " 전체 " :
st . query_params [ " status " ] = status
else :
st . query_params . pop ( " status " , None )
st . rerun ( )
def _open_monthly_settlement_dialog ( db_path : str ) - > None :
@st.dialog ( " 월정산 추가 " )
def dialog ( ) - > None :
target_month = st . text_input ( " 대상 월 " , placeholder = " YYYY-MM " , key = " monthly_settlement_dialog_month " )
if st . button ( " 추가 " , type = " primary " , key = " monthly_settlement_dialog_submit " ) :
result = settlement_gateway . create_monthly_settlement ( target_month , db_path = db_path )
settlement_id = result . get ( " settlement_id " )
if result . get ( " success " ) and settlement_id :
st . query_params [ " page " ] = " monthly_settlement "
st . query_params [ " id " ] = settlement_id
st . query_params . pop ( " month " , None )
st . rerun ( )
elif result . get ( " error_code " ) == " duplicate_month " and settlement_id :
st . error ( " 이미 생성된 월정산입니다. 기존 상세로 이동합니다. " )
st . query_params [ " page " ] = " monthly_settlement "
st . query_params [ " id " ] = settlement_id
st . rerun ( )
else :
st . error ( result . get ( " message " , " 월정산을 추가할 수 없습니다. " ) )
dialog ( )
def _render_monthly_list ( db_path : str ) - > None :
overview = overview_gateway . list_monthly_settlement_overview ( db_path = db_path )
title_col , action_col = st . columns ( [ 7 , 1 ] )
with title_col :
st . header ( " 월정산 " )
with action_col :
if st . button ( " + " , type = " primary " , key = " monthly_settlement_add " , help = " 월정산 추가 " ) :
_open_monthly_settlement_dialog ( db_path )
st . caption ( " 월을 선택하면 해당 월의 독립 상세 화면으로 이동합니다. 조회만으로 초안을 만들지 않습니다. " )
years = sorted ( { str ( r . get ( " month " , " " ) ) [ : 4 ] for r in overview . get ( " months " , [ ] ) if r . get ( " month " ) } , reverse = True )
year = st . selectbox ( " 연도 " , [ " 전체 " ] + years , index = ( [ " 전체 " ] + years ) . index ( _query_value ( " year " , " 전체 " ) ) if _query_value ( " year " , " 전체 " ) in ( [ " 전체 " ] + years ) else 0 , key = " settlement_overview_year " )
statuses = [ " 전체 " , " 마감완료 " , " 미마감 " , " 정정진행중 " ]
status = st . selectbox ( " 상태 " , statuses , index = statuses . index ( _query_value ( " status " , " 전체 " ) ) if _query_value ( " status " , " 전체 " ) in statuses else 0 , key = " settlement_overview_status " )
rows = overview . get ( " months " , [ ] ) if overview . get ( " success " ) else [ ]
if year != " 전체 " :
rows = [ r for r in rows if str ( r . get ( " month " , " " ) ) . startswith ( year ) ]
if status != " 전체 " :
rows = [ r for r in rows if status in str ( r . get ( " status_label " , " " ) ) ]
if not rows :
st . info ( " 표시할 월정산이 없습니다. " )
return
# Keep the seven-column table shape while using an in-app button only for
# the month cell. A markdown href reloads the parent document and flashes
# the authenticated iframe; the callback changes query params in-place.
st . caption ( " 월 텍스트를 클릭하면 해당 월정산 상세 화면으로 이동합니다. " )
st . markdown (
""" <style>
.st-key-monthly-settlement-table [data-testid= " stHorizontalBlock " ] {
align-items: stretch; gap: 0 !important; border-bottom: 1px solid var(--secondary-background-color);
}
.st-key-monthly-settlement-table [data-testid= " column " ] {
min-width: 0; padding: 0.52rem 0.65rem; display: flex; align-items: center;
}
.st-key-monthly-settlement-table [data-testid= " column " ]:not(:first-child) { justify-content: flex-end; text-align: right; }
.st-key-monthly-settlement-table [data-testid= " stHorizontalBlock " ]:first-child {
background: var(--secondary-background-color); border-top: 1px solid var(--border-color);
font-weight: 700; white-space: nowrap;
}
.st-key-monthly-settlement-table [data-testid= " stButton " ] { width: 100 % ; }
.st-key-monthly-settlement-table [data-testid= " stButton " ] button {
width: 100 % ; justify-content: flex-start; padding: 0; min-height: 0;
font-weight: 700; color: var(--text-color);
}
.st-key-monthly-settlement-table [data-testid= " stMarkdownContainer " ] p { margin: 0; line-height: 1.35; }
</style> """ ,
unsafe_allow_html = True ,
)
widths = [ 1.25 , 1.55 , 1.25 , 1.25 , 1.5 , 1.45 , 1.65 ]
headers = [ " 월 " , " 정산상태 " , " 수입 " , " 지출 " , " 순현금흐름 " , " 순자산 " , " 최종마감일 " ]
with st . container ( key = " monthly_settlement_table " ) :
header_cells = st . columns ( widths )
for cell , label in zip ( header_cells , headers ) :
with cell :
st . markdown ( label )
for row_index , row in enumerate ( rows ) :
month = str ( row . get ( " month " , " " ) )
settlement_id = row . get ( " settlement_id " )
cells = st . columns ( widths )
with cells [ 0 ] :
if settlement_id :
st . button (
month ,
type = " tertiary " ,
key = f " monthly_settlement_month_ { row_index } _ { settlement_id } " ,
on_click = _set_monthly_detail ,
args = ( str ( settlement_id ) , year , status ) ,
)
else :
st . markdown ( month )
values = [
row . get ( " status_label " , " " ) ,
format_krw ( row . get ( " income " ) ) if row . get ( " income " ) is not None else " 미확정 " ,
format_krw ( row . get ( " expense " ) ) if row . get ( " expense " ) is not None else " 미확정 " ,
format_krw ( row . get ( " net_cashflow_display " ) ) if row . get ( " net_cashflow_display " ) is not None else " 미확정 " ,
format_krw ( row . get ( " net_worth_display " ) ) if row . get ( " net_worth_display " ) is not None else " 미확정 " ,
row . get ( " closed_at " ) or " 미마감 " ,
]
for cell , value in zip ( cells [ 1 : ] , values ) :
with cell :
st . markdown ( str ( value ) )
def page_monthly_settlement ( db_path : str ) - > None :
query_month = st . query_params . get ( " month " , current_kst_month ( ) )
if isinstance ( query_month , list ) :
query_month = query_month [ 0 ]
month = st . text_input ( " 대상 월 " , value = str ( query_month ) , key = " monthly_settlement_month " , help = " YYYY-MM 형식 " )
settlement_id = _query_value ( " id " )
legacy_month = _query_value ( " month " )
if not settlement_id and legacy_month :
overview = overview_gateway . list_monthly_settlement_overview ( db_path = db_path )
settlement_id = next ( ( str ( row . get ( " settlement_id " ) ) for row in overview . get ( " months " , [ ] ) if row . get ( " month " ) == legacy_month and row . get ( " settlement_id " ) ) , " " )
if settlement_id :
st . query_params [ " page " ] = " monthly_settlement "
st . query_params [ " id " ] = settlement_id
st . query_params . pop ( " month " , None )
else :
st . error ( " 월정산을 찾을 수 없습니다. " )
return
if settlement_id :
year = _query_value ( " year " , " 전체 " )
status = _query_value ( " status " , " 전체 " )
parent = settlement_gateway . get_monthly_settlement_record ( settlement_id , db_path = db_path )
if not parent . get ( " success " ) :
st . error ( " 월정산을 찾을 수 없습니다. " )
return
month = str ( parent [ " settlement " ] [ " month " ] )
settlement_row = parent . get ( " settlement " ) or { }
settlement_status = settlement_row . get ( " status " )
settlement_version = settlement_row . get ( " version " )
# Fetch the review payload once and feed the same read-only result to
# both the header action and the cashflow renderer.
review_payload = None
if settlement_status in { " draft " , " in_review " } :
review_payload = settlement_gateway . review_monthly_settlement ( settlement_id , db_path = db_path )
with st . container ( key = " monthly-settlement-detail-header " ) :
st . markdown (
""" <style>
.st-key-monthly-settlement-detail-header h1 { margin: 0; padding: 0; }
.st-key-monthly-settlement-detail-header [data-testid= " stPopover " ] svg { display: none; }
</style> """ ,
unsafe_allow_html = True ,
)
try :
back_col , title_col , menu_col = st . columns ( [ 1.5 , 6 , 1 ] , vertical_alignment = " center " )
except TypeError :
# Keep lightweight navigation fakes compatible; real Streamlit
# supports vertical_alignment (verified in the active env).
back_col , title_col , menu_col = st . columns ( [ 1.5 , 6 , 1 ] )
with back_col :
st . button (
" ← 월정산 목록 " ,
key = " monthly_settlement_back " ,
on_click = _clear_monthly_detail ,
args = ( year , status ) ,
)
with title_col :
st . title ( f " 월정산 상세 · { month } " )
with menu_col :
with st . popover ( " ⋯ " , type = " tertiary " , help = " 월정산 메뉴 " ) :
if settlement_status in { " draft " , " in_review " } :
approve_disabled = not bool ( ( review_payload or { } ) . get ( " success " ) )
if st . button ( " 검토승인 " , key = f " monthly_approve_ { settlement_id } " , disabled = approve_disabled ) :
fingerprint = ( review_payload or { } ) . get ( " review_fingerprint " )
if settlement_version is None or not fingerprint :
st . error ( " 검토 승인에 필요한 정보가 없습니다. " )
result = { " success " : False }
else :
result = settlement_gateway . approve_monthly_settlement (
settlement_id , int ( settlement_version ) , str ( fingerprint ) , db_path = db_path ,
)
if result . get ( " success " ) :
st . success ( " 승인되었습니다. " )
st . rerun ( )
else :
st . error ( result . get ( " error " , " 승인에 실패했습니다. " ) )
elif settlement_status == " approved " :
if st . button ( " 승인 취소 " , key = f " monthly_cancel_approval_ { settlement_id } " ) :
_open_monthly_approval_cancel_dialog ( db_path , settlement_id , month , settlement_version )
if st . button ( " 마감 " , key = f " monthly_close_ { settlement_id } " ) :
_open_monthly_close_dialog ( db_path , settlement_id , month , settlement_version )
try :
_render_monthly_detail ( db_path , month , settlement_id , review_payload = review_payload )
except TypeError as exc :
# Keep lightweight navigation fakes that predate the payload arg usable.
if " review_payload " not in str ( exc ) :
raise
_render_monthly_detail ( db_path , month )
return
_render_monthly_list ( db_path )
def _account_evaluation_totals ( rows : list [ dict ] ) - > dict :
""" Aggregate linked snapshots into signed assets, debt, and net assets.
Unknown categories stay out of the net calculation; NULL amounts stay unknown.
Debt follows the existing signed debt-positive contract (no unconditional abs()).
"""
registered = [ row for row in rows if row . get ( " snapshot_id " ) ]
registered_accounts = { row . get ( " account_id " ) or row . get ( " source_id " ) for row in registered }
all_accounts = { row . get ( " account_id " ) or row . get ( " source_id " ) for row in rows }
unregistered_accounts = all_accounts - registered_accounts
metrics : dict [ str , dict [ str , int ] ] = { }
legacy_totals : dict [ str , int ] = { }
unknown_count = 0
unknown_by_currency : dict [ str , int ] = { }
for row in registered :
currency = str ( row . get ( " currency " ) or " 미지정 " )
amount = row . get ( " amount " )
if amount is None :
unknown_count + = 1
continue
legacy_totals [ currency ] = legacy_totals . get ( currency , 0 ) + int ( amount )
account_type = str ( row . get ( " account_type " ) or " " ) . lower ( )
category = str ( row . get ( " snapshot_account_category " ) or row . get ( " account_liquidity_bucket " ) or " " ) . lower ( )
is_debt = account_type in { " loan " , " card " , " liability " , " debt " , " credit_card " } or category in { " loan " , " card " , " liability " , " debt " }
is_asset = account_type in { " asset " , " bank " , " cash " , " checking " , " savings " , " investment " , " securities " } or category in { " asset " , " cash " , " bank " , " investment " }
bucket = metrics . setdefault ( currency , { " assets " : 0 , " liabilities " : 0 , " net_assets " : 0 } )
if is_debt :
bucket [ " liabilities " ] + = int ( amount )
elif is_asset :
bucket [ " assets " ] + = int ( amount )
else :
if account_type or category :
unknown_by_currency [ currency ] = unknown_by_currency . get ( currency , 0 ) + 1
continue
bucket [ " net_assets " ] = bucket [ " assets " ] - bucket [ " liabilities " ]
return {
" metrics " : metrics ,
" totals " : legacy_totals ,
" unknown_count " : unknown_count + sum ( unknown_by_currency . values ( ) ) ,
" unknown_by_currency " : unknown_by_currency ,
" registered_count " : len ( registered_accounts ) ,
" unregistered_count " : len ( unregistered_accounts ) ,
" total_count " : len ( all_accounts ) ,
" has_amount " : bool ( metrics ) ,
}
def _format_evaluation_total ( currency : str , amount : int ) - > str :
return f " { amount : , } 원 " if currency == " KRW " else f " { amount : , } { currency } "
_ACCOUNT_EVALUATION_BUCKETS = (
( " cash " , " 현금(cash) " ) ,
( " deposit " , " 예금(deposit) " ) ,
( " investment " , " 투자(investment) " ) ,
( " real_estate " , " 부동산(real_estate) " ) ,
( " loan " , " 대출·부채(loan) " ) ,
( " other " , " 기타(other) " ) ,
)
_ACCOUNT_EVALUATION_BUCKET_CODES = { code for code , _ in _ACCOUNT_EVALUATION_BUCKETS }
def _snapshot_display_bucket ( item : dict ) - > str :
""" Use frozen snapshot classification; unknown/missing values are other. """
raw = item . get ( " snapshot_account_category " )
if raw is None :
raw = item . get ( " account_liquidity_bucket " )
value = str ( raw or " " ) . strip ( ) . lower ( )
return value if value in _ACCOUNT_EVALUATION_BUCKET_CODES else " other "
def _open_monthly_approval_cancel_dialog ( db_path : str , settlement_id : str , month : str , expected_version : int | None ) - > None :
@st.dialog ( " 승인 취소 " )
def dialog ( ) - > None :
st . markdown ( f " **대상 월:** { month } " )
st . warning ( " 승인을 취소하면 검토중으로 돌아가며 재승인이 필요합니다. " )
cancel_col , confirm_col = st . columns ( 2 )
with cancel_col :
st . button ( " 취소 " , key = f " monthly_approval_cancel_abort_ { settlement_id } " )
with confirm_col :
if st . button ( " 승인 취소 " , key = f " monthly_approval_cancel_confirm_ { settlement_id } " , type = " primary " ) :
if expected_version is None :
st . error ( " 승인 취소에 필요한 버전 정보가 없습니다. " )
return
result = settlement_gateway . cancel_monthly_settlement_approval ( settlement_id , int ( expected_version ) , db_path = db_path )
if result . get ( " success " ) :
st . success ( " 승인이 취소되었습니다. 검토 후 다시 승인해야 합니다. " )
st . rerun ( )
else :
st . error ( result . get ( " error " , " 승인 취소에 실패했습니다. " ) )
dialog ( )
def _open_monthly_close_dialog ( db_path : str , settlement_id : str , month : str , expected_version : int | None ) - > None :
@st.dialog ( " 월정산 마감 " )
def dialog ( ) - > None :
st . markdown ( f " **대상 월:** { month } " )
st . warning ( " 마감 후에는 이 월정산의 평가 등록·분류 수정·연결 해제 등을 변경할 수 없습니다. " )
cancel_col , confirm_col = st . columns ( 2 )
with cancel_col :
st . button ( " 취소 " , key = f " monthly_close_cancel_ { settlement_id } " )
with confirm_col :
if st . button ( " 마감 " , key = f " monthly_close_confirm_ { settlement_id } " , type = " primary " ) :
if expected_version is None :
st . error ( " 마감에 필요한 버전 정보가 없습니다. " )
return
result = settlement_gateway . close_monthly_settlement ( settlement_id , int ( expected_version ) , db_path = db_path )
if result . get ( " success " ) :
st . success ( " 마감되었습니다. " )
st . rerun ( )
else :
st . error ( result . get ( " error " , " 마감에 실패했습니다. " ) )
dialog ( )
def _render_monthly_detail ( db_path : str , month : str , settlement_id : str | None = None , * , review_payload : dict | None = None ) - > None :
settlement_id = settlement_id or _query_value ( " id " )
settlement = overview_gateway . get_monthly_settlement_summary ( month , db_path = db_path )
if not settlement . get ( " success " ) :
st . error ( settlement . get ( " error " , settlement ) )
st . caption ( f " 입력한 월: { month } " )
return
st . subheader ( " 계정별 평가 내역 " )
# The summary is a month-level report and does not carry lifecycle fields.
# Read the canonical parent row by the selected settlement id instead of
# guessing status/version from the nested report payload.
settlement_record = settlement_gateway . get_monthly_settlement_record ( settlement_id , db_path = db_path ) if settlement_id else { }
parent = settlement_record . get ( " settlement " ) or { }
settlement_status = parent . get ( " status " )
settlement_version = parent . get ( " version " )
snapshots = settlement_gateway . list_account_snapshots_for_settlement ( settlement_id , db_path = db_path )
snapshot_rows = snapshots . get ( " snapshots " , [ ] ) if snapshots . get ( " success " ) else [ ]
rows = [ ]
for item in snapshot_rows :
amount = item . get ( " amount " )
status = item . get ( " display_status " ) or ( " 미등록 " if not item . get ( " snapshot_id " ) else ( " 미확인 " if amount is None else " 확인 " ) )
rows . append ( {
" snapshot_id " : item . get ( " snapshot_id " ) ,
" account_id " : item . get ( " account_id " ) or item . get ( " source_id " ) ,
" account_name " : item . get ( " account_name " ) or item . get ( " name " ) or " " ,
" currency " : item . get ( " currency " ) or " " ,
" amount " : amount ,
" basis_date " : item . get ( " basis_date " ) or " " ,
" previous_ledger " : item . get ( " previous_ledger " ) ,
" next_ledger " : item . get ( " next_ledger " ) ,
" unlinked_candidates " : item . get ( " unlinked_candidates " ) or [ ] ,
" account_type " : item . get ( " account_type " ) ,
" account_liquidity_bucket " : item . get ( " account_liquidity_bucket " ) ,
" snapshot_account_category " : item . get ( " snapshot_account_category " ) ,
" display_bucket " : _snapshot_display_bucket ( item ) ,
" projection " : item . get ( " projection " ) ,
" 계정명 " : item . get ( " account_name " ) or item . get ( " name " ) or " " ,
" 평가금액 " : " — " if status == " 미등록 " else ( " 미확인 " if amount is None else f " { amount : , } " ) ,
" 통화 " : item . get ( " currency " ) or " " ,
" 기준일 " : item . get ( " basis_date " ) or " " ,
" 확인상태 " : status ,
" 등록가능 " : not snapshots . get ( " closed " , False ) and status == " 미등록 " ,
" 연결해제가능 " : snapshots . get ( " status " ) in { " draft " , " in_review " } and status != " 미등록 " and bool ( item . get ( " snapshot_id " ) ) ,
} )
registered = [ r for r in rows if r . get ( " snapshot_id " ) ]
unregistered = [ r for r in rows if not r . get ( " snapshot_id " ) ]
totals = _account_evaluation_totals ( rows )
if snapshots . get ( " success " ) :
st . caption ( f " 평가등록 { totals [ ' registered_count ' ] } / 전체 { totals [ ' total_count ' ] } · 금액 미확인 { totals [ ' unknown_count ' ] } · 미등록 { totals [ ' unregistered_count ' ] } " )
st . caption ( " 미검증 평가금액이 포함될 수 있습니다. " )
for currency , values in sorted ( totals [ " metrics " ] . items ( ) ) :
suffix = " 원 " if currency == " KRW " else currency
metric_columns = st . columns ( 3 )
with metric_columns [ 0 ] :
st . metric ( f " 자산 합계 ( { currency } ) " , f " { values [ ' assets ' ] : , } { suffix } " )
with metric_columns [ 1 ] :
st . metric ( f " 부채 합계 ( { currency } ) " , f " { values [ ' liabilities ' ] : , } { suffix } " )
with metric_columns [ 2 ] :
st . metric ( f " 순자산 ( { currency } ) " , f " { values [ ' net_assets ' ] : , } { suffix } " )
selected_month = settlement . get ( " month " ) or settlement . get ( " selected_month " ) or month
basis_date = settlement . get ( " basis_date " ) or f " { selected_month } -01 "
source_coverage = settlement . get ( " source_coverage " , { } ) if isinstance ( settlement . get ( " source_coverage " ) , dict ) else { }
unresolved_blockers = settlement . get ( " unresolved_blockers " , { } ) if isinstance ( settlement . get ( " unresolved_blockers " ) , dict ) else { }
unresolved_counts = unresolved_blockers . get ( " counts " , { } ) if isinstance ( unresolved_blockers . get ( " counts " ) , dict ) else { }
review_queue_res = overview_gateway . list_cross_transfer_review_queue ( limit = 300 , db_path = db_path )
review_queue_items = get_transfer_review_items ( review_queue_res )
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 { }
reconciliation_res = reconciliation_gateway . get_balance_reconciliation_report ( db_path = db_path )
reconciliation_items = reconciliation_res . get ( " items " , [ ] ) if reconciliation_res . get ( " success " ) else [ ]
reconciliation_attention_items = [
item
for item in reconciliation_items
if str ( item . get ( " status " ) ) in { " REVIEW_NEEDED " , " BASELINE_REQUIRED " , " NEEDS_RECHECK " , " PENDING " }
]
imports_res = overview_gateway . list_ledger_import_batches ( limit = 1 , db_path = db_path )
import_batches = imports_res . get ( " import_batches " , [ ] ) if imports_res . get ( " success " ) else [ ]
asset_snapshot = overview_gateway . get_asset_snapshot_report ( basis_date = basis_date , db_path = db_path )
read_only_stamp = datetime . now ( ZoneInfo ( " Asia/Seoul " ) ) . strftime ( " % Y- % m- %d % H: % M: % S KST " )
render_section_label ( " 📘 " , " 월정산 셸 " )
st . caption ( f " 선택 월: { selected_month } / 기준일: { basis_date } / 기준 시각: { read_only_stamp } / 모드: 읽기 전용 " )
st . markdown (
" <span style= ' display:inline-block; padding:0.15rem 0.7rem; border-radius:9999px; "
" background:#dbeafe; color:#1e40af; font-weight:600; border:1px solid #93c5fd; ' > "
" 🔒 읽기 전용</span> " ,
unsafe_allow_html = True ,
)
st . info ( " 거래/예산 수정은 각 운영 화면(거래내역, 요약)에서 처리하세요. " )
st . markdown ( " - [거래내역으로 이동](?page=거래내역) " )
st . markdown ( " - [예산 확인으로 이동](?page=요약) " )
st . markdown ( " 월간 핵심 KPI " )
st . caption ( " 월간 핵심 지표를 통해 수입·지출·잔고 흐름을 점검하세요. " )
k1 , k2 , k3 , k4 = st . columns ( 4 )
k1 . metric ( " 실제 수입 " , format_krw ( settlement . get ( " actual_income " , 0 ) ) )
k2 . metric ( " 실제 지출 " , format_krw ( settlement . get ( " actual_expense " , 0 ) ) )
k3 . metric ( " 순현금흐름 " , format_krw ( settlement . get ( " net_cashflow " , 0 ) ) )
k4 . metric ( " 저축률 " , format_percent ( settlement . get ( " savings_rate " ) ) )
st . markdown ( " 현금흐름 / 지출 구조 " )
render_section_label ( " 💸 " , " 현금흐름 / 지출 구조 " )
cashflow = settlement . get ( " reports " , { } ) . get ( " cashflow " , { } ) if isinstance ( settlement . get ( " reports " ) , dict ) else { }
if cashflow :
c1 , c2 = st . columns ( 2 )
by_type = cashflow . get ( " by_type " , { } ) or { }
by_type_df = pd . DataFrame (
[ { " 구분 " : str ( key ) , " 금액 " : int ( value ) } for key , value in by_type . items ( ) ]
)
if not by_type_df . empty :
by_type_df [ " 금액 " ] = by_type_df [ " 금액 " ] . map ( format_krw )
by_type_df = by_type_df . sort_values ( " 금액 " , ascending = False )
c1 . dataframe ( by_type_df , width = " stretch " , height = 170 )
by_kind = cashflow . get ( " by_cashflow_kind " , { } ) or { }
by_kind_df = pd . DataFrame (
[ { " 현금흐름 구분 " : str ( key ) , " 금액 " : int ( value ) } for key , value in by_kind . items ( ) ]
)
if not by_kind_df . empty :
by_kind_df [ " 금액 " ] = by_kind_df [ " 금액 " ] . map ( format_krw )
c2 . dataframe ( by_kind_df , width = " stretch " , height = 170 )
st . markdown ( " ### 평가 등록 계좌 " )
if not registered :
st . caption ( " 등록된 평가 내역이 없습니다. " )
else :
st . info ( " 해당 월 현금흐름 정보가 아직 없습니다. " )
# Keep the group header and every row on the same four-column grid.
widths = [ 2.6 , 1.5 , 0.45 , 0.45 ]
st . markdown ( """ <style>
[class*= " monthly-account-evaluation-group " ] [data-testid= " stHorizontalBlock " ] { align-items: stretch; gap: 0 !important; border-bottom: 1px solid var(--border-color); border-left: 1px solid var(--border-color); border-right: 1px solid var(--border-color); }
[class*= " monthly-account-evaluation-group " ] [data-testid= " stHorizontalBlock " ] [data-testid= " column " ] { min-width: 0; padding: 0.42rem 0.55rem; display: flex; align-items: center; border-right: 1px solid var(--border-color); }
[class*= " monthly-account-evaluation-group " ] [data-testid= " column " ]:last-child { border-right: 0; }
[class*= " monthly-account-evaluation-group " ] [data-testid= " stHorizontalBlock " ]:first-child { background: var(--secondary-background-color); border-top: 1px solid var(--border-color); font-weight: 700; white-space: nowrap; }
[class*= " monthly-account-evaluation-group " ] [data-testid= " stHorizontalBlock " ]:not(:first-child) [data-testid= " column " ]:nth-child(2) { justify-content: flex-end; text-align: right; }
[class*= " monthly-account-evaluation-group " ] [data-testid= " stHorizontalBlock " ]:not(:first-child) [data-testid= " column " ]:nth-child(3),
[class*= " monthly-account-evaluation-group " ] [data-testid= " stHorizontalBlock " ]:not(:first-child) [data-testid= " column " ]:nth-child(4) { justify-content: center; }
[class*= " monthly-account-evaluation-group " ] .monthly-account-name { padding-left: 1.1rem; }
</style> """ , unsafe_allow_html = True )
for bucket , label in _ACCOUNT_EVALUATION_BUCKETS :
group_rows = [ item for item in registered if item [ " display_bucket " ] == bucket ]
if not group_rows :
continue
group_totals = _account_evaluation_totals ( group_rows ) [ " totals " ]
subtotal = " · " . join ( _format_evaluation_total ( currency , amount ) for currency , amount in sorted ( group_totals . items ( ) ) ) or " — "
with st . container ( key = f " monthly-account-evaluation-group- { bucket } " ) :
header_cells = st . columns ( widths )
with header_cells [ 0 ] : st . markdown ( label )
with header_cells [ 1 ] : st . markdown ( f " ** { subtotal } ** " )
for cell in header_cells [ 2 : ] :
with cell : st . markdown ( " " )
for item in group_rows :
cells = st . columns ( widths )
with cells [ 0 ] : st . markdown ( f ' <div class= " monthly-account-name " > { item [ " 계정명 " ] } </div> ' , unsafe_allow_html = True )
amount = item [ " amount " ]
amount_display = " 미확인 " if amount is None else f ' { amount : , } { " 원 " if item [ " currency " ] == " KRW " else " " + str ( item [ " currency " ] or " " ) } '
with cells [ 1 ] : st . markdown ( amount_display )
with cells [ 2 ] :
if item [ " 연결해제가능 " ] and st . button ( " × " , key = f " monthly_unlink_ { settlement_id } _ { item [ ' snapshot_id ' ] } " , help = " 연결 해제 " , type = " tertiary " , use_container_width = True ) :
_render_snapshot_unlink_dialog ( db_path , settlement_id , item )
with cells [ 3 ] :
if item [ " 연결해제가능 " ] and st . button ( " ✎ " , key = f " monthly_category_ { settlement_id } _ { item [ ' snapshot_id ' ] } " , help = " 분류 수정 " , type = " tertiary " , use_container_width = True ) :
_render_snapshot_category_dialog ( db_path , settlement_id , item )
st . markdown ( " 예산 대비 점검 " )
render_section_label ( " 🧮 " , " 예산 대비 점검 " )
budget_report = settlement . get ( " reports " , { } ) . get ( " budget " , { } ) if isinstance ( settlement . get ( " reports " ) , dict ) else { }
b1 , b2 , b3 = st . columns ( 3 )
b1 . metric ( " 예산 항목 수 " , f " { int ( budget_report . get ( ' count ' , 0 ) ) } 건 " )
b2 . metric ( " 월 예산 " , format_krw ( budget_report . get ( " total_budget " , 0 ) ) )
b3 . metric ( " 예산 초과 항목 " , f " { int ( settlement . get ( ' budget_overrun_count ' , 0 ) ) } 건 " )
budget_items = budget_report . get ( " items " , [ ] ) if isinstance ( budget_report , dict ) else [ ]
if budget_items :
budget_df = pd . DataFrame ( budget_items )
budget_df [ " 예산 " ] = budget_df [ " budget " ] . map ( format_krw )
budget_df [ " 실제 " ] = budget_df [ " actual " ] . map ( format_krw )
budget_df [ " 잔여 " ] = budget_df [ " remaining " ] . map ( format_krw )
budget_df [ " 사용률 " ] = budget_df [ " usage_rate " ] . map ( format_percent )
st . dataframe (
budget_df [ [ " category " , " subcategory " , " person " , " 예산 " , " 실제 " , " 잔여 " , " 사용률 " , " status " ] ] ,
width = " stretch " ,
height = 220 ,
)
st . markdown ( " ### 평가 미등록 계좌 " )
if not unregistered :
st . caption ( " 모든 계좌의 평가가 등록되었습니다. " )
else :
st . info ( f " { selected_month } 예산 항목이 없습니다. " )
for item in unregistered :
if item [ " 등록가능 " ] :
if st . button ( item [ " 계정명 " ] , type = " tertiary " , key = f " monthly_evaluation_account_ { settlement_id } _ { item [ ' account_id ' ] } " ) :
_render_account_registration_dialog ( db_path , settlement_id , settlement , item )
else :
st . markdown ( item [ " 계정명 " ] )
render_section_label ( " ⚠️ " , " 정산 전 운영 블로커 점검 " )
unresolved_blocker_count = int ( settlement . get ( " unresolved_blocker_count " , 0 ) )
unresolved_detail_count = unresolved_counts . get ( " unresolved_count " , unresolved_blocker_count )
st . caption ( f " 미해결 블로커: { int ( unresolved_detail_count ) } 건 " )
_render_monthly_cashflow ( db_path , month , settlement_id , bool ( snapshots . get ( " closed " , False ) ) , settlement_status , settlement_version , review_payload = review_payload )
render_section_label ( " 🧯 " , " 정산 전 검토 " )
review_count = int ( unresolved_counts . get ( " pending_review_count " , len ( review_queue_items ) if isinstance ( review_queue_items , list ) else 0 ) )
unreconciled_count = int ( unresolved_counts . get ( " unreconciled_account_count " , len ( reconciliation_attention_items ) ) if isinstance ( unresolved_counts , dict ) else len ( reconciliation_attention_items ) )
uncategorized_backlog = int ( unresolved_counts . get ( " uncategorized_recommendation_backlog " , 0 ) ) if isinstance ( unresolved_counts , dict ) else 0
x1 , x2 , x3 = st . columns ( 3 )
x1 . metric ( " 미처리 review " , f " { review_count } 건 " )
x2 . metric ( " 미대조 계좌 " , f " { unreconciled_count } 건 " )
x3 . metric ( " 미분류 추천 대기 " , f " { uncategorized_backlog } 건 " )
if review_queue_items :
st . markdown ( " 확인필요 큐 최근 항목 " )
for item in review_queue_items [ : 5 ] :
tx_date = item . get ( " transaction_date " ) or item . get ( " reviewed_at " ) or " - "
merchant = item . get ( " merchant " ) or item . get ( " counterparty " ) or " - "
amount = format_krw ( item . get ( " amount " ) if item . get ( " amount " ) is not None else item . get ( " difference " ) )
status = item . get ( " review_status " ) or item . get ( " status " ) or " pending "
st . markdown ( f " - { tx_date } · { merchant } · { amount } · { status } " )
else :
st . caption ( " 확인필요 큐는 비어 있습니다. " )
st . markdown ( " - [확인필요 큐로 이동](?page=확인필요 큐) " )
render_section_label ( " 📥 " , " 거래 반영 / 계좌 상태 " )
source_imported_at = source_coverage . get ( " imports " , { } ) . get ( " latest_imported_at " )
t1 , t2 , t3 = st . columns ( 3 )
t1 . metric ( " 최근 Import 건수 " , f " { int ( source_coverage . get ( ' imports ' , { } ) . get ( ' recent_batch_count ' , 0 ) ) } 건 " )
t2 . metric ( " 최근 Import 시각 " , source_imported_at or " 없음 " )
t3 . metric ( " 보류 후 처리 " , str ( review_indicators . get ( " held_recheck_count " , " - " ) or " 0 " ) + " 건 " )
def _render_monthly_cashflow ( db_path : str , month : str , settlement_id : str , closed : bool , settlement_status : str | None = None , settlement_version : int | None = None , * , review_payload : dict | None = None ) - > None :
report = settlement_gateway . get_monthly_cashflow ( month , settlement_id = settlement_id , closed = closed , db_path = db_path )
if not report . get ( " success " ) :
st . warning ( " 현금흐름을 불러오지 못했습니다. " )
return
st . subheader ( " 수입·지출 " )
st . caption ( " 정산 스냅샷 기준 " if closed else " 현재 원장 기준 " )
if not report . get ( " saved " , True ) :
st . info ( report . get ( " message " , " 저장된 현금흐름 스냅샷이 없습니다. " ) )
return
if import_batches :
latest_import = import_batches [ 0 ]
st . caption ( f " 최근 Import: 출처 { latest_import . get ( ' source ' , ' 알 수 없음 ' ) } / 수집일 { latest_import . get ( ' imported_at ' , ' 미기록 ' ) } " )
else :
st . info ( " 최근 Import 배치가 없습니다. " )
cols = st . columns ( 3 )
for col , label , key in zip ( cols , ( " 수입 " , " 지출 " , " 수지 " ) , ( " income " , " expenses " , " net_cashflow " ) ) :
with col :
value = report . get ( key )
st . metric ( label , " 통화별 " if value is None and report . get ( " by_currency " ) else ( " 미확정 " if value is None else f " { int ( value ) : , } " ) )
if report . get ( " by_currency " ) :
for currency , totals in report [ " by_currency " ] . items ( ) :
st . caption ( f " { currency } : 수입 { totals [ ' income ' ] : , } · 지출 { totals [ ' expenses ' ] : , } · 순 { totals [ ' net_cashflow ' ] : , } " )
unknown_rows = report . get ( " unknown_category_items " ) or [ r for r in ( report . get ( " categories " ) or [ ] ) if r . get ( " cashflow_kind " ) == " unknown " ]
if unknown_rows :
unknown_count = sum ( int ( r . get ( " count " ) or 0 ) for r in unknown_rows )
st . warning ( f " 분류가 필요한 거래가 { unknown_count } 건 있습니다. 미분류 금액은 수입·지출 합계에 포함되지 않습니다. " )
st . markdown ( " ### 미분류 내역 " )
st . dataframe ( [ { " 카테고리 " : r . get ( " category " ) or " 미지정 " , " 하위카테고리 " : r . get ( " subcategory " ) or " 미지정 " , " 금액 " : r . get ( " amount " ) , " 거래수 " : int ( r . get ( " count " ) or 0 ) , " 통화 " : r . get ( " currency " ) or " 미지정 " } for r in unknown_rows ] , hide_index = True , use_container_width = True )
st . markdown ( " ### 카테고리별 내역 " )
categories = report . get ( " categories " ) or [ ]
for kind , title in ( ( " income " , " 수입 " ) , ( " expense " , " 지출 " ) ) :
rows = [ r for r in categories if r . get ( " cashflow_kind " ) == kind ]
st . markdown ( f " ** { title } ** " )
if not rows :
st . caption ( " 내역이 없습니다. " )
continue
st . dataframe ( [ { " 카테고리 " : r . get ( " category " ) or " 미분류 " , " 하위카테고리 " : r . get ( " subcategory " ) or " 미분류 " , " 금액 " : int ( r . get ( " amount " ) or 0 ) , " 거래수 " : int ( r . get ( " count " ) or 0 ) , " 통화 " : r . get ( " currency " ) or " 미지정 " } for r in rows ] , hide_index = True , use_container_width = True )
st . markdown ( " - [계좌 화면에서 잔액대조 확인](?page=계좌) " )
st . markdown ( " - [Import 화면에서 이력 확인](?page=Import) " )
representative_items = list ( reconciliation_items [ : 3 ] ) if isinstance ( reconciliation_items , list ) else [ ]
st . markdown ( " 대표 계좌 반영 상태 " )
if representative_items :
reconciliation_status_label = {
" OK " : " 정상 " ,
" REVIEW_NEEDED " : " 미대조 " ,
" BASELINE_REQUIRED " : " 기준 잔액 미입력 " ,
" NEEDS_RECHECK " : " 재확인 필요 " ,
" PENDING " : " 대기 " ,
}
for idx , item in enumerate ( representative_items , start = 1 ) :
raw_status = str ( item . get ( " status " , " - " ) or " - " )
synced_at = item . get ( " last_synced_at " ) or item . get ( " baseline_at " ) or item . get ( " calculation_basis_at " ) or " - "
baseline_balance = item . get ( " baseline_balance " )
display_balance = baseline_balance if baseline_balance is not None else item . get ( " calculated_balance " )
item_line = (
f " - [ { idx } ] 계좌명: { item . get ( ' account_name ' , ' - ' ) } , "
f " 기관: { item . get ( ' institution ' , item . get ( ' bank ' , ' 미기록 ' ) ) } , "
f " 유형: { item . get ( ' account_type ' , item . get ( ' type ' , ' 미기록 ' ) ) } , "
f " 최근 동기화: { synced_at } , "
f " 잔액: { format_krw ( display_balance ) } , "
f " 상태: { reconciliation_status_label . get ( raw_status , raw_status ) } "
def _render_snapshot_unlink_dialog ( db_path : str , settlement_id : str , item : dict ) - > None :
@st.dialog ( " 평가 연결 해제 " )
def dialog ( ) - > None :
account_name = str ( item . get ( " account_name " ) or item . get ( " name " ) or " " )
amount = item . get ( " amount " )
amount_display = " 미확인 " if amount is None else f " { amount : , } "
st . markdown ( f " **계좌명:** { account_name } " )
st . markdown ( f " **평가금액:** { amount_display } " )
st . info ( " 스냅샷은 보존되고 이 월정산과의 연결만 해제됩니다. " )
confirm , cancel = st . columns ( 2 )
if cancel . button ( " 닫기 " , key = f " monthly_unlink_cancel_ { settlement_id } _ { item [ ' snapshot_id ' ] } " ) :
st . rerun ( )
if confirm . button ( " 연결해제 " , key = f " monthly_unlink_confirm_ { settlement_id } _ { item [ ' snapshot_id ' ] } " ) :
result = settlement_gateway . unlink_account_snapshot (
snapshot_id = str ( item [ " snapshot_id " ] ) , monthly_settlement_id = str ( settlement_id ) , db_path = db_path ,
)
st . markdown (
item_line ,
if result . get ( " success " ) :
st . rerun ( )
else :
st . error ( result . get ( " error " , " 연결 해제에 실패했습니다. " ) )
dialog ( )
def _render_snapshot_category_dialog ( db_path : str , settlement_id : str , item : dict ) - > None :
@st.dialog ( " 평가 분류 수정 " )
def dialog ( ) - > None :
buckets = [ " cash " , " deposit " , " investment " , " real_estate " , " loan " , " other " ]
current = item . get ( " snapshot_account_category " ) or item . get ( " account_liquidity_bucket " ) or " other "
value = st . selectbox ( " 분류 " , buckets , index = buckets . index ( current ) if current in buckets else 5 , key = f " monthly_category_value_ { settlement_id } _ { item [ ' snapshot_id ' ] } " )
if st . button ( " 저장 " , key = f " monthly_category_save_ { settlement_id } _ { item [ ' snapshot_id ' ] } " ) :
result = settlement_gateway . update_account_snapshot_category ( str ( item [ " snapshot_id " ] ) , str ( settlement_id ) , value , db_path = db_path )
if result . get ( " success " ) :
st . rerun ( )
else :
st . error ( result . get ( " error " , " 분류 수정에 실패했습니다. " ) )
dialog ( )
def _render_account_registration_dialog ( db_path : str , settlement_id : str , settlement : dict , item : dict ) - > None :
@st.dialog ( " 미등록 계정 평가 등록 " )
def dialog ( ) - > None :
account_id = str ( item . get ( " account_id " ) or item . get ( " source_id " ) )
account_name = str ( item . get ( " account_name " ) or item . get ( " name " ) or account_id )
prefix = f " monthly_evaluation_ { settlement_id } _ { account_id } "
st . text_input ( " 계정 " , value = account_name , disabled = True , key = f " { prefix } _account " )
candidates = item . get ( " unlinked_candidates " ) or [ ]
modes = [ " 기존 스냅샷 연결 " , " 새 평가 등록 " ] if candidates else [ " 새 평가 등록 " ]
mode = st . radio ( " 등록 방식 " , modes , index = 0 , horizontal = True , key = f " { prefix } _mode " )
if mode == " 기존 스냅샷 연결 " :
labels = [ ]
by_label = { }
for candidate in candidates :
amount = " 미확인 " if candidate . get ( " amount " ) is None else f " { candidate [ ' amount ' ] : , } "
label = f " 기준일 { candidate . get ( ' basis_date ' , ' ' ) } · { amount } { candidate . get ( ' currency ' , ' ' ) } · { candidate . get ( ' verification_status ' ) or ' 미확인 ' } "
labels . append ( label ) ; by_label [ label ] = candidate
selected_label = st . selectbox ( " 기존 스냅샷 " , labels , key = f " { prefix } _candidate " )
selected = by_label [ selected_label ]
st . text_input ( " 선택 금액 " , value = " 미확인 " if selected . get ( " amount " ) is None else f " { selected [ ' amount ' ] : , } " , disabled = True , key = f " { prefix } _candidate_amount " )
mismatch = str ( selected . get ( " basis_date " ) ) != str ( settlement . get ( " basis_date " ) )
allow_mismatch = False
if mismatch :
st . warning ( " 기준일이 다른 스냅샷을 연결합니다 " )
allow_mismatch = st . checkbox ( " 기준일 불일치를 확인하고 연결 " , key = f " { prefix } _mismatch " )
connect , cancel = st . columns ( 2 )
if cancel . button ( " 취소 " , key = f " { prefix } _candidate_cancel " ) :
st . rerun ( )
if connect . button ( " 연결 " , key = f " { prefix } _connect " ) :
if mismatch and not allow_mismatch :
st . error ( " 기준일 불일치 동의가 필요합니다. " )
return
result = settlement_gateway . link_account_snapshot ( str ( selected [ " snapshot_id " ] ) , str ( settlement_id ) , allow_basis_date_mismatch = allow_mismatch , db_path = db_path )
if result . get ( " success " ) :
st . rerun ( )
else :
st . error ( result . get ( " error " , " 스냅샷 연결에 실패했습니다. " ) )
return
settlement_basis_date = settlement . get ( " basis_date " ) or settlement . get ( " selected_basis_date " )
if not settlement_basis_date :
settlement_basis_date = f " { settlement . get ( ' month ' ) or settlement . get ( ' selected_month ' ) } -01 "
basis_date = st . date_input ( " 기준일 " , value = date . fromisoformat ( str ( settlement_basis_date ) [ : 10 ] ) , key = f " { prefix } _basis_date " )
currency = st . text_input ( " 통화 " , value = str ( item . get ( " currency " ) or " " ) , disabled = True , key = f " { prefix } _currency " )
previous = item . get ( " previous_ledger " )
following = item . get ( " next_ledger " )
choices = [ ]
choice_map = { }
if previous :
label = f " 이전 기록 · { previous [ ' transaction_date ' ] } · 잔액 { previous [ ' balance_after ' ] } { previous [ ' currency ' ] } "
choices . append ( label ) ; choice_map [ label ] = ( " previous " , previous )
if following :
label = f " 이후 기록 · { following [ ' transaction_date ' ] } · 잔액 { following [ ' balance_after ' ] } { following [ ' currency ' ] } "
choices . append ( label ) ; choice_map [ label ] = ( " next " , following )
choices . append ( " 수동 기입 " )
selected = st . selectbox ( " 평가 출처 " , choices , index = 0 if previous else len ( choices ) - 1 , key = f " { prefix } _source " )
policy , ledger = choice_map . get ( selected , ( " manual " , None ) )
if ledger :
amount_text = str ( ledger [ " balance_after " ] )
st . text_input ( " 평가금액 (정수) " , value = amount_text , disabled = True , key = f " { prefix } _amount_ledger " )
else :
amount_text = st . text_input ( " 평가금액 (정수) " , key = f " { prefix } _amount_manual " )
if policy == " next " :
st . caption ( " 기준일 이후 기록입니다. " )
buckets = [ " cash " , " deposit " , " investment " , " real_estate " , " loan " , " other " ]
account_bucket = item . get ( " account_liquidity_bucket " ) or " other "
liquidity_bucket = st . selectbox ( " 분류 " , buckets , index = buckets . index ( account_bucket ) if account_bucket in buckets else 5 , key = f " { prefix } _liquidity_bucket " )
memo = st . text_area ( " 메모 (선택) " , key = f " { prefix } _memo " )
st . caption ( " 초기 등록은 draft이며 검증 완료가 아닙니다. 금액이 있어도 확인 상태로 바뀌지 않습니다. " )
save , cancel = st . columns ( 2 )
if cancel . button ( " 취소 " , key = f " { prefix } _cancel " ) :
st . rerun ( )
if save . button ( " 저장 " , key = f " { prefix } _save " ) :
if not amount_text . strip ( ) :
st . error ( " 평가금액을 입력하세요. " )
return
try :
amount = int ( amount_text . strip ( ) )
except ValueError :
st . error ( " 평가금액은 정수여야 합니다. " )
return
result = settlement_gateway . register_account_valuation (
account_id = account_id , monthly_settlement_id = settlement_id ,
basis_date = basis_date . isoformat ( ) , amount = amount , currency = currency ,
memo = memo , selection_policy = policy , valuation_source = " ledger " if ledger else " manual " ,
source_note = ledger [ " id " ] if ledger else " " , db_path = db_path ,
liquidity_bucket = liquidity_bucket ,
)
st . markdown ( f " 상태: { reconciliation_status_label . get ( raw_status , ' 정상 ' if idx == 1 else raw_status ) } " )
else :
st . caption ( " 잔액대조 대상 계좌가 현재 없습니다. " )
st . markdown ( " - [미분류 페이지](?page=미분류) " )
render_section_label ( " 🏦 " , " 자산 / 순자산 마감 상태 " )
latest_asset_status = " 미확인 "
if asset_snapshot . get ( " success " ) :
latest_asset_status = " 마감 완료 " if bool ( asset_snapshot . get ( " is_closed_snapshot " ) ) else " 미마감 "
warnings = asset_snapshot . get ( " warnings " ) if isinstance ( asset_snapshot , dict ) else [ ]
missing_valuation_items = (
asset_snapshot . get ( " excluded_items " , asset_snapshot . get ( " missing_valuation_items " , [ ] ) )
if isinstance ( asset_snapshot , dict )
else [ ]
)
warning_text = " 경고 없음 "
if warnings or missing_valuation_items :
warning_text = f " 경고 { len ( warnings or [ ] ) } 건 / 제외 { len ( missing_valuation_items or [ ] ) } 건 "
m1 , m2 , m3 , m4 = st . columns ( 4 )
m1 . metric ( " 자산 합계 " , format_krw ( asset_snapshot . get ( " total_assets " ) if isinstance ( asset_snapshot , dict ) else source_coverage . get ( " assets " , { } ) . get ( " total_assets " ) ) )
m2 . metric ( " 총부채 " , format_krw ( asset_snapshot . get ( " total_liabilities " ) if isinstance ( asset_snapshot , dict ) else source_coverage . get ( " assets " , { } ) . get ( " total_liabilities " ) ) )
m3 . metric ( " 순자산 " , format_krw (
asset_snapshot . get ( " net_worth " )
if isinstance ( asset_snapshot , dict )
else source_coverage . get ( " assets " , { } ) . get ( " net_worth " )
) )
m4 . metric ( " 자산 마감 상태 " , latest_asset_status )
st . caption ( f " 자산 상세: { warning_text } " )
st . caption ( f " 마감 기준일: { asset_snapshot . get ( ' basis_date ' , basis_date ) } · 선택 정책: { asset_snapshot . get ( ' selection_policy ' , ' exact ' ) } · close_status: { asset_snapshot . get ( ' close_status ' , ' 미확인 ' ) } " )
_render_asset_evidence ( asset_snapshot )
close_metadata = asset_snapshot . get ( " close_metadata " , { } ) if isinstance ( asset_snapshot , dict ) else { }
if close_metadata . get ( " approved_by " ) or close_metadata . get ( " approval_reason " ) :
st . caption ( f " 조건부 승인: { close_metadata . get ( ' approved_by ' , ' - ' ) } · { close_metadata . get ( ' approval_reason ' , ' - ' ) } · { close_metadata . get ( ' approval_reference ' , ' - ' ) } " )
st . markdown ( " - [자산/마감 화면에서 스냅샷 확인](?page=자산/마감) " )
render_section_label ( " 📜 " , " 월정산 결과 / Evidence " )
st . markdown ( " ### 읽기 전용 근거 요약 " )
evidence_close_status = " 마감 완료 " if bool ( asset_snapshot . get ( " is_closed_snapshot " ) ) else " 미마감 "
import_summary = source_coverage . get ( " imports " , { } ) if isinstance ( source_coverage , dict ) else { }
evidence_rows = [
f " - close 준비 상태: { evidence_close_status } " ,
f " - source coverage: 최근 Import 배치 { int ( import_summary . get ( ' recent_batch_count ' , 0 ) ) } 건 " ,
f " - 최근 Import: { import_summary . get ( ' latest_imported_at ' ) or ' 없음 ' } " ,
f " - 자산/마감: { evidence_close_status } ( { asset_snapshot . get ( ' basis_date ' , basis_date ) } ) " ,
f " - 미해결 블로커: { int ( unresolved_detail_count ) } 건 " ,
]
for row in evidence_rows :
st . markdown ( row )
st . markdown ( " ### 다음 액션 " )
st . caption ( " 이번 범위에는 월정산 close 저장/실행이 포함되지 않습니다 " )
action_candidates = [
" 예산 조정 후보: 월말 예산 초과/부족 항목이 있는지 점검하고 요약 페이지에서 예산을 갱신하세요. " ,
" 분류/검토 규칙 후보: 미분류 추천 및 확인필요 큐에서 규칙 후보를 먼저 점검하세요. " ,
" 대조/Import/백업 후속 작업: 계좌 잔액대조, 최근 Import 이력, 백업 최신화 상태를 함께 확인하세요. " ,
" 수기 자산/마감 화면에서 기준일·평가 상태를 정합성 확인 후 운영 승인 여부를 결정하세요. " ,
]
for action in action_candidates :
st . markdown ( f " - { action } " )
st . divider ( )
st . markdown ( " ### 저장된 월정산 스냅샷 " )
st . caption ( " 아래는 저장 시점의 불변 기록이며 위의 live KPI/리포트와 별도입니다. " )
exact_run = st . text_input ( " 정확한 run ID (선택) " , key = " saved_settlement_run_id " )
saved = settlement_gateway . get_monthly_settlement_snapshot ( month = selected_month , run_id = exact_run , db_path = db_path )
if not saved . get ( " success " ) :
st . info ( " 저장된 스냅샷이 없거나 조회할 수 없습니다. " )
else :
snapshot = saved . get ( " snapshot " ) if isinstance ( saved . get ( " snapshot " ) , dict ) else saved
st . caption ( f " run { snapshot . get ( ' run_id ' , snapshot . get ( ' snapshot_run_id ' , ' 미보존 ' ) ) } · 버전 { snapshot . get ( ' aggregation_version ' ) or ' legacy ' } · 출처 { snapshot . get ( ' source ' , ' 미보존 ' ) } " )
st . caption ( f " close_status: { snapshot . get ( ' close_status ' , ' 미보존 ' ) } · legacy: { snapshot . get ( ' legacy ' , ' 미보존 ' ) } · comprehensive: { snapshot . get ( ' is_comprehensive ' , ' 미보존 ' ) } " )
if snapshot . get ( " close_status " ) == " conditional " :
st . caption ( " 조건부 마감 상태 (부분 승인 조건 적용) " )
for key , label in ( ( " income_total " , " 수입 " ) , ( " expense_total " , " 지출 " ) , ( " net_cashflow " , " 순현금흐름 " ) , ( " total_assets " , " 자산 " ) , ( " total_liabilities " , " 부채 " ) , ( " net_worth " , " 순자산 " ) ) :
st . write ( label , snapshot . get ( key ) if snapshot . get ( key ) is not None else " 미보존 " )
rows = snapshot . 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 ( " **저장된 헤더 근거** " )
_render_asset_evidence ( { key : snapshot . get ( key ) for key in ( " excluded_items " , " carried_items " , " approval " ) } )
source_snapshot = snapshot . get ( " source_snapshot " ) if isinstance ( snapshot . get ( " source_snapshot " ) , dict ) else { }
asset_evidence = snapshot . get ( " items " ) if isinstance ( snapshot . get ( " items " ) , list ) else [ ]
source_assets = source_snapshot . get ( " assets " ) if isinstance ( source_snapshot . get ( " assets " ) , list ) else [ ]
safe_asset_fields = ( " source_type " , " source_id " , " status " , " valuation_id " , " valuation_date " , " amount " , " applied_amount " , " verification_status " , " authority_source " , " exclusion_reason " , " currency " , " name " , " evidence_kind " , " valuation_source " , " selection_mode " , " selection_policy " )
st . markdown ( " **저장 자산 근거** " )
st . caption ( " 누락(missing)·제외(excluded) 항목과 저장된 사유를 표시합니다. 미보존 근거는 추정하지 않습니다. " )
asset_rows = list ( asset_evidence )
asset_rows . extend ( source_assets )
st . dataframe ( [ { k : row . get ( k ) for k in safe_asset_fields } for row in asset_rows if isinstance ( row , dict ) ] )
st . divider ( )
render_settlement_drafts ( db_path = db_path , month = selected_month )
st . divider ( )
st . markdown ( " - [요약 페이지로 이동](?page=요약) " )
if result . get ( " success " ) :
st . rerun ( )
else :
st . error ( result . get ( " error " , " 평가 등록에 실패했습니다. " ) )
dialog ( )