Files
jarca-ledger-mcp/dashboard/page_views/rules.py
T

395 lines
20 KiB
Python

from __future__ import annotations
# ruff: noqa: F405
import pandas as pd
import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import categorization_gateway
from dashboard.common import * # noqa: F403, F405
RULE_MATCH_FIELDS = ["merchant", "raw_summary", "counterparty", "payment_method", "branch", "description", "memo"]
RULE_MATCH_TYPES = ["contains", "equals", "starts_with", "ends_with", "regex"]
RULE_TX_TYPES = ["", "income", "expense", "transfer", "refund", "adjustment"]
RULE_FIXED_LABELS = ["변경안함", "예", "아니오"]
@st.dialog("분류 규칙 추가", width="large")
def category_rule_create_dialog(db_path: str) -> None:
category_options, _ = get_category_options(db_path)
editable_categories = [c for c in category_options if c]
with st.form("rule_create_dialog"):
cols = st.columns(4)
match_field = cols[0].selectbox("대상 필드", RULE_MATCH_FIELDS)
match_type = cols[1].selectbox("매칭 방식", RULE_MATCH_TYPES)
pattern = cols[2].text_input("패턴")
tx_type = cols[3].selectbox("거래 유형", RULE_TX_TYPES)
cols2 = st.columns(5)
category = cols2[0].selectbox("대분류", editable_categories)
subcategory = cols2[1].text_input("소분류")
person = cols2[2].text_input("person")
priority = cols2[3].number_input("우선순위", value=100, step=10)
is_fixed = cols2[4].selectbox("고정비", RULE_FIXED_LABELS)
cols3 = st.columns(3)
amount_min_text = cols3[0].text_input("최소 금액(선택)", placeholder="예: 1200000")
amount_max_text = cols3[1].text_input("최대 금액(선택)", placeholder="예: 1200000")
day_of_month_text = cols3[2].text_input("매월 일자(선택)", placeholder="1~31")
memo = st.text_input("메모", value="dashboard: user rule")
submitted = st.form_submit_button("규칙 저장", type="primary")
if submitted:
try:
amount_min = parse_optional_int(amount_min_text, "최소 금액")
amount_max = parse_optional_int(amount_max_text, "최대 금액")
day_of_month = parse_optional_int(day_of_month_text, "매월 일자")
except ValueError as exc:
st.error(str(exc))
else:
fixed_value = None if is_fixed == "변경안함" else is_fixed == "예"
res = categorization_gateway.create_category_rule(match_field, pattern, category, subcategory, tx_type, match_type, int(priority), person, fixed_value, 1.0, memo, amount_min, amount_max, day_of_month, db_path=db_path)
clear_cache()
if res.get("success"):
st.success("규칙을 저장했습니다.")
st.rerun()
else:
st.error(res.get("error", res))
@st.dialog("분류 규칙 수정", width="large")
def category_rule_edit_dialog(rule: dict[str, Any], db_path: str) -> None:
category_options, _ = get_category_options(db_path)
current_category = rule.get("category", "") or ""
editable_categories = [c for c in category_options if c]
if current_category and current_category not in editable_categories:
editable_categories.append(current_category)
fixed_value = "변경안함" if rule.get("is_fixed") is None else ("예" if rule.get("is_fixed") else "아니오")
st.caption(f"규칙 ID: `{rule['id']}`")
st.write(f"**{rule.get('pattern') or '-'}**")
with st.form(f"rule_edit_dialog_{rule['id']}"):
cols = st.columns(4)
edit_match_field = cols[0].selectbox("대상 필드", RULE_MATCH_FIELDS, index=RULE_MATCH_FIELDS.index(rule.get("match_field", "merchant")) if rule.get("match_field", "merchant") in RULE_MATCH_FIELDS else 0)
edit_match_type = cols[1].selectbox("매칭 방식", RULE_MATCH_TYPES, index=RULE_MATCH_TYPES.index(rule.get("match_type", "contains")) if rule.get("match_type", "contains") in RULE_MATCH_TYPES else 0)
edit_pattern = cols[2].text_input("패턴", value=rule.get("pattern", "") or "")
edit_tx_type = cols[3].selectbox("거래 유형", RULE_TX_TYPES, index=RULE_TX_TYPES.index(rule.get("transaction_type", "") or "") if (rule.get("transaction_type", "") or "") in RULE_TX_TYPES else 0)
cols2 = st.columns(5)
edit_category = cols2[0].selectbox("대분류", editable_categories, index=editable_categories.index(current_category) if current_category in editable_categories else 0)
edit_subcategory = cols2[1].text_input("소분류", value=rule.get("subcategory", "") or "")
edit_person = cols2[2].text_input("person", value=rule.get("person", "") or "")
edit_priority = cols2[3].number_input("우선순위", value=int(rule.get("priority", 100) or 100), step=10)
edit_is_fixed = cols2[4].selectbox("고정비", RULE_FIXED_LABELS, index=RULE_FIXED_LABELS.index(fixed_value))
cols3 = st.columns(4)
edit_amount_min_text = cols3[0].text_input("최소 금액(선택)", value="" if rule.get("amount_min") is None else str(rule.get("amount_min")))
edit_amount_max_text = cols3[1].text_input("최대 금액(선택)", value="" if rule.get("amount_max") is None else str(rule.get("amount_max")))
edit_day_of_month_text = cols3[2].text_input("매월 일자(선택)", value="" if rule.get("day_of_month") is None else str(rule.get("day_of_month")))
edit_active = cols3[3].checkbox("활성", value=bool(rule.get("active", True)))
edit_memo = st.text_input("메모", value=rule.get("memo", "") or "")
edit_submitted = st.form_submit_button("규칙 수정", type="primary")
if edit_submitted:
try:
edit_amount_min = parse_optional_int(edit_amount_min_text, "최소 금액")
edit_amount_max = parse_optional_int(edit_amount_max_text, "최대 금액")
edit_day_of_month = parse_optional_int(edit_day_of_month_text, "매월 일자")
except ValueError as exc:
st.error(str(exc))
else:
edit_fixed_value = None if edit_is_fixed == "변경안함" else edit_is_fixed == "예"
update_rule = getattr(categorization_gateway, "update_category_rule", None)
if update_rule is None:
st.error("현재 실행 중인 서비스에 update_category_rule 함수가 없습니다. 앱을 재시작해 최신 코드를 다시 불러오세요.")
else:
res = update_rule(
rule["id"], edit_match_field, edit_pattern, edit_category, edit_subcategory,
edit_tx_type, edit_match_type, int(edit_priority), edit_person, edit_fixed_value,
1.0, edit_memo, edit_amount_min, edit_amount_max, edit_day_of_month, edit_active,
db_path=db_path,
)
clear_cache()
if res.get("success"):
st.success("규칙을 수정했습니다.")
st.rerun()
else:
st.error(res.get("error", res))
@st.dialog("규칙 적용 미리보기", width="large")
def category_rule_preview_dialog(rule: dict[str, Any], db_path: str) -> None:
rule_id = rule.get("id", "")
pattern = rule.get("pattern") or "-"
result_text = f"{rule.get('category') or '-'} / {rule.get('subcategory') or '-'}"
st.caption(f"규칙 ID: `{rule_id}`")
st.write(f"**{pattern}** → **{result_text}**")
preview_fn = get_service_callable(categorization_gateway, "preview_category_rule_matches")
if not callable(preview_fn):
st.error("현재 실행 중인 서비스에서 단일 규칙 미리보기 함수를 찾지 못했습니다. Streamlit 서버를 완전히 종료한 뒤 다시 시작해 주세요.")
return
preview = preview_fn(rule_id, limit=10000, sample_limit=3, db_path=db_path)
if not preview.get("success"):
st.error(preview.get("error", preview))
return
m1, m2, m3 = st.columns(3)
m1.metric("스캔한 활성 거래", f"{preview.get('scanned', 0):,}건")
m2.metric("이 규칙과 매칭", f"{preview.get('matched_count', 0):,}건")
m3.metric("미분류 중 적용 대상", f"{preview.get('uncategorized_matched_count', 0):,}건")
sample = preview.get("sample", [])
if sample:
df = pd.DataFrame(sample)
if "amount" in df:
df["금액"] = df["amount"].map(format_krw)
display_cols = [
c for c in [
"transaction_date", "transaction_datetime", "type", "금액", "merchant", "raw_summary",
"counterparty", "current_category", "current_subcategory", "new_category", "new_subcategory",
] if c in df.columns
]
st.dataframe(df[display_cols], width="stretch", height=160)
else:
st.info("이 규칙에 부합하는 거래가 없습니다.")
def render_rule_list_items(rules: list[dict[str, Any]], db_path: str) -> None:
if not rules:
st.info("등록된 규칙이 없습니다.")
return
st.markdown(
"""
<style>
div[data-testid="stExpander"] {
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
margin: 0 !important;
}
div[data-testid="stExpander"] details {
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
border-bottom: 1px solid rgba(226, 232, 240, 0.92) !important;
}
div[data-testid="stExpander"] details > summary {
min-height: 2.55rem;
padding: 0.38rem 0.35rem !important;
background: transparent !important;
border-radius: 0 !important;
}
div[data-testid="stExpander"] details > summary:hover {
background: rgba(37,99,235,0.045) !important;
}
div[data-testid="stExpander"] details[open] > summary {
border-bottom: 1px solid rgba(226,232,240,0.82);
background: rgba(248,250,252,0.72) !important;
}
div[data-testid="stExpander"] summary p {
font-size: 0.84rem;
font-weight: 700;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rule-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: stretch;
gap: 0.62rem;
margin: 0.25rem 0 0.72rem;
}
.rule-detail-box {
border: 1px solid rgba(226,232,240,0.9);
border-radius: 12px;
padding: 0.58rem 0.72rem;
background: rgba(248,250,252,0.68);
}
.rule-detail-label {
color: #64748b;
font-size: 0.66rem;
font-weight: 850;
letter-spacing: 0.04em;
text-transform: uppercase;
margin-bottom: 0.18rem;
}
.rule-detail-value {
color: #0f172a;
font-size: 0.86rem;
font-weight: 760;
line-height: 1.3;
word-break: break-word;
}
.rule-detail-arrow {
display: flex;
align-items: center;
color: #2563eb;
font-weight: 900;
font-size: 1.1rem;
}
.rule-meta-row {
display: flex;
flex-wrap: wrap;
gap: 0.36rem;
margin-bottom: 0.62rem;
}
.rule-meta-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.25rem 0.58rem;
color: #475569;
background: rgba(241,245,249,0.94);
border: 1px solid rgba(226,232,240,0.85);
font-size: 0.72rem;
font-weight: 720;
line-height: 1.1;
}
.rule-memo {
color: #64748b;
font-size: 0.74rem;
line-height: 1.4;
margin: 0.1rem 0 0.65rem;
}
.rule-danger-note {
color: #ef4444;
background: rgba(239,68,68,0.07);
border: 1px solid rgba(239,68,68,0.16);
border-radius: 12px;
padding: 0.42rem 0.54rem;
font-size: 0.68rem;
line-height: 1.28;
}
@media (max-width: 900px) {
.rule-detail-grid { grid-template-columns: 1fr; }
.rule-detail-arrow { justify-content: center; transform: rotate(90deg); }
}
</style>
""",
unsafe_allow_html=True,
)
for idx, rule in enumerate(rules):
rule_id = rule.get("id", "")
active = bool(rule.get("active", True))
status_label = "활성" if active else "비활성"
tx_type = rule.get("transaction_type") or "전체"
person = rule.get("person") or "-"
fixed = rule.get("is_fixed")
fixed_label = "예" if fixed is True else "아니오" if fixed is False else "변경안함"
amount_bits = []
if rule.get("amount_min") is not None:
amount_bits.append(f"최소 {format_krw(rule.get('amount_min'))}")
if rule.get("amount_max") is not None:
amount_bits.append(f"최대 {format_krw(rule.get('amount_max'))}")
if rule.get("day_of_month") is not None:
amount_bits.append(f"매월 {rule.get('day_of_month')}일")
condition_text = " · ".join(amount_bits) if amount_bits else "추가 조건 없음"
match_field = rule.get("match_field") or "-"
match_type = rule.get("match_type") or "-"
pattern_text = rule.get("pattern") or "-"
result_text = f"{rule.get('category') or '-'} / {rule.get('subcategory') or '-'}"
memo = rule.get("memo") or ""
priority = rule.get("priority", "-")
summary = f"{'🟢' if active else '⚪'} {pattern_text} · {match_field}/{match_type} → {result_text} · p{priority} · {tx_type}"
with st.expander(summary, expanded=False):
st.markdown(
f"""
<div class="rule-detail-grid">
<div class="rule-detail-box">
<div class="rule-detail-label">IF</div>
<div class="rule-detail-value">{escape(match_field)} {escape(match_type)} “{escape(pattern_text)}”</div>
</div>
<div class="rule-detail-arrow">→</div>
<div class="rule-detail-box">
<div class="rule-detail-label">THEN</div>
<div class="rule-detail-value">{escape(result_text)}</div>
</div>
</div>
<div class="rule-meta-row">
<span class="rule-meta-chip">상태: {escape(status_label)}</span>
<span class="rule-meta-chip">우선순위: p{escape(str(priority))}</span>
<span class="rule-meta-chip">거래유형: {escape(tx_type)}</span>
<span class="rule-meta-chip">조건: {escape(condition_text)}</span>
<span class="rule-meta-chip">person: {escape(person)}</span>
<span class="rule-meta-chip">고정비: {escape(fixed_label)}</span>
<span class="rule-meta-chip">ID: {escape(rule_id)}</span>
</div>
{f'<div class="rule-memo">메모: {escape(memo)}</div>' if memo else ''}
""",
unsafe_allow_html=True,
)
action_cols = st.columns([1.35, 0.9, 0.8, 1.4, 3.2], vertical_alignment="center")
if action_cols[0].button("🔎 적용(미리보기)", key=f"rule_expander_preview_{rule_id}_{idx}", use_container_width=True):
category_rule_preview_dialog(rule, db_path)
if action_cols[1].button("✅ 적용", key=f"rule_expander_apply_{rule_id}_{idx}", use_container_width=True):
apply_fn = get_service_callable(categorization_gateway, "apply_category_rule")
if not callable(apply_fn):
st.error("현재 실행 중인 서비스에서 단일 규칙 적용 함수를 찾지 못했습니다. Streamlit 서버를 완전히 종료한 뒤 다시 시작해 주세요.")
else:
res = apply_fn(rule_id, limit=10000, db_path=db_path)
clear_cache()
if res.get("success"):
st.success(f"미분류 매칭 {res.get('applied_count', 0):,}건에 규칙을 적용했습니다.")
st.rerun()
else:
st.error(res.get("error", res))
if action_cols[2].button("✏️ 수정", key=f"rule_expander_edit_{rule_id}_{idx}", use_container_width=True):
category_rule_edit_dialog(rule, db_path)
with action_cols[3]:
st.markdown('<div class="rule-danger-note">삭제보다 비활성화를 권장합니다.</div>', unsafe_allow_html=True)
if action_cols[4].button("🗑️ 삭제", key=f"rule_expander_delete_{rule_id}_{idx}"):
delete_rule = getattr(categorization_gateway, "delete_category_rule", None)
if delete_rule is None:
st.error("현재 실행 중인 서비스에 delete_category_rule 함수가 없습니다. 앱을 재시작해 최신 코드를 다시 불러오세요.")
else:
res = delete_rule(rule_id, db_path=db_path)
clear_cache()
if res.get("success"):
if st.session_state.get("rules_edit_rule_id") == rule_id:
st.session_state.pop("rules_edit_rule_id", None)
st.success("규칙을 삭제했습니다.")
st.rerun()
else:
st.error(res.get("error", res))
def page_rules(db_path: str) -> None:
action_cols = st.columns([5, 1], vertical_alignment="center")
with action_cols[1]:
if st.button("추가", key="rule_add_dialog_open", use_container_width=True, type="primary"):
category_rule_create_dialog(db_path)
rules = categorization_gateway.list_category_rules(active_only=False, db_path=db_path).get("rules", [])
render_section_label("📚", "현재 분류 규칙")
render_rule_list_items(rules, db_path)
st.caption("규칙 추가는 제목 오른쪽 `추가` 버튼, 기존 규칙 수정은 각 항목의 `⋮ → 변경` 팝업을 사용하세요.")
c1, c2, c3 = st.columns(3)
if c1.button("기본 규칙 seed/update"):
res = categorization_gateway.seed_default_category_rules(db_path=db_path)
clear_cache(); st.success(res)
if c2.button("규칙 preview"):
st.session_state["rule_preview"] = categorization_gateway.preview_categorization(limit=5000, db_path=db_path)
if c3.button("규칙 적용"):
res = categorization_gateway.apply_categorization_rules(limit=5000, db_path=db_path)
clear_cache(); st.success(f"{res.get('applied_count', 0)}건 적용")
if "rule_preview" in st.session_state:
prev = st.session_state["rule_preview"]
st.info(f"스캔 {prev.get('scanned')}건 · 매칭 {prev.get('matched_count')}건 · 미분류 {prev.get('unmatched_count')}건")
st.write("카테고리별 예상")
st.dataframe(pd.DataFrame([{"category": k, **v} for k, v in prev.get("by_category", {}).items()]), width="stretch")
with st.expander("매칭 샘플"):
st.dataframe(pd.DataFrame(prev.get("matches", [])), width="stretch")
with st.expander("미매칭 샘플"):
st.dataframe(pd.DataFrame(prev.get("unmatched", [])), width="stretch")