feat: deliver schema 15 monthly settlement lifecycle

This commit is contained in:
2026-10-17 15:19:46 +09:00
parent dda76b3bbd
commit 38a854d525
79 changed files with 4003 additions and 1831 deletions
+2
View File
@@ -19,6 +19,7 @@ ACCOUNT_TYPE_LABELS = {
"investment": "투자",
"loan": "대출",
"other": "기타",
"real_estate": "부동산",
}
ACCOUNT_TYPE_STYLES = {
@@ -28,6 +29,7 @@ ACCOUNT_TYPE_STYLES = {
"investment": "#8250df",
"loan": "#bc4c00",
"other": "#57606a",
"real_estate": "#9a6700",
}
+1 -59
View File
@@ -44,65 +44,7 @@ def _render_asset_evidence(report: dict) -> None:
def page_assets(db_path: str) -> None:
st.caption("표시 기준: preview는 live_authority 추정값, close 이후 조회는 closed snapshot 값입니다. 실운영 전 synthetic/validation DB에서 먼저 검증하세요.")
render_section_label("✍️", "수기 자산/부채 등록")
with st.form("manual_asset_create"):
c1, c2, c3 = st.columns([1.3, 1, 1])
name = c1.text_input("자산/부채명", placeholder="예: 전세보증금, 주택담보대출")
amount = c2.number_input("평가금액(원)", min_value=0, value=0, step=10000)
valuation_date = c3.date_input("평가 기준일")
c4, c5 = st.columns([1, 2])
bucket = c4.selectbox("유동성 버킷", LIQUIDITY_BUCKET_OPTIONS, format_func=lambda x: LIQUIDITY_BUCKET_LABELS.get(x, x))
source_note = c5.text_input("출처 메모", placeholder="예: 월말 앱 잔액, 계약서, synthetic")
memo = st.text_input("운영 메모", value="")
submitted = st.form_submit_button("수기 자산 추가", type="primary")
if submitted:
res = assets_gateway.create_manual_asset(name, int(amount), valuation_date.isoformat(), bucket, source_note=source_note, memo=memo, db_path=db_path)
if res.get("success"):
clear_cache()
st.success("수기 자산을 추가했습니다.")
st.rerun()
else:
st.error(res.get("error", res))
assets_res = assets_gateway.list_manual_assets(active_only=False, db_path=db_path)
assets = assets_res.get("manual_assets", []) if assets_res.get("success") else []
render_section_label("📋", "수기 자산/부채 목록")
if assets:
df = pd.DataFrame(assets)
df["bucket_label"] = df["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(df["liquidity_bucket"])
df["valuation_display"] = df["valuation_amount"].map(format_krw)
st.dataframe(df[["name", "active", "bucket_label", "valuation_display", "valuation_date", "id"]], width="stretch", height=220)
with st.form("manual_asset_update"):
asset_options = {f"{a['name']} ({a['id']})": a for a in assets}
selected_label = st.selectbox("수정/비활성 대상", list(asset_options.keys()))
selected = asset_options[selected_label]
u1, u2, u3 = st.columns([1.3, 1, 1])
new_name = u1.text_input("이름", value=selected.get("name", ""))
new_amount = u2.number_input("평가금액(원)", min_value=0, value=int(selected.get("valuation_amount") or 0), step=10000, key="asset_update_amount")
new_date = u3.text_input("평가 기준일", value=selected.get("valuation_date", ""))
u4, u5 = st.columns([1, 2])
current_bucket = selected.get("liquidity_bucket", "other") if selected.get("liquidity_bucket", "other") in LIQUIDITY_BUCKET_OPTIONS else "other"
new_bucket = u4.selectbox("버킷", LIQUIDITY_BUCKET_OPTIONS, index=LIQUIDITY_BUCKET_OPTIONS.index(current_bucket), format_func=lambda x: LIQUIDITY_BUCKET_LABELS.get(x, x), key="asset_update_bucket")
new_source = u5.text_input("출처 메모", value=selected.get("source_note", ""))
new_memo = st.text_input("메모", value=selected.get("memo", ""), key="asset_update_memo")
make_active = st.checkbox("활성", value=bool(selected.get("active", True)))
col_update, col_deactivate = st.columns(2)
do_update = col_update.form_submit_button("선택 항목 수정", type="primary")
do_deactivate = col_deactivate.form_submit_button("선택 항목 비활성", type="secondary")
if do_update:
res = assets_gateway.update_manual_asset(selected["id"], name=new_name, valuation_amount=int(new_amount), valuation_date=new_date, liquidity_bucket=new_bucket, source_note=new_source, memo=new_memo, active=make_active, db_path=db_path)
if res.get("success"):
clear_cache(); st.success("수정했습니다."); st.rerun()
else:
st.error(res.get("error", res))
if do_deactivate:
res = assets_gateway.deactivate_manual_asset(selected["id"], db_path=db_path)
if res.get("success"):
clear_cache(); st.warning("비활성 처리했습니다."); st.rerun()
else:
st.error(res.get("error", res))
else:
st.info("등록된 수기 자산/부채가 없습니다.")
st.info("수기 자산/부채 입력·조회·수정은 schema 16에서 지원하지 않습니다. 계정의 last_balance를 사용하세요.")
st.divider()
render_section_label("🔒", "월말 스냅샷 Preview / Close")
+639 -270
View File
@@ -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()
+1 -7
View File
@@ -35,7 +35,7 @@ def page_overview(db_path: str) -> None:
c4.metric("저축률", format_percent(cashflow.get("savings_rate")))
render_section_label("🏦", "자산 유동성 현황")
st.caption("기준: service 공통 평가 헬퍼(accounts.last_balance + manual_assets 보강). 거래 합계로 비거래 자산을 추정하지 않습니다.")
st.caption("기준: service 공통 평가 헬퍼(accounts.last_balance). 거래 합계로 비거래 자산을 추정하지 않습니다.")
a1, a2, a3 = st.columns(3)
a1.metric("자산 합계(부채 제외)", format_krw(asset_report.get("total_assets", 0)))
a2.metric("총부채", format_krw(asset_report.get("total_liabilities", 0)))
@@ -52,7 +52,6 @@ def page_overview(db_path: str) -> None:
render_section_label("🧭", "자산 구성")
source_accounts = pd.DataFrame(asset_report.get("accounts", []))
manual_assets = pd.DataFrame(asset_report.get("manual_assets", []))
if not source_accounts.empty:
source_accounts["bucket_label"] = source_accounts["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(source_accounts["liquidity_bucket"])
source_accounts["last_balance_display"] = source_accounts["last_balance"].map(format_krw)
@@ -60,11 +59,6 @@ def page_overview(db_path: str) -> None:
st.dataframe(source_accounts[show_cols], width="stretch", height=220)
else:
st.info("last_balance가 입력된 활성 계좌/자산 항목이 없습니다.")
if not manual_assets.empty:
manual_assets["bucket_label"] = manual_assets["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(manual_assets["liquidity_bucket"])
manual_assets["valuation_display"] = manual_assets["amount"].map(format_krw)
st.caption("수기 자산/부채(manual_assets) — 비거래 자산 권위 소스")
st.dataframe(manual_assets[["name", "bucket_label", "valuation_display", "valuation_date", "source_note", "memo"]], width="stretch", height=180)
render_section_label("📉", "지출 구조/예산")
budgets = pd.DataFrame(budget_report.get("items", []))
+92 -40
View File
@@ -26,20 +26,70 @@ def _invalidate_and_rerun(draft_id: str, result: dict) -> None:
st.rerun()
def _authenticated_actor() -> str | None:
from dashboard.authentication import get_authenticated_actor
return get_authenticated_actor()
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"):
created_id = (result.get("draft") or {}).get("id", "")
# 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("월정산 초안 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))
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 []
@@ -47,19 +97,20 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
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("완료 초안은 이력에서 불변으로 조회하고, 정정은 이전 마감 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.button("정산시작", disabled=not actor, key="settlement_start_empty")
st.caption("제외 사유는 항목별로 명시 입력합니다.")
st.caption("거래 정산 상태(transaction readiness)는 자산 마감 준비와 별도입니다.")
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)
@@ -70,7 +121,6 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
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)
@@ -79,11 +129,15 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
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', '')}")
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(f"완료 — run {draft.get('final_run_id', '-')}; 변경 불가, 정정은 새 초안으로 생성합니다.")
st.success("완료 — 변경 불가, 정정은 새 초안으로 생성합니다.")
if draft.get("status") in {"completed", "cancelled"}:
final = detail.get("final_response") if isinstance(detail.get("final_response"), dict) else {}
if final:
@@ -93,10 +147,8 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
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", []))
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 []
@@ -116,7 +168,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
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("정책 저장")
policy_submit = st.form_submit_button("정책 저장", disabled=not actor)
if policy_submit:
try:
policy = json.loads(policy_text)
@@ -138,7 +190,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
selection_reason = st.text_input("선택 사유")
included = st.checkbox("포함", value=True)
exclusion_reason = st.text_input("제외 사유")
save = st.form_submit_button("저장 / 지정 / 포함·제외")
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
@@ -164,7 +216,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
verification = st.selectbox("확인 상태", ["unverified", "verified", "rejected"])
valuation_memo = st.text_input("평가 메모")
registration_key = st.text_input("등록 idempotency key")
register = st.form_submit_button("평가자료 등록")
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,
@@ -173,7 +225,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
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:
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", [])])
@@ -185,7 +237,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
correction_key = st.text_input("평가 정정 키")
correction_verification = st.selectbox("정정 확인 상태", ["unverified", "verified", "rejected"])
correction_source_ref = st.text_input("정정 증빙 참조 ID")
correct = st.form_submit_button("평가자료 정정")
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,
@@ -195,10 +247,10 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
_invalidate_and_rerun(selected_id, result)
_safe_result(result)
review_result = None
if st.button("검토 / 새로고침 대상", key=f"review_{selected_id}"):
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("대상 명시적 갱신", key=f"refresh_{selected_id}"):
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)
@@ -229,7 +281,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
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")))
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):
@@ -237,7 +289,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
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.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
@@ -248,7 +300,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
_safe_result(result)
with st.form(f"cancel_{selected_id}"):
cancel_reason = st.text_input("취소 사유")
cancel = st.form_submit_button("초안 취소")
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"):