1700 lines
82 KiB
Python
1700 lines
82 KiB
Python
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 import service
|
||
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(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(service, 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(service)
|
||
return getattr(service, 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 = service.list_ledger_categories(active_only=True, include_usage=False, db_path=db_path)
|
||
if result.get("success") and result.get("count", 0) == 0:
|
||
service.seed_default_ledger_categories(db_path=db_path)
|
||
result = service.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 = service.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 = service.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 = service.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 = service.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 = service.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 = service.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()
|
||
|
||
|
||
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 = service.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(service, "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 service.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 = service.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 = service.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)
|
||
|
||
|
||
|
||
@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("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_overview(db_path: str) -> None:
|
||
health = service.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 = service.get_monthly_cashflow_report(month, db_path=db_path)
|
||
asset_report = service.get_asset_liquidity_report(db_path=db_path)
|
||
budget_report = service.get_budget_vs_actual_report(month, db_path=db_path)
|
||
uncategorized_res = service.list_uncategorized_transactions(limit=1000, db_path=db_path)
|
||
transfer_queue_res = service.list_cross_transfer_review_queue(limit=300, db_path=db_path)
|
||
reconciliation_res = service.get_balance_reconciliation_report(db_path=db_path)
|
||
backups_res = service.list_ledger_backups(limit=1)
|
||
imports_res = service.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("기준: accounts.liquidity_bucket + accounts.last_balance. 거래 합계로 비거래 자산을 추정하지 않습니다.")
|
||
a1, a2, a3 = st.columns(3)
|
||
a1.metric("자산 합계(부채 제외)", format_krw(asset_report.get("total_assets", 0)))
|
||
a2.metric("총부채", format_krw(asset_report.get("total_liabilities", 0)))
|
||
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", []))
|
||
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=260)
|
||
else:
|
||
st.info("last_balance가 입력된 활성 계좌/자산 항목이 없습니다.")
|
||
|
||
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 = st.columns(4)
|
||
w1.metric("미분류 거래", f"{uncategorized_res.get('count', 0):,}건")
|
||
w2.metric("확인필요 큐", f"{len(review_items):,}건")
|
||
w3.metric("잔액대조 필요", f"{len(reconciliation_review):,}건")
|
||
w4.metric("잔액 미입력", f"{len(missing_balance):,}건")
|
||
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_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"] = service.preview_cross_account_transfers(db_path=db_path)
|
||
with c2:
|
||
if st.button("자동 매칭 실행"):
|
||
res = service.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(service, "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()))
|
||
update_transaction_form(options[selected], "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("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("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(service, "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 = service.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 = service.seed_default_category_rules(db_path=db_path)
|
||
clear_cache(); st.success(res)
|
||
if c2.button("규칙 preview"):
|
||
st.session_state["rule_preview"] = service.preview_categorization(limit=5000, db_path=db_path)
|
||
if c3.button("규칙 적용"):
|
||
res = service.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 = service.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 = service.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 = service.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 = service.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 = service.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 = service.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"] = service.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}건")
|
||
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 = service.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 = service.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 = service.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 = service.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 = service.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 = service.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_overview(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()
|