Files
jarca-ledger-mcp/dashboard/app.py
T
genichin 835728793e feat: complete dcy-015 monthly settlement dashboard
Merge dcy-015 monthly settlement dashboard delivery after local and GitHub Actions verification.
2026-07-12 20:16:36 +09:00

2110 lines
110 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import sqlite3
from datetime import datetime
from html import escape
from pathlib import Path
from typing import Any
from urllib.parse import quote
import importlib
import inspect
from zoneinfo import ZoneInfo
import pandas as pd
import plotly.express as px
import plotly.io as pio
import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import accounts_gateway as accounts_gateway
from jarca_ledger_mcp.adapters.streamlit import assets_gateway as assets_gateway
from jarca_ledger_mcp.adapters.streamlit import categorization_gateway as categorization_gateway
from jarca_ledger_mcp.adapters.streamlit import imports_gateway as imports_gateway
from jarca_ledger_mcp.adapters.streamlit import overview_gateway as overview_gateway
from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway as reconciliation_gateway
from jarca_ledger_mcp.adapters.streamlit import transactions_gateway as transactions_gateway
from jarca_ledger_mcp.adapters.streamlit import transfers_gateway as transfers_gateway
from jarca_ledger_mcp.db import resolve_db_path
st.set_page_config(page_title="Jarca Ledger", page_icon="💰", layout="wide")
JARCA_COLORS = {
"navy": "#0f172a",
"slate": "#475569",
"muted": "#64748b",
"border": "#e2e8f0",
"surface": "#ffffff",
"surface_soft": "#f8fafc",
"blue": "#2563eb",
"cyan": "#06b6d4",
"emerald": "#10b981",
"amber": "#f59e0b",
"rose": "#f43f5e",
}
JARCA_PLOTLY_TEMPLATE = "jarca_clean"
pio.templates[JARCA_PLOTLY_TEMPLATE] = pio.templates["plotly_white"].update(
{
"layout": {
"font": {"family": "Inter, Pretendard, Noto Sans KR, Arial, sans-serif", "color": JARCA_COLORS["navy"]},
"paper_bgcolor": "rgba(0,0,0,0)",
"plot_bgcolor": "rgba(255,255,255,0)",
"colorway": [JARCA_COLORS["blue"], JARCA_COLORS["rose"], JARCA_COLORS["emerald"], JARCA_COLORS["amber"], JARCA_COLORS["cyan"]],
"title": {"font": {"size": 16, "color": JARCA_COLORS["navy"]}, "x": 0.02, "xanchor": "left"},
"legend": {"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
"margin": {"l": 12, "r": 12, "t": 56, "b": 28},
"xaxis": {"gridcolor": "rgba(148, 163, 184, 0.20)", "zerolinecolor": "rgba(148, 163, 184, 0.35)"},
"yaxis": {"gridcolor": "rgba(148, 163, 184, 0.20)", "zerolinecolor": "rgba(148, 163, 184, 0.35)"},
}
}
)
px.defaults.template = JARCA_PLOTLY_TEMPLATE
DEFAULT_CATEGORIES = [
"", "수입", "이체", "식비", "주거", "통신", "교통", "교육", "의료", "생활", "문화/여가", "금융", "가족/용돈", "미분류"
]
DEFAULT_SUBCATEGORIES: dict[str, list[str]] = {
"수입": ["", "급여", "이자", "환급", "배당", "기타수입"],
"이체": ["", "본인계좌이동", "현금입출금", "가족송금", "투자이동", "확인필요"],
"식비": ["", "마트", "외식", "카페", "배달"],
"주거": ["", "관리비", "전기", "가스", "수도", "인터넷"],
"통신": ["", "휴대폰", "인터넷", "통신요금"],
"교통": ["", "대중교통", "택시", "주유", "주차", "차량정비"],
"교육": ["", "학원", "교재", "학교", "예체능"],
"의료": ["", "병원", "약국", "치과", "보험"],
"생활": ["", "생필품", "의류", "미용", "세탁"],
"문화/여가": ["", "여행", "숙박", "공연", "취미", "스포츠"],
"금융": ["", "카드대금", "대출상환", "수수료", "보험료"],
"가족/용돈": ["", "부모님", "자녀", "가족송금"],
"미분류": ["", "확인필요"],
}
CASHFLOW_KIND_OPTIONS = ["income", "expense", "transfer", "investment", "debt_payment", "exclude", "unknown"]
CASHFLOW_KIND_LABELS = {
"income": "실제 수입",
"expense": "실제 지출",
"transfer": "이체/제외",
"investment": "투자이동/제외",
"debt_payment": "부채상환/별도",
"exclude": "분석 제외",
"unknown": "확인필요",
}
STATEMENT_STATUS_OPTIONS = ["unknown", "import_supported", "manual_only", "partial_import"]
STATEMENT_STATUS_LABELS = {
"unknown": "미정",
"import_supported": "명세서 import 지원",
"manual_only": "수동 증빙 전용",
"partial_import": "부분 import + 수동 보완",
}
LIQUIDITY_BUCKET_OPTIONS = ["cash", "investment", "deposit", "real_estate", "loan", "other"]
LIQUIDITY_BUCKET_LABELS = {
"cash": "현금성",
"investment": "투자자산",
"deposit": "예적금",
"real_estate": "부동산",
"loan": "부채",
"other": "기타",
}
def format_krw(value: Any) -> str:
try:
if value is None:
return "-"
return f"{int(value):,}원"
except Exception:
return str(value)
def current_kst_month() -> str:
return datetime.now(ZoneInfo("Asia/Seoul")).strftime("%Y-%m")
def format_percent(value: Any) -> str:
if value is None:
return "-"
try:
return f"{float(value):.1%}"
except Exception:
return str(value)
def get_transfer_review_items(payload: dict[str, Any] | None) -> list[dict[str, Any]]:
if not payload or not payload.get("success"):
return []
items = payload.get("review_items")
if isinstance(items, list):
return items
items = payload.get("transactions")
if isinstance(items, list):
return items
return []
@st.cache_data(ttl=5)
def read_sql(db_path: str, sql: str, params: tuple[Any, ...] = ()) -> pd.DataFrame:
with sqlite3.connect(db_path) as conn:
return pd.read_sql_query(sql, conn, params=params)
def clear_cache() -> None:
read_sql.clear()
def get_db_path() -> str:
return str(resolve_db_path())
def get_service_callable(module, name: str, required_params: list[str] | None = None):
"""Return a service function, reloading once for Streamlit hot-reload edge cases.
Streamlit can keep an already-imported service module in memory while the
dashboard script itself reloads. When a UI change starts passing a new
keyword argument, a stale service function may still exist but have the old
signature. In that case reload the service module before returning it.
"""
required_params = required_params or []
fn = getattr(module, name, None)
if callable(fn):
try:
params = inspect.signature(fn).parameters
if all(param in params for param in required_params):
return fn
except (TypeError, ValueError):
if not required_params:
return fn
importlib.reload(module)
return getattr(module, name, None)
def inject_app_css() -> None:
st.markdown(
"""
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
:root {
--jarca-navy: #0f172a;
--jarca-slate: #475569;
--jarca-muted: #64748b;
--jarca-border: #e2e8f0;
--jarca-soft: #f8fafc;
--jarca-blue: #2563eb;
--jarca-cyan: #06b6d4;
--jarca-emerald: #10b981;
--jarca-amber: #f59e0b;
--jarca-rose: #f43f5e;
}
html, body, [class*="css"] {
font-family: Inter, Pretendard, "Noto Sans KR", -apple-system, BlinkMacSystemFont, sans-serif;
}
[data-testid="stAppViewContainer"] {
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.10), transparent 30rem),
radial-gradient(circle at top right, rgba(6, 182, 212, 0.10), transparent 28rem),
linear-gradient(180deg, #f8fafc 0%, #ffffff 45%, #f8fafc 100%);
}
[data-testid="stHeader"] {
background: rgba(248, 250, 252, 0.78);
backdrop-filter: blur(16px);
}
.block-container {
/* Streamlit's fixed top toolbar overlays the app body. Keep the first
custom hero safely below it so the title block is not clipped. */
padding-top: 4.75rem;
padding-bottom: 2.5rem;
}
[data-testid="stSidebar"] {
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border-right: 1px solid rgba(148, 163, 184, 0.22);
}
[data-testid="stSidebar"] h2 {
letter-spacing: -0.04em;
}
[data-testid="stSidebar"] .jarca-nav-title {
font-size: 0.82rem;
font-weight: 700;
color: var(--jarca-muted);
margin: 0.4rem 0 1.15rem 0.1rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
[data-testid="stAppViewContainer"] h1 {
font-size: 1.68rem;
line-height: 1.25;
margin-top: 0.2rem;
margin-bottom: 0.65rem;
letter-spacing: -0.045em;
}
[data-testid="stAppViewContainer"] h2 {
font-size: 1.16rem;
line-height: 1.3;
margin-top: 0.15rem;
margin-bottom: 0.5rem;
letter-spacing: -0.025em;
}
[data-testid="stAppViewContainer"] h3 {
font-size: 0.95rem;
line-height: 1.35;
margin-top: 0.1rem;
margin-bottom: 0.4rem;
}
[data-testid="stSidebar"] .jarca-nav-list {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
[data-testid="stSidebar"] .jarca-nav-item {
display: block;
width: 100%;
border-radius: 14px;
padding: 0.62rem 0.88rem;
font-weight: 650;
font-size: 0.82rem;
line-height: 1.2;
color: var(--jarca-slate);
text-decoration: none;
background: transparent;
border: 1px solid transparent;
box-sizing: border-box;
transition: all 140ms ease;
}
[data-testid="stSidebar"] .jarca-nav-item:hover {
background: rgba(37, 99, 235, 0.07);
border-color: rgba(37, 99, 235, 0.12);
color: var(--jarca-blue);
}
[data-testid="stSidebar"] .jarca-nav-item.active {
background: linear-gradient(135deg, var(--jarca-blue), #0ea5e9);
color: white;
font-weight: 800;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.22);
}
div[data-testid="stMetric"] {
background: rgba(255,255,255,0.84);
border: 1px solid rgba(226, 232, 240, 0.92);
border-radius: 20px;
padding: 1rem 1.05rem;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.06);
}
div[data-testid="stMetric"] label {
color: var(--jarca-muted) !important;
font-weight: 700;
}
div[data-testid="stMetricValue"] {
color: var(--jarca-navy);
letter-spacing: -0.04em;
}
div[data-testid="stDataFrame"], div[data-testid="stDataFrameResizable"] {
border-radius: 18px;
overflow: hidden;
border: 1px solid rgba(226, 232, 240, 0.85);
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.04);
}
div[data-testid="stPlotlyChart"] {
background: rgba(255,255,255,0.82);
border: 1px solid rgba(226, 232, 240, 0.82);
border-radius: 22px;
padding: 0.65rem 0.55rem 0.25rem;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.055);
}
.stButton > button {
border-radius: 14px;
font-weight: 750;
border-color: rgba(148, 163, 184, 0.32);
transition: transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease;
}
.stButton > button:hover {
transform: translateY(-1px);
border-color: rgba(37, 99, 235, 0.48);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.10);
}
.jarca-hero {
position: relative;
padding: 1.25rem 1.35rem;
margin: 0 0 1rem 0;
border-radius: 28px;
color: white;
background:
radial-gradient(circle at 12% 18%, rgba(255,255,255,0.22), transparent 10rem),
linear-gradient(135deg, #0f172a 0%, #1d4ed8 54%, #06b6d4 100%);
box-shadow: 0 22px 48px rgba(15, 23, 42, 0.22);
overflow: hidden;
}
.jarca-hero::after {
content: "";
position: absolute;
inset: auto -5rem -8rem auto;
width: 18rem;
height: 18rem;
border-radius: 999px;
background: rgba(255,255,255,0.13);
}
.jarca-hero-kicker {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.26rem 0.68rem;
margin-bottom: 0.66rem;
border-radius: 999px;
background: rgba(255,255,255,0.16);
color: rgba(255,255,255,0.92);
font-size: 0.78rem;
font-weight: 750;
letter-spacing: 0.02em;
}
.jarca-hero h1 {
margin: 0 0 0.32rem 0 !important;
color: white !important;
font-size: 1.92rem !important;
}
.jarca-hero p {
margin: 0;
max-width: 56rem;
color: rgba(255,255,255,0.82);
font-size: 0.94rem;
line-height: 1.55;
}
.jarca-card {
border: 1px solid rgba(226, 232, 240, 0.86);
border-radius: 22px;
padding: 1rem 1.1rem;
background: rgba(255,255,255,0.82);
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.055);
}
.jarca-section-label {
display: inline-flex;
align-items: center;
gap: 0.42rem;
margin: 1.1rem 0 0.5rem 0;
color: var(--jarca-navy);
font-size: 1.02rem;
font-weight: 850;
letter-spacing: -0.03em;
}
.jarca-pill-row {
display: flex;
flex-wrap: wrap;
gap: 0.42rem;
margin-top: 0.72rem;
}
.jarca-pill {
border-radius: 999px;
padding: 0.28rem 0.7rem;
background: rgba(255,255,255,0.18);
color: rgba(255,255,255,0.90);
font-size: 0.76rem;
font-weight: 700;
}
</style>
""",
unsafe_allow_html=True,
)
def render_page_header(title: str, subtitle: str, kicker: str = "Jarca Ledger", pills: list[str] | None = None) -> None:
pill_html = "".join(f'<span class="jarca-pill">{escape(pill)}</span>' for pill in (pills or []))
st.markdown(
f"""
<div class="jarca-hero">
<div class="jarca-hero-kicker">{escape(kicker)}</div>
<h1>{escape(title)}</h1>
<p>{escape(subtitle)}</p>
{f'<div class="jarca-pill-row">{pill_html}</div>' if pill_html else ''}
</div>
""",
unsafe_allow_html=True,
)
def render_section_label(icon: str, label: str) -> None:
st.markdown(f'<div class="jarca-section-label"><span>{escape(icon)}</span><span>{escape(label)}</span></div>', unsafe_allow_html=True)
def render_sidebar_nav(pages: list[str], current_page: str) -> None:
items = []
for name in pages:
active = " active" if name == current_page else ""
href = f"?page={quote(name)}"
items.append(f'<a class="jarca-nav-item{active}" href="{href}" target="_self">{escape(name)}</a>')
st.markdown(f'<div class="jarca-nav-list">{"".join(items)}</div>', unsafe_allow_html=True)
def parse_optional_int(value: str, field_name: str) -> int | None:
value = (value or "").strip()
if not value:
return None
try:
return int(value)
except ValueError as exc:
raise ValueError(f"{field_name}는 정수여야 합니다.") from exc
def get_category_options(db_path: str) -> tuple[list[str], dict[str, list[str]]]:
result = categorization_gateway.list_ledger_categories(active_only=True, include_usage=False, db_path=db_path)
if result.get("success") and result.get("count", 0) == 0:
categorization_gateway.seed_default_ledger_categories(db_path=db_path)
result = categorization_gateway.list_ledger_categories(active_only=True, include_usage=False, db_path=db_path)
grouped = result.get("grouped", {}) if result.get("success") else {}
categories = [""] + sorted(grouped.keys())
subcategories = {cat: [""] + sorted([s for s in subs if s]) for cat, subs in grouped.items()}
# Keep hardcoded defaults as fallback for fresh/broken DBs and for existing transaction values.
for cat in DEFAULT_CATEGORIES:
if cat and cat not in categories:
categories.append(cat)
for cat, subs in DEFAULT_SUBCATEGORIES.items():
subcategories.setdefault(cat, [""])
for sub in subs:
if sub not in subcategories[cat]:
subcategories[cat].append(sub)
return categories, subcategories
def transaction_df(db_path: str, where: str = "", params: tuple[Any, ...] = (), limit: int = 1000) -> pd.DataFrame:
sql = f"""
SELECT t.id, t.transaction_datetime, t.transaction_date, t.type, t.amount, t.category, t.subcategory,
COALESCE(lc.cashflow_kind, 'unknown') AS cashflow_kind,
t.merchant, t.raw_summary, t.counterparty, t.person, t.is_fixed, t.balance_after, t.branch,
t.source, t.import_batch_id, t.memo, t.status
FROM transactions t
LEFT JOIN ledger_categories lc
ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
{where}
ORDER BY COALESCE(NULLIF(t.transaction_datetime,''), t.transaction_date) DESC, t.created_at DESC
LIMIT ?
"""
df = read_sql(db_path, sql, params + (limit,))
if not df.empty:
df["금액"] = df["amount"].map(format_krw)
df["거래후잔액"] = df["balance_after"].map(format_krw)
df["고정비"] = df["is_fixed"].fillna(0).astype(bool)
return df
def update_transaction_form(tx: dict[str, Any], key_prefix: str, db_path: str | None = None) -> None:
db_path = db_path or get_db_path()
category_options, subcategory_options = get_category_options(db_path)
category_key = f"{key_prefix}_category"
subcategory_key = f"{key_prefix}_subcategory"
person_key = f"{key_prefix}_person"
fixed_key = f"{key_prefix}_fixed"
memo_key = f"{key_prefix}_memo"
save_rule_key = f"{key_prefix}_save_rule"
tx_marker_key = f"{key_prefix}_tx_id"
current_category = tx.get("category", "") or ""
if current_category and current_category not in category_options:
category_options = category_options + [current_category]
if st.session_state.get(tx_marker_key) != tx["id"]:
st.session_state[tx_marker_key] = tx["id"]
st.session_state[category_key] = current_category
st.session_state[subcategory_key] = tx.get("subcategory", "") or ""
st.session_state[person_key] = tx.get("person", "") or ""
st.session_state[fixed_key] = bool(tx.get("is_fixed") or False)
st.session_state[memo_key] = tx.get("memo", "") or ""
st.session_state[save_rule_key] = False
st.caption(f"거래 ID: `{tx['id']}`")
st.write(f"**{tx.get('transaction_datetime') or tx.get('transaction_date')}** · {tx.get('merchant')} · {format_krw(tx.get('amount'))}")
category = st.selectbox(
"대분류",
category_options,
index=category_options.index(st.session_state.get(category_key, current_category)) if st.session_state.get(category_key, current_category) in category_options else 0,
key=category_key,
)
sub_opts = subcategory_options.get(category, [""])
current_sub = st.session_state.get(subcategory_key, tx.get("subcategory", "") or "") or ""
if current_sub and current_sub not in sub_opts:
current_sub = ""
st.session_state[subcategory_key] = ""
subcategory = st.selectbox(
"소분류",
sub_opts,
index=sub_opts.index(current_sub) if current_sub in sub_opts else 0,
key=subcategory_key,
)
person = st.text_input("가족 구성원/person", key=person_key)
is_fixed = st.checkbox("고정비", key=fixed_key)
memo = st.text_input("메모", key=memo_key)
save_rule = st.checkbox("이 사용처 패턴을 규칙으로 저장", key=save_rule_key)
submitted = st.button("저장", type="primary", key=f"{key_prefix}_submit")
if submitted:
result = transactions_gateway.update_ledger_transaction(
tx["id"], category=category, subcategory=subcategory, person=person,
is_fixed=is_fixed, memo=memo, db_path=db_path,
)
if not result.get("success"):
st.error(result.get("error", result))
return
if save_rule and tx.get("merchant") and category:
rule = categorization_gateway.create_category_rule(
match_field="merchant",
pattern=tx["merchant"],
category=category,
subcategory=subcategory,
transaction_type=tx.get("type", ""),
person=person,
is_fixed=is_fixed,
priority=70,
memo="dashboard: user-confirmed rule",
db_path=db_path,
)
if not rule.get("success"):
st.warning(f"거래는 저장됐지만 규칙 저장 실패: {rule.get('error')}")
clear_cache()
st.success("저장했습니다.")
st.rerun()
@st.dialog("거래 분류", width="large")
def transaction_edit_dialog(tx: dict[str, Any], key_prefix: str, db_path: str) -> None:
update_transaction_form(tx, key_prefix, db_path)
@st.dialog("연결 거래 후보", width="large")
def linked_transfer_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
st.caption(f"기준 거래: `{tx['id']}`")
st.write(f"**{tx.get('transaction_datetime') or tx.get('transaction_date')}** · {tx.get('merchant')} · {format_krw(tx.get('amount'))}")
result = transfers_gateway.find_self_transfer_candidates(tx["id"], limit=20, db_path=db_path)
if not result.get("success"):
st.error(result.get("error", result))
return
candidates = result.get("candidates", [])
if not candidates:
st.info("같은 금액/가까운 날짜 조건에서 연결 후보를 찾지 못했습니다.")
return
st.success(f"연결 후보 {result.get('count', len(candidates))}건")
for idx, candidate in enumerate(candidates):
cols = st.columns([1.45, 0.95, 0.7, 1.2, 1.15, 0.95])
cols[0].write(candidate.get("transaction_datetime") or candidate.get("transaction_date") or "")
cols[1].write(candidate.get("source") or "")
cols[2].write(candidate.get("type") or "")
cols[3].write(format_krw(candidate.get("amount")))
cols[4].write(candidate.get("merchant") or candidate.get("counterparty") or "")
if cols[5].button("이 거래와 연결", key=f"link_pair_{tx['id']}_{candidate['id']}_{idx}", use_container_width=True, type="primary"):
link_res = transfers_gateway.link_self_transfer_pair(tx["id"], candidate["id"], db_path=db_path)
if not link_res.get("success"):
st.error(link_res.get("error", link_res))
else:
clear_cache()
st.success("양쪽 거래를 본인계좌이동으로 연결했습니다.")
st.rerun()
meta = []
if candidate.get("account_name"):
meta.append(f"계좌: {candidate['account_name']}")
if candidate.get("category") or candidate.get("subcategory"):
meta.append(f"현재 분류: {candidate.get('category', '')} / {candidate.get('subcategory', '')}")
if candidate.get("score") is not None:
meta.append(f"score={candidate['score']}")
if candidate.get("reason"):
meta.append(candidate["reason"])
st.caption(" · ".join(meta))
if idx < len(candidates) - 1:
st.divider()
@st.dialog("원거래 후보", width="large")
def cancel_original_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
st.caption(f"취소/환급 거래: `{tx['id']}`")
st.write(f"**{tx.get('transaction_datetime') or tx.get('transaction_date')}** · {tx.get('merchant')} · {format_krw(tx.get('amount'))}")
result = transfers_gateway.find_cancel_original_candidates(tx["id"], limit=20, db_path=db_path)
if not result.get("success"):
st.error(result.get("error", result))
return
candidates = result.get("candidates", [])
if not candidates:
st.info("같은 금액 기준으로 연결할 원거래 후보를 찾지 못했습니다.")
return
st.success(f"원거래 후보 {result.get('count', len(candidates))}건")
for idx, candidate in enumerate(candidates):
cols = st.columns([1.4, 0.95, 0.7, 1.25, 1.15, 0.95])
cols[0].write(candidate.get("transaction_datetime") or candidate.get("transaction_date") or "")
cols[1].write(candidate.get("source") or "")
cols[2].write(candidate.get("type") or "")
cols[3].write(format_krw(candidate.get("amount")))
cols[4].write(candidate.get("merchant") or candidate.get("counterparty") or "")
if cols[5].button("이 원거래와 연결", key=f"cancel_pair_{tx['id']}_{candidate['id']}_{idx}", use_container_width=True, type="primary"):
link_res = transfers_gateway.link_cancel_to_original(tx["id"], candidate["id"], db_path=db_path)
if not link_res.get("success"):
st.error(link_res.get("error", link_res))
else:
clear_cache()
st.success("취소/환급 거래를 원거래와 연결했습니다.")
st.rerun()
meta = []
if candidate.get("account_name"):
meta.append(f"계좌: {candidate['account_name']}")
if candidate.get("category") or candidate.get("subcategory"):
meta.append(f"현재 분류: {candidate.get('category', '')} / {candidate.get('subcategory', '')}")
if candidate.get("score") is not None:
meta.append(f"score={candidate['score']}")
if candidate.get("reason"):
meta.append(candidate["reason"])
st.caption(" · ".join(meta))
if idx < len(candidates) - 1:
st.divider()
def render_review_decision_actions(gateway: Any, target_type: str, target_id: str, key_prefix: str, db_path: str, target_payload: dict[str, Any] | None = None) -> None:
decision_type = st.selectbox(
"판정",
["unknown", "account_transfer", "real_expense", "real_income", "investment_transfer", "excluded_from_analysis", "duplicate_or_cancel"],
key=f"{key_prefix}_decision_type",
)
reason_code = st.text_input("사유 코드", value="operator_review", key=f"{key_prefix}_reason")
memo = st.text_input("결정 메모", value="", key=f"{key_prefix}_memo")
cols = st.columns(4)
if cols[0].button("결정 저장", key=f"{key_prefix}_decided", use_container_width=True, type="primary"):
res = gateway.upsert_review_decision(target_type, target_id, status="decided", decision_type=decision_type, reason_code=reason_code, memo=memo, target_payload=target_payload or {}, db_path=db_path)
clear_cache(); st.success("저장했습니다." if res.get("success") else res.get("error", res)); st.rerun()
if cols[1].button("보류", key=f"{key_prefix}_held", use_container_width=True):
res = gateway.hold_review_decision(target_type, target_id, reason_code=reason_code or "held", memo=memo, db_path=db_path)
clear_cache(); st.success("보류했습니다." if res.get("success") else res.get("error", res)); st.rerun()
if cols[2].button("재검토", key=f"{key_prefix}_recheck", use_container_width=True):
res = gateway.mark_review_recheck(target_type, target_id, reason_code=reason_code or "needs_recheck", memo=memo, db_path=db_path)
clear_cache(); st.success("재검토로 표시했습니다." if res.get("success") else res.get("error", res)); st.rerun()
if cols[3].button("해제", key=f"{key_prefix}_release", use_container_width=True):
res = gateway.release_review_decision(target_type, target_id, memo=memo, db_path=db_path)
clear_cache(); st.success("pending으로 해제했습니다." if res.get("success") else res.get("error", res)); st.rerun()
RULE_MATCH_FIELDS = ["merchant", "raw_summary", "counterparty", "payment_method", "branch", "description", "memo"]
RULE_MATCH_TYPES = ["contains", "equals", "starts_with", "ends_with", "regex"]
RULE_TX_TYPES = ["", "income", "expense", "transfer", "refund", "adjustment"]
RULE_FIXED_LABELS = ["변경안함", "예", "아니오"]
@st.dialog("분류 규칙 추가", width="large")
def category_rule_create_dialog(db_path: str) -> None:
category_options, _ = get_category_options(db_path)
editable_categories = [c for c in category_options if c]
with st.form("rule_create_dialog"):
cols = st.columns(4)
match_field = cols[0].selectbox("대상 필드", RULE_MATCH_FIELDS)
match_type = cols[1].selectbox("매칭 방식", RULE_MATCH_TYPES)
pattern = cols[2].text_input("패턴")
tx_type = cols[3].selectbox("거래 유형", RULE_TX_TYPES)
cols2 = st.columns(5)
category = cols2[0].selectbox("대분류", editable_categories)
subcategory = cols2[1].text_input("소분류")
person = cols2[2].text_input("person")
priority = cols2[3].number_input("우선순위", value=100, step=10)
is_fixed = cols2[4].selectbox("고정비", RULE_FIXED_LABELS)
cols3 = st.columns(3)
amount_min_text = cols3[0].text_input("최소 금액(선택)", placeholder="예: 1200000")
amount_max_text = cols3[1].text_input("최대 금액(선택)", placeholder="예: 1200000")
day_of_month_text = cols3[2].text_input("매월 일자(선택)", placeholder="1~31")
memo = st.text_input("메모", value="dashboard: user rule")
submitted = st.form_submit_button("규칙 저장", type="primary")
if submitted:
try:
amount_min = parse_optional_int(amount_min_text, "최소 금액")
amount_max = parse_optional_int(amount_max_text, "최대 금액")
day_of_month = parse_optional_int(day_of_month_text, "매월 일자")
except ValueError as exc:
st.error(str(exc))
else:
fixed_value = None if is_fixed == "변경안함" else is_fixed == "예"
res = categorization_gateway.create_category_rule(match_field, pattern, category, subcategory, tx_type, match_type, int(priority), person, fixed_value, 1.0, memo, amount_min, amount_max, day_of_month, db_path=db_path)
clear_cache()
if res.get("success"):
st.success("규칙을 저장했습니다.")
st.rerun()
else:
st.error(res.get("error", res))
@st.dialog("분류 규칙 수정", width="large")
def category_rule_edit_dialog(rule: dict[str, Any], db_path: str) -> None:
category_options, _ = get_category_options(db_path)
current_category = rule.get("category", "") or ""
editable_categories = [c for c in category_options if c]
if current_category and current_category not in editable_categories:
editable_categories.append(current_category)
fixed_value = "변경안함" if rule.get("is_fixed") is None else ("예" if rule.get("is_fixed") else "아니오")
st.caption(f"규칙 ID: `{rule['id']}`")
st.write(f"**{rule.get('pattern') or '-'}**")
with st.form(f"rule_edit_dialog_{rule['id']}"):
cols = st.columns(4)
edit_match_field = cols[0].selectbox("대상 필드", RULE_MATCH_FIELDS, index=RULE_MATCH_FIELDS.index(rule.get("match_field", "merchant")) if rule.get("match_field", "merchant") in RULE_MATCH_FIELDS else 0)
edit_match_type = cols[1].selectbox("매칭 방식", RULE_MATCH_TYPES, index=RULE_MATCH_TYPES.index(rule.get("match_type", "contains")) if rule.get("match_type", "contains") in RULE_MATCH_TYPES else 0)
edit_pattern = cols[2].text_input("패턴", value=rule.get("pattern", "") or "")
edit_tx_type = cols[3].selectbox("거래 유형", RULE_TX_TYPES, index=RULE_TX_TYPES.index(rule.get("transaction_type", "") or "") if (rule.get("transaction_type", "") or "") in RULE_TX_TYPES else 0)
cols2 = st.columns(5)
edit_category = cols2[0].selectbox("대분류", editable_categories, index=editable_categories.index(current_category) if current_category in editable_categories else 0)
edit_subcategory = cols2[1].text_input("소분류", value=rule.get("subcategory", "") or "")
edit_person = cols2[2].text_input("person", value=rule.get("person", "") or "")
edit_priority = cols2[3].number_input("우선순위", value=int(rule.get("priority", 100) or 100), step=10)
edit_is_fixed = cols2[4].selectbox("고정비", RULE_FIXED_LABELS, index=RULE_FIXED_LABELS.index(fixed_value))
cols3 = st.columns(4)
edit_amount_min_text = cols3[0].text_input("최소 금액(선택)", value="" if rule.get("amount_min") is None else str(rule.get("amount_min")))
edit_amount_max_text = cols3[1].text_input("최대 금액(선택)", value="" if rule.get("amount_max") is None else str(rule.get("amount_max")))
edit_day_of_month_text = cols3[2].text_input("매월 일자(선택)", value="" if rule.get("day_of_month") is None else str(rule.get("day_of_month")))
edit_active = cols3[3].checkbox("활성", value=bool(rule.get("active", True)))
edit_memo = st.text_input("메모", value=rule.get("memo", "") or "")
edit_submitted = st.form_submit_button("규칙 수정", type="primary")
if edit_submitted:
try:
edit_amount_min = parse_optional_int(edit_amount_min_text, "최소 금액")
edit_amount_max = parse_optional_int(edit_amount_max_text, "최대 금액")
edit_day_of_month = parse_optional_int(edit_day_of_month_text, "매월 일자")
except ValueError as exc:
st.error(str(exc))
else:
edit_fixed_value = None if edit_is_fixed == "변경안함" else edit_is_fixed == "예"
update_rule = getattr(categorization_gateway, "update_category_rule", None)
if update_rule is None:
st.error("현재 실행 중인 서비스에 update_category_rule 함수가 없습니다. 앱을 재시작해 최신 코드를 다시 불러오세요.")
else:
res = update_rule(
rule["id"], edit_match_field, edit_pattern, edit_category, edit_subcategory,
edit_tx_type, edit_match_type, int(edit_priority), edit_person, edit_fixed_value,
1.0, edit_memo, edit_amount_min, edit_amount_max, edit_day_of_month, edit_active,
db_path=db_path,
)
clear_cache()
if res.get("success"):
st.success("규칙을 수정했습니다.")
st.rerun()
else:
st.error(res.get("error", res))
def render_uncategorized_action_table(rows: list[dict[str, Any]], db_path: str) -> None:
header_cols = st.columns([1.45, 0.6, 0.8, 1.35, 1.3, 0.7, 1.0, 1.0])
headers = ["일시", "구분", "금액", "사용처", "요약", "", "", ""]
for col, label in zip(header_cols, headers):
col.markdown(f"**{label}**")
for idx, row in enumerate(rows):
cols = st.columns([1.45, 0.6, 0.8, 1.35, 1.3, 0.7, 1.0, 1.0])
cols[0].write(row.get("transaction_datetime") or row.get("transaction_date") or "")
cols[1].write(row.get("type") or "")
cols[2].write(format_krw(row.get("amount")))
cols[3].write(row.get("merchant") or "")
cols[4].write(row.get("raw_summary") or "")
if cols[5].button("분류", key=f"uncat_dialog_{row['id']}_{idx}", use_container_width=True, type="tertiary"):
transaction_edit_dialog(row, f"uncat_dialog_{row['id']}", db_path)
if cols[6].button("연결거래찾기", key=f"uncat_link_{row['id']}_{idx}", use_container_width=True, type="tertiary"):
linked_transfer_candidates_dialog(row, db_path)
if cols[7].button("원거래찾기", key=f"uncat_cancel_{row['id']}_{idx}", use_container_width=True, type="tertiary"):
cancel_original_candidates_dialog(row, db_path)
def recommendation_text(rec: dict[str, Any] | None) -> str:
rec = rec or {}
if not rec.get("category"):
return "추천 없음"
pct = int(round(float(rec.get("confidence") or 0) * 100))
return f"{rec.get('category')} / {rec.get('subcategory') or '-'} · {pct}%"
def save_merchant_rule_from_recommendation(merchant: str, tx_type: str, rec: dict[str, Any], db_path: str) -> dict[str, Any]:
if not merchant:
return {"success": False, "error": "merchant가 비어 있어 규칙을 만들 수 없습니다."}
if not rec.get("category"):
return {"success": False, "error": "추천 분류가 없어 규칙을 만들 수 없습니다."}
return categorization_gateway.create_category_rule(
match_field="merchant",
match_type="contains",
pattern=merchant,
transaction_type=tx_type or "",
category=rec.get("category", ""),
subcategory=rec.get("subcategory", ""),
priority=70,
confidence=float(rec.get("confidence") or 0.8),
memo="dashboard: uncategorized workbench rule",
db_path=db_path,
)
def render_uncategorized_groups(groups: list[dict[str, Any]], db_path: str, repeated_only: bool = False) -> None:
visible = [g for g in groups if not repeated_only or int(g.get("count") or 0) >= 2]
visible = visible[:10]
if not visible:
st.info("반복 후보가 없습니다.")
return
for idx, group in enumerate(visible):
rec = group.get("recommendation") or {}
cols = st.columns([2.0, 0.7, 1.0, 1.55, 1.0, 1.0], vertical_alignment="center")
merchant = group.get("merchant") or group.get("raw_summary") or "-"
cols[0].markdown(f"**{merchant}** \n<span style='color:#64748b;font-size:0.82rem'>{group.get('raw_summary') or '-'} · {group.get('type') or '-'}</span>", unsafe_allow_html=True)
cols[1].write(f"{int(group.get('count') or 0):,}건")
cols[2].write(format_krw(group.get("total_amount")))
cols[3].write(recommendation_text(rec))
apply_disabled = not bool(rec.get("category"))
if cols[4].button("그룹 적용", key=f"uncat_group_apply_{idx}_{merchant}", disabled=apply_disabled, use_container_width=True):
res = categorization_gateway.apply_uncategorized_group_recommendation(
group.get("merchant", ""), group.get("raw_summary", ""), group.get("type", ""), db_path=db_path
)
clear_cache()
if res.get("success"):
st.success(f"그룹 {res.get('applied_count', 0):,}건을 {recommendation_text(res.get('recommendation'))}로 분류했습니다.")
st.rerun()
else:
st.error(res.get("error", res))
if cols[5].button("규칙 만들기", key=f"uncat_group_rule_{idx}_{merchant}", disabled=apply_disabled, use_container_width=True):
res = save_merchant_rule_from_recommendation(group.get("merchant", ""), group.get("type", ""), rec, db_path)
clear_cache()
if res.get("success"):
st.success("사용처 규칙을 저장했습니다.")
st.rerun()
else:
st.warning(res.get("error", res))
st.caption(f"최근: {group.get('latest_date') or '-'} · 최대금액: {format_krw(group.get('max_amount'))} · 근거: {rec.get('reason') or '추천 없음'}")
if idx < len(visible) - 1:
st.divider()
def render_uncategorized_work_items(rows: list[dict[str, Any]], db_path: str, recommended_only: bool = False) -> None:
visible = [r for r in rows if not recommended_only or r.get("recommended_category")]
if not visible:
st.info("조건에 맞는 미분류 거래가 없습니다.")
return
for idx, row in enumerate(visible):
rec = row.get("recommendation") or {}
date_text = row.get("transaction_datetime") or row.get("transaction_date") or ""
title = f"{date_text} · {format_krw(row.get('amount'))} · {row.get('merchant') or row.get('counterparty') or '-'} · 추천: {recommendation_text(rec)}"
with st.expander(title, expanded=False):
c1, c2, c3, c4 = st.columns(4)
c1.metric("거래유형", row.get("type") or "-")
c2.metric("금액", format_krw(row.get("amount")))
c3.metric("추천", recommendation_text(rec))
c4.metric("추천 근거", rec.get("source") or "none")
st.caption(f"사용처: {row.get('merchant') or '-'} · 요약: {row.get('raw_summary') or '-'} · 상대: {row.get('counterparty') or '-'} · source: {row.get('source') or '-'} · ID: `{row.get('id')}`")
if rec.get("reason"):
st.info(rec.get("reason"))
action_cols = st.columns([1.0, 1.0, 1.0, 1.1, 1.1, 2.5], vertical_alignment="center")
apply_disabled = not bool(rec.get("category"))
if action_cols[0].button("추천 적용", key=f"uncat_apply_rec_{row['id']}_{idx}", disabled=apply_disabled, use_container_width=True, type="primary"):
res = categorization_gateway.apply_uncategorized_recommendation(row["id"], db_path=db_path)
clear_cache()
if res.get("success"):
st.success(f"{recommendation_text(res.get('recommendation'))}로 분류했습니다.")
st.rerun()
else:
st.error(res.get("error", res))
if action_cols[1].button("직접 분류", key=f"uncat_direct_{row['id']}_{idx}", use_container_width=True):
transaction_edit_dialog(row, f"uncat_direct_{row['id']}", db_path)
if action_cols[2].button("규칙 만들기", key=f"uncat_rule_{row['id']}_{idx}", disabled=apply_disabled, use_container_width=True):
res = save_merchant_rule_from_recommendation(row.get("merchant", ""), row.get("type", ""), rec, db_path)
clear_cache()
if res.get("success"):
st.success("사용처 규칙을 저장했습니다.")
st.rerun()
else:
st.warning(res.get("error", res))
if action_cols[3].button("연결거래찾기", key=f"uncat_link_{row['id']}_{idx}", use_container_width=True):
linked_transfer_candidates_dialog(row, db_path)
if action_cols[4].button("원거래찾기", key=f"uncat_cancel_{row['id']}_{idx}", use_container_width=True):
cancel_original_candidates_dialog(row, db_path)
with st.expander(f"Review decision · 현재 {row.get('review_status', 'pending')}", expanded=False):
render_review_decision_actions(categorization_gateway, "uncategorized_transaction", row["id"], f"uncat_review_decision_{row['id']}_{idx}", db_path, target_payload=row)
@st.dialog("규칙 적용 미리보기", width="large")
def category_rule_preview_dialog(rule: dict[str, Any], db_path: str) -> None:
rule_id = rule.get("id", "")
pattern = rule.get("pattern") or "-"
result_text = f"{rule.get('category') or '-'} / {rule.get('subcategory') or '-'}"
st.caption(f"규칙 ID: `{rule_id}`")
st.write(f"**{pattern}** → **{result_text}**")
preview_fn = get_service_callable(categorization_gateway, "preview_category_rule_matches")
if not callable(preview_fn):
st.error("현재 실행 중인 서비스에서 단일 규칙 미리보기 함수를 찾지 못했습니다. Streamlit 서버를 완전히 종료한 뒤 다시 시작해 주세요.")
return
preview = preview_fn(rule_id, limit=10000, sample_limit=3, db_path=db_path)
if not preview.get("success"):
st.error(preview.get("error", preview))
return
m1, m2, m3 = st.columns(3)
m1.metric("스캔한 활성 거래", f"{preview.get('scanned', 0):,}건")
m2.metric("이 규칙과 매칭", f"{preview.get('matched_count', 0):,}건")
m3.metric("미분류 중 적용 대상", f"{preview.get('uncategorized_matched_count', 0):,}건")
sample = preview.get("sample", [])
if sample:
df = pd.DataFrame(sample)
if "amount" in df:
df["금액"] = df["amount"].map(format_krw)
display_cols = [
c for c in [
"transaction_date", "transaction_datetime", "type", "금액", "merchant", "raw_summary",
"counterparty", "current_category", "current_subcategory", "new_category", "new_subcategory",
] if c in df.columns
]
st.dataframe(df[display_cols], width="stretch", height=160)
else:
st.info("이 규칙에 부합하는 거래가 없습니다.")
def page_monthly_settlement(db_path: str) -> None:
render_page_header(
"월정산",
"선택한 월을 기준으로 월말 결산 상태와 미해결 블로커를 빠르게 점검합니다.",
kicker="🧾 월말 점검",
pills=["read-model 기반", "마감 전 점검", "fixture-safe"],
)
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 = 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
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)
else:
st.info("해당 월 현금흐름 정보가 아직 없습니다.")
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,
)
else:
st.info(f"{selected_month} 예산 항목이 없습니다.")
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_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") + "건")
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 배치가 없습니다.")
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)}"
)
st.markdown(
item_line,
)
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("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)}")
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("- [요약 페이지로 이동](?page=요약)")
def page_overview(db_path: str) -> None:
health = overview_gateway.health_check(db_path)
render_page_header(
"가계부 운영 대시보드",
"이번 달 실제 현금흐름, 자산 유동성, 운영 경고를 우선순위대로 확인합니다.",
kicker="📊 요약",
pills=[f"schema v{health.get('schema_version')}", "KST 이번 달", "last_balance 기준 자산"],
)
st.caption(f"DB: `{health.get('db_path')}`")
month = current_kst_month()
cashflow = overview_gateway.get_monthly_cashflow_report(month, db_path=db_path)
asset_report = overview_gateway.get_asset_liquidity_report(db_path=db_path)
budget_report = overview_gateway.get_budget_vs_actual_report(month, db_path=db_path)
uncategorized_res = overview_gateway.list_uncategorized_transactions(limit=1000, db_path=db_path)
transfer_queue_res = overview_gateway.list_cross_transfer_review_queue(limit=300, db_path=db_path)
reconciliation_res = reconciliation_gateway.get_balance_reconciliation_report(db_path=db_path)
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 {}
backups_res = overview_gateway.list_ledger_backups(limit=1)
imports_res = overview_gateway.list_ledger_import_batches(limit=1, db_path=db_path)
render_section_label("💎", "이번 달 수입/지출 현황")
st.caption(f"기준: KST 현재 월 {month}, transactions.transaction_date, ledger_categories.cashflow_kind 실제 현금흐름")
c1, c2, c3, c4 = st.columns(4)
c1.metric("실제 수입", format_krw(cashflow.get("income", 0)))
c2.metric("실제 지출", format_krw(cashflow.get("expenses", 0)))
c3.metric("순현금흐름", format_krw(cashflow.get("net_cashflow", 0)))
c4.metric("저축률", format_percent(cashflow.get("savings_rate")))
render_section_label("🏦", "자산 유동성 현황")
st.caption("기준: service 공통 평가 헬퍼(accounts.last_balance + manual_assets 보강). 거래 합계로 비거래 자산을 추정하지 않습니다.")
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)))
a3.metric("순자산", format_krw(asset_report.get("net_worth", 0)))
buckets_df = pd.DataFrame(asset_report.get("buckets", []))
if not buckets_df.empty:
buckets_df["label"] = buckets_df["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(buckets_df["liquidity_bucket"])
buckets_df["금액"] = buckets_df["amount"].map(format_krw)
buckets_df["구성비"] = buckets_df["asset_share"].map(format_percent)
st.dataframe(buckets_df[["label", "liquidity_bucket", "금액", "account_count", "구성비"]], width="stretch", height=250)
chart_df = buckets_df[(buckets_df["liquidity_bucket"] != "loan") & (buckets_df["amount"] != 0)]
if not chart_df.empty:
st.plotly_chart(px.pie(chart_df, names="label", values="amount", title="자산 구성(loan 제외)"), width="stretch")
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)
show_cols = ["name", "type", "bucket_label", "last_balance_display", "last_synced_at", "memo"]
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", []))
if not budgets.empty:
over_count = int((budgets["status"] == "over").sum()) if "status" in budgets else 0
b1, b2, b3 = st.columns(3)
b1.metric("월 예산", format_krw(budget_report.get("total_budget", 0)))
b2.metric("예산 대상 지출", format_krw(budget_report.get("total_actual", 0)))
b3.metric("초과 항목", f"{over_count}건")
budgets["budget_display"] = budgets["budget"].map(format_krw)
budgets["actual_display"] = budgets["actual"].map(format_krw)
budgets["remaining_display"] = budgets["remaining"].map(format_krw)
budgets["usage_rate_display"] = budgets["usage_rate"].map(format_percent)
st.dataframe(budgets[["category", "subcategory", "person", "budget_display", "actual_display", "remaining_display", "usage_rate_display", "status"]], width="stretch", height=240)
else:
st.info(f"{month} 예산이 없습니다. 예산 메뉴에서 월별 예산을 등록하면 이 영역에 표시됩니다.")
render_section_label("⚠️", "운영 경고")
review_items = get_transfer_review_items(transfer_queue_res)
reconciliation_items = reconciliation_res.get("items", []) if reconciliation_res.get("success") else []
reconciliation_review = [item for item in reconciliation_items if item.get("status") == "REVIEW_NEEDED"]
missing_balance = asset_report.get("missing_balance_accounts", [])
w1, w2, w3, w4, w5 = st.columns(5)
w1.metric("미처리 review", f"{review_indicators.get('pending_review_count', len(review_items)):,}건")
w2.metric("보류/재검토", f"{review_indicators.get('held_recheck_count', 0):,}건")
w3.metric("미대조 계좌", f"{review_indicators.get('unreconciled_account_count', len(reconciliation_review)):,}개")
w4.metric("최근 대조 마감", review_indicators.get("last_reconciliation_closed_at") or "-")
w5.metric("미분류 추천 대기", f"{review_indicators.get('uncategorized_recommendation_backlog', uncategorized_res.get('count', 0)):,}건")
latest_backup = (backups_res.get("backups") or [{}])[0] if backups_res.get("success") else {}
latest_import = (imports_res.get("import_batches") or [{}])[0] if imports_res.get("success") else {}
st.caption(
"상세 이동: 미분류 → 미분류, 확인필요 큐 → 확인필요 큐, 잔액대조 필요/잔액 미입력 → 계좌, "
f"최근 백업: {latest_backup.get('name', '없음')}, 최근 import: {latest_import.get('source', '없음')} {latest_import.get('imported_at', '')}"
)
if missing_balance:
st.warning("last_balance가 없는 계좌/자산은 자산 유동성 계산에서 제외했습니다. 계좌 화면에서 기준 잔액과 기준 시각을 입력하세요.")
def page_assets(db_path: str) -> None:
render_page_header(
"자산/마감",
"수기 자산·부채를 관리하고 월말 순자산 스냅샷을 preview 후 마감합니다.",
kicker="🏦 Asset Close",
pills=["수기 자산 CRUD", "Preview 우선", "Append-only 정정"],
)
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", "source_note", "memo", "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.divider()
render_section_label("🔒", "월말 스냅샷 Preview / Close")
st.caption("Preview는 DB를 변경하지 않습니다. 같은 기준일을 다시 close하면 정정 사유가 필요하며 기존 행을 덮어쓰지 않고 새 correction run을 append합니다.")
c1, c2, c3 = st.columns([1, 1.5, 1.5])
basis_date = c1.text_input("마감 기준일", value=f"{current_kst_month()}-01", help="YYYY-MM-DD")
close_note = c2.text_input("Close 출처 메모", value="month-end close")
correction_reason = c3.text_input("정정 사유(재마감 시 필수)", value="")
p1, p2 = st.columns(2)
if p1.button("스냅샷 Preview", type="secondary"):
st.session_state["asset_snapshot_preview"] = assets_gateway.preview_asset_snapshot_close(basis_date, db_path=db_path)
if p2.button("스냅샷 Close 실행", type="primary"):
st.session_state["asset_snapshot_close"] = assets_gateway.close_asset_snapshot(basis_date, source_note=close_note, correction_reason=correction_reason, db_path=db_path)
clear_cache()
preview = st.session_state.get("asset_snapshot_preview")
close_res = st.session_state.get("asset_snapshot_close")
if preview:
if preview.get("success"):
st.success(f"Preview: source={preview.get('source')}, closed={preview.get('is_closed_snapshot')}, warnings={len(preview.get('warnings', []))}")
m1, m2, m3 = st.columns(3)
m1.metric("자산 합계", format_krw(preview.get("total_assets")))
m2.metric("총부채", format_krw(preview.get("total_liabilities")))
m3.metric("순자산", format_krw(preview.get("net_worth")))
st.dataframe(pd.DataFrame(preview.get("included_items", [])), width="stretch", height=220)
if preview.get("warnings"):
st.warning(preview.get("warnings"))
else:
st.error(preview.get("error", preview))
if close_res:
if close_res.get("success"):
st.success(f"Close 완료: run={close_res.get('snapshot_run_id')}, correction={close_res.get('is_correction')}, inserted={close_res.get('inserted_count')}")
st.json({k: close_res.get(k) for k in ["basis_date", "source", "is_closed_snapshot", "snapshot_run_id", "correction_of_run_id", "correction_reason", "closed_at", "net_worth"]})
else:
st.error(close_res.get("error", close_res))
def page_transactions(db_path: str) -> None:
render_page_header(
"거래내역",
"날짜, 카테고리, 가족 구성원, 사용처를 기준으로 거래를 빠르게 찾아 수정합니다.",
kicker="📒 Ledger Explorer",
pills=["필터링", "거래 수정", "원장 검토"],
)
category_options, _ = get_category_options(db_path)
people_df = read_sql(db_path, "SELECT DISTINCT person FROM transactions WHERE status='active' AND person<>'' ORDER BY person")
people = people_df["person"].tolist() if not people_df.empty else []
col1, col2, col3, col4, col5 = st.columns(5)
start = col1.date_input("시작일", value=None)
end = col2.date_input("종료일", value=None)
category = col3.selectbox("카테고리", ["전체"] + [c for c in category_options if c])
person = col4.selectbox("인원", ["전체"] + people)
text = col5.text_input("사용처 검색")
clauses = ["t.status='active'"]
params: list[Any] = []
if start:
clauses.append("t.transaction_date>=?"); params.append(str(start))
if end:
clauses.append("t.transaction_date<=?"); params.append(str(end))
if category != "전체":
clauses.append("t.category=?"); params.append(category)
if person != "전체":
clauses.append("t.person=?"); params.append(person)
if text:
clauses.append("t.merchant LIKE ?"); params.append(f"%{text}%")
df = transaction_df(db_path, "WHERE " + " AND ".join(clauses), tuple(params), limit=2000)
st.dataframe(df.drop(columns=["id", "amount", "balance_after", "is_fixed"], errors="ignore"), width="stretch", height=500)
if not df.empty:
render_section_label("✏️", "선택 거래 수정")
options = {f"{r.transaction_datetime or r.transaction_date} · {r.merchant} · {format_krw(r.amount)} · {r.category or '미분류'}": r.id for r in df.itertuples()}
label = st.selectbox("수정할 거래", list(options.keys()))
tx_id = options[label]
tx = df[df["id"] == tx_id].iloc[0].to_dict()
update_transaction_form(tx, "tx", db_path)
def page_cross_transfer_review(db_path: str) -> None:
render_page_header(
"KB↔토스뱅크 확인필요 큐",
"날짜·금액·상대 기준으로 계좌 간 이동 후보를 대조하고, 드라이런 후 안전하게 매칭합니다.",
kicker="🔁 Transfer Review",
pills=["드라이런 우선", "본인계좌이동", "수동 검토"],
)
c1, c2 = st.columns(2)
with c1:
if st.button("미리보기(드라이런)"):
st.session_state["cross_transfer_preview"] = transfers_gateway.preview_cross_account_transfers(db_path=db_path)
with c2:
if st.button("자동 매칭 실행"):
res = transfers_gateway.run_cross_account_transfer_match(db_path=db_path)
clear_cache()
st.session_state["cross_transfer_last_run"] = res
st.success(f"매칭 {res.get('matched_count', 0)}건 적용, 확인필요 {res.get('review_count', 0)}건")
st.rerun()
if "cross_transfer_preview" in st.session_state:
prev = st.session_state["cross_transfer_preview"]
render_section_label("🧪", "미리보기 결과")
st.info(f"예상 매칭 {prev.get('matched_count', 0)}건 / 확인필요 {prev.get('review_count', 0)}건")
if prev.get("matched_pairs"):
st.dataframe(pd.DataFrame(prev.get("matched_pairs")), width="stretch", height=250)
if prev.get("review_items"):
st.dataframe(pd.DataFrame(prev.get("review_items")), width="stretch", height=250)
st.divider()
render_section_label("📌", "확인필요 큐")
list_queue_fn = getattr(transfers_gateway, "list_cross_transfer_review_queue", None)
if callable(list_queue_fn):
queue = list_queue_fn(db_path=db_path)
rows = queue.get("transactions", [])
else:
# 호환성 보호: 구버전 서비스에서 메서드가 없을 때 직접 조회
st.info("확인필요 큐 조회 함수가 현재 서비스 모듈에 없어 직접 DB 조회로 표시합니다.")
with sqlite3.connect(db_path) as _conn:
_conn.row_factory = lambda cursor, row: {description[0]: row[idx] for idx, description in enumerate(cursor.description)}
_rows = _conn.execute(
"""
SELECT * FROM transactions
WHERE status='active'
AND source IN ('kb_bank_pdf','toss_bank_pdf')
AND memo LIKE '%cross_transfer_review:%'
ORDER BY transaction_date DESC, created_at DESC
LIMIT 300
"""
).fetchall()
rows = [dict(r) for r in _rows]
if not rows:
st.success("현재 확인필요 큐가 비어 있습니다.")
return
df = pd.DataFrame(rows)
if "cashflow_kind" in df.columns:
df["cashflow_kind"] = df["cashflow_kind"].fillna("")
st.dataframe(df[["transaction_date", "source", "type", "amount", "counterparty", "category", "subcategory", "memo", "id"]].rename(columns={"amount": "금액"}), width="stretch", height=320)
options = {
f"{r.get('transaction_date')} · {r.get('source')} · {r.get('type')} · {int(r.get('amount', 0)):,}원 · {r.get('counterparty', '')}": r
for r in rows
}
selected = st.selectbox("수동 수정할 거래", list(options.keys()))
selected_row = options[selected]
st.caption(f"현재 review_status: {selected_row.get('review_status', 'pending')}")
with st.expander("Review decision 저장/보류/재검토/해제", expanded=False):
render_review_decision_actions(transfers_gateway, "cross_transfer", selected_row["id"], f"transfer_review_decision_{selected_row['id']}", db_path, target_payload=selected_row)
update_transaction_form(selected_row, "transfer_review", db_path)
def page_uncategorized(db_path: str) -> None:
render_page_header(
"미분류 거래",
"추천 분류를 확인하고, 반복 사용처는 규칙으로 전환해 다음 import를 자동화합니다.",
kicker="🧩 Categorization Inbox",
pills=["추천", "반복 후보", "규칙화"],
)
filter_cols = st.columns([1.0, 1.2, 1.15, 1.15, 1.25])
min_amount = filter_cols[0].number_input("최소 금액", min_value=0, value=0, step=10000)
limit = filter_cols[1].slider("표시 건수", 10, 500, 100)
recommended_only = filter_cols[2].checkbox("추천 있는 항목만", value=False)
repeated_only = filter_cols[3].checkbox("반복 후보만", value=False)
sort_label = filter_cols[4].selectbox("작업목록 정렬", ["금액순", "날짜순(최신순)"], index=0)
sort_by = "date_desc" if sort_label == "날짜순(최신순)" else "amount_desc"
workbench_fn = get_service_callable(categorization_gateway, "get_uncategorized_workbench", required_params=["sort_by"])
if not callable(workbench_fn):
st.error("현재 실행 중인 서비스에서 미분류 작업대 함수를 찾지 못했습니다. Streamlit 서버를 완전히 재시작해 주세요.")
return
result = workbench_fn(limit=limit, min_amount=int(min_amount), sort_by=sort_by, db_path=db_path)
if not result.get("success"):
st.error(result.get("error", result))
return
summary = result.get("summary", {})
metric_cols = st.columns(5)
metric_cols[0].metric("미분류", f"{summary.get('uncategorized_count', 0):,}건")
metric_cols[1].metric("미분류 총액", format_krw(summary.get("uncategorized_total_amount")))
metric_cols[2].metric("사용처 그룹", f"{summary.get('merchant_group_count', 0):,}개")
metric_cols[3].metric("추천 가능", f"{summary.get('recommendable_count', 0):,}건")
metric_cols[4].metric("반복 후보", f"{summary.get('repeated_group_count', 0):,}개")
rows = result.get("transactions", [])
groups = result.get("groups", [])
if not rows:
st.success("미분류 거래가 없습니다.")
return
render_section_label("🔁", "반복 사용처 후보")
st.caption("같은 사용처/요약/거래유형으로 묶인 미분류 거래입니다. 추천이 있는 그룹은 한 번에 적용하거나 규칙으로 저장할 수 있습니다.")
render_uncategorized_groups(groups, db_path, repeated_only=repeated_only)
render_section_label("🛠️", "미분류 작업 목록")
st.caption("거래를 펼치면 추천 근거와 작업 버튼이 보입니다. 추천 적용은 카테고리가 비어 있는 현재 거래에만 적용됩니다.")
render_uncategorized_work_items(rows, db_path, recommended_only=recommended_only)
def render_rule_list_items(rules: list[dict[str, Any]], db_path: str) -> None:
if not rules:
st.info("등록된 규칙이 없습니다.")
return
st.markdown(
"""
<style>
div[data-testid="stExpander"] {
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
margin: 0 !important;
}
div[data-testid="stExpander"] details {
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
border-bottom: 1px solid rgba(226, 232, 240, 0.92) !important;
}
div[data-testid="stExpander"] details > summary {
min-height: 2.55rem;
padding: 0.38rem 0.35rem !important;
background: transparent !important;
border-radius: 0 !important;
}
div[data-testid="stExpander"] details > summary:hover {
background: rgba(37,99,235,0.045) !important;
}
div[data-testid="stExpander"] details[open] > summary {
border-bottom: 1px solid rgba(226,232,240,0.82);
background: rgba(248,250,252,0.72) !important;
}
div[data-testid="stExpander"] summary p {
font-size: 0.84rem;
font-weight: 700;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rule-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: stretch;
gap: 0.62rem;
margin: 0.25rem 0 0.72rem;
}
.rule-detail-box {
border: 1px solid rgba(226,232,240,0.9);
border-radius: 12px;
padding: 0.58rem 0.72rem;
background: rgba(248,250,252,0.68);
}
.rule-detail-label {
color: #64748b;
font-size: 0.66rem;
font-weight: 850;
letter-spacing: 0.04em;
text-transform: uppercase;
margin-bottom: 0.18rem;
}
.rule-detail-value {
color: #0f172a;
font-size: 0.86rem;
font-weight: 760;
line-height: 1.3;
word-break: break-word;
}
.rule-detail-arrow {
display: flex;
align-items: center;
color: #2563eb;
font-weight: 900;
font-size: 1.1rem;
}
.rule-meta-row {
display: flex;
flex-wrap: wrap;
gap: 0.36rem;
margin-bottom: 0.62rem;
}
.rule-meta-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.25rem 0.58rem;
color: #475569;
background: rgba(241,245,249,0.94);
border: 1px solid rgba(226,232,240,0.85);
font-size: 0.72rem;
font-weight: 720;
line-height: 1.1;
}
.rule-memo {
color: #64748b;
font-size: 0.74rem;
line-height: 1.4;
margin: 0.1rem 0 0.65rem;
}
.rule-danger-note {
color: #ef4444;
background: rgba(239,68,68,0.07);
border: 1px solid rgba(239,68,68,0.16);
border-radius: 12px;
padding: 0.42rem 0.54rem;
font-size: 0.68rem;
line-height: 1.28;
}
@media (max-width: 900px) {
.rule-detail-grid { grid-template-columns: 1fr; }
.rule-detail-arrow { justify-content: center; transform: rotate(90deg); }
}
</style>
""",
unsafe_allow_html=True,
)
for idx, rule in enumerate(rules):
rule_id = rule.get("id", "")
active = bool(rule.get("active", True))
status_label = "활성" if active else "비활성"
tx_type = rule.get("transaction_type") or "전체"
person = rule.get("person") or "-"
fixed = rule.get("is_fixed")
fixed_label = "예" if fixed is True else "아니오" if fixed is False else "변경안함"
amount_bits = []
if rule.get("amount_min") is not None:
amount_bits.append(f"최소 {format_krw(rule.get('amount_min'))}")
if rule.get("amount_max") is not None:
amount_bits.append(f"최대 {format_krw(rule.get('amount_max'))}")
if rule.get("day_of_month") is not None:
amount_bits.append(f"매월 {rule.get('day_of_month')}일")
condition_text = " · ".join(amount_bits) if amount_bits else "추가 조건 없음"
match_field = rule.get("match_field") or "-"
match_type = rule.get("match_type") or "-"
pattern_text = rule.get("pattern") or "-"
result_text = f"{rule.get('category') or '-'} / {rule.get('subcategory') or '-'}"
memo = rule.get("memo") or ""
priority = rule.get("priority", "-")
summary = f"{'🟢' if active else '⚪'} {pattern_text} · {match_field}/{match_type} → {result_text} · p{priority} · {tx_type}"
with st.expander(summary, expanded=False):
st.markdown(
f"""
<div class="rule-detail-grid">
<div class="rule-detail-box">
<div class="rule-detail-label">IF</div>
<div class="rule-detail-value">{escape(match_field)} {escape(match_type)} “{escape(pattern_text)}”</div>
</div>
<div class="rule-detail-arrow">→</div>
<div class="rule-detail-box">
<div class="rule-detail-label">THEN</div>
<div class="rule-detail-value">{escape(result_text)}</div>
</div>
</div>
<div class="rule-meta-row">
<span class="rule-meta-chip">상태: {escape(status_label)}</span>
<span class="rule-meta-chip">우선순위: p{escape(str(priority))}</span>
<span class="rule-meta-chip">거래유형: {escape(tx_type)}</span>
<span class="rule-meta-chip">조건: {escape(condition_text)}</span>
<span class="rule-meta-chip">person: {escape(person)}</span>
<span class="rule-meta-chip">고정비: {escape(fixed_label)}</span>
<span class="rule-meta-chip">ID: {escape(rule_id)}</span>
</div>
{f'<div class="rule-memo">메모: {escape(memo)}</div>' if memo else ''}
""",
unsafe_allow_html=True,
)
action_cols = st.columns([1.35, 0.9, 0.8, 1.4, 3.2], vertical_alignment="center")
if action_cols[0].button("🔎 적용(미리보기)", key=f"rule_expander_preview_{rule_id}_{idx}", use_container_width=True):
category_rule_preview_dialog(rule, db_path)
if action_cols[1].button("✅ 적용", key=f"rule_expander_apply_{rule_id}_{idx}", use_container_width=True):
apply_fn = get_service_callable(categorization_gateway, "apply_category_rule")
if not callable(apply_fn):
st.error("현재 실행 중인 서비스에서 단일 규칙 적용 함수를 찾지 못했습니다. Streamlit 서버를 완전히 종료한 뒤 다시 시작해 주세요.")
else:
res = apply_fn(rule_id, limit=10000, db_path=db_path)
clear_cache()
if res.get("success"):
st.success(f"미분류 매칭 {res.get('applied_count', 0):,}건에 규칙을 적용했습니다.")
st.rerun()
else:
st.error(res.get("error", res))
if action_cols[2].button("✏️ 수정", key=f"rule_expander_edit_{rule_id}_{idx}", use_container_width=True):
category_rule_edit_dialog(rule, db_path)
with action_cols[3]:
st.markdown('<div class="rule-danger-note">삭제보다 비활성화를 권장합니다.</div>', unsafe_allow_html=True)
if action_cols[4].button("🗑️ 삭제", key=f"rule_expander_delete_{rule_id}_{idx}"):
delete_rule = getattr(categorization_gateway, "delete_category_rule", None)
if delete_rule is None:
st.error("현재 실행 중인 서비스에 delete_category_rule 함수가 없습니다. 앱을 재시작해 최신 코드를 다시 불러오세요.")
else:
res = delete_rule(rule_id, db_path=db_path)
clear_cache()
if res.get("success"):
if st.session_state.get("rules_edit_rule_id") == rule_id:
st.session_state.pop("rules_edit_rule_id", None)
st.success("규칙을 삭제했습니다.")
st.rerun()
else:
st.error(res.get("error", res))
def page_rules(db_path: str) -> None:
render_page_header(
"분류 규칙",
"반복되는 사용처와 거래 패턴을 규칙으로 관리해 분류 자동화 품질을 높입니다.",
kicker="⚙️ Rule Engine",
pills=["자동 분류", "Preview", "Seed/Update"],
)
action_cols = st.columns([5, 1], vertical_alignment="center")
with action_cols[1]:
if st.button("추가", key="rule_add_dialog_open", use_container_width=True, type="primary"):
category_rule_create_dialog(db_path)
rules = categorization_gateway.list_category_rules(active_only=False, db_path=db_path).get("rules", [])
render_section_label("📚", "현재 분류 규칙")
render_rule_list_items(rules, db_path)
st.caption("규칙 추가는 제목 오른쪽 `추가` 버튼, 기존 규칙 수정은 각 항목의 `⋮ → 변경` 팝업을 사용하세요.")
c1, c2, c3 = st.columns(3)
if c1.button("기본 규칙 seed/update"):
res = categorization_gateway.seed_default_category_rules(db_path=db_path)
clear_cache(); st.success(res)
if c2.button("규칙 preview"):
st.session_state["rule_preview"] = categorization_gateway.preview_categorization(limit=5000, db_path=db_path)
if c3.button("규칙 적용"):
res = categorization_gateway.apply_categorization_rules(limit=5000, db_path=db_path)
clear_cache(); st.success(f"{res.get('applied_count', 0)}건 적용")
if "rule_preview" in st.session_state:
prev = st.session_state["rule_preview"]
st.info(f"스캔 {prev.get('scanned')}건 · 매칭 {prev.get('matched_count')}건 · 미분류 {prev.get('unmatched_count')}건")
st.write("카테고리별 예상")
st.dataframe(pd.DataFrame([{"category": k, **v} for k, v in prev.get("by_category", {}).items()]), width="stretch")
with st.expander("매칭 샘플"):
st.dataframe(pd.DataFrame(prev.get("matches", [])), width="stretch")
with st.expander("미매칭 샘플"):
st.dataframe(pd.DataFrame(prev.get("unmatched", [])), width="stretch")
def page_categories(db_path: str) -> None:
render_page_header(
"카테고리 관리",
"현금흐름 성격까지 함께 관리해 요약 리포트와 투자 가능 현금흐름 계산의 기준을 정돈합니다.",
kicker="🏷️ Category Taxonomy",
pills=["수입/지출/이체", "비활성화 안전장치", "사용량 확인"],
)
st.caption("카테고리 삭제는 기본적으로 비활성화 처리합니다. 기존 거래의 과거 category 값은 보존됩니다.")
c1, c2 = st.columns(2)
if c1.button("기본 카테고리 seed/update"):
res = categorization_gateway.seed_default_ledger_categories(db_path=db_path)
clear_cache()
st.success(f"{res.get('created_or_updated', 0)}개 카테고리를 생성/갱신했습니다.")
st.rerun()
show_inactive = c2.checkbox("비활성 카테고리도 보기", value=False)
render_section_label("➕", "카테고리 추가")
with st.form("create_ledger_category"):
cols = st.columns([2, 2, 2, 1, 3])
category = cols[0].text_input("대분류", placeholder="예: 반려동물")
subcategory = cols[1].text_input("소분류", placeholder="예: 사료")
cashflow_kind_label = cols[2].selectbox("현금흐름 성격", [CASHFLOW_KIND_LABELS[k] for k in CASHFLOW_KIND_OPTIONS], index=1)
sort_order = cols[3].number_input("정렬", value=100, step=10)
memo = cols[4].text_input("메모", value="dashboard: user category")
submitted = st.form_submit_button("추가/재활성화", type="primary")
if submitted:
cashflow_kind = next(k for k, v in CASHFLOW_KIND_LABELS.items() if v == cashflow_kind_label)
res = categorization_gateway.create_ledger_category(category, subcategory, int(sort_order), memo, cashflow_kind, db_path=db_path)
clear_cache()
if res.get("success"):
st.success("카테고리를 저장했습니다.")
st.rerun()
else:
st.error(res.get("error", res))
result = categorization_gateway.list_ledger_categories(active_only=not show_inactive, include_usage=True, db_path=db_path)
rows = result.get("categories", [])
render_section_label("🗂️", "카테고리 목록")
if rows:
df = pd.DataFrame(rows)
if "cashflow_kind" in df:
df["cashflow_kind_label"] = df["cashflow_kind"].map(CASHFLOW_KIND_LABELS).fillna(df["cashflow_kind"])
if "total_amount" in df:
df["total_amount_display"] = df["total_amount"].map(format_krw)
st.dataframe(df, width="stretch", height=360)
else:
st.info("등록된 카테고리가 없습니다. 기본 카테고리를 seed하거나 새 카테고리를 추가하세요.")
return
render_section_label("🛡️", "카테고리 삭제/비활성화")
options = {
f"{r['category']} / {r['subcategory'] or '-'} · 사용 {r.get('transaction_count', 0)}건 · {'활성' if r.get('active') else '비활성'}": r
for r in rows
}
selected = st.selectbox("대상", list(options.keys()))
target = options[selected]
hard_delete = st.checkbox("DB에서 완전 삭제", value=False, help="보통은 끄고 비활성화만 하세요. 거래 기록은 별도 보존됩니다.")
force = st.checkbox("사용 중이어도 강제", value=False, help="거래/예산/규칙에서 사용 중인 카테고리도 삭제/비활성화합니다. 거래 category 값은 변경하지 않습니다.")
if st.button("삭제/비활성화 실행", type="secondary"):
res = categorization_gateway.delete_ledger_category(target["category"], target.get("subcategory", ""), hard_delete=hard_delete, force=force, db_path=db_path)
clear_cache()
if res.get("success"):
st.warning(f"처리했습니다. 사용 거래 {res.get('transaction_count', 0)}건")
st.rerun()
else:
st.error(res.get("error", res))
def page_accounts(db_path: str) -> None:
render_page_header(
"계좌",
"수집·import에 연결된 계좌와 카드, 마지막 잔액 정보를 확인합니다.",
kicker="🏦 Accounts",
pills=["잔액", "기관", "statement_status", "수동 잔액대조"],
)
accounts_res = accounts_gateway.list_ledger_accounts(active_only=False, db_path=db_path)
accounts_data = accounts_res.get("accounts", []) if accounts_res.get("success") else []
accounts = pd.DataFrame(accounts_data)
if not accounts.empty:
accounts["statement_status_label"] = accounts["statement_status"].map(STATEMENT_STATUS_LABELS).fillna(accounts["statement_status"])
accounts["liquidity_bucket_label"] = accounts["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(accounts["liquidity_bucket"])
accounts["last_balance_display"] = accounts["last_balance"].map(format_krw)
display_cols = [c for c in ["id", "name", "type", "institution", "liquidity_bucket", "liquidity_bucket_label", "statement_status", "statement_status_label", "last_balance_display", "last_synced_at", "active", "memo"] if c in accounts.columns]
st.dataframe(accounts[display_cols], width="stretch")
else:
st.info("등록된 계좌가 없습니다.")
st.divider()
render_section_label("🛠️", "계좌 운영 속성 수정")
if accounts_data:
account_options = {f"{a['name']} ({a['id']})": a for a in accounts_data}
with st.form("account_statement_status_form"):
selected_label = st.selectbox("계좌", list(account_options.keys()))
selected = account_options[selected_label]
current_status = selected.get("statement_status", "unknown")
status = st.selectbox(
"statement_status",
STATEMENT_STATUS_OPTIONS,
index=STATEMENT_STATUS_OPTIONS.index(current_status) if current_status in STATEMENT_STATUS_OPTIONS else 0,
format_func=lambda v: f"{v} — {STATEMENT_STATUS_LABELS.get(v, v)}",
)
current_bucket = selected.get("liquidity_bucket", "other") or "other"
liquidity_bucket = st.selectbox(
"liquidity_bucket",
LIQUIDITY_BUCKET_OPTIONS,
index=LIQUIDITY_BUCKET_OPTIONS.index(current_bucket) if current_bucket in LIQUIDITY_BUCKET_OPTIONS else LIQUIDITY_BUCKET_OPTIONS.index("other"),
format_func=lambda v: f"{v} — {LIQUIDITY_BUCKET_LABELS.get(v, v)}",
)
last_balance_raw = st.text_input("기준 잔액(last_balance, 선택)", value="" if selected.get("last_balance") is None else str(selected.get("last_balance")))
last_synced_at = st.text_input("기준 시각(last_synced_at, 선택)", value=selected.get("last_synced_at") or "", help="예: 2026-06 또는 2026-06-30T23:59:00+09:00")
memo = st.text_input("메모", value=selected.get("memo") or "")
submitted = st.form_submit_button("계좌 상태 저장", type="primary")
if submitted:
update_fields: dict[str, Any] = {"statement_status": status, "liquidity_bucket": liquidity_bucket, "last_synced_at": last_synced_at, "memo": memo}
if last_balance_raw.strip():
try:
update_fields["last_balance"] = int(last_balance_raw.replace(",", ""))
except ValueError:
st.error("기준 잔액은 정수로 입력하세요.")
return
res = accounts_gateway.update_ledger_account(selected["id"], db_path=db_path, **update_fields)
clear_cache()
if res.get("success"):
st.success("계좌 상태를 저장했습니다.")
st.rerun()
else:
st.error(res.get("error", res))
st.divider()
render_section_label("⚖️", "최소 잔액대조 결과")
st.caption("대상: statement_status가 manual_only 또는 partial_import인 계좌. 결과는 preview/report이며 자동 확정·알림·restore를 수행하지 않습니다.")
c1, c2, c3 = st.columns([1, 1, 1])
baseline_at = c1.text_input("스냅샷 기준 시각", value="", placeholder="2026-06 또는 2026-06-30T23:59:00+09:00")
tolerance = c2.number_input("허용 오차(원)", min_value=0, value=1000, step=100)
source_note = c3.text_input("출처 메모", value="", placeholder="예: 월말 앱 잔액")
if st.button("잔액대조 실행", type="secondary"):
st.session_state["balance_reconciliation_report"] = reconciliation_gateway.get_balance_reconciliation_report(
baseline_at=baseline_at,
source_note=source_note,
tolerance=int(tolerance),
db_path=db_path,
)
report = st.session_state.get("balance_reconciliation_report")
if report:
if report.get("success"):
items = pd.DataFrame(report.get("items", []))
if not items.empty:
for col in ["baseline_balance", "calculated_balance", "difference"]:
if col in items.columns:
items[f"{col}_display"] = items[col].map(format_krw)
show_cols = [c for c in ["account_name", "statement_status", "status", "baseline_at", "baseline_balance_display", "calculated_balance_display", "difference_display", "difference_ratio", "calculation_basis", "transaction_count"] if c in items.columns]
st.dataframe(items[show_cols], width="stretch")
review_count = int((items["status"] == "REVIEW_NEEDED").sum()) if "status" in items else 0
st.metric("확인필요", f"{review_count}건")
selectable = report.get("items", [])
if selectable:
labels = {f"{item.get('account_name')} · {item.get('status')} · {format_krw(item.get('difference'))}": item for item in selectable}
chosen_label = st.selectbox("대조 세션/판정 대상", list(labels.keys()))
chosen = labels[chosen_label]
with st.expander("Reconciliation session 기록/마감", expanded=False):
status = st.selectbox("Session status", ["open", "closed", "needs_recheck", "ignored"], key="recon_session_status")
memo = st.text_input("Session memo", value="", key="recon_session_memo")
if st.button("Session 저장/갱신", key="recon_session_save", type="primary"):
res = reconciliation_gateway.upsert_reconciliation_session(
chosen["account_id"],
str(chosen.get("baseline_at", ""))[:10],
expected_balance=chosen.get("baseline_balance"),
actual_balance=chosen.get("calculated_balance"),
difference=chosen.get("difference"),
status=status,
source_note=source_note,
memo=memo,
db_path=db_path,
)
clear_cache(); st.success("저장했습니다." if res.get("success") else res.get("error", res)); st.rerun()
existing_session = chosen.get("reconciliation_session") or {}
if existing_session.get("id") and st.button("기존 Session 마감", key="recon_session_close"):
res = reconciliation_gateway.close_reconciliation_session(session_id=existing_session["id"], memo=memo, db_path=db_path)
clear_cache(); st.success("마감했습니다." if res.get("success") else res.get("error", res)); st.rerun()
with st.expander(f"Review decision · 현재 {chosen.get('review_status', 'pending')}", expanded=False):
render_review_decision_actions(reconciliation_gateway, "reconciliation_account", chosen["account_id"], f"recon_review_{chosen['account_id']}", db_path, target_payload=chosen)
else:
st.info("잔액대조 대상 계좌가 없습니다.")
else:
st.error(report.get("error", report))
def page_imports(db_path: str) -> None:
render_page_header(
"Import",
"PDF/XLSX 거래내역을 먼저 Preview로 검증한 뒤 원장에 반영합니다.",
kicker="📥 Statement Import",
pills=["Preview 우선", "중복 skip", "Import 이력"],
)
render_section_label("📈", "토스증권 거래내역 PDF 가져오기")
st.caption("토스증권 거래내역서 PDF를 업로드하면 투자계좌 거래로 가져옵니다. 매수/매도/환전/입출금은 `이체/투자이동`, 배당/이자는 실제 수입으로 분류합니다.")
uploaded = st.file_uploader("토스증권 PDF", type=["pdf"], key="toss_pdf")
if uploaded is not None:
upload_dir = Path.home() / ".local" / "share" / "jarca" / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
upload_path = upload_dir / uploaded.name
upload_path.write_bytes(uploaded.getbuffer())
c1, c2 = st.columns(2)
if c1.button("토스증권 Preview"):
res = imports_gateway.preview_toss_securities_pdf_import(str(upload_path), db_path=db_path)
st.session_state["toss_preview"] = res
if c2.button("토스증권 Import", type="primary"):
res = imports_gateway.import_toss_securities_pdf(str(upload_path), db_path=db_path)
clear_cache()
st.session_state["toss_import_result"] = res
st.rerun()
if "toss_preview" in st.session_state:
res = st.session_state["toss_preview"]
if res.get("success"):
st.success(f"Preview: {res.get('row_count', 0)}건, 중복 {res.get('duplicate_count', 0)}건")
st.json({"metadata": res.get("metadata"), "by_action": res.get("by_action"), "validation": res.get("validation")})
st.dataframe(pd.DataFrame(res.get("preview", [])), width="stretch", height=300)
else:
st.error(res)
if "toss_import_result" in st.session_state:
res = st.session_state["toss_import_result"]
if res.get("success"):
st.success(f"Import 완료: {res.get('inserted_count', 0)}건 추가, 중복 skip {res.get('skipped_duplicate_count', 0)}건")
st.json(res)
else:
st.error(res)
st.divider()
render_section_label("🏦", "토스뱅크 거래내역 PDF 가져오기")
st.caption("토스뱅크 계좌 거래내역 PDF를 업로드하면 입출금/이체/카드결제를 가져옵니다. 타인 이체/출금은 `이체/확인필요`로 분류됩니다.")
toss_bank_uploaded = st.file_uploader("토스뱅크 PDF", type=["pdf"], key="tossbank_pdf")
if toss_bank_uploaded is not None:
upload_dir = Path.home() / ".local" / "share" / "jarca" / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
upload_path = upload_dir / toss_bank_uploaded.name
upload_path.write_bytes(toss_bank_uploaded.getbuffer())
c1, c2 = st.columns(2)
if c1.button("토스뱅크 Preview"):
res = imports_gateway.preview_toss_bank_pdf_import(str(upload_path), db_path=db_path)
st.session_state["toss_bank_preview"] = res
if c2.button("토스뱅크 Import", type="primary"):
res = imports_gateway.import_toss_bank_pdf(str(upload_path), db_path=db_path)
clear_cache()
st.session_state["toss_bank_import_result"] = res
st.rerun()
if "toss_bank_preview" in st.session_state:
res = st.session_state["toss_bank_preview"]
if res.get("success"):
st.success(f"Preview: {res.get('row_count', 0)}건, 중복 {res.get('duplicate_count', 0)}건")
st.json({"metadata": res.get("metadata"), "by_action": res.get("by_action"), "validation": res.get("validation")})
st.dataframe(pd.DataFrame(res.get("preview", [])), width="stretch", height=300)
else:
st.error(res)
if "toss_bank_import_result" in st.session_state:
res = st.session_state["toss_bank_import_result"]
if res.get("success"):
st.success(f"Import 완료: {res.get('inserted_count', 0)}건 추가, 중복 skip {res.get('skipped_duplicate_count', 0)}건")
st.json(res)
else:
st.error(res)
st.divider()
render_section_label("🧾", "Import 이력")
batches = overview_gateway.list_ledger_import_batches(limit=100, db_path=db_path).get("import_batches", [])
st.dataframe(pd.DataFrame(batches), width="stretch", height=350)
if batches:
with st.form("rollback"):
batch_id = st.selectbox("Rollback batch", [b["id"] for b in batches])
hard = st.checkbox("Hard delete", value=False)
submitted = st.form_submit_button("Rollback 실행", type="secondary")
if submitted:
res = imports_gateway.rollback_ledger_import_batch(batch_id, hard_delete=hard, db_path=db_path)
clear_cache()
st.warning(res)
st.rerun()
def main() -> None:
db_path = get_db_path()
inject_app_css()
pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]
query_page = st.query_params.get("page", "요약")
page = query_page if query_page in pages else "요약"
st.session_state["page"] = page
with st.sidebar:
st.markdown("## 💰 Jarca Ledger")
st.caption("가계부 · 분류 · Import 운영 콘솔")
st.markdown('<div class="jarca-nav-title">메뉴</div>', unsafe_allow_html=True)
render_sidebar_nav(pages, page)
if st.button("새로고침", key="refresh_button", use_container_width=True):
clear_cache()
st.rerun()
if page == "월정산":
page_monthly_settlement(db_path)
elif page == "요약":
page_overview(db_path)
elif page == "자산/마감":
page_assets(db_path)
elif page == "거래내역":
page_transactions(db_path)
elif page == "확인필요 큐":
page_cross_transfer_review(db_path)
elif page == "미분류":
page_uncategorized(db_path)
elif page == "분류 규칙":
page_rules(db_path)
elif page == "카테고리":
page_categories(db_path)
elif page == "계좌":
page_accounts(db_path)
elif page == "Import":
page_imports(db_path)
if __name__ == "__main__":
main()