648 lines
27 KiB
Python
648 lines
27 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import sqlite3
|
|
from datetime import datetime
|
|
from html import escape
|
|
from typing import Any
|
|
import importlib
|
|
import inspect
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pandas as pd
|
|
import plotly.express as px
|
|
import plotly.io as pio
|
|
import streamlit as st
|
|
|
|
from jarca_ledger_mcp.adapters.streamlit import accounts_gateway as accounts_gateway
|
|
from jarca_ledger_mcp.adapters.streamlit import assets_gateway as assets_gateway
|
|
from jarca_ledger_mcp.adapters.streamlit import categorization_gateway as categorization_gateway
|
|
from jarca_ledger_mcp.adapters.streamlit import imports_gateway as imports_gateway
|
|
from jarca_ledger_mcp.adapters.streamlit import overview_gateway as overview_gateway
|
|
from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway as reconciliation_gateway
|
|
from jarca_ledger_mcp.adapters.streamlit import transactions_gateway as transactions_gateway
|
|
from jarca_ledger_mcp.adapters.streamlit import transfers_gateway as transfers_gateway
|
|
from jarca_ledger_mcp.db import resolve_db_path
|
|
|
|
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"
|
|
logger = logging.getLogger(__name__)
|
|
pio.templates[JARCA_PLOTLY_TEMPLATE] = pio.templates["plotly_white"].update(
|
|
{
|
|
"layout": {
|
|
"font": {"family": "Inter, Pretendard, Noto Sans KR, Arial, sans-serif", "color": JARCA_COLORS["navy"]},
|
|
"paper_bgcolor": "rgba(0,0,0,0)",
|
|
"plot_bgcolor": "rgba(255,255,255,0)",
|
|
"colorway": [JARCA_COLORS["blue"], JARCA_COLORS["rose"], JARCA_COLORS["emerald"], JARCA_COLORS["amber"], JARCA_COLORS["cyan"]],
|
|
"title": {"font": {"size": 16, "color": JARCA_COLORS["navy"]}, "x": 0.02, "xanchor": "left"},
|
|
"legend": {"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
|
|
"margin": {"l": 12, "r": 12, "t": 56, "b": 28},
|
|
"xaxis": {"gridcolor": "rgba(148, 163, 184, 0.20)", "zerolinecolor": "rgba(148, 163, 184, 0.35)"},
|
|
"yaxis": {"gridcolor": "rgba(148, 163, 184, 0.20)", "zerolinecolor": "rgba(148, 163, 184, 0.35)"},
|
|
}
|
|
}
|
|
)
|
|
px.defaults.template = JARCA_PLOTLY_TEMPLATE
|
|
|
|
DEFAULT_CATEGORIES = [
|
|
"", "수입", "이체", "식비", "주거", "통신", "교통", "교육", "의료", "생활", "문화/여가", "금융", "가족/용돈", "미분류"
|
|
]
|
|
DEFAULT_SUBCATEGORIES: dict[str, list[str]] = {
|
|
"수입": ["", "급여", "이자", "환급", "배당", "기타수입"],
|
|
"이체": ["", "본인계좌이동", "현금입출금", "가족송금", "투자이동", "확인필요"],
|
|
"식비": ["", "마트", "외식", "카페", "배달"],
|
|
"주거": ["", "관리비", "전기", "가스", "수도", "인터넷"],
|
|
"통신": ["", "휴대폰", "인터넷", "통신요금"],
|
|
"교통": ["", "대중교통", "택시", "주유", "주차", "차량정비"],
|
|
"교육": ["", "학원", "교재", "학교", "예체능"],
|
|
"의료": ["", "병원", "약국", "치과", "보험"],
|
|
"생활": ["", "생필품", "의류", "미용", "세탁"],
|
|
"문화/여가": ["", "여행", "숙박", "공연", "취미", "스포츠"],
|
|
"금융": ["", "카드대금", "대출상환", "수수료", "보험료"],
|
|
"가족/용돈": ["", "부모님", "자녀", "가족송금"],
|
|
"미분류": ["", "확인필요"],
|
|
}
|
|
CASHFLOW_KIND_OPTIONS = ["income", "expense", "transfer", "investment", "debt_payment", "exclude", "unknown"]
|
|
CASHFLOW_KIND_LABELS = {
|
|
"income": "실제 수입",
|
|
"expense": "실제 지출",
|
|
"transfer": "이체/제외",
|
|
"investment": "투자이동/제외",
|
|
"debt_payment": "부채상환/별도",
|
|
"exclude": "분석 제외",
|
|
"unknown": "확인필요",
|
|
}
|
|
STATEMENT_STATUS_OPTIONS = ["unknown", "import_supported", "manual_only", "partial_import"]
|
|
STATEMENT_STATUS_LABELS = {
|
|
"unknown": "미정",
|
|
"import_supported": "명세서 import 지원",
|
|
"manual_only": "수동 증빙 전용",
|
|
"partial_import": "부분 import + 수동 보완",
|
|
}
|
|
LIQUIDITY_BUCKET_OPTIONS = ["cash", "investment", "deposit", "real_estate", "loan", "other"]
|
|
LIQUIDITY_BUCKET_LABELS = {
|
|
"cash": "현금성",
|
|
"investment": "투자자산",
|
|
"deposit": "예적금",
|
|
"real_estate": "부동산",
|
|
"loan": "부채",
|
|
"other": "기타",
|
|
}
|
|
|
|
|
|
def format_krw(value: Any) -> str:
|
|
try:
|
|
if value is None:
|
|
return "-"
|
|
return f"{int(value):,}원"
|
|
except Exception:
|
|
return str(value)
|
|
|
|
|
|
def current_kst_month() -> str:
|
|
return datetime.now(ZoneInfo("Asia/Seoul")).strftime("%Y-%m")
|
|
|
|
|
|
def format_percent(value: Any) -> str:
|
|
if value is None:
|
|
return "-"
|
|
try:
|
|
return f"{float(value):.1%}"
|
|
except Exception:
|
|
return str(value)
|
|
|
|
|
|
def get_transfer_review_items(payload: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
if not payload or not payload.get("success"):
|
|
return []
|
|
items = payload.get("review_items")
|
|
if isinstance(items, list):
|
|
return items
|
|
items = payload.get("transactions")
|
|
if isinstance(items, list):
|
|
return items
|
|
return []
|
|
|
|
|
|
@st.cache_data(ttl=5)
|
|
def read_sql(db_path: str, sql: str, params: tuple[Any, ...] = ()) -> pd.DataFrame:
|
|
with sqlite3.connect(db_path) as conn:
|
|
return pd.read_sql_query(sql, conn, params=params)
|
|
|
|
|
|
def clear_cache() -> None:
|
|
read_sql.clear()
|
|
|
|
|
|
def get_db_path() -> str:
|
|
return str(resolve_db_path())
|
|
|
|
|
|
def get_service_callable(module, name: str, required_params: list[str] | None = None):
|
|
"""Return a service function, reloading once for Streamlit hot-reload edge cases.
|
|
|
|
Streamlit can keep an already-imported service module in memory while the
|
|
dashboard script itself reloads. When a UI change starts passing a new
|
|
keyword argument, a stale service function may still exist but have the old
|
|
signature. In that case reload the service module before returning it.
|
|
"""
|
|
required_params = required_params or []
|
|
fn = getattr(module, name, None)
|
|
if callable(fn):
|
|
try:
|
|
params = inspect.signature(fn).parameters
|
|
if all(param in params for param in required_params):
|
|
return fn
|
|
except (TypeError, ValueError):
|
|
if not required_params:
|
|
return fn
|
|
importlib.reload(module)
|
|
return getattr(module, name, None)
|
|
|
|
|
|
def inject_app_css() -> None:
|
|
st.markdown(
|
|
"""
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
|
:root {
|
|
--jarca-navy: #0f172a;
|
|
--jarca-slate: #475569;
|
|
--jarca-muted: #64748b;
|
|
--jarca-border: #e2e8f0;
|
|
--jarca-soft: #f8fafc;
|
|
--jarca-blue: #2563eb;
|
|
--jarca-cyan: #06b6d4;
|
|
--jarca-emerald: #10b981;
|
|
--jarca-amber: #f59e0b;
|
|
--jarca-rose: #f43f5e;
|
|
}
|
|
html, body, [class*="css"] {
|
|
font-family: Inter, Pretendard, "Noto Sans KR", -apple-system, BlinkMacSystemFont, sans-serif;
|
|
}
|
|
[data-testid="stAppViewContainer"] {
|
|
background:
|
|
radial-gradient(circle at top left, rgba(37, 99, 235, 0.10), transparent 30rem),
|
|
radial-gradient(circle at top right, rgba(6, 182, 212, 0.10), transparent 28rem),
|
|
linear-gradient(180deg, #f8fafc 0%, #ffffff 45%, #f8fafc 100%);
|
|
}
|
|
[data-testid="stHeader"] {
|
|
background: rgba(248, 250, 252, 0.78);
|
|
backdrop-filter: blur(16px);
|
|
}
|
|
.block-container {
|
|
/* Streamlit's fixed top toolbar overlays the app body. Keep the first
|
|
custom hero safely below it so the title block is not clipped. */
|
|
padding-top: 4.75rem;
|
|
padding-bottom: 2.5rem;
|
|
}
|
|
[data-testid="stSidebar"] {
|
|
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
|
border-right: 1px solid rgba(148, 163, 184, 0.22);
|
|
}
|
|
[data-testid="stSidebar"] h2 {
|
|
letter-spacing: -0.04em;
|
|
}
|
|
[data-testid="stSidebar"] .jarca-nav-title {
|
|
font-size: 0.82rem;
|
|
font-weight: 700;
|
|
color: var(--jarca-muted);
|
|
margin: 0.4rem 0 1.15rem 0.1rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
[data-testid="stAppViewContainer"] h1 {
|
|
font-size: 1.68rem;
|
|
line-height: 1.25;
|
|
margin-top: 0.2rem;
|
|
margin-bottom: 0.65rem;
|
|
letter-spacing: -0.045em;
|
|
}
|
|
[data-testid="stAppViewContainer"] h2 {
|
|
font-size: 1.16rem;
|
|
line-height: 1.3;
|
|
margin-top: 0.15rem;
|
|
margin-bottom: 0.5rem;
|
|
letter-spacing: -0.025em;
|
|
}
|
|
[data-testid="stAppViewContainer"] h3 {
|
|
font-size: 0.95rem;
|
|
line-height: 1.35;
|
|
margin-top: 0.1rem;
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
[data-testid="stSidebar"] .jarca-nav-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
}
|
|
[data-testid="stSidebar"] .jarca-nav-item {
|
|
display: block;
|
|
width: 100%;
|
|
border-radius: 14px;
|
|
padding: 0.62rem 0.88rem;
|
|
font-weight: 650;
|
|
font-size: 0.82rem;
|
|
line-height: 1.2;
|
|
color: var(--jarca-slate);
|
|
text-decoration: none;
|
|
background: transparent;
|
|
border: 1px solid transparent;
|
|
box-sizing: border-box;
|
|
transition: all 140ms ease;
|
|
}
|
|
[data-testid="stSidebar"] .jarca-nav-item:hover {
|
|
background: rgba(37, 99, 235, 0.07);
|
|
border-color: rgba(37, 99, 235, 0.12);
|
|
color: var(--jarca-blue);
|
|
}
|
|
[data-testid="stSidebar"] .jarca-nav-item.active {
|
|
background: linear-gradient(135deg, var(--jarca-blue), #0ea5e9);
|
|
color: white;
|
|
font-weight: 800;
|
|
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.22);
|
|
}
|
|
div[data-testid="stMetric"] {
|
|
background: rgba(255,255,255,0.84);
|
|
border: 1px solid rgba(226, 232, 240, 0.92);
|
|
border-radius: 10px;
|
|
padding: 1rem 1rem;
|
|
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;
|
|
font-size: 1rem;
|
|
}
|
|
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: 10px;
|
|
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);
|
|
}
|
|
.st-key-dashboard_body:has(.st-key-dashboard_loading [data-testid="stAlert"]) .st-key-dashboard_content {
|
|
display: none;
|
|
}
|
|
.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_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 validate_dashboard_page(page: str | None, pages: list[str]) -> str:
|
|
return page if page in pages else "요약"
|
|
|
|
|
|
def _sync_dashboard_page_query() -> None:
|
|
page = st.session_state["dashboard_page"]
|
|
st.query_params["page"] = {"월정산": "monthly_settlement"}.get(page, page)
|
|
|
|
|
|
def render_sidebar_nav(pages: list[str], current_page: str) -> str:
|
|
if "dashboard_page" not in st.session_state:
|
|
st.session_state["dashboard_page"] = current_page
|
|
return st.radio(
|
|
"메뉴",
|
|
pages,
|
|
key="dashboard_page",
|
|
label_visibility="collapsed",
|
|
on_change=_sync_dashboard_page_query,
|
|
)
|
|
|
|
|
|
def render_dashboard_body(page: str, renderer) -> None:
|
|
body = st.empty()
|
|
body.empty()
|
|
try:
|
|
with body.container():
|
|
with st.container(key="dashboard_body"):
|
|
loading = st.empty()
|
|
with loading.container():
|
|
with st.container(key="dashboard_loading"):
|
|
st.info(f"{page} 불러오는 중…")
|
|
with st.container(key="dashboard_content"):
|
|
renderer()
|
|
loading.empty()
|
|
except Exception:
|
|
logger.exception("Dashboard page render failed: page=%s", page)
|
|
body.empty()
|
|
with body.container():
|
|
st.error("페이지를 불러오지 못했습니다. 다시 시도해 주세요.")
|
|
if st.button("다시 시도", key=f"retry_{page}"):
|
|
st.rerun()
|
|
|
|
|
|
def parse_optional_int(value: str, field_name: str) -> int | None:
|
|
value = (value or "").strip()
|
|
if not value:
|
|
return None
|
|
try:
|
|
return int(value)
|
|
except ValueError as exc:
|
|
raise ValueError(f"{field_name}는 정수여야 합니다.") from exc
|
|
|
|
|
|
def get_category_options(db_path: str) -> tuple[list[str], dict[str, list[str]]]:
|
|
result = categorization_gateway.list_ledger_categories(active_only=True, include_usage=False, db_path=db_path)
|
|
if result.get("success") and result.get("count", 0) == 0:
|
|
categorization_gateway.seed_default_ledger_categories(db_path=db_path)
|
|
result = categorization_gateway.list_ledger_categories(active_only=True, include_usage=False, db_path=db_path)
|
|
grouped = result.get("grouped", {}) if result.get("success") else {}
|
|
categories = [""] + sorted(grouped.keys())
|
|
subcategories = {cat: [""] + sorted([s for s in subs if s]) for cat, subs in grouped.items()}
|
|
# Keep hardcoded defaults as fallback for fresh/broken DBs and for existing transaction values.
|
|
for cat in DEFAULT_CATEGORIES:
|
|
if cat and cat not in categories:
|
|
categories.append(cat)
|
|
for cat, subs in DEFAULT_SUBCATEGORIES.items():
|
|
subcategories.setdefault(cat, [""])
|
|
for sub in subs:
|
|
if sub not in subcategories[cat]:
|
|
subcategories[cat].append(sub)
|
|
return categories, subcategories
|
|
|
|
|
|
def transaction_df(db_path: str, where: str = "", params: tuple[Any, ...] = (), limit: int = 1000) -> pd.DataFrame:
|
|
sql = f"""
|
|
SELECT t.id, t.transaction_datetime, t.transaction_date, t.type, t.amount, t.category, t.subcategory,
|
|
COALESCE(lc.cashflow_kind, 'unknown') AS cashflow_kind,
|
|
t.merchant, t.raw_summary, t.counterparty, t.person, t.is_fixed, t.balance_after, t.branch,
|
|
t.source, t.import_batch_id, t.memo, t.status
|
|
FROM transactions t
|
|
LEFT JOIN ledger_categories lc
|
|
ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
|
|
{where}
|
|
ORDER BY COALESCE(NULLIF(t.transaction_datetime,''), t.transaction_date) DESC, t.created_at DESC
|
|
LIMIT ?
|
|
"""
|
|
df = read_sql(db_path, sql, params + (limit,))
|
|
if not df.empty:
|
|
df["금액"] = df["amount"].map(format_krw)
|
|
df["거래후잔액"] = df["balance_after"].map(format_krw)
|
|
df["고정비"] = df["is_fixed"].fillna(0).astype(bool)
|
|
return df
|
|
|
|
|
|
def update_transaction_form(tx: dict[str, Any], key_prefix: str, db_path: str | None = None) -> None:
|
|
db_path = db_path or get_db_path()
|
|
category_options, subcategory_options = get_category_options(db_path)
|
|
|
|
category_key = f"{key_prefix}_category"
|
|
subcategory_key = f"{key_prefix}_subcategory"
|
|
person_key = f"{key_prefix}_person"
|
|
fixed_key = f"{key_prefix}_fixed"
|
|
memo_key = f"{key_prefix}_memo"
|
|
save_rule_key = f"{key_prefix}_save_rule"
|
|
tx_marker_key = f"{key_prefix}_tx_id"
|
|
|
|
current_category = tx.get("category", "") or ""
|
|
if current_category and current_category not in category_options:
|
|
category_options = category_options + [current_category]
|
|
|
|
if st.session_state.get(tx_marker_key) != tx["id"]:
|
|
st.session_state[tx_marker_key] = tx["id"]
|
|
st.session_state[category_key] = current_category
|
|
st.session_state[subcategory_key] = tx.get("subcategory", "") or ""
|
|
st.session_state[person_key] = tx.get("person", "") or ""
|
|
st.session_state[fixed_key] = bool(tx.get("is_fixed") or False)
|
|
st.session_state[memo_key] = tx.get("memo", "") or ""
|
|
st.session_state[save_rule_key] = False
|
|
|
|
st.caption(f"거래 ID: `{tx['id']}`")
|
|
st.write(f"**{tx.get('transaction_datetime') or tx.get('transaction_date')}** · {tx.get('merchant')} · {format_krw(tx.get('amount'))}")
|
|
|
|
category = st.selectbox(
|
|
"대분류",
|
|
category_options,
|
|
index=category_options.index(st.session_state.get(category_key, current_category)) if st.session_state.get(category_key, current_category) in category_options else 0,
|
|
key=category_key,
|
|
)
|
|
|
|
sub_opts = subcategory_options.get(category, [""])
|
|
current_sub = st.session_state.get(subcategory_key, tx.get("subcategory", "") or "") or ""
|
|
if current_sub and current_sub not in sub_opts:
|
|
current_sub = ""
|
|
st.session_state[subcategory_key] = ""
|
|
subcategory = st.selectbox(
|
|
"소분류",
|
|
sub_opts,
|
|
index=sub_opts.index(current_sub) if current_sub in sub_opts else 0,
|
|
key=subcategory_key,
|
|
)
|
|
person = st.text_input("가족 구성원/person", key=person_key)
|
|
is_fixed = st.checkbox("고정비", key=fixed_key)
|
|
memo = st.text_input("메모", key=memo_key)
|
|
save_rule = st.checkbox("이 사용처 패턴을 규칙으로 저장", key=save_rule_key)
|
|
submitted = st.button("저장", type="primary", key=f"{key_prefix}_submit")
|
|
|
|
if submitted:
|
|
result = transactions_gateway.update_ledger_transaction(
|
|
tx["id"], category=category, subcategory=subcategory, person=person,
|
|
is_fixed=is_fixed, memo=memo, db_path=db_path,
|
|
)
|
|
if not result.get("success"):
|
|
st.error(result.get("error", result))
|
|
return
|
|
if save_rule and tx.get("merchant") and category:
|
|
rule = categorization_gateway.create_category_rule(
|
|
match_field="merchant",
|
|
pattern=tx["merchant"],
|
|
category=category,
|
|
subcategory=subcategory,
|
|
transaction_type=tx.get("type", ""),
|
|
person=person,
|
|
is_fixed=is_fixed,
|
|
priority=70,
|
|
memo="dashboard: user-confirmed rule",
|
|
db_path=db_path,
|
|
)
|
|
if not rule.get("success"):
|
|
st.warning(f"거래는 저장됐지만 규칙 저장 실패: {rule.get('error')}")
|
|
clear_cache()
|
|
st.success("저장했습니다.")
|
|
st.rerun()
|
|
|
|
|
|
@st.dialog("거래 분류", width="large")
|
|
def transaction_edit_dialog(tx: dict[str, Any], key_prefix: str, db_path: str) -> None:
|
|
update_transaction_form(tx, key_prefix, db_path)
|
|
|
|
|
|
@st.dialog("연결 거래 후보", width="large")
|
|
def linked_transfer_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
|
|
st.caption(f"기준 거래: `{tx['id']}`")
|
|
st.write(f"**{tx.get('transaction_datetime') or tx.get('transaction_date')}** · {tx.get('merchant')} · {format_krw(tx.get('amount'))}")
|
|
result = transfers_gateway.find_self_transfer_candidates(tx["id"], limit=20, db_path=db_path)
|
|
if not result.get("success"):
|
|
st.error(result.get("error", result))
|
|
return
|
|
candidates = result.get("candidates", [])
|
|
if not candidates:
|
|
st.info("같은 금액/가까운 날짜 조건에서 연결 후보를 찾지 못했습니다.")
|
|
return
|
|
|
|
st.success(f"연결 후보 {result.get('count', len(candidates))}건")
|
|
for idx, candidate in enumerate(candidates):
|
|
cols = st.columns([1.45, 0.95, 0.7, 1.2, 1.15, 0.95])
|
|
cols[0].write(candidate.get("transaction_datetime") or candidate.get("transaction_date") or "")
|
|
cols[1].write(candidate.get("source") or "")
|
|
cols[2].write(candidate.get("type") or "")
|
|
cols[3].write(format_krw(candidate.get("amount")))
|
|
cols[4].write(candidate.get("merchant") or candidate.get("counterparty") or "")
|
|
if cols[5].button("이 거래와 연결", key=f"link_pair_{tx['id']}_{candidate['id']}_{idx}", use_container_width=True, type="primary"):
|
|
link_res = transfers_gateway.link_self_transfer_pair(tx["id"], candidate["id"], db_path=db_path)
|
|
if not link_res.get("success"):
|
|
st.error(link_res.get("error", link_res))
|
|
else:
|
|
clear_cache()
|
|
st.success("양쪽 거래를 본인계좌이동으로 연결했습니다.")
|
|
st.rerun()
|
|
meta = []
|
|
if candidate.get("account_name"):
|
|
meta.append(f"계좌: {candidate['account_name']}")
|
|
if candidate.get("category") or candidate.get("subcategory"):
|
|
meta.append(f"현재 분류: {candidate.get('category', '')} / {candidate.get('subcategory', '')}")
|
|
if candidate.get("score") is not None:
|
|
meta.append(f"score={candidate['score']}")
|
|
if candidate.get("reason"):
|
|
meta.append(candidate["reason"])
|
|
st.caption(" · ".join(meta))
|
|
if idx < len(candidates) - 1:
|
|
st.divider()
|
|
|
|
|
|
@st.dialog("원거래 후보", width="large")
|
|
def cancel_original_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
|
|
st.caption(f"취소/환급 거래: `{tx['id']}`")
|
|
st.write(f"**{tx.get('transaction_datetime') or tx.get('transaction_date')}** · {tx.get('merchant')} · {format_krw(tx.get('amount'))}")
|
|
result = transfers_gateway.find_cancel_original_candidates(tx["id"], limit=20, db_path=db_path)
|
|
if not result.get("success"):
|
|
st.error(result.get("error", result))
|
|
return
|
|
candidates = result.get("candidates", [])
|
|
if not candidates:
|
|
st.info("같은 금액 기준으로 연결할 원거래 후보를 찾지 못했습니다.")
|
|
return
|
|
|
|
st.success(f"원거래 후보 {result.get('count', len(candidates))}건")
|
|
for idx, candidate in enumerate(candidates):
|
|
cols = st.columns([1.4, 0.95, 0.7, 1.25, 1.15, 0.95])
|
|
cols[0].write(candidate.get("transaction_datetime") or candidate.get("transaction_date") or "")
|
|
cols[1].write(candidate.get("source") or "")
|
|
cols[2].write(candidate.get("type") or "")
|
|
cols[3].write(format_krw(candidate.get("amount")))
|
|
cols[4].write(candidate.get("merchant") or candidate.get("counterparty") or "")
|
|
if cols[5].button("이 원거래와 연결", key=f"cancel_pair_{tx['id']}_{candidate['id']}_{idx}", use_container_width=True, type="primary"):
|
|
link_res = transfers_gateway.link_cancel_to_original(tx["id"], candidate["id"], db_path=db_path)
|
|
if not link_res.get("success"):
|
|
st.error(link_res.get("error", link_res))
|
|
else:
|
|
clear_cache()
|
|
st.success("취소/환급 거래를 원거래와 연결했습니다.")
|
|
st.rerun()
|
|
meta = []
|
|
if candidate.get("account_name"):
|
|
meta.append(f"계좌: {candidate['account_name']}")
|
|
if candidate.get("category") or candidate.get("subcategory"):
|
|
meta.append(f"현재 분류: {candidate.get('category', '')} / {candidate.get('subcategory', '')}")
|
|
if candidate.get("score") is not None:
|
|
meta.append(f"score={candidate['score']}")
|
|
if candidate.get("reason"):
|
|
meta.append(candidate["reason"])
|
|
st.caption(" · ".join(meta))
|
|
if idx < len(candidates) - 1:
|
|
st.divider()
|
|
|
|
|
|
def render_review_decision_actions(gateway: Any, target_type: str, target_id: str, key_prefix: str, db_path: str, target_payload: dict[str, Any] | None = None) -> None:
|
|
decision_type = st.selectbox(
|
|
"판정",
|
|
["unknown", "account_transfer", "real_expense", "real_income", "investment_transfer", "excluded_from_analysis", "duplicate_or_cancel"],
|
|
key=f"{key_prefix}_decision_type",
|
|
)
|
|
reason_code = st.text_input("사유 코드", value="operator_review", key=f"{key_prefix}_reason")
|
|
memo = st.text_input("결정 메모", value="", key=f"{key_prefix}_memo")
|
|
cols = st.columns(4)
|
|
if cols[0].button("결정 저장", key=f"{key_prefix}_decided", use_container_width=True, type="primary"):
|
|
res = gateway.upsert_review_decision(target_type, target_id, status="decided", decision_type=decision_type, reason_code=reason_code, memo=memo, target_payload=target_payload or {}, db_path=db_path)
|
|
clear_cache(); st.success("저장했습니다." if res.get("success") else res.get("error", res)); st.rerun()
|
|
if cols[1].button("보류", key=f"{key_prefix}_held", use_container_width=True):
|
|
res = gateway.hold_review_decision(target_type, target_id, reason_code=reason_code or "held", memo=memo, db_path=db_path)
|
|
clear_cache(); st.success("보류했습니다." if res.get("success") else res.get("error", res)); st.rerun()
|
|
if cols[2].button("재검토", key=f"{key_prefix}_recheck", use_container_width=True):
|
|
res = gateway.mark_review_recheck(target_type, target_id, reason_code=reason_code or "needs_recheck", memo=memo, db_path=db_path)
|
|
clear_cache(); st.success("재검토로 표시했습니다." if res.get("success") else res.get("error", res)); st.rerun()
|
|
if cols[3].button("해제", key=f"{key_prefix}_release", use_container_width=True):
|
|
res = gateway.release_review_decision(target_type, target_id, memo=memo, db_path=db_path)
|
|
clear_cache(); st.success("pending으로 해제했습니다." if res.get("success") else res.get("error", res)); st.rerun()
|