1041 lines
50 KiB
Python
1041 lines
50 KiB
Python
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from html import escape
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import quote
|
|
|
|
import pandas as pd
|
|
import plotly.express as px
|
|
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")
|
|
|
|
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": "확인필요",
|
|
}
|
|
|
|
|
|
def format_krw(value: Any) -> str:
|
|
try:
|
|
if value is None:
|
|
return "-"
|
|
return f"{int(value):,}원"
|
|
except Exception:
|
|
return str(value)
|
|
|
|
|
|
@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 inject_sidebar_nav_css() -> None:
|
|
st.markdown(
|
|
"""
|
|
<style>
|
|
[data-testid="stSidebar"] .jarca-nav-title {
|
|
font-size: 0.82rem;
|
|
font-weight: 700;
|
|
color: #6b7280;
|
|
margin: 0.4rem 0 1.15rem 0.1rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
[data-testid="stAppViewContainer"] h1 {
|
|
font-size: 1.45rem;
|
|
line-height: 1.25;
|
|
margin-top: 0.2rem;
|
|
margin-bottom: 0.65rem;
|
|
}
|
|
[data-testid="stAppViewContainer"] h2 {
|
|
font-size: 1.12rem;
|
|
line-height: 1.3;
|
|
margin-top: 0.15rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
[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.14rem;
|
|
}
|
|
[data-testid="stSidebar"] .jarca-nav-item {
|
|
display: block;
|
|
width: 100%;
|
|
border-radius: 12px;
|
|
padding: 0.48rem 0.85rem;
|
|
font-weight: 500;
|
|
font-size: 0.76rem;
|
|
line-height: 1.2;
|
|
color: #111827;
|
|
text-decoration: none;
|
|
background: transparent;
|
|
box-sizing: border-box;
|
|
}
|
|
[data-testid="stSidebar"] .jarca-nav-item:hover {
|
|
background: rgba(15, 23, 42, 0.06);
|
|
color: #111827;
|
|
}
|
|
[data-testid="stSidebar"] .jarca-nav-item.active {
|
|
background: #e5e7eb;
|
|
color: #111827;
|
|
font-weight: 700;
|
|
}
|
|
</style>
|
|
""",
|
|
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,
|
|
)
|
|
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",
|
|
)
|
|
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 page_overview(db_path: str) -> None:
|
|
st.title("📊 요약")
|
|
health = service.health_check(db_path)
|
|
st.caption(f"DB: `{health.get('db_path')}` · schema v{health.get('schema_version')}")
|
|
df = read_sql(db_path, "SELECT * FROM transactions WHERE status='active'")
|
|
accounts = read_sql(db_path, "SELECT * FROM accounts WHERE active=1")
|
|
categories = read_sql(db_path, "SELECT category, subcategory, cashflow_kind FROM ledger_categories WHERE active=1")
|
|
if df.empty:
|
|
st.info("거래가 없습니다.")
|
|
return
|
|
df["month"] = pd.to_datetime(df["transaction_date"]).dt.strftime("%Y-%m")
|
|
if not categories.empty:
|
|
df = df.merge(categories, how="left", on=["category", "subcategory"])
|
|
else:
|
|
df["cashflow_kind"] = None
|
|
df["cashflow_kind"] = df["cashflow_kind"].fillna("unknown")
|
|
|
|
account_in = int(df.loc[df["type"] == "income", "amount"].sum())
|
|
account_out = int(df.loc[df["type"] == "expense", "amount"].sum())
|
|
account_net = account_in - account_out
|
|
actual_income = int(df.loc[df["cashflow_kind"] == "income", "amount"].sum())
|
|
actual_expense = int(df.loc[df["cashflow_kind"] == "expense", "amount"].sum())
|
|
actual_net = actual_income - actual_expense
|
|
savings_rate = actual_net / actual_income if actual_income else None
|
|
transfer_like = int(df.loc[df["cashflow_kind"].isin(["transfer", "investment", "exclude"]), "amount"].sum())
|
|
unknown_amount = int(df.loc[df["cashflow_kind"] == "unknown", "amount"].sum())
|
|
uncategorized = int((df["category"].fillna("") == "").sum())
|
|
|
|
st.subheader("실제 가계부 기준")
|
|
c1, c2, c3, c4, c5 = st.columns(5)
|
|
c1.metric("실제 수입", format_krw(actual_income))
|
|
c2.metric("실제 지출", format_krw(actual_expense))
|
|
c3.metric("실제 순현금흐름", format_krw(actual_net))
|
|
c4.metric("저축률", f"{savings_rate:.1%}" if savings_rate is not None else "-")
|
|
c5.metric("확인필요", format_krw(unknown_amount))
|
|
|
|
st.subheader("계좌 입출금 흐름")
|
|
b1, b2, b3, b4, b5 = st.columns(5)
|
|
b1.metric("계좌", len(accounts))
|
|
b2.metric("거래", len(df))
|
|
b3.metric("총 입금", format_krw(account_in))
|
|
b4.metric("총 출금", format_krw(account_out))
|
|
b5.metric("계좌 순증감", format_krw(account_net))
|
|
st.caption(f"이체/투자/분석제외성 거래 합계: {format_krw(transfer_like)} · 미분류 거래: {uncategorized:,}건")
|
|
|
|
monthly = df.pivot_table(index="month", columns="cashflow_kind", values="amount", aggfunc="sum", fill_value=0).reset_index()
|
|
for col in ["income", "expense"]:
|
|
if col not in monthly.columns:
|
|
monthly[col] = 0
|
|
monthly["actual_net_cashflow"] = monthly["income"] - monthly["expense"]
|
|
st.plotly_chart(px.bar(monthly, x="month", y=["income", "expense", "actual_net_cashflow"], barmode="group", title="월별 실제 수입/지출"), width="stretch")
|
|
|
|
account_monthly = df.pivot_table(index="month", columns="type", values="amount", aggfunc="sum", fill_value=0).reset_index()
|
|
for col in ["income", "expense"]:
|
|
if col not in account_monthly.columns:
|
|
account_monthly[col] = 0
|
|
account_monthly["account_net"] = account_monthly["income"] - account_monthly["expense"]
|
|
st.plotly_chart(px.line(account_monthly, x="month", y=["income", "expense", "account_net"], title="월별 계좌 입출금 흐름"), width="stretch")
|
|
|
|
person_summary = df[df["cashflow_kind"].isin(["income", "expense"])].pivot_table(index="person", columns="cashflow_kind", values="amount", aggfunc="sum", fill_value=0).reset_index()
|
|
if not person_summary.empty:
|
|
person_summary["person"] = person_summary["person"].replace("", "미지정")
|
|
for col in ["income", "expense"]:
|
|
if col not in person_summary.columns:
|
|
person_summary[col] = 0
|
|
person_summary["net_cashflow"] = person_summary["income"] - person_summary["expense"]
|
|
st.subheader("인원별 실제 수입/지출")
|
|
st.dataframe(person_summary, width="stretch", height=220)
|
|
st.plotly_chart(px.bar(person_summary, x="person", y=["income", "expense", "net_cashflow"], barmode="group", title="인원별 실제 수입/지출"), width="stretch")
|
|
|
|
cat = df[df["cashflow_kind"] == "expense"].groupby(["category", "subcategory"], dropna=False)["amount"].sum().reset_index()
|
|
cat["label"] = cat.apply(lambda r: f"{r['category'] or '미분류'} / {r['subcategory'] or '-'}", axis=1)
|
|
cat = cat.sort_values("amount", ascending=False).head(20)
|
|
st.plotly_chart(px.bar(cat, x="amount", y="label", orientation="h", title="실제 지출 카테고리 Top 20"), width="stretch")
|
|
|
|
|
|
def page_transactions(db_path: str) -> None:
|
|
st.title("📒 거래내역")
|
|
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:
|
|
st.subheader("선택 거래 수정")
|
|
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:
|
|
st.header("🔁 KB↔토스뱅크 확인필요 큐")
|
|
st.caption("KB와 토스뱅크 거래를 날짜·금액·상대 기준으로 자동 대조합니다. 먼저 미리보기(드라이런)로 후보를 확인한 뒤 실행하세요.")
|
|
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"]
|
|
st.subheader("미리보기 결과")
|
|
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()
|
|
st.subheader("확인필요 큐")
|
|
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:
|
|
st.title("🧩 미분류 거래")
|
|
col1, col2 = st.columns([1, 3])
|
|
min_amount = col1.number_input("최소 금액", min_value=0, value=0, step=10000)
|
|
limit = col2.slider("표시 건수", 10, 500, 100)
|
|
summary = service.get_uncategorized_merchant_summary(limit=50)
|
|
st.header("미분류 사용처 요약")
|
|
st.dataframe(pd.DataFrame(summary.get("items", [])), width="stretch", height=280)
|
|
|
|
result = service.list_uncategorized_transactions(limit=limit, min_amount=int(min_amount))
|
|
rows = result.get("transactions", [])
|
|
if not rows:
|
|
st.success("미분류 거래가 없습니다.")
|
|
return
|
|
df = pd.DataFrame(rows)
|
|
df["금액"] = df["amount"].map(format_krw)
|
|
st.subheader("미분류 거래 수정")
|
|
render_uncategorized_action_table(rows, db_path)
|
|
|
|
|
|
def render_rule_list_items(rules: list[dict[str, Any]], db_path: str) -> None:
|
|
if not rules:
|
|
st.info("등록된 규칙이 없습니다.")
|
|
return
|
|
|
|
st.markdown(
|
|
"""
|
|
<style>
|
|
.rule-title-line {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.38rem;
|
|
flex-wrap: wrap;
|
|
font-weight: 600;
|
|
line-height: 1.2;
|
|
word-break: break-word;
|
|
}
|
|
.rule-priority {
|
|
font-size: 0.72rem;
|
|
line-height: 1;
|
|
color: #6b7280;
|
|
border: 1px solid rgba(120,120,120,0.26);
|
|
border-radius: 999px;
|
|
padding: 0.14rem 0.34rem;
|
|
white-space: nowrap;
|
|
}
|
|
.rule-body {
|
|
margin-top: 0.08rem;
|
|
}
|
|
.rule-body-line {
|
|
font-size: 0.76rem;
|
|
line-height: 1.15;
|
|
color: #6b7280;
|
|
margin: 0.06rem 0 0 0;
|
|
}
|
|
.rule-body-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
column-gap: 0.6rem;
|
|
row-gap: 0.08rem;
|
|
margin-top: 0.08rem;
|
|
}
|
|
.rule-body-meta {
|
|
font-size: 0.74rem;
|
|
line-height: 1.12;
|
|
color: #6b7280;
|
|
margin: 0;
|
|
}
|
|
</style>
|
|
""",
|
|
unsafe_allow_html=True,
|
|
)
|
|
|
|
wrapper = st.container(border=True)
|
|
with wrapper:
|
|
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 "추가 조건 없음"
|
|
target_text = f"{rule.get('match_field') or '-'} / {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 ""
|
|
|
|
title_cols = st.columns([16, 1], gap="small", vertical_alignment="center")
|
|
with title_cols[0]:
|
|
st.markdown(
|
|
f'<div class="rule-title-line"><span>{escape(pattern_text)}</span><span class="rule-priority">p{escape(str(rule.get("priority", "-")))}</span></div>',
|
|
unsafe_allow_html=True,
|
|
)
|
|
with title_cols[1]:
|
|
with st.popover("⋮"):
|
|
if st.button("변경", key=f"rule_menu_edit_{rule_id}", use_container_width=True):
|
|
category_rule_edit_dialog(rule, db_path)
|
|
if st.button("삭제", key=f"rule_menu_delete_{rule_id}", use_container_width=True):
|
|
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))
|
|
|
|
st.markdown(
|
|
f"""
|
|
<div class="rule-body">
|
|
<div class="rule-body-line">대상: {escape(target_text)}</div>
|
|
<div class="rule-body-line">추가 조건: {escape(condition_text)}</div>
|
|
<div class="rule-body-line">분류 결과: {escape(result_text)}</div>
|
|
<div class="rule-body-grid">
|
|
<div class="rule-body-meta">거래유형: {escape(tx_type)}</div>
|
|
<div class="rule-body-meta">person: {escape(person)}</div>
|
|
<div class="rule-body-meta">고정비: {escape(fixed_label)}</div>
|
|
<div class="rule-body-meta">상태: {escape(status_label)}</div>
|
|
<div class="rule-body-meta">ID: {escape(rule_id)}</div>
|
|
{f'<div class="rule-body-meta">메모: {escape(memo)}</div>' if memo else '<div class="rule-body-meta"></div>'}
|
|
</div>
|
|
</div>
|
|
""",
|
|
unsafe_allow_html=True,
|
|
)
|
|
if idx < len(rules) - 1:
|
|
st.divider()
|
|
|
|
|
|
def page_rules(db_path: str) -> None:
|
|
title_cols = st.columns([8, 1], vertical_alignment="center")
|
|
with title_cols[0]:
|
|
st.title("⚙️ 분류 규칙")
|
|
with title_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", [])
|
|
st.subheader("현재 분류 규칙")
|
|
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()
|
|
clear_cache(); st.success(res)
|
|
if c2.button("규칙 preview"):
|
|
st.session_state["rule_preview"] = service.preview_categorization(limit=5000)
|
|
if c3.button("규칙 적용"):
|
|
res = service.apply_categorization_rules(limit=5000)
|
|
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:
|
|
st.header("🏷️ 카테고리 관리")
|
|
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)
|
|
|
|
st.subheader("카테고리 추가")
|
|
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", [])
|
|
st.subheader("카테고리 목록")
|
|
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
|
|
|
|
st.subheader("카테고리 삭제/비활성화")
|
|
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:
|
|
st.header("🏦 계좌")
|
|
accounts = read_sql(db_path, "SELECT * FROM accounts ORDER BY created_at DESC")
|
|
if not accounts.empty:
|
|
accounts["last_balance_display"] = accounts["last_balance"].map(format_krw)
|
|
st.dataframe(accounts, width="stretch")
|
|
|
|
|
|
def page_imports(db_path: str) -> None:
|
|
st.header("📥 Import")
|
|
|
|
st.subheader("토스증권 거래내역 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()
|
|
st.subheader("토스뱅크 거래내역 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()
|
|
st.subheader("Import 이력")
|
|
batches = service.list_ledger_import_batches(limit=100).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)
|
|
clear_cache()
|
|
st.warning(res)
|
|
st.rerun()
|
|
|
|
|
|
def main() -> None:
|
|
db_path = get_db_path()
|
|
inject_sidebar_nav_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.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()
|