678 lines
38 KiB
Python
678 lines
38 KiB
Python
from __future__ import annotations
|
||
|
||
# ruff: noqa: F405
|
||
|
||
from datetime import date, datetime
|
||
|
||
import streamlit as st
|
||
|
||
from jarca_ledger_mcp.adapters.streamlit import overview_gateway
|
||
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
|
||
from dashboard.common import * # noqa: F403, F405
|
||
|
||
|
||
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:
|
||
if _query_value("legacy") == "1":
|
||
from .legacy_monthly_settlement import page_legacy_monthly_settlement
|
||
page_legacy_monthly_settlement(db_path)
|
||
return
|
||
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:
|
||
from .legacy_monthly_settlement import page_legacy_monthly_settlement
|
||
page_legacy_monthly_settlement(db_path)
|
||
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)
|
||
st.link_button("기존 자산 마감 · 초안 및 이력", "?page=monthly_settlement&legacy=1")
|
||
|
||
|
||
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}")
|
||
|
||
st.markdown("### 평가 등록 계좌")
|
||
if not registered:
|
||
st.caption("등록된 평가 내역이 없습니다.")
|
||
else:
|
||
# 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("### 평가 미등록 계좌")
|
||
if not unregistered:
|
||
st.caption("모든 계좌의 평가가 등록되었습니다.")
|
||
else:
|
||
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_monthly_cashflow(db_path, month, settlement_id, bool(snapshots.get("closed", False)), settlement_status, settlement_version, review_payload=review_payload)
|
||
|
||
|
||
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
|
||
|
||
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)
|
||
|
||
|
||
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,
|
||
)
|
||
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,
|
||
)
|
||
if result.get("success"):
|
||
st.rerun()
|
||
else:
|
||
st.error(result.get("error", "평가 등록에 실패했습니다."))
|
||
dialog()
|