Refactor import and workflow services plus adapter seams
This commit is contained in:
+65
-58
@@ -15,7 +15,14 @@ import plotly.express as px
|
|||||||
import plotly.io as pio
|
import plotly.io as pio
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from jarca_ledger_mcp.adapters.streamlit import service_gateway as service
|
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
|
from jarca_ledger_mcp.db import resolve_db_path
|
||||||
|
|
||||||
st.set_page_config(page_title="Jarca Ledger", page_icon="💰", layout="wide")
|
st.set_page_config(page_title="Jarca Ledger", page_icon="💰", layout="wide")
|
||||||
@@ -146,7 +153,7 @@ def get_db_path() -> str:
|
|||||||
return str(resolve_db_path())
|
return str(resolve_db_path())
|
||||||
|
|
||||||
|
|
||||||
def get_service_callable(name: str, required_params: list[str] | None = None):
|
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.
|
"""Return a service function, reloading once for Streamlit hot-reload edge cases.
|
||||||
|
|
||||||
Streamlit can keep an already-imported service module in memory while the
|
Streamlit can keep an already-imported service module in memory while the
|
||||||
@@ -155,7 +162,7 @@ def get_service_callable(name: str, required_params: list[str] | None = None):
|
|||||||
signature. In that case reload the service module before returning it.
|
signature. In that case reload the service module before returning it.
|
||||||
"""
|
"""
|
||||||
required_params = required_params or []
|
required_params = required_params or []
|
||||||
fn = getattr(service, name, None)
|
fn = getattr(module, name, None)
|
||||||
if callable(fn):
|
if callable(fn):
|
||||||
try:
|
try:
|
||||||
params = inspect.signature(fn).parameters
|
params = inspect.signature(fn).parameters
|
||||||
@@ -164,8 +171,8 @@ def get_service_callable(name: str, required_params: list[str] | None = None):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
if not required_params:
|
if not required_params:
|
||||||
return fn
|
return fn
|
||||||
importlib.reload(service)
|
importlib.reload(module)
|
||||||
return getattr(service, name, None)
|
return getattr(module, name, None)
|
||||||
|
|
||||||
|
|
||||||
def inject_app_css() -> None:
|
def inject_app_css() -> None:
|
||||||
@@ -431,10 +438,10 @@ def parse_optional_int(value: str, field_name: str) -> int | None:
|
|||||||
|
|
||||||
|
|
||||||
def get_category_options(db_path: str) -> tuple[list[str], dict[str, list[str]]]:
|
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)
|
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:
|
if result.get("success") and result.get("count", 0) == 0:
|
||||||
service.seed_default_ledger_categories(db_path=db_path)
|
categorization_gateway.seed_default_ledger_categories(db_path=db_path)
|
||||||
result = service.list_ledger_categories(active_only=True, include_usage=False, 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 {}
|
grouped = result.get("grouped", {}) if result.get("success") else {}
|
||||||
categories = [""] + sorted(grouped.keys())
|
categories = [""] + sorted(grouped.keys())
|
||||||
subcategories = {cat: [""] + sorted([s for s in subs if s]) for cat, subs in grouped.items()}
|
subcategories = {cat: [""] + sorted([s for s in subs if s]) for cat, subs in grouped.items()}
|
||||||
@@ -524,7 +531,7 @@ def update_transaction_form(tx: dict[str, Any], key_prefix: str, db_path: str |
|
|||||||
submitted = st.button("저장", type="primary", key=f"{key_prefix}_submit")
|
submitted = st.button("저장", type="primary", key=f"{key_prefix}_submit")
|
||||||
|
|
||||||
if submitted:
|
if submitted:
|
||||||
result = service.update_ledger_transaction(
|
result = transactions_gateway.update_ledger_transaction(
|
||||||
tx["id"], category=category, subcategory=subcategory, person=person,
|
tx["id"], category=category, subcategory=subcategory, person=person,
|
||||||
is_fixed=is_fixed, memo=memo, db_path=db_path,
|
is_fixed=is_fixed, memo=memo, db_path=db_path,
|
||||||
)
|
)
|
||||||
@@ -532,7 +539,7 @@ def update_transaction_form(tx: dict[str, Any], key_prefix: str, db_path: str |
|
|||||||
st.error(result.get("error", result))
|
st.error(result.get("error", result))
|
||||||
return
|
return
|
||||||
if save_rule and tx.get("merchant") and category:
|
if save_rule and tx.get("merchant") and category:
|
||||||
rule = service.create_category_rule(
|
rule = categorization_gateway.create_category_rule(
|
||||||
match_field="merchant",
|
match_field="merchant",
|
||||||
pattern=tx["merchant"],
|
pattern=tx["merchant"],
|
||||||
category=category,
|
category=category,
|
||||||
@@ -560,7 +567,7 @@ def transaction_edit_dialog(tx: dict[str, Any], key_prefix: str, db_path: str) -
|
|||||||
def linked_transfer_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
|
def linked_transfer_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
|
||||||
st.caption(f"기준 거래: `{tx['id']}`")
|
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'))}")
|
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)
|
result = transfers_gateway.find_self_transfer_candidates(tx["id"], limit=20, db_path=db_path)
|
||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
st.error(result.get("error", result))
|
st.error(result.get("error", result))
|
||||||
return
|
return
|
||||||
@@ -578,7 +585,7 @@ def linked_transfer_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
|
|||||||
cols[3].write(format_krw(candidate.get("amount")))
|
cols[3].write(format_krw(candidate.get("amount")))
|
||||||
cols[4].write(candidate.get("merchant") or candidate.get("counterparty") or "")
|
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"):
|
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)
|
link_res = transfers_gateway.link_self_transfer_pair(tx["id"], candidate["id"], db_path=db_path)
|
||||||
if not link_res.get("success"):
|
if not link_res.get("success"):
|
||||||
st.error(link_res.get("error", link_res))
|
st.error(link_res.get("error", link_res))
|
||||||
else:
|
else:
|
||||||
@@ -603,7 +610,7 @@ def linked_transfer_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
|
|||||||
def cancel_original_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
|
def cancel_original_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
|
||||||
st.caption(f"취소/환급 거래: `{tx['id']}`")
|
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'))}")
|
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)
|
result = transfers_gateway.find_cancel_original_candidates(tx["id"], limit=20, db_path=db_path)
|
||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
st.error(result.get("error", result))
|
st.error(result.get("error", result))
|
||||||
return
|
return
|
||||||
@@ -621,7 +628,7 @@ def cancel_original_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
|
|||||||
cols[3].write(format_krw(candidate.get("amount")))
|
cols[3].write(format_krw(candidate.get("amount")))
|
||||||
cols[4].write(candidate.get("merchant") or candidate.get("counterparty") or "")
|
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"):
|
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)
|
link_res = transfers_gateway.link_cancel_to_original(tx["id"], candidate["id"], db_path=db_path)
|
||||||
if not link_res.get("success"):
|
if not link_res.get("success"):
|
||||||
st.error(link_res.get("error", link_res))
|
st.error(link_res.get("error", link_res))
|
||||||
else:
|
else:
|
||||||
@@ -683,7 +690,7 @@ def category_rule_create_dialog(db_path: str) -> None:
|
|||||||
st.error(str(exc))
|
st.error(str(exc))
|
||||||
else:
|
else:
|
||||||
fixed_value = None if is_fixed == "변경안함" else is_fixed == "예"
|
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)
|
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()
|
clear_cache()
|
||||||
if res.get("success"):
|
if res.get("success"):
|
||||||
st.success("규칙을 저장했습니다.")
|
st.success("규칙을 저장했습니다.")
|
||||||
@@ -735,7 +742,7 @@ def category_rule_edit_dialog(rule: dict[str, Any], db_path: str) -> None:
|
|||||||
st.error(str(exc))
|
st.error(str(exc))
|
||||||
else:
|
else:
|
||||||
edit_fixed_value = None if edit_is_fixed == "변경안함" else edit_is_fixed == "예"
|
edit_fixed_value = None if edit_is_fixed == "변경안함" else edit_is_fixed == "예"
|
||||||
update_rule = getattr(service, "update_category_rule", None)
|
update_rule = getattr(categorization_gateway, "update_category_rule", None)
|
||||||
if update_rule is None:
|
if update_rule is None:
|
||||||
st.error("현재 실행 중인 서비스에 update_category_rule 함수가 없습니다. 앱을 재시작해 최신 코드를 다시 불러오세요.")
|
st.error("현재 실행 중인 서비스에 update_category_rule 함수가 없습니다. 앱을 재시작해 최신 코드를 다시 불러오세요.")
|
||||||
else:
|
else:
|
||||||
@@ -787,7 +794,7 @@ def save_merchant_rule_from_recommendation(merchant: str, tx_type: str, rec: dic
|
|||||||
return {"success": False, "error": "merchant가 비어 있어 규칙을 만들 수 없습니다."}
|
return {"success": False, "error": "merchant가 비어 있어 규칙을 만들 수 없습니다."}
|
||||||
if not rec.get("category"):
|
if not rec.get("category"):
|
||||||
return {"success": False, "error": "추천 분류가 없어 규칙을 만들 수 없습니다."}
|
return {"success": False, "error": "추천 분류가 없어 규칙을 만들 수 없습니다."}
|
||||||
return service.create_category_rule(
|
return categorization_gateway.create_category_rule(
|
||||||
match_field="merchant",
|
match_field="merchant",
|
||||||
match_type="contains",
|
match_type="contains",
|
||||||
pattern=merchant,
|
pattern=merchant,
|
||||||
@@ -817,7 +824,7 @@ def render_uncategorized_groups(groups: list[dict[str, Any]], db_path: str, repe
|
|||||||
cols[3].write(recommendation_text(rec))
|
cols[3].write(recommendation_text(rec))
|
||||||
apply_disabled = not bool(rec.get("category"))
|
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):
|
if cols[4].button("그룹 적용", key=f"uncat_group_apply_{idx}_{merchant}", disabled=apply_disabled, use_container_width=True):
|
||||||
res = service.apply_uncategorized_group_recommendation(
|
res = categorization_gateway.apply_uncategorized_group_recommendation(
|
||||||
group.get("merchant", ""), group.get("raw_summary", ""), group.get("type", ""), db_path=db_path
|
group.get("merchant", ""), group.get("raw_summary", ""), group.get("type", ""), db_path=db_path
|
||||||
)
|
)
|
||||||
clear_cache()
|
clear_cache()
|
||||||
@@ -860,7 +867,7 @@ def render_uncategorized_work_items(rows: list[dict[str, Any]], db_path: str, re
|
|||||||
action_cols = st.columns([1.0, 1.0, 1.0, 1.1, 1.1, 2.5], vertical_alignment="center")
|
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"))
|
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"):
|
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)
|
res = categorization_gateway.apply_uncategorized_recommendation(row["id"], db_path=db_path)
|
||||||
clear_cache()
|
clear_cache()
|
||||||
if res.get("success"):
|
if res.get("success"):
|
||||||
st.success(f"{recommendation_text(res.get('recommendation'))}로 분류했습니다.")
|
st.success(f"{recommendation_text(res.get('recommendation'))}로 분류했습니다.")
|
||||||
@@ -892,7 +899,7 @@ def category_rule_preview_dialog(rule: dict[str, Any], db_path: str) -> None:
|
|||||||
st.caption(f"규칙 ID: `{rule_id}`")
|
st.caption(f"규칙 ID: `{rule_id}`")
|
||||||
st.write(f"**{pattern}** → **{result_text}**")
|
st.write(f"**{pattern}** → **{result_text}**")
|
||||||
|
|
||||||
preview_fn = get_service_callable("preview_category_rule_matches")
|
preview_fn = get_service_callable(categorization_gateway, "preview_category_rule_matches")
|
||||||
if not callable(preview_fn):
|
if not callable(preview_fn):
|
||||||
st.error("현재 실행 중인 서비스에서 단일 규칙 미리보기 함수를 찾지 못했습니다. Streamlit 서버를 완전히 종료한 뒤 다시 시작해 주세요.")
|
st.error("현재 실행 중인 서비스에서 단일 규칙 미리보기 함수를 찾지 못했습니다. Streamlit 서버를 완전히 종료한 뒤 다시 시작해 주세요.")
|
||||||
return
|
return
|
||||||
@@ -922,7 +929,7 @@ def category_rule_preview_dialog(rule: dict[str, Any], db_path: str) -> None:
|
|||||||
st.info("이 규칙에 부합하는 거래가 없습니다.")
|
st.info("이 규칙에 부합하는 거래가 없습니다.")
|
||||||
|
|
||||||
def page_overview(db_path: str) -> None:
|
def page_overview(db_path: str) -> None:
|
||||||
health = service.health_check(db_path)
|
health = overview_gateway.health_check(db_path)
|
||||||
render_page_header(
|
render_page_header(
|
||||||
"가계부 운영 대시보드",
|
"가계부 운영 대시보드",
|
||||||
"이번 달 실제 현금흐름, 자산 유동성, 운영 경고를 우선순위대로 확인합니다.",
|
"이번 달 실제 현금흐름, 자산 유동성, 운영 경고를 우선순위대로 확인합니다.",
|
||||||
@@ -931,14 +938,14 @@ def page_overview(db_path: str) -> None:
|
|||||||
)
|
)
|
||||||
st.caption(f"DB: `{health.get('db_path')}`")
|
st.caption(f"DB: `{health.get('db_path')}`")
|
||||||
month = current_kst_month()
|
month = current_kst_month()
|
||||||
cashflow = service.get_monthly_cashflow_report(month, db_path=db_path)
|
cashflow = overview_gateway.get_monthly_cashflow_report(month, db_path=db_path)
|
||||||
asset_report = service.get_asset_liquidity_report(db_path=db_path)
|
asset_report = overview_gateway.get_asset_liquidity_report(db_path=db_path)
|
||||||
budget_report = service.get_budget_vs_actual_report(month, db_path=db_path)
|
budget_report = overview_gateway.get_budget_vs_actual_report(month, db_path=db_path)
|
||||||
uncategorized_res = service.list_uncategorized_transactions(limit=1000, db_path=db_path)
|
uncategorized_res = overview_gateway.list_uncategorized_transactions(limit=1000, db_path=db_path)
|
||||||
transfer_queue_res = service.list_cross_transfer_review_queue(limit=300, db_path=db_path)
|
transfer_queue_res = overview_gateway.list_cross_transfer_review_queue(limit=300, db_path=db_path)
|
||||||
reconciliation_res = service.get_balance_reconciliation_report(db_path=db_path)
|
reconciliation_res = reconciliation_gateway.get_balance_reconciliation_report(db_path=db_path)
|
||||||
backups_res = service.list_ledger_backups(limit=1)
|
backups_res = overview_gateway.list_ledger_backups(limit=1)
|
||||||
imports_res = service.list_ledger_import_batches(limit=1, db_path=db_path)
|
imports_res = overview_gateway.list_ledger_import_batches(limit=1, db_path=db_path)
|
||||||
|
|
||||||
render_section_label("💎", "이번 달 수입/지출 현황")
|
render_section_label("💎", "이번 달 수입/지출 현황")
|
||||||
st.caption(f"기준: KST 현재 월 {month}, transactions.transaction_date, ledger_categories.cashflow_kind 실제 현금흐름")
|
st.caption(f"기준: KST 현재 월 {month}, transactions.transaction_date, ledger_categories.cashflow_kind 실제 현금흐름")
|
||||||
@@ -1037,7 +1044,7 @@ def page_assets(db_path: str) -> None:
|
|||||||
memo = st.text_input("운영 메모", value="")
|
memo = st.text_input("운영 메모", value="")
|
||||||
submitted = st.form_submit_button("수기 자산 추가", type="primary")
|
submitted = st.form_submit_button("수기 자산 추가", type="primary")
|
||||||
if submitted:
|
if submitted:
|
||||||
res = service.create_manual_asset(name, int(amount), valuation_date.isoformat(), bucket, source_note=source_note, memo=memo, db_path=db_path)
|
res = assets_gateway.create_manual_asset(name, int(amount), valuation_date.isoformat(), bucket, source_note=source_note, memo=memo, db_path=db_path)
|
||||||
if res.get("success"):
|
if res.get("success"):
|
||||||
clear_cache()
|
clear_cache()
|
||||||
st.success("수기 자산을 추가했습니다.")
|
st.success("수기 자산을 추가했습니다.")
|
||||||
@@ -1045,7 +1052,7 @@ def page_assets(db_path: str) -> None:
|
|||||||
else:
|
else:
|
||||||
st.error(res.get("error", res))
|
st.error(res.get("error", res))
|
||||||
|
|
||||||
assets_res = service.list_manual_assets(active_only=False, db_path=db_path)
|
assets_res = assets_gateway.list_manual_assets(active_only=False, db_path=db_path)
|
||||||
assets = assets_res.get("manual_assets", []) if assets_res.get("success") else []
|
assets = assets_res.get("manual_assets", []) if assets_res.get("success") else []
|
||||||
render_section_label("📋", "수기 자산/부채 목록")
|
render_section_label("📋", "수기 자산/부채 목록")
|
||||||
if assets:
|
if assets:
|
||||||
@@ -1071,13 +1078,13 @@ def page_assets(db_path: str) -> None:
|
|||||||
do_update = col_update.form_submit_button("선택 항목 수정", type="primary")
|
do_update = col_update.form_submit_button("선택 항목 수정", type="primary")
|
||||||
do_deactivate = col_deactivate.form_submit_button("선택 항목 비활성", type="secondary")
|
do_deactivate = col_deactivate.form_submit_button("선택 항목 비활성", type="secondary")
|
||||||
if do_update:
|
if do_update:
|
||||||
res = service.update_manual_asset(selected["id"], name=new_name, valuation_amount=int(new_amount), valuation_date=new_date, liquidity_bucket=new_bucket, source_note=new_source, memo=new_memo, active=make_active, db_path=db_path)
|
res = assets_gateway.update_manual_asset(selected["id"], name=new_name, valuation_amount=int(new_amount), valuation_date=new_date, liquidity_bucket=new_bucket, source_note=new_source, memo=new_memo, active=make_active, db_path=db_path)
|
||||||
if res.get("success"):
|
if res.get("success"):
|
||||||
clear_cache(); st.success("수정했습니다."); st.rerun()
|
clear_cache(); st.success("수정했습니다."); st.rerun()
|
||||||
else:
|
else:
|
||||||
st.error(res.get("error", res))
|
st.error(res.get("error", res))
|
||||||
if do_deactivate:
|
if do_deactivate:
|
||||||
res = service.deactivate_manual_asset(selected["id"], db_path=db_path)
|
res = assets_gateway.deactivate_manual_asset(selected["id"], db_path=db_path)
|
||||||
if res.get("success"):
|
if res.get("success"):
|
||||||
clear_cache(); st.warning("비활성 처리했습니다."); st.rerun()
|
clear_cache(); st.warning("비활성 처리했습니다."); st.rerun()
|
||||||
else:
|
else:
|
||||||
@@ -1094,9 +1101,9 @@ def page_assets(db_path: str) -> None:
|
|||||||
correction_reason = c3.text_input("정정 사유(재마감 시 필수)", value="")
|
correction_reason = c3.text_input("정정 사유(재마감 시 필수)", value="")
|
||||||
p1, p2 = st.columns(2)
|
p1, p2 = st.columns(2)
|
||||||
if p1.button("스냅샷 Preview", type="secondary"):
|
if p1.button("스냅샷 Preview", type="secondary"):
|
||||||
st.session_state["asset_snapshot_preview"] = service.preview_asset_snapshot_close(basis_date, db_path=db_path)
|
st.session_state["asset_snapshot_preview"] = assets_gateway.preview_asset_snapshot_close(basis_date, db_path=db_path)
|
||||||
if p2.button("스냅샷 Close 실행", type="primary"):
|
if p2.button("스냅샷 Close 실행", type="primary"):
|
||||||
st.session_state["asset_snapshot_close"] = service.close_asset_snapshot(basis_date, source_note=close_note, correction_reason=correction_reason, db_path=db_path)
|
st.session_state["asset_snapshot_close"] = assets_gateway.close_asset_snapshot(basis_date, source_note=close_note, correction_reason=correction_reason, db_path=db_path)
|
||||||
clear_cache()
|
clear_cache()
|
||||||
preview = st.session_state.get("asset_snapshot_preview")
|
preview = st.session_state.get("asset_snapshot_preview")
|
||||||
close_res = st.session_state.get("asset_snapshot_close")
|
close_res = st.session_state.get("asset_snapshot_close")
|
||||||
@@ -1170,10 +1177,10 @@ def page_cross_transfer_review(db_path: str) -> None:
|
|||||||
c1, c2 = st.columns(2)
|
c1, c2 = st.columns(2)
|
||||||
with c1:
|
with c1:
|
||||||
if st.button("미리보기(드라이런)"):
|
if st.button("미리보기(드라이런)"):
|
||||||
st.session_state["cross_transfer_preview"] = service.preview_cross_account_transfers(db_path=db_path)
|
st.session_state["cross_transfer_preview"] = transfers_gateway.preview_cross_account_transfers(db_path=db_path)
|
||||||
with c2:
|
with c2:
|
||||||
if st.button("자동 매칭 실행"):
|
if st.button("자동 매칭 실행"):
|
||||||
res = service.run_cross_account_transfer_match(db_path=db_path)
|
res = transfers_gateway.run_cross_account_transfer_match(db_path=db_path)
|
||||||
clear_cache()
|
clear_cache()
|
||||||
st.session_state["cross_transfer_last_run"] = res
|
st.session_state["cross_transfer_last_run"] = res
|
||||||
st.success(f"매칭 {res.get('matched_count', 0)}건 적용, 확인필요 {res.get('review_count', 0)}건")
|
st.success(f"매칭 {res.get('matched_count', 0)}건 적용, 확인필요 {res.get('review_count', 0)}건")
|
||||||
@@ -1190,7 +1197,7 @@ def page_cross_transfer_review(db_path: str) -> None:
|
|||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
render_section_label("📌", "확인필요 큐")
|
render_section_label("📌", "확인필요 큐")
|
||||||
list_queue_fn = getattr(service, "list_cross_transfer_review_queue", None)
|
list_queue_fn = getattr(transfers_gateway, "list_cross_transfer_review_queue", None)
|
||||||
if callable(list_queue_fn):
|
if callable(list_queue_fn):
|
||||||
queue = list_queue_fn(db_path=db_path)
|
queue = list_queue_fn(db_path=db_path)
|
||||||
rows = queue.get("transactions", [])
|
rows = queue.get("transactions", [])
|
||||||
@@ -1242,7 +1249,7 @@ def page_uncategorized(db_path: str) -> None:
|
|||||||
sort_label = filter_cols[4].selectbox("작업목록 정렬", ["금액순", "날짜순(최신순)"], index=0)
|
sort_label = filter_cols[4].selectbox("작업목록 정렬", ["금액순", "날짜순(최신순)"], index=0)
|
||||||
sort_by = "date_desc" if sort_label == "날짜순(최신순)" else "amount_desc"
|
sort_by = "date_desc" if sort_label == "날짜순(최신순)" else "amount_desc"
|
||||||
|
|
||||||
workbench_fn = get_service_callable("get_uncategorized_workbench", required_params=["sort_by"])
|
workbench_fn = get_service_callable(categorization_gateway, "get_uncategorized_workbench", required_params=["sort_by"])
|
||||||
if not callable(workbench_fn):
|
if not callable(workbench_fn):
|
||||||
st.error("현재 실행 중인 서비스에서 미분류 작업대 함수를 찾지 못했습니다. Streamlit 서버를 완전히 재시작해 주세요.")
|
st.error("현재 실행 중인 서비스에서 미분류 작업대 함수를 찾지 못했습니다. Streamlit 서버를 완전히 재시작해 주세요.")
|
||||||
return
|
return
|
||||||
@@ -1449,7 +1456,7 @@ def render_rule_list_items(rules: list[dict[str, Any]], db_path: str) -> None:
|
|||||||
if action_cols[0].button("🔎 적용(미리보기)", key=f"rule_expander_preview_{rule_id}_{idx}", use_container_width=True):
|
if action_cols[0].button("🔎 적용(미리보기)", key=f"rule_expander_preview_{rule_id}_{idx}", use_container_width=True):
|
||||||
category_rule_preview_dialog(rule, db_path)
|
category_rule_preview_dialog(rule, db_path)
|
||||||
if action_cols[1].button("✅ 적용", key=f"rule_expander_apply_{rule_id}_{idx}", use_container_width=True):
|
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")
|
apply_fn = get_service_callable(categorization_gateway, "apply_category_rule")
|
||||||
if not callable(apply_fn):
|
if not callable(apply_fn):
|
||||||
st.error("현재 실행 중인 서비스에서 단일 규칙 적용 함수를 찾지 못했습니다. Streamlit 서버를 완전히 종료한 뒤 다시 시작해 주세요.")
|
st.error("현재 실행 중인 서비스에서 단일 규칙 적용 함수를 찾지 못했습니다. Streamlit 서버를 완전히 종료한 뒤 다시 시작해 주세요.")
|
||||||
else:
|
else:
|
||||||
@@ -1465,7 +1472,7 @@ def render_rule_list_items(rules: list[dict[str, Any]], db_path: str) -> None:
|
|||||||
with action_cols[3]:
|
with action_cols[3]:
|
||||||
st.markdown('<div class="rule-danger-note">삭제보다 비활성화를 권장합니다.</div>', unsafe_allow_html=True)
|
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}"):
|
if action_cols[4].button("🗑️ 삭제", key=f"rule_expander_delete_{rule_id}_{idx}"):
|
||||||
delete_rule = getattr(service, "delete_category_rule", None)
|
delete_rule = getattr(categorization_gateway, "delete_category_rule", None)
|
||||||
if delete_rule is None:
|
if delete_rule is None:
|
||||||
st.error("현재 실행 중인 서비스에 delete_category_rule 함수가 없습니다. 앱을 재시작해 최신 코드를 다시 불러오세요.")
|
st.error("현재 실행 중인 서비스에 delete_category_rule 함수가 없습니다. 앱을 재시작해 최신 코드를 다시 불러오세요.")
|
||||||
else:
|
else:
|
||||||
@@ -1491,7 +1498,7 @@ def page_rules(db_path: str) -> None:
|
|||||||
with action_cols[1]:
|
with action_cols[1]:
|
||||||
if st.button("추가", key="rule_add_dialog_open", use_container_width=True, type="primary"):
|
if st.button("추가", key="rule_add_dialog_open", use_container_width=True, type="primary"):
|
||||||
category_rule_create_dialog(db_path)
|
category_rule_create_dialog(db_path)
|
||||||
rules = service.list_category_rules(active_only=False, db_path=db_path).get("rules", [])
|
rules = categorization_gateway.list_category_rules(active_only=False, db_path=db_path).get("rules", [])
|
||||||
render_section_label("📚", "현재 분류 규칙")
|
render_section_label("📚", "현재 분류 규칙")
|
||||||
render_rule_list_items(rules, db_path)
|
render_rule_list_items(rules, db_path)
|
||||||
|
|
||||||
@@ -1499,12 +1506,12 @@ def page_rules(db_path: str) -> None:
|
|||||||
|
|
||||||
c1, c2, c3 = st.columns(3)
|
c1, c2, c3 = st.columns(3)
|
||||||
if c1.button("기본 규칙 seed/update"):
|
if c1.button("기본 규칙 seed/update"):
|
||||||
res = service.seed_default_category_rules(db_path=db_path)
|
res = categorization_gateway.seed_default_category_rules(db_path=db_path)
|
||||||
clear_cache(); st.success(res)
|
clear_cache(); st.success(res)
|
||||||
if c2.button("규칙 preview"):
|
if c2.button("규칙 preview"):
|
||||||
st.session_state["rule_preview"] = service.preview_categorization(limit=5000, db_path=db_path)
|
st.session_state["rule_preview"] = categorization_gateway.preview_categorization(limit=5000, db_path=db_path)
|
||||||
if c3.button("규칙 적용"):
|
if c3.button("규칙 적용"):
|
||||||
res = service.apply_categorization_rules(limit=5000, db_path=db_path)
|
res = categorization_gateway.apply_categorization_rules(limit=5000, db_path=db_path)
|
||||||
clear_cache(); st.success(f"{res.get('applied_count', 0)}건 적용")
|
clear_cache(); st.success(f"{res.get('applied_count', 0)}건 적용")
|
||||||
if "rule_preview" in st.session_state:
|
if "rule_preview" in st.session_state:
|
||||||
prev = st.session_state["rule_preview"]
|
prev = st.session_state["rule_preview"]
|
||||||
@@ -1528,7 +1535,7 @@ def page_categories(db_path: str) -> None:
|
|||||||
|
|
||||||
c1, c2 = st.columns(2)
|
c1, c2 = st.columns(2)
|
||||||
if c1.button("기본 카테고리 seed/update"):
|
if c1.button("기본 카테고리 seed/update"):
|
||||||
res = service.seed_default_ledger_categories(db_path=db_path)
|
res = categorization_gateway.seed_default_ledger_categories(db_path=db_path)
|
||||||
clear_cache()
|
clear_cache()
|
||||||
st.success(f"{res.get('created_or_updated', 0)}개 카테고리를 생성/갱신했습니다.")
|
st.success(f"{res.get('created_or_updated', 0)}개 카테고리를 생성/갱신했습니다.")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
@@ -1545,7 +1552,7 @@ def page_categories(db_path: str) -> None:
|
|||||||
submitted = st.form_submit_button("추가/재활성화", type="primary")
|
submitted = st.form_submit_button("추가/재활성화", type="primary")
|
||||||
if submitted:
|
if submitted:
|
||||||
cashflow_kind = next(k for k, v in CASHFLOW_KIND_LABELS.items() if v == cashflow_kind_label)
|
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)
|
res = categorization_gateway.create_ledger_category(category, subcategory, int(sort_order), memo, cashflow_kind, db_path=db_path)
|
||||||
clear_cache()
|
clear_cache()
|
||||||
if res.get("success"):
|
if res.get("success"):
|
||||||
st.success("카테고리를 저장했습니다.")
|
st.success("카테고리를 저장했습니다.")
|
||||||
@@ -1553,7 +1560,7 @@ def page_categories(db_path: str) -> None:
|
|||||||
else:
|
else:
|
||||||
st.error(res.get("error", res))
|
st.error(res.get("error", res))
|
||||||
|
|
||||||
result = service.list_ledger_categories(active_only=not show_inactive, include_usage=True, db_path=db_path)
|
result = categorization_gateway.list_ledger_categories(active_only=not show_inactive, include_usage=True, db_path=db_path)
|
||||||
rows = result.get("categories", [])
|
rows = result.get("categories", [])
|
||||||
render_section_label("🗂️", "카테고리 목록")
|
render_section_label("🗂️", "카테고리 목록")
|
||||||
if rows:
|
if rows:
|
||||||
@@ -1577,7 +1584,7 @@ def page_categories(db_path: str) -> None:
|
|||||||
hard_delete = st.checkbox("DB에서 완전 삭제", value=False, help="보통은 끄고 비활성화만 하세요. 거래 기록은 별도 보존됩니다.")
|
hard_delete = st.checkbox("DB에서 완전 삭제", value=False, help="보통은 끄고 비활성화만 하세요. 거래 기록은 별도 보존됩니다.")
|
||||||
force = st.checkbox("사용 중이어도 강제", value=False, help="거래/예산/규칙에서 사용 중인 카테고리도 삭제/비활성화합니다. 거래 category 값은 변경하지 않습니다.")
|
force = st.checkbox("사용 중이어도 강제", value=False, help="거래/예산/규칙에서 사용 중인 카테고리도 삭제/비활성화합니다. 거래 category 값은 변경하지 않습니다.")
|
||||||
if st.button("삭제/비활성화 실행", type="secondary"):
|
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)
|
res = categorization_gateway.delete_ledger_category(target["category"], target.get("subcategory", ""), hard_delete=hard_delete, force=force, db_path=db_path)
|
||||||
clear_cache()
|
clear_cache()
|
||||||
if res.get("success"):
|
if res.get("success"):
|
||||||
st.warning(f"처리했습니다. 사용 거래 {res.get('transaction_count', 0)}건")
|
st.warning(f"처리했습니다. 사용 거래 {res.get('transaction_count', 0)}건")
|
||||||
@@ -1593,7 +1600,7 @@ def page_accounts(db_path: str) -> None:
|
|||||||
kicker="🏦 Accounts",
|
kicker="🏦 Accounts",
|
||||||
pills=["잔액", "기관", "statement_status", "수동 잔액대조"],
|
pills=["잔액", "기관", "statement_status", "수동 잔액대조"],
|
||||||
)
|
)
|
||||||
accounts_res = service.list_ledger_accounts(active_only=False, db_path=db_path)
|
accounts_res = accounts_gateway.list_ledger_accounts(active_only=False, db_path=db_path)
|
||||||
accounts_data = accounts_res.get("accounts", []) if accounts_res.get("success") else []
|
accounts_data = accounts_res.get("accounts", []) if accounts_res.get("success") else []
|
||||||
accounts = pd.DataFrame(accounts_data)
|
accounts = pd.DataFrame(accounts_data)
|
||||||
if not accounts.empty:
|
if not accounts.empty:
|
||||||
@@ -1638,7 +1645,7 @@ def page_accounts(db_path: str) -> None:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
st.error("기준 잔액은 정수로 입력하세요.")
|
st.error("기준 잔액은 정수로 입력하세요.")
|
||||||
return
|
return
|
||||||
res = service.update_ledger_account(selected["id"], db_path=db_path, **update_fields)
|
res = accounts_gateway.update_ledger_account(selected["id"], db_path=db_path, **update_fields)
|
||||||
clear_cache()
|
clear_cache()
|
||||||
if res.get("success"):
|
if res.get("success"):
|
||||||
st.success("계좌 상태를 저장했습니다.")
|
st.success("계좌 상태를 저장했습니다.")
|
||||||
@@ -1654,7 +1661,7 @@ def page_accounts(db_path: str) -> None:
|
|||||||
tolerance = c2.number_input("허용 오차(원)", min_value=0, value=1000, step=100)
|
tolerance = c2.number_input("허용 오차(원)", min_value=0, value=1000, step=100)
|
||||||
source_note = c3.text_input("출처 메모", value="", placeholder="예: 월말 앱 잔액")
|
source_note = c3.text_input("출처 메모", value="", placeholder="예: 월말 앱 잔액")
|
||||||
if st.button("잔액대조 실행", type="secondary"):
|
if st.button("잔액대조 실행", type="secondary"):
|
||||||
st.session_state["balance_reconciliation_report"] = service.get_balance_reconciliation_report(
|
st.session_state["balance_reconciliation_report"] = reconciliation_gateway.get_balance_reconciliation_report(
|
||||||
baseline_at=baseline_at,
|
baseline_at=baseline_at,
|
||||||
source_note=source_note,
|
source_note=source_note,
|
||||||
tolerance=int(tolerance),
|
tolerance=int(tolerance),
|
||||||
@@ -1696,10 +1703,10 @@ def page_imports(db_path: str) -> None:
|
|||||||
upload_path.write_bytes(uploaded.getbuffer())
|
upload_path.write_bytes(uploaded.getbuffer())
|
||||||
c1, c2 = st.columns(2)
|
c1, c2 = st.columns(2)
|
||||||
if c1.button("토스증권 Preview"):
|
if c1.button("토스증권 Preview"):
|
||||||
res = service.preview_toss_securities_pdf_import(str(upload_path), db_path=db_path)
|
res = imports_gateway.preview_toss_securities_pdf_import(str(upload_path), db_path=db_path)
|
||||||
st.session_state["toss_preview"] = res
|
st.session_state["toss_preview"] = res
|
||||||
if c2.button("토스증권 Import", type="primary"):
|
if c2.button("토스증권 Import", type="primary"):
|
||||||
res = service.import_toss_securities_pdf(str(upload_path), db_path=db_path)
|
res = imports_gateway.import_toss_securities_pdf(str(upload_path), db_path=db_path)
|
||||||
clear_cache()
|
clear_cache()
|
||||||
st.session_state["toss_import_result"] = res
|
st.session_state["toss_import_result"] = res
|
||||||
st.rerun()
|
st.rerun()
|
||||||
@@ -1730,10 +1737,10 @@ def page_imports(db_path: str) -> None:
|
|||||||
upload_path.write_bytes(toss_bank_uploaded.getbuffer())
|
upload_path.write_bytes(toss_bank_uploaded.getbuffer())
|
||||||
c1, c2 = st.columns(2)
|
c1, c2 = st.columns(2)
|
||||||
if c1.button("토스뱅크 Preview"):
|
if c1.button("토스뱅크 Preview"):
|
||||||
res = service.preview_toss_bank_pdf_import(str(upload_path), db_path=db_path)
|
res = imports_gateway.preview_toss_bank_pdf_import(str(upload_path), db_path=db_path)
|
||||||
st.session_state["toss_bank_preview"] = res
|
st.session_state["toss_bank_preview"] = res
|
||||||
if c2.button("토스뱅크 Import", type="primary"):
|
if c2.button("토스뱅크 Import", type="primary"):
|
||||||
res = service.import_toss_bank_pdf(str(upload_path), db_path=db_path)
|
res = imports_gateway.import_toss_bank_pdf(str(upload_path), db_path=db_path)
|
||||||
clear_cache()
|
clear_cache()
|
||||||
st.session_state["toss_bank_import_result"] = res
|
st.session_state["toss_bank_import_result"] = res
|
||||||
st.rerun()
|
st.rerun()
|
||||||
@@ -1755,7 +1762,7 @@ def page_imports(db_path: str) -> None:
|
|||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
render_section_label("🧾", "Import 이력")
|
render_section_label("🧾", "Import 이력")
|
||||||
batches = service.list_ledger_import_batches(limit=100, db_path=db_path).get("import_batches", [])
|
batches = overview_gateway.list_ledger_import_batches(limit=100, db_path=db_path).get("import_batches", [])
|
||||||
st.dataframe(pd.DataFrame(batches), width="stretch", height=350)
|
st.dataframe(pd.DataFrame(batches), width="stretch", height=350)
|
||||||
if batches:
|
if batches:
|
||||||
with st.form("rollback"):
|
with st.form("rollback"):
|
||||||
@@ -1763,7 +1770,7 @@ def page_imports(db_path: str) -> None:
|
|||||||
hard = st.checkbox("Hard delete", value=False)
|
hard = st.checkbox("Hard delete", value=False)
|
||||||
submitted = st.form_submit_button("Rollback 실행", type="secondary")
|
submitted = st.form_submit_button("Rollback 실행", type="secondary")
|
||||||
if submitted:
|
if submitted:
|
||||||
res = service.rollback_ledger_import_batch(batch_id, hard_delete=hard, db_path=db_path)
|
res = imports_gateway.rollback_ledger_import_batch(batch_id, hard_delete=hard, db_path=db_path)
|
||||||
clear_cache()
|
clear_cache()
|
||||||
st.warning(res)
|
st.warning(res)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# BAT-012 Design
|
||||||
|
|
||||||
|
## Architecture summary
|
||||||
|
- Introduce `jarca_ledger_mcp/application/imports_service.py` as the single application-layer home for import preview/import/rollback orchestration.
|
||||||
|
- Introduce `jarca_ledger_mcp/infrastructure/parsers/` as the parser seam so application code no longer imports top-level parser implementations directly.
|
||||||
|
- Keep current top-level parser modules for compatibility if needed, but route application/shared usage through the infrastructure seam.
|
||||||
|
- Preserve `application/import_transfer_services.py` as the advanced workflow home; import-facing public functions become delegations into `imports_service.py`.
|
||||||
|
|
||||||
|
## Changed areas
|
||||||
|
- application import orchestration
|
||||||
|
- infrastructure parser seam
|
||||||
|
- service export surface / compatibility proxy
|
||||||
|
- import regression verification docs
|
||||||
|
|
||||||
|
## Key decisions
|
||||||
|
1. Parser relocation is seam-first, not algorithm rewrite.
|
||||||
|
2. Import account auto-linking/classification stays in the application layer, not in parser modules.
|
||||||
|
3. Public API/tool names remain unchanged.
|
||||||
|
|
||||||
|
## Verification focus
|
||||||
|
- Import preview/import/rollback regression
|
||||||
|
- Evidence field preservation
|
||||||
|
- No breakage in MCP discovery and public exports
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# BAT-012 Implementation
|
||||||
|
|
||||||
|
## Plan summary
|
||||||
|
|
||||||
|
BAT-012 implements dcy-011 Phase 5 by extracting import preview/import/rollback orchestration into `application/imports_service.py`, introducing an `infrastructure/parsers/*` seam, and shrinking `application/import_transfer_services.py` toward advanced workflow ownership while preserving public contracts.
|
||||||
|
|
||||||
|
## Changed files
|
||||||
|
|
||||||
|
- `jarca_ledger_mcp/application/imports_service.py`
|
||||||
|
- `jarca_ledger_mcp/application/import_transfer_services.py`
|
||||||
|
- `jarca_ledger_mcp/application/shared.py`
|
||||||
|
- `jarca_ledger_mcp/application/__init__.py`
|
||||||
|
- `jarca_ledger_mcp/infrastructure/parsers/__init__.py`
|
||||||
|
- `jarca_ledger_mcp/infrastructure/parsers/kb_bank_pdf.py`
|
||||||
|
- `jarca_ledger_mcp/infrastructure/parsers/toss_bank_pdf.py`
|
||||||
|
- `jarca_ledger_mcp/infrastructure/parsers/toss_securities_pdf.py`
|
||||||
|
- `jarca_ledger_mcp/infrastructure/parsers/seoulpay_xlsx.py`
|
||||||
|
- `jarca_ledger_mcp/service.py`
|
||||||
|
- `docs/srs/index.md`
|
||||||
|
- `docs/srs/Non-Functional/req-016_import-pipeline-application-infrastructure-split.md`
|
||||||
|
- `docs/batches/index.md`
|
||||||
|
- `docs/batches/bat-012_20260703_import-pipeline-split/*`
|
||||||
|
- `docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md`
|
||||||
|
|
||||||
|
## Execution log
|
||||||
|
|
||||||
|
- 2026-07-03 07:49 KST: dcy-011 Phase 5 범위로 `REQ-016`/`BAT-012`를 생성하고 승인 상태를 정리했다.
|
||||||
|
- 2026-07-03 07:50 KST: `application/import_transfer_services.py`의 AST function ranges를 추출해 import-owned 함수와 advanced workflow 함수를 기계적으로 분리할 준비를 했다.
|
||||||
|
- 2026-07-03 07:51 KST: `application/imports_service.py`를 생성하고 import preview/import/rollback + import-specific account/classification helpers를 이동했다.
|
||||||
|
- 2026-07-03 07:51 KST: `application/import_transfer_services.py`를 advanced workflow 중심 모듈로 축소하고 import-facing public functions는 compatibility delegation으로 전환했다.
|
||||||
|
- 2026-07-03 07:51 KST: `infrastructure/parsers/*` seam을 추가하고 `application/shared.py`가 top-level parser 구현 대신 infrastructure seam을 통해 parser를 가져오도록 정렬했다.
|
||||||
|
- 2026-07-03 07:52~07:53 KST: targeted import regression, full pytest, MCP discovery, export provenance를 검증했고 모두 PASS했다.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `python3 -m py_compile jarca_ledger_mcp/application/*.py jarca_ledger_mcp/infrastructure/parsers/*.py jarca_ledger_mcp/service.py` → PASS
|
||||||
|
- `pytest -q tests/test_kb_bank_pdf.py tests/test_seoulpay_xlsx.py tests/test_toss_securities_pdf.py tests/test_service.py` → PASS (`12 passed, 4 skipped`)
|
||||||
|
- `pytest -q` → PASS (`42 passed, 4 skipped`)
|
||||||
|
- `hermes mcp test jarca-ledger` → PASS (`Connected`, `Tools discovered: 56`)
|
||||||
|
- `python3` provenance check → PASS (all import preview/import/rollback exports resolve to `jarca_ledger_mcp.application.imports_service`)
|
||||||
|
|
||||||
|
## Remaining risks / follow-up notes
|
||||||
|
|
||||||
|
- `application/import_transfer_services.py`에는 cross-transfer review/matching advanced workflow가 아직 남아 있으며 Phase 6 배치에서 별도 분리해야 한다.
|
||||||
|
- parser relocation은 seam-first 방식이므로 top-level parser implementation files는 아직 compatibility/implementation 위치로 남아 있다. 필요 시 후속 cleanup은 adapter/baseline 갱신과 함께 진행한다.
|
||||||
|
- UI/adapter 표면 축소와 baseline 문서 갱신은 BAT-012 범위 밖이다.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# BAT-012 Import pipeline split
|
||||||
|
|
||||||
|
- Profile: standard
|
||||||
|
- Status: release-candidate
|
||||||
|
- Included REQ: REQ-016
|
||||||
|
- Discovery: dcy-011
|
||||||
|
- Owner: 렛저장인
|
||||||
|
- Date (KST): 2026-07-03
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
REQ-016에 따라 import preview/import/rollback 경로를 `application/imports_service.py`와 `infrastructure/parsers/*` 중심으로 재배치하고, `application/import_transfer_services.py`는 advanced workflow 중심 모듈로 축소한다.
|
||||||
|
|
||||||
|
## Delivery checklist
|
||||||
|
|
||||||
|
- [ ] planning complete
|
||||||
|
- [ ] design complete
|
||||||
|
- [ ] implementation complete
|
||||||
|
- [ ] verification complete
|
||||||
|
- [ ] release-candidate promoted
|
||||||
|
- [ ] REQ sync complete
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# BAT-012 Planning
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Implement REQ-016 import pipeline split.
|
||||||
|
- Keep import contract/evidence fields stable while relocating parser access under `infrastructure/parsers/*`.
|
||||||
|
- Leave cross-transfer/reconciliation/advanced workflows in follow-up batches.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- dcy-011 confirmed Discovery.
|
||||||
|
- REQ-016 Approved.
|
||||||
|
- BAT-011 write-service split baseline.
|
||||||
|
- Existing import regression tests and MCP discovery.
|
||||||
|
|
||||||
|
## Work plan
|
||||||
|
1. Create `application/imports_service.py` and move import preview/import/rollback + import-specific helpers there.
|
||||||
|
2. Create `infrastructure/parsers/*` wrappers or relocated parser modules and point application/shared imports to the infrastructure seam.
|
||||||
|
3. Shrink `application/import_transfer_services.py` to advanced workflow + compatibility delegation for import functions.
|
||||||
|
4. Run focused import regression, then full pytest and MCP discovery.
|
||||||
|
5. Update batch evidence and promote REQ/BAT if ACs pass.
|
||||||
|
|
||||||
|
## Design Gate
|
||||||
|
- Result: standard batch 유지.
|
||||||
|
- Reason: application/infrastructure seam 변경과 compatibility delegation, import evidence preservation, parser path relocation 검증이 함께 필요하다.
|
||||||
|
|
||||||
|
## Risks and mitigations
|
||||||
|
- Import evidence drift: preserve existing payload fields and run import-specific regression.
|
||||||
|
- Hidden helper coupling between import and advanced workflows: move only import-facing helpers in this batch and leave advanced helpers in place when not import-owned.
|
||||||
|
- Parser path breakage: add infrastructure parser wrappers first, then re-point shared/application imports.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# BAT-012 Verification
|
||||||
|
|
||||||
|
## Verification summary
|
||||||
|
|
||||||
|
Status: PASS
|
||||||
|
|
||||||
|
Evidence was collected against synthetic/temp data only. No production ledger DB mutation or real financial files were used.
|
||||||
|
|
||||||
|
## Acceptance criteria mapping
|
||||||
|
|
||||||
|
### REQ-016
|
||||||
|
- AC-1: PASS — `application/imports_service.py`와 `infrastructure/parsers/*`가 생성됐고 import preview/import/rollback 구현이 이동했다.
|
||||||
|
- AC-2: PASS — public API/MCP tools/import response envelope and evidence fields remained stable; MCP discovery still reports 56 tools.
|
||||||
|
- AC-3: PASS — import-focused regression and full pytest PASS.
|
||||||
|
- AC-4: PASS — BAT-012 implementation/verification docs record actual changed files, commands, and outcomes.
|
||||||
|
|
||||||
|
## Requirement state decision
|
||||||
|
|
||||||
|
- REQ-016: Implemented.
|
||||||
|
|
||||||
|
## Validation evidence
|
||||||
|
|
||||||
|
- `python3 -m py_compile jarca_ledger_mcp/application/*.py jarca_ledger_mcp/infrastructure/parsers/*.py jarca_ledger_mcp/service.py` → PASS
|
||||||
|
- `pytest -q tests/test_kb_bank_pdf.py tests/test_seoulpay_xlsx.py tests/test_toss_securities_pdf.py tests/test_service.py` → PASS (`12 passed, 4 skipped`)
|
||||||
|
- `pytest -q` → PASS (`42 passed, 4 skipped`)
|
||||||
|
- `hermes mcp test jarca-ledger` → PASS (`Connected`, `Tools discovered: 56`)
|
||||||
|
- Python provenance check on import-facing exports → PASS
|
||||||
|
- preview/import/rollback APIs all resolve to `jarca_ledger_mcp.application.imports_service`
|
||||||
|
|
||||||
|
## Remaining issues
|
||||||
|
|
||||||
|
### Hard blockers
|
||||||
|
- None.
|
||||||
|
|
||||||
|
### Non-blocking follow-up
|
||||||
|
- cross-transfer / reconciliation / asset / workbench advanced workflow split
|
||||||
|
- MCP/Streamlit adapter surface narrowing and baseline docs update
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# BAT-013 Design
|
||||||
|
|
||||||
|
## Architecture summary
|
||||||
|
- `cross_transfer_service.py` owns matching helpers and review queue APIs.
|
||||||
|
- `reconciliation_service.py` owns manual/partial-import balance reconciliation preview.
|
||||||
|
- `assets_service.py` owns manual asset CRUD, snapshot close/read, liquidity/net-worth reporting, and valuation helpers.
|
||||||
|
- `uncategorized_workbench_service.py` owns uncategorized list/summary/workbench/recommendation apply flows.
|
||||||
|
- Existing broad modules become thin compatibility layers or keep only their non-moved responsibilities.
|
||||||
|
|
||||||
|
## Verification focus
|
||||||
|
- workflow-specific regression for cross-transfer/assets/reconciliation/workbench
|
||||||
|
- full pytest and MCP discovery
|
||||||
|
- provenance checks that public exports resolve to the new owning modules
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# BAT-013 Implementation
|
||||||
|
|
||||||
|
## Plan summary
|
||||||
|
|
||||||
|
BAT-013 implements dcy-011 Phase 6 by extracting advanced finance workflows into dedicated owning application services: `cross_transfer_service.py`, `reconciliation_service.py`, `assets_service.py`, and `uncategorized_workbench_service.py`. Existing broad modules remain as compatibility layers so MCP/service/dashboard callers keep their contracts.
|
||||||
|
|
||||||
|
## Changed files
|
||||||
|
|
||||||
|
- `jarca_ledger_mcp/application/assets_service.py`
|
||||||
|
- `jarca_ledger_mcp/application/asset_account_services.py`
|
||||||
|
- `jarca_ledger_mcp/application/cross_transfer_service.py`
|
||||||
|
- `jarca_ledger_mcp/application/import_transfer_services.py`
|
||||||
|
- `jarca_ledger_mcp/application/reconciliation_service.py`
|
||||||
|
- `jarca_ledger_mcp/application/transaction_report_services.py`
|
||||||
|
- `jarca_ledger_mcp/application/uncategorized_workbench_service.py`
|
||||||
|
- `jarca_ledger_mcp/application/categorization_services.py`
|
||||||
|
- `jarca_ledger_mcp/application/__init__.py`
|
||||||
|
- `jarca_ledger_mcp/service.py`
|
||||||
|
- `docs/srs/index.md`
|
||||||
|
- `docs/srs/Non-Functional/req-017_advanced-finance-workflow-service-split.md`
|
||||||
|
- `docs/batches/index.md`
|
||||||
|
- `docs/batches/bat-013_20260703_advanced-workflow-service-split/*`
|
||||||
|
- `docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md`
|
||||||
|
|
||||||
|
## Execution log
|
||||||
|
|
||||||
|
- 2026-07-03 07:55 KST: dcy-011 Phase 6 범위로 `REQ-017`/`BAT-013`를 생성하고 Approved 상태를 확정했다.
|
||||||
|
- 2026-07-03 07:57~07:59 KST: AST-extracted function ranges를 기준으로 assets / cross-transfer / reconciliation / uncategorized workbench 흐름을 새 owning services로 이동했다.
|
||||||
|
- 2026-07-03 07:59 KST: `asset_account_services.py`, `import_transfer_services.py`, `transaction_report_services.py`, `categorization_services.py`를 compatibility delegation 레이어로 축소했다.
|
||||||
|
- 2026-07-03 08:00 KST: `application/__init__.py`와 `service.py` export/proxy surface를 새 owning modules까지 확장했다.
|
||||||
|
- 2026-07-03 08:00~08:01 KST: targeted workflow regression, full pytest, MCP discovery, provenance checks를 실행했고 모두 PASS했다.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `python3 -m py_compile jarca_ledger_mcp/application/*.py jarca_ledger_mcp/service.py` → PASS
|
||||||
|
- `pytest -q tests/test_cross_account_transfer_matching.py tests/test_asset_snapshots.py tests/test_categorization.py tests/test_contract_regression.py tests/test_service.py` → PASS (`24 passed`)
|
||||||
|
- `pytest -q` → PASS (`42 passed, 4 skipped`)
|
||||||
|
- `hermes mcp test jarca-ledger` → PASS (`Connected`, `Tools discovered: 56`)
|
||||||
|
- `python3` provenance check → PASS
|
||||||
|
- cross-transfer APIs → `jarca_ledger_mcp.application.cross_transfer_service`
|
||||||
|
- reconciliation API → `jarca_ledger_mcp.application.reconciliation_service`
|
||||||
|
- asset workflows → `jarca_ledger_mcp.application.assets_service`
|
||||||
|
- uncategorized workbench APIs → `jarca_ledger_mcp.application.uncategorized_workbench_service`
|
||||||
|
|
||||||
|
## Remaining risks / follow-up notes
|
||||||
|
|
||||||
|
- broad compatibility layers still exist by design; final dcy-011 batch should narrow MCP/Streamlit adapter imports and refresh baseline docs.
|
||||||
|
- top-level module names remain public to avoid breaking monkeypatch/tests; final cleanup should preserve this constraint.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# BAT-013 Advanced workflow service split
|
||||||
|
|
||||||
|
- Profile: standard
|
||||||
|
- Status: release-candidate
|
||||||
|
- Included REQ: REQ-017
|
||||||
|
- Discovery: dcy-011
|
||||||
|
- Owner: 렛저장인
|
||||||
|
- Date (KST): 2026-07-03
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
REQ-017에 따라 cross-transfer, reconciliation, assets, uncategorized workbench를 owning application services로 분리하고 broad modules는 compatibility delegation으로 축소한다.
|
||||||
|
|
||||||
|
## Delivery checklist
|
||||||
|
|
||||||
|
- [ ] planning complete
|
||||||
|
- [ ] design complete
|
||||||
|
- [ ] implementation complete
|
||||||
|
- [ ] verification complete
|
||||||
|
- [ ] release-candidate promoted
|
||||||
|
- [ ] REQ sync complete
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# BAT-013 Planning
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Implement REQ-017 advanced workflow service split.
|
||||||
|
- Preserve public API and workflow response contracts while relocating owning helpers with their workflows.
|
||||||
|
- Leave adapter narrowing/baseline docs for the final dcy-011 batch.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- dcy-011 confirmed Discovery.
|
||||||
|
- REQ-017 Approved.
|
||||||
|
- BAT-011/BAT-012 compatibility seams.
|
||||||
|
- Existing workflow regression tests.
|
||||||
|
|
||||||
|
## Work plan
|
||||||
|
1. Extract cross-transfer workflow from `import_transfer_services.py` into `cross_transfer_service.py`.
|
||||||
|
2. Extract reconciliation preview from `transaction_report_services.py` into `reconciliation_service.py`.
|
||||||
|
3. Extract manual asset/snapshot/liquidity workflows from `asset_account_services.py` into `assets_service.py`.
|
||||||
|
4. Extract uncategorized list/summary/workbench/recommendation flows from `categorization_services.py` into `uncategorized_workbench_service.py`.
|
||||||
|
5. Update application/service export surfaces, run focused workflow regression + full pytest, then record evidence.
|
||||||
|
|
||||||
|
## Design Gate
|
||||||
|
- Result: standard batch 유지.
|
||||||
|
- Reason: multiple domain modules and compatibility layers change together; broad-module decomposition needs explicit verification.
|
||||||
|
|
||||||
|
## Risks and mitigations
|
||||||
|
- Shared helper drift across moved workflows: move helper with first owner and verify with focused tests.
|
||||||
|
- Contract regressions in dashboard/MCP callers: preserve public names and run full pytest + MCP discovery.
|
||||||
|
- Partial extraction leaving dead imports: run py_compile and provenance check after relocation.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# BAT-013 Verification
|
||||||
|
|
||||||
|
## Verification summary
|
||||||
|
|
||||||
|
Status: PASS
|
||||||
|
|
||||||
|
Evidence was collected against synthetic/temp data only. No production ledger DB mutation or real financial files were used.
|
||||||
|
|
||||||
|
## Acceptance criteria mapping
|
||||||
|
|
||||||
|
### REQ-017
|
||||||
|
- AC-1: PASS — `cross_transfer_service.py`, `reconciliation_service.py`, `assets_service.py`, `uncategorized_workbench_service.py` were created and now own their workflows.
|
||||||
|
- AC-2: PASS — service/MCP/dashboard contract remained stable; MCP discovery still reports 56 tools.
|
||||||
|
- AC-3: PASS — workflow-focused regression and full pytest both passed.
|
||||||
|
- AC-4: PASS — BAT-013 implementation/verification docs record the actual changed files, commands, and outcomes.
|
||||||
|
|
||||||
|
## Requirement state decision
|
||||||
|
|
||||||
|
- REQ-017: Implemented.
|
||||||
|
|
||||||
|
## Validation evidence
|
||||||
|
|
||||||
|
- `python3 -m py_compile jarca_ledger_mcp/application/*.py jarca_ledger_mcp/service.py` → PASS
|
||||||
|
- `pytest -q tests/test_cross_account_transfer_matching.py tests/test_asset_snapshots.py tests/test_categorization.py tests/test_contract_regression.py tests/test_service.py` → PASS (`24 passed`)
|
||||||
|
- `pytest -q` → PASS (`42 passed, 4 skipped`)
|
||||||
|
- `hermes mcp test jarca-ledger` → PASS (`Connected`, `Tools discovered: 56`)
|
||||||
|
- Python provenance check on advanced workflow exports → PASS
|
||||||
|
|
||||||
|
## Remaining issues
|
||||||
|
|
||||||
|
### Hard blockers
|
||||||
|
- None.
|
||||||
|
|
||||||
|
### Non-blocking follow-up
|
||||||
|
- MCP/Streamlit adapter surface narrowing
|
||||||
|
- dcy-011 baseline/final status refresh and QC evidence sync
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# BAT-014 Design
|
||||||
|
|
||||||
|
## Architecture summary
|
||||||
|
- `adapters/mcp/tool_contracts.py` becomes the MCP adapter’s narrow contract dependency surface.
|
||||||
|
- `adapters/streamlit/{overview,assets,transactions,categorization,imports,accounts,transfers,reconciliation}_gateway.py` expose page/domain-sized call sets.
|
||||||
|
- `adapters/streamlit/service_gateway.py` remains a compatibility aggregator while `dashboard/app.py` shifts to page/domain gateway aliases.
|
||||||
|
- Baseline docs record the post-Phase-7 structure and runtime flow.
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# BAT-014 Implementation
|
||||||
|
|
||||||
|
## Plan summary
|
||||||
|
|
||||||
|
BAT-014 implements dcy-011 Phase 7 by narrowing the remaining adapter surfaces without changing public MCP tool names or dashboard behavior. The MCP server now depends on `adapters/mcp/tool_contracts.py` instead of a broad direct `service` import, and the Streamlit dashboard now calls page/domain gateways under `adapters/streamlit/*_gateway.py` rather than a flat broad `service_gateway` namespace. `service_gateway.py` remains as a compatibility aggregator.
|
||||||
|
|
||||||
|
## Delivered changes
|
||||||
|
|
||||||
|
1. Added `jarca_ledger_mcp/adapters/mcp/tool_contracts.py` as the MCP adapter contract surface and repointed `adapters/mcp/server.py` wrappers to `contracts.*` callables.
|
||||||
|
2. Added page/domain Streamlit gateways:
|
||||||
|
- `overview_gateway.py`
|
||||||
|
- `assets_gateway.py`
|
||||||
|
- `transactions_gateway.py`
|
||||||
|
- `categorization_gateway.py`
|
||||||
|
- `transfers_gateway.py`
|
||||||
|
- `imports_gateway.py`
|
||||||
|
- `accounts_gateway.py`
|
||||||
|
- `reconciliation_gateway.py`
|
||||||
|
3. Reworked `adapters/streamlit/service_gateway.py` into a compatibility aggregator over the new gateway modules.
|
||||||
|
4. Repointed `dashboard/app.py` static call sites away from `service.*` to the owning page/domain gateway modules.
|
||||||
|
5. Preserved hot-reload fallback by changing `get_service_callable()` to reload the passed gateway module rather than a broad flat service namespace.
|
||||||
|
6. Refreshed `docs/project-structure.md` and `docs/runtime-flows.md` so the baseline reflects post-Phase-7 ownership and adapter seams.
|
||||||
|
|
||||||
|
## Compatibility notes
|
||||||
|
|
||||||
|
- MCP tool names, argument shapes, and response contracts were intentionally preserved.
|
||||||
|
- `adapters/streamlit/service_gateway.py` still exists as a compatibility surface for any legacy dashboard-side imports.
|
||||||
|
- No SQL/UI business-rule ownership was moved into the dashboard; the app still goes through adapter gateways.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `jarca_ledger_mcp/adapters/mcp/tool_contracts.py`
|
||||||
|
- `jarca_ledger_mcp/adapters/mcp/server.py`
|
||||||
|
- `jarca_ledger_mcp/adapters/streamlit/__init__.py`
|
||||||
|
- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py`
|
||||||
|
- `jarca_ledger_mcp/adapters/streamlit/*_gateway.py`
|
||||||
|
- `dashboard/app.py`
|
||||||
|
- `docs/project-structure.md`
|
||||||
|
- `docs/runtime-flows.md`
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
The repository now has explicit MCP contract and Streamlit page/domain gateway seams, satisfying the final dcy-011 narrowing target while preserving all public compatibility surfaces.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# BAT-014 Adapter narrowing and baseline refresh
|
||||||
|
|
||||||
|
- Profile: standard
|
||||||
|
- Status: release-candidate
|
||||||
|
- Included REQ: REQ-018
|
||||||
|
- Discovery: dcy-011
|
||||||
|
- Owner: 렛저장인
|
||||||
|
- Date (KST): 2026-07-03
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
REQ-018에 따라 MCP/Streamlit adapter surfaces를 더 좁히고 baseline 문서를 갱신해 dcy-011을 완료한다.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# BAT-014 Planning
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Implement REQ-018 adapter narrowing.
|
||||||
|
- Preserve MCP/tool/dashboard contracts while shifting adapter imports to helper/page gateways.
|
||||||
|
- Refresh structure/runtime baselines and finalize dcy-011 metadata.
|
||||||
|
|
||||||
|
## Work plan
|
||||||
|
1. Add MCP contract helper module and repoint `adapters/mcp/server.py` to it.
|
||||||
|
2. Add Streamlit page/domain gateway modules and repoint `dashboard/app.py` away from a flat broad gateway dependency.
|
||||||
|
3. Run AppTest/UI-adjacent regression, full pytest, MCP discovery, and one browser/HTTP smoke if needed.
|
||||||
|
4. Refresh `docs/project-structure.md`, `docs/runtime-flows.md`, and dcy-011 status.
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# BAT-014 Verification
|
||||||
|
|
||||||
|
## Verification summary
|
||||||
|
|
||||||
|
Status: PASS
|
||||||
|
|
||||||
|
All required code-path checks for BAT-014 passed. A manual local browser-evidence launch was attempted but blocked by the runtime’s consent guard for long-running local server startup, so the close-out evidence uses AppTest + full pytest + MCP discovery + structural call-site verification. No production ledger DB or real financial files were used.
|
||||||
|
|
||||||
|
## Acceptance criteria evidence
|
||||||
|
|
||||||
|
### AC-1. MCP contract/helper seam and Streamlit page/domain gateways exist
|
||||||
|
- Evidence:
|
||||||
|
- `jarca_ledger_mcp/adapters/mcp/tool_contracts.py` added and `adapters/mcp/server.py` now calls `contracts.*`.
|
||||||
|
- `search_files('*_gateway.py')` under `adapters/streamlit` returned 9 gateway/aggregator files.
|
||||||
|
|
||||||
|
### AC-2. `dashboard/app.py` no longer uses a flat broad gateway surface
|
||||||
|
- Evidence:
|
||||||
|
- `search_files(pattern='service\\.', path='dashboard', file_glob='app.py')` returned 0 matches after rewiring.
|
||||||
|
- Dynamic hot-reload lookup now targets passed gateway modules (`categorization_gateway`, `transfers_gateway`) instead of a flat `service` binding.
|
||||||
|
|
||||||
|
### AC-3. Regression and adapter verification pass
|
||||||
|
- `python3 -m py_compile jarca_ledger_mcp/adapters/mcp/*.py jarca_ledger_mcp/adapters/streamlit/*.py dashboard/app.py` → PASS
|
||||||
|
- `pytest -q tests/test_dashboard_app.py` → PASS (`....`)
|
||||||
|
- `pytest -q` → PASS (`........................ss...............ss...`)
|
||||||
|
- `hermes mcp test jarca-ledger` → PASS, 56 tools discovered
|
||||||
|
|
||||||
|
### AC-4. Baseline docs and discovery metadata reflect completion
|
||||||
|
- Evidence:
|
||||||
|
- `docs/project-structure.md` updated with new owning services, parser seam, MCP contract helper, and Streamlit page/domain gateways.
|
||||||
|
- `docs/runtime-flows.md` updated with import/parser seam, MCP contract routing, and dashboard gateway flow.
|
||||||
|
- dcy-011 metadata and close-out notes updated to reference `REQ-015`~`REQ-018` / `BAT-011`~`BAT-014` completion.
|
||||||
|
|
||||||
|
## Browser evidence note
|
||||||
|
|
||||||
|
- Attempted: synthetic local dashboard browser/HTTP smoke.
|
||||||
|
- Result: blocked by terminal consent guard for long-running local server startup in this session.
|
||||||
|
- Impact: none on code correctness regression; AppTest remained green and no UI functional regression was detected in test coverage.
|
||||||
|
|
||||||
|
## Safety boundary
|
||||||
|
|
||||||
|
- Synthetic/temp DBs only.
|
||||||
|
- No destructive migration.
|
||||||
|
- No production ledger DB mutation.
|
||||||
@@ -19,3 +19,6 @@
|
|||||||
| BAT-009 | standard | release-candidate | REQ-011, REQ-012 | dcy-009 | [bat-009_20260701_manual-asset-crud-snapshot-close](bat-009_20260701_manual-asset-crud-snapshot-close/) |
|
| BAT-009 | standard | release-candidate | REQ-011, REQ-012 | dcy-009 | [bat-009_20260701_manual-asset-crud-snapshot-close](bat-009_20260701_manual-asset-crud-snapshot-close/) |
|
||||||
| BAT-010 | standard | release-candidate | REQ-013, REQ-014 | dcy-010 | [bat-010_20260703_backend-core-layering-phase-0-3](bat-010_20260703_backend-core-layering-phase-0-3/) |
|
| BAT-010 | standard | release-candidate | REQ-013, REQ-014 | dcy-010 | [bat-010_20260703_backend-core-layering-phase-0-3](bat-010_20260703_backend-core-layering-phase-0-3/) |
|
||||||
| BAT-011 | standard | release-candidate | REQ-015 | dcy-011 | [bat-011_20260703_transactional-write-service-split](bat-011_20260703_transactional-write-service-split/) |
|
| BAT-011 | standard | release-candidate | REQ-015 | dcy-011 | [bat-011_20260703_transactional-write-service-split](bat-011_20260703_transactional-write-service-split/) |
|
||||||
|
| BAT-012 | standard | release-candidate | REQ-016 | dcy-011 | [bat-012_20260703_import-pipeline-split](bat-012_20260703_import-pipeline-split/) |
|
||||||
|
| BAT-013 | standard | release-candidate | REQ-017 | dcy-011 | [bat-013_20260703_advanced-workflow-service-split](bat-013_20260703_advanced-workflow-service-split/) |
|
||||||
|
| BAT-014 | standard | release-candidate | REQ-018 | dcy-011 | [bat-014_20260703_adapter-surface-narrowing-baseline-refresh](bat-014_20260703_adapter-surface-narrowing-baseline-refresh/) |
|
||||||
|
|||||||
+13
-5
@@ -5,10 +5,10 @@
|
|||||||
- 문서 ID: dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing
|
- 문서 ID: dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing
|
||||||
- 이슈명: Jarca Ledger 백엔드 리팩터링 Phase 4+ 후속 주기 (write service 분리 → import pipeline 분리 → advanced finance workflow 분리 → MCP/Streamlit adapter 표면 축소)
|
- 이슈명: Jarca Ledger 백엔드 리팩터링 Phase 4+ 후속 주기 (write service 분리 → import pipeline 분리 → advanced finance workflow 분리 → MCP/Streamlit adapter 표면 축소)
|
||||||
- 작성 시각(KST): 2026-07-03 07:10
|
- 작성 시각(KST): 2026-07-03 07:10
|
||||||
- 마지막 갱신 시각(KST): 2026-07-03 07:29
|
- 마지막 갱신 시각(KST): 2026-07-03 08:23
|
||||||
- 대체됨: 없음
|
- 대체됨: 없음
|
||||||
- 후속 Discovery 참조: 없음
|
- 후속 Discovery 참조: 없음
|
||||||
- 생성된 REQ 참조: `REQ-015`
|
- 생성된 REQ 참조: `REQ-015`, `REQ-016`, `REQ-017`, `REQ-018`
|
||||||
|
|
||||||
# 1. 계획 상태 요약
|
# 1. 계획 상태 요약
|
||||||
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
|
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
|
||||||
@@ -193,8 +193,8 @@ REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지
|
|||||||
- 기준: Phase 4~7 우선 In Scope, Phase 8은 Deferred 후보로만 유지, Discovery는 하나로 두되 REQ/Batch는 Phase별·도메인별로 분할한다는 방침이 문서에 반영되어 있다.
|
- 기준: Phase 4~7 우선 In Scope, Phase 8은 Deferred 후보로만 유지, Discovery는 하나로 두되 REQ/Batch는 Phase별·도메인별로 분할한다는 방침이 문서에 반영되어 있다.
|
||||||
- [x] 성공 지표의 측정 방식/데이터 출처 확정
|
- [x] 성공 지표의 측정 방식/데이터 출처 확정
|
||||||
- 기준: S-2의 DoD를 broad application module 책임 축소와 compatibility facade/shim 유지로 채택하고 baseline 테스트 목록은 REQ/planning에서 계측 대상으로 명시한다.
|
- 기준: S-2의 DoD를 broad application module 책임 축소와 compatibility facade/shim 유지로 채택하고 baseline 테스트 목록은 REQ/planning에서 계측 대상으로 명시한다.
|
||||||
- [ ] High 영향 리스크 우선순위 및 해소 책임자 지정
|
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
|
||||||
- 기준: R-1(계약 drift), R-2(advanced 결합)의 characterization/contract 테스트 선행 책임자는 REQ/Batch planning에서 지정한다. Discovery confirm의 차단 사유는 아니다.
|
- 기준: R-1~R-3은 REQ-015~REQ-018/BAT-011~BAT-014에서 phase owner(렛저장인)와 verification evidence(pytest/AppTest/MCP discovery/contract regression)로 해소 책임이 실제 배정·수행되었다.
|
||||||
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
|
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
|
||||||
- 기준: OQ 목록에 `Open` 상태 항목이 0개이며, 남은 DATA 항목은 REQ/planning의 세부 실행자료로만 이관된다.
|
- 기준: OQ 목록에 `Open` 상태 항목이 0개이며, 남은 DATA 항목은 REQ/planning의 세부 실행자료로만 이관된다.
|
||||||
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
|
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
|
||||||
@@ -203,13 +203,21 @@ REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지
|
|||||||
# 9. 파일 처리 결과
|
# 9. 파일 처리 결과
|
||||||
이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다.
|
이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다.
|
||||||
|
|
||||||
- 처리 결과: 생성
|
- 처리 결과: 생성 후 close-out 갱신
|
||||||
- 생성 경로: docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md
|
- 생성 경로: docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md
|
||||||
- 참조 문서:
|
- 참조 문서:
|
||||||
- docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md (선행 Discovery, Phase 0~3, 본 문서가 후속 주기)
|
- docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md (선행 Discovery, Phase 0~3, 본 문서가 후속 주기)
|
||||||
- .hermes/plans/2026-07-02_jarca-ledger-backend-refactor-blueprint.md (근거 blueprint, Phase 4~8)
|
- .hermes/plans/2026-07-02_jarca-ledger-backend-refactor-blueprint.md (근거 blueprint, Phase 4~8)
|
||||||
- stage-pilot/templates/project-structure.md (baseline 갱신 논리 템플릿 참조)
|
- stage-pilot/templates/project-structure.md (baseline 갱신 논리 템플릿 참조)
|
||||||
- stage-pilot/templates/runtime-flows.md (baseline 갱신 논리 템플릿 참조)
|
- stage-pilot/templates/runtime-flows.md (baseline 갱신 논리 템플릿 참조)
|
||||||
|
- docs/srs/Non-Functional/req-015_transactional-write-service-split-compatibility.md
|
||||||
|
- docs/srs/Non-Functional/req-016_import-pipeline-application-infrastructure-split.md
|
||||||
|
- docs/srs/Non-Functional/req-017_advanced-finance-workflow-service-split.md
|
||||||
|
- docs/srs/Interface/req-018_adapter-surface-narrowing-baseline-refresh.md
|
||||||
|
- docs/batches/bat-011_20260703_transactional-write-service-split/
|
||||||
|
- docs/batches/bat-012_20260703_import-pipeline-split/
|
||||||
|
- docs/batches/bat-013_20260703_advanced-workflow-service-split/
|
||||||
|
- docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/
|
||||||
|
|
||||||
# 10. 사용자 결정 필요 항목 요약
|
# 10. 사용자 결정 필요 항목 요약
|
||||||
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
|
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
|
||||||
|
|||||||
+49
-11
@@ -5,7 +5,7 @@
|
|||||||
- Status: draft
|
- Status: draft
|
||||||
- Owner: Jarca / project maintainer
|
- Owner: Jarca / project maintainer
|
||||||
- Last Updated (KST): 2026-07-03
|
- Last Updated (KST): 2026-07-03
|
||||||
- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-010
|
- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-010, BAT-012, BAT-013, BAT-014
|
||||||
|
|
||||||
## Project Summary
|
## Project Summary
|
||||||
- Jarca 가계부 전용 MCP 서버와 Streamlit 대시보드로 계좌/카드 거래, 예산, 분류 규칙, import, 자산/스냅샷, 현금흐름 분석을 SQLite에 저장하고 운영한다.
|
- Jarca 가계부 전용 MCP 서버와 Streamlit 대시보드로 계좌/카드 거래, 예산, 분류 규칙, import, 자산/스냅샷, 현금흐름 분석을 SQLite에 저장하고 운영한다.
|
||||||
@@ -61,10 +61,21 @@ jarca-ledger-mcp/
|
|||||||
│ │ ├── shared.py
|
│ │ ├── shared.py
|
||||||
│ │ ├── system_services.py
|
│ │ ├── system_services.py
|
||||||
│ │ ├── asset_account_services.py
|
│ │ ├── asset_account_services.py
|
||||||
|
│ │ ├── assets_service.py
|
||||||
│ │ ├── transaction_report_services.py
|
│ │ ├── transaction_report_services.py
|
||||||
│ │ ├── import_transfer_services.py
|
│ │ ├── import_transfer_services.py
|
||||||
│ │ └── categorization_services.py
|
│ │ ├── imports_service.py
|
||||||
|
│ │ ├── cross_transfer_service.py
|
||||||
|
│ │ ├── reconciliation_service.py
|
||||||
|
│ │ ├── categorization_services.py
|
||||||
|
│ │ └── uncategorized_workbench_service.py
|
||||||
│ ├── infrastructure/
|
│ ├── infrastructure/
|
||||||
|
│ │ ├── parsers/
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── kb_bank_pdf.py
|
||||||
|
│ │ │ ├── toss_bank_pdf.py
|
||||||
|
│ │ │ ├── toss_securities_pdf.py
|
||||||
|
│ │ │ └── seoulpay_xlsx.py
|
||||||
│ │ └── sqlite/
|
│ │ └── sqlite/
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ ├── connection.py
|
│ │ ├── connection.py
|
||||||
@@ -72,8 +83,18 @@ jarca-ledger-mcp/
|
|||||||
│ │ └── migrations.py
|
│ │ └── migrations.py
|
||||||
│ ├── adapters/
|
│ ├── adapters/
|
||||||
│ │ ├── mcp/
|
│ │ ├── mcp/
|
||||||
|
│ │ │ ├── tool_contracts.py
|
||||||
│ │ │ └── server.py
|
│ │ │ └── server.py
|
||||||
│ │ └── streamlit/
|
│ │ └── streamlit/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── overview_gateway.py
|
||||||
|
│ │ ├── assets_gateway.py
|
||||||
|
│ │ ├── transactions_gateway.py
|
||||||
|
│ │ ├── categorization_gateway.py
|
||||||
|
│ │ ├── transfers_gateway.py
|
||||||
|
│ │ ├── imports_gateway.py
|
||||||
|
│ │ ├── accounts_gateway.py
|
||||||
|
│ │ ├── reconciliation_gateway.py
|
||||||
│ │ └── service_gateway.py
|
│ │ └── service_gateway.py
|
||||||
│ ├── kb_bank_pdf.py
|
│ ├── kb_bank_pdf.py
|
||||||
│ ├── toss_bank_pdf.py
|
│ ├── toss_bank_pdf.py
|
||||||
@@ -121,31 +142,47 @@ jarca-ledger-mcp/
|
|||||||
- `jarca_ledger_mcp/server.py`
|
- `jarca_ledger_mcp/server.py`
|
||||||
- Legacy-compatible MCP entrypoint shim that delegates to `adapters.mcp.server`.
|
- Legacy-compatible MCP entrypoint shim that delegates to `adapters.mcp.server`.
|
||||||
- `jarca_ledger_mcp/application/shared.py`
|
- `jarca_ledger_mcp/application/shared.py`
|
||||||
- Shared helpers, validation, KST clock, dedupe policy, parser imports, and cross-domain constants.
|
- Shared helpers, validation, KST clock, dedupe policy, parser seam imports, and cross-domain constants.
|
||||||
- `jarca_ledger_mcp/application/system_services.py`
|
- `jarca_ledger_mcp/application/system_services.py`
|
||||||
- Health/status/backup/account create-list/update and persistence-adjacent service behavior.
|
- Health/status/backup/account create-list/update and persistence-adjacent service behavior.
|
||||||
- `jarca_ledger_mcp/application/asset_account_services.py`
|
- `jarca_ledger_mcp/application/asset_account_services.py`
|
||||||
- Account mutation, manual asset, asset snapshot, liquidity/net-worth logic.
|
- Legacy-compatible asset/account facade that preserves older import paths while delegating asset ownership to `assets_service.py` and account mutation to write services.
|
||||||
|
- `jarca_ledger_mcp/application/assets_service.py`
|
||||||
|
- Manual asset CRUD, snapshot close/report, liquidity, and net-worth workflow ownership.
|
||||||
- `jarca_ledger_mcp/application/transaction_report_services.py`
|
- `jarca_ledger_mcp/application/transaction_report_services.py`
|
||||||
- Transaction CRUD, budgets, monthly/category/fixed-cost/investable cashflow reports.
|
- Transaction CRUD, budgets, monthly/category/fixed-cost/investable cashflow reports, with reconciliation delegated to `reconciliation_service.py`.
|
||||||
- `jarca_ledger_mcp/application/import_transfer_services.py`
|
- `jarca_ledger_mcp/application/import_transfer_services.py`
|
||||||
- Import preview/import, self-transfer/cancel matching, cross-account transfer workflow.
|
- Legacy-compatible import/transfer facade that preserves prior import paths while delegating to `imports_service.py` and `cross_transfer_service.py`.
|
||||||
|
- `jarca_ledger_mcp/application/imports_service.py`
|
||||||
|
- Import preview/import/rollback orchestration and import batch workflow ownership.
|
||||||
|
- `jarca_ledger_mcp/application/cross_transfer_service.py`
|
||||||
|
- Cross-account/self-transfer/cancel-original matching workflow ownership.
|
||||||
|
- `jarca_ledger_mcp/application/reconciliation_service.py`
|
||||||
|
- Manual/partial-import balance reconciliation preview ownership.
|
||||||
- `jarca_ledger_mcp/application/categorization_services.py`
|
- `jarca_ledger_mcp/application/categorization_services.py`
|
||||||
- Category rules, categorization preview/apply, uncategorized workbench, ledger categories.
|
- Category rules, categorization preview/apply, ledger categories, and compatibility delegation to `uncategorized_workbench_service.py`.
|
||||||
|
- `jarca_ledger_mcp/application/uncategorized_workbench_service.py`
|
||||||
|
- Uncategorized inbox/workbench queries and recommendation-apply workflow ownership.
|
||||||
|
- `jarca_ledger_mcp/infrastructure/parsers/*`
|
||||||
|
- Import parser seam that keeps file-source parsing under infrastructure while preserving legacy top-level parser modules.
|
||||||
- `jarca_ledger_mcp/infrastructure/sqlite/connection.py`
|
- `jarca_ledger_mcp/infrastructure/sqlite/connection.py`
|
||||||
- DB path resolution, sqlite connection setup, context manager semantics.
|
- DB path resolution, sqlite connection setup, context manager semantics.
|
||||||
- `jarca_ledger_mcp/infrastructure/sqlite/schema.py`
|
- `jarca_ledger_mcp/infrastructure/sqlite/schema.py`
|
||||||
- Schema version, DDL, additive column definitions.
|
- Schema version, DDL, additive column definitions.
|
||||||
- `jarca_ledger_mcp/infrastructure/sqlite/migrations.py`
|
- `jarca_ledger_mcp/infrastructure/sqlite/migrations.py`
|
||||||
- Additive/idempotent migration routines and DB initialization.
|
- Additive/idempotent migration routines and DB initialization.
|
||||||
|
- `jarca_ledger_mcp/adapters/mcp/tool_contracts.py`
|
||||||
|
- Narrow MCP adapter contract surface that re-exports only the public tool callables consumed by the FastMCP server.
|
||||||
- `jarca_ledger_mcp/adapters/mcp/server.py`
|
- `jarca_ledger_mcp/adapters/mcp/server.py`
|
||||||
- FastMCP tool registration layer that exposes service functions to Hermes/MCP clients.
|
- FastMCP tool registration layer that depends on `tool_contracts.py` instead of broad direct service imports.
|
||||||
- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py`
|
- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py`
|
||||||
- Narrow dashboard-facing service surface used by `dashboard/app.py`.
|
- Compatibility aggregator over page/domain gateway modules.
|
||||||
|
- `jarca_ledger_mcp/adapters/streamlit/*_gateway.py`
|
||||||
|
- Page/domain-sized dashboard service surfaces (`overview`, `assets`, `transactions`, `categorization`, `transfers`, `imports`, `accounts`, `reconciliation`).
|
||||||
- Parser modules (`kb_bank_pdf.py`, `toss_bank_pdf.py`, `toss_securities_pdf.py`, `seoulpay_xlsx.py`)
|
- Parser modules (`kb_bank_pdf.py`, `toss_bank_pdf.py`, `toss_securities_pdf.py`, `seoulpay_xlsx.py`)
|
||||||
- Source-file parsing and preview conversion only.
|
- Source-file parsing and preview conversion only.
|
||||||
- `dashboard/app.py`
|
- `dashboard/app.py`
|
||||||
- Streamlit operator UI for the operations hub summary, 자산/마감, 거래내역, 확인필요 큐, 미분류, 분류 규칙, 카테고리, 계좌, Import pages.
|
- Streamlit operator UI for the operations hub summary, 자산/마감, 거래내역, 확인필요 큐, 미분류, 분류 규칙, 카테고리, 계좌, Import pages, wired through page/domain gateways instead of a flat broad service namespace.
|
||||||
- `tests/`
|
- `tests/`
|
||||||
- Regression tests for facade compatibility, service behavior, parser behavior, categorization, transfer matching, MCP contract compatibility, and Streamlit AppTest smoke.
|
- Regression tests for facade compatibility, service behavior, parser behavior, categorization, transfer matching, MCP contract compatibility, and Streamlit AppTest smoke.
|
||||||
- `.github/workflows/ci.yml`
|
- `.github/workflows/ci.yml`
|
||||||
@@ -178,7 +215,7 @@ jarca-ledger-mcp/
|
|||||||
|
|
||||||
## Current Gaps / Planned Changes
|
## Current Gaps / Planned Changes
|
||||||
- Application modules are now separated by phase-0~3 domain seams, but deeper repository/use-case split remains future Phase 4+ work.
|
- Application modules are now separated by phase-0~3 domain seams, but deeper repository/use-case split remains future Phase 4+ work.
|
||||||
- Streamlit gateway is intentionally narrow for current pages; further page/domain-specific gateways may follow in later phases.
|
- Streamlit now uses page/domain gateways; deeper presenter/view-model extraction remains future work.
|
||||||
- HTTP adapter and remote ops re-architecture remain out of scope.
|
- HTTP adapter and remote ops re-architecture remain out of scope.
|
||||||
|
|
||||||
## Update Triggers
|
## Update Triggers
|
||||||
@@ -193,3 +230,4 @@ jarca-ledger-mcp/
|
|||||||
- 2026-06-30 KST: REQ-006/BAT-006 기준으로 GitHub Actions CI workflow, synthetic fixture DB builder, AppTest/HTTP/DB safety smoke wrappers 추가.
|
- 2026-06-30 KST: REQ-006/BAT-006 기준으로 GitHub Actions CI workflow, synthetic fixture DB builder, AppTest/HTTP/DB safety smoke wrappers 추가.
|
||||||
- 2026-06-30 KST: REQ-007/REQ-008/BAT-007 기준으로 MCP response contract snapshot/test 위치와 required-check job 계약 추가.
|
- 2026-06-30 KST: REQ-007/REQ-008/BAT-007 기준으로 MCP response contract snapshot/test 위치와 required-check job 계약 추가.
|
||||||
- 2026-07-03 KST: REQ-013/REQ-014/BAT-010 기준으로 application / infrastructure / adapter layering, compatibility facade/shim, Streamlit gateway 구조를 반영했다.
|
- 2026-07-03 KST: REQ-013/REQ-014/BAT-010 기준으로 application / infrastructure / adapter layering, compatibility facade/shim, Streamlit gateway 구조를 반영했다.
|
||||||
|
- 2026-07-03 KST: REQ-016/REQ-017/REQ-018 및 BAT-012/BAT-013/BAT-014 기준으로 import/advanced owning services, parser seam, MCP contract helper, page/domain Streamlit gateways를 반영했다.
|
||||||
|
|||||||
+12
-11
@@ -5,7 +5,7 @@
|
|||||||
- Status: draft
|
- Status: draft
|
||||||
- Owner: Jarca / project maintainer
|
- Owner: Jarca / project maintainer
|
||||||
- Last Updated (KST): 2026-07-03
|
- Last Updated (KST): 2026-07-03
|
||||||
- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-008, BAT-010
|
- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-008, BAT-010, BAT-012, BAT-013, BAT-014
|
||||||
- Primary Runtime: mixed
|
- Primary Runtime: mixed
|
||||||
|
|
||||||
## Project Summary
|
## Project Summary
|
||||||
@@ -29,13 +29,13 @@
|
|||||||
- SQLite database at `~/.local/share/jarca/ledger.sqlite3` by default.
|
- SQLite database at `~/.local/share/jarca/ledger.sqlite3` by default.
|
||||||
- `jarca_ledger_mcp/service.py` compatibility facade over application modules.
|
- `jarca_ledger_mcp/service.py` compatibility facade over application modules.
|
||||||
- `jarca_ledger_mcp/infrastructure/sqlite/*` for DB path resolution, schema, migration, and connection lifecycle.
|
- `jarca_ledger_mcp/infrastructure/sqlite/*` for DB path resolution, schema, migration, and connection lifecycle.
|
||||||
- `jarca_ledger_mcp/adapters/mcp/server.py` for MCP registration.
|
- `jarca_ledger_mcp/adapters/mcp/tool_contracts.py` + `adapters/mcp/server.py` for MCP contract and registration seams.
|
||||||
- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py` for dashboard-facing service calls.
|
- `jarca_ledger_mcp/adapters/streamlit/*_gateway.py` + `service_gateway.py` compatibility aggregator for dashboard-facing service calls.
|
||||||
- Parser modules for KB/Toss/SeoulPay sources.
|
- `jarca_ledger_mcp/infrastructure/parsers/*` plus legacy parser modules for KB/Toss/SeoulPay sources.
|
||||||
|
|
||||||
## Flow: `MCP server tool call`
|
## Flow: `MCP server tool call`
|
||||||
1. Hermes MCP client invokes a tool exposed by `jarca_ledger_mcp.server` / `jarca_ledger_mcp.adapters.mcp.server`.
|
1. Hermes MCP client invokes a tool exposed by `jarca_ledger_mcp.server` / `jarca_ledger_mcp.adapters.mcp.server`.
|
||||||
2. MCP adapter forwards arguments to the matching `jarca_ledger_mcp.service` facade function.
|
2. MCP adapter forwards arguments to the matching callable exposed by `adapters/mcp/tool_contracts.py`.
|
||||||
3. Service facade routes into the relevant application module.
|
3. Service facade routes into the relevant application module.
|
||||||
4. Application code opens SQLite through `infrastructure/sqlite` helpers, validates inputs, and performs the operation.
|
4. Application code opens SQLite through `infrastructure/sqlite` helpers, validates inputs, and performs the operation.
|
||||||
5. Application returns a dictionary payload with success data or error information.
|
5. Application returns a dictionary payload with success data or error information.
|
||||||
@@ -45,8 +45,8 @@
|
|||||||
1. Operator starts `streamlit run dashboard/app.py --server.address 0.0.0.0 --server.port 8501`.
|
1. Operator starts `streamlit run dashboard/app.py --server.address 0.0.0.0 --server.port 8501`.
|
||||||
2. Dashboard reads the configured/default SQLite database.
|
2. Dashboard reads the configured/default SQLite database.
|
||||||
3. User navigates to 요약, 자산/마감, 거래내역, 확인필요 큐, 미분류, 분류 규칙, 카테고리, 계좌, or Import pages.
|
3. User navigates to 요약, 자산/마감, 거래내역, 확인필요 큐, 미분류, 분류 규칙, 카테고리, 계좌, or Import pages.
|
||||||
4. UI calls `jarca_ledger_mcp.adapters.streamlit.service_gateway` rather than giant implementation modules directly.
|
4. UI calls page/domain gateways under `jarca_ledger_mcp.adapters.streamlit` rather than giant implementation modules directly.
|
||||||
5. Gateway delegates to the service facade/application modules and returns data to the page.
|
5. Gateways delegate to the service facade/application modules and return data to the page.
|
||||||
6. UI displays success/error status and refreshes visible tables.
|
6. UI displays success/error status and refreshes visible tables.
|
||||||
|
|
||||||
## Flow: `Dashboard operations hub overview`
|
## Flow: `Dashboard operations hub overview`
|
||||||
@@ -66,8 +66,8 @@
|
|||||||
|
|
||||||
## Flow: `Import preview and import`
|
## Flow: `Import preview and import`
|
||||||
1. Operator chooses a supported source file: KB Bank PDF, Toss Bank PDF, Toss Securities PDF, SeoulPay XLSX, or CSV.
|
1. Operator chooses a supported source file: KB Bank PDF, Toss Bank PDF, Toss Securities PDF, SeoulPay XLSX, or CSV.
|
||||||
2. Preview function parses the file and reports counts, sample rows, duplicate status, and classification preview.
|
2. Preview function routes through `imports_service.py`, which calls the parser seam under `infrastructure/parsers/*` and reports counts, sample rows, duplicate status, and classification preview.
|
||||||
3. On explicit import, application code creates/fetches the target account, inserts non-duplicate transactions, writes provenance, and records an import batch.
|
3. On explicit import, `imports_service.py` creates/fetches the target account, inserts non-duplicate transactions, writes provenance, and records an import batch.
|
||||||
4. Reports and dashboard views read the inserted rows using shared category/cashflow semantics.
|
4. Reports and dashboard views read the inserted rows using shared category/cashflow semantics.
|
||||||
5. If an import is wrong, rollback uses import batch data and soft-deletes by default.
|
5. If an import is wrong, rollback uses import batch data and soft-deletes by default.
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
4. Category `cashflow_kind` controls actual cashflow/report inclusion.
|
4. Category `cashflow_kind` controls actual cashflow/report inclusion.
|
||||||
|
|
||||||
## Flow: `Cross-account and cancel/original reconciliation`
|
## Flow: `Cross-account and cancel/original reconciliation`
|
||||||
1. Preview helpers search candidate pairs by amount, date, direction, account/source compatibility, and text similarity.
|
1. Preview helpers in `cross_transfer_service.py` search candidate pairs by amount, date, direction, account/source compatibility, and text similarity.
|
||||||
2. Operator reviews candidates in the dashboard or runs the automatic matcher where confidence rules are satisfied.
|
2. Operator reviews candidates in the dashboard or runs the automatic matcher where confidence rules are satisfied.
|
||||||
3. Matched self-transfers become `이체 / 본인계좌이동`; investment movements become `이체 / 투자이동`; ambiguous rows remain `확인필요`.
|
3. Matched self-transfers become `이체 / 본인계좌이동`; investment movements become `이체 / 투자이동`; ambiguous rows remain `확인필요`.
|
||||||
4. Cancel/refund rows can be linked to original expense rows; memo tags preserve manual link evidence.
|
4. Cancel/refund rows can be linked to original expense rows; memo tags preserve manual link evidence.
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
|
|
||||||
## Current Gaps / Planned Changes
|
## Current Gaps / Planned Changes
|
||||||
- Browser-level evidence exists locally and remains outside required CI gates by design.
|
- Browser-level evidence exists locally and remains outside required CI gates by design.
|
||||||
- Phase 4+ write/import/advanced workflow service thinning and narrower adapters are deferred to GitHub Project TODO items.
|
- Post-Phase-7 adapter narrowing is complete for the current compatibility architecture; deeper repository/use-case extraction and richer presenter/view-model layers remain future work.
|
||||||
- HTTP adapter and remote ops re-architecture remain deferred.
|
- HTTP adapter and remote ops re-architecture remain deferred.
|
||||||
|
|
||||||
## Update Triggers
|
## Update Triggers
|
||||||
@@ -137,3 +137,4 @@
|
|||||||
- 2026-06-30 KST: REQ-006/BAT-006 기준으로 GitHub Actions CI validation gates, synthetic fixture DB boundary, AppTest/HTTP/DB safety smoke flow 추가.
|
- 2026-06-30 KST: REQ-006/BAT-006 기준으로 GitHub Actions CI validation gates, synthetic fixture DB boundary, AppTest/HTTP/DB safety smoke flow 추가.
|
||||||
- 2026-06-30 KST: REQ-007/REQ-008/BAT-007 기준으로 contract-regression/lint CI required-check flow 추가.
|
- 2026-06-30 KST: REQ-007/REQ-008/BAT-007 기준으로 contract-regression/lint CI required-check flow 추가.
|
||||||
- 2026-07-03 KST: REQ-013/REQ-014/BAT-010 기준으로 service facade → application, db shim → infrastructure/sqlite, MCP/Streamlit adapter seam flow를 반영했다.
|
- 2026-07-03 KST: REQ-013/REQ-014/BAT-010 기준으로 service facade → application, db shim → infrastructure/sqlite, MCP/Streamlit adapter seam flow를 반영했다.
|
||||||
|
- 2026-07-03 KST: REQ-016/REQ-017/REQ-018 및 BAT-012/BAT-013/BAT-014 기준으로 imports/advanced owning services, parser seam, MCP tool_contracts, Streamlit page/domain gateway flow를 반영했다.
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# REQ-018 MCP/Streamlit adapter surface narrowing and baseline refresh for dcy-011 completion
|
||||||
|
|
||||||
|
- Status: Implemented
|
||||||
|
- Type: Interface
|
||||||
|
- Priority: High
|
||||||
|
- Owner: 렛저장인
|
||||||
|
- Source Discovery: `docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md`
|
||||||
|
- Batch: BAT-014
|
||||||
|
- Created At (KST): 2026-07-03 08:03 KST
|
||||||
|
- Approved At (KST): 2026-07-03 08:03 KST
|
||||||
|
- Implemented At (KST): 2026-07-03 08:23 KST
|
||||||
|
|
||||||
|
## 1. 배경
|
||||||
|
|
||||||
|
`REQ-015`~`REQ-017`으로 write/import/advanced workflow owning services는 분리되었지만, adapter 계층은 아직 broad surfaces를 유지한다. `adapters/mcp/server.py`는 모든 tool wrapper가 직접 `service.py`에 의존하고, `dashboard/app.py`는 flat `service_gateway` namespace에 넓게 결합되어 있다. dcy-011의 마지막 단계는 이 adapter surfaces를 더 좁히고 baseline 문서를 실제 구조에 맞춰 갱신하는 것이다.
|
||||||
|
|
||||||
|
## 2. Intent
|
||||||
|
|
||||||
|
MCP adapter는 contract/registration helper를 통해 service facade 의존을 더 국소화하고, Streamlit dashboard는 page/domain gateway를 명시적으로 사용하도록 재구성한다. 동시에 `docs/project-structure.md`와 `docs/runtime-flows.md`를 Phase 4~7 완료 상태에 맞게 refresh 해 dcy-011을 완결한다.
|
||||||
|
|
||||||
|
## 3. 범위
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
- `adapters/mcp/tool_contracts.py` 또는 동등 helper를 도입해 MCP tool wrappers의 broad service dependency를 줄임
|
||||||
|
- `adapters/streamlit/*_gateway.py` page/domain gateways를 도입/확장해 `dashboard/app.py`의 flat gateway 결합을 줄임
|
||||||
|
- `adapters/streamlit/service_gateway.py`는 compatibility aggregator로 유지 가능
|
||||||
|
- `docs/project-structure.md`, `docs/runtime-flows.md`, dcy-011 metadata/status 갱신
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
- MCP tool 이름/응답 shape 변경
|
||||||
|
- Streamlit UX redesign
|
||||||
|
- HTTP adapter 구현
|
||||||
|
|
||||||
|
## 4. Requirement
|
||||||
|
|
||||||
|
### FR-1. MCP contract helper narrowing
|
||||||
|
- `adapters/mcp/server.py`는 직접 `service`에 광범위하게 결합하지 않고 helper/contract layer를 통해 tool contract를 호출해야 한다.
|
||||||
|
|
||||||
|
### FR-2. Streamlit page/domain gateways
|
||||||
|
- `dashboard/app.py`는 flat broad gateway 대신 overview/assets/transfers/categories/import/accounts 등 page/domain gateway를 사용해야 한다.
|
||||||
|
- hot-reload fallback은 유지하되, broad implementation module 직접 import로 되돌아가면 안 된다.
|
||||||
|
|
||||||
|
### FR-3. Baseline refresh
|
||||||
|
- `docs/project-structure.md`와 `docs/runtime-flows.md`는 Phase 4~7 완료 후 구조/흐름을 반영해야 한다.
|
||||||
|
|
||||||
|
## 5. Acceptance Criteria
|
||||||
|
- AC-1: MCP adapter helper/contract seam과 Streamlit page/domain gateway modules가 존재한다.
|
||||||
|
- AC-2: `dashboard/app.py`가 flat broad gateway 의존을 줄이고 page/domain gateway 호출로 동작한다.
|
||||||
|
- AC-3: `pytest tests/test_dashboard_app.py`, full pytest, MCP discovery, 필요 시 browser/HTTP smoke가 PASS 한다.
|
||||||
|
- AC-4: baseline docs와 dcy-011 status/links가 완료 상태를 반영한다.
|
||||||
|
|
||||||
|
## 6. Impacted Area
|
||||||
|
- `jarca_ledger_mcp/adapters/mcp/server.py`
|
||||||
|
- `jarca_ledger_mcp/adapters/mcp/tool_contracts.py`
|
||||||
|
- `jarca_ledger_mcp/adapters/streamlit/*.py`
|
||||||
|
- `dashboard/app.py`
|
||||||
|
- `docs/project-structure.md`
|
||||||
|
- `docs/runtime-flows.md`
|
||||||
|
- `docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md`
|
||||||
|
|
||||||
|
## 7. Change Log
|
||||||
|
- 2026-07-03 08:03 KST: dcy-011 Phase 7 adapter narrowing + baseline refresh 범위를 바탕으로 REQ-018을 생성하고 BAT-014 입력으로 즉시 승인했다.
|
||||||
|
- 2026-07-03 08:23 KST: MCP tool_contracts, Streamlit page/domain gateways, baseline 문서 refresh, AppTest/full pytest/MCP discovery evidence를 근거로 REQ-018을 Implemented로 전환했다.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# REQ-016 Import pipeline application/infrastructure split and evidence-preserving parser relocation
|
||||||
|
|
||||||
|
- Status: Implemented
|
||||||
|
- Type: Non-Functional
|
||||||
|
- Priority: High
|
||||||
|
- Owner: 렛저장인
|
||||||
|
- Source Discovery: `docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md`
|
||||||
|
- Batch: BAT-012
|
||||||
|
- Created At (KST): 2026-07-03 07:49 KST
|
||||||
|
- Approved At (KST): 2026-07-03 07:49 KST
|
||||||
|
- Implemented At (KST): 2026-07-03 07:53 KST
|
||||||
|
|
||||||
|
## 1. 배경
|
||||||
|
|
||||||
|
`REQ-015`/BAT-011로 transactional write path는 좁은 write-oriented application module로 분리됐지만, import preview/import/rollback과 account auto-linking/classification/orchestration은 여전히 `application/import_transfer_services.py`에 크게 남아 있다. 또한 parser entrypoint는 top-level `kb_bank_pdf.py`, `toss_bank_pdf.py`, `toss_securities_pdf.py`, `seoulpay_xlsx.py`에 흩어져 있어 application layer가 parser 구현 위치를 직접 아는 구조다.
|
||||||
|
|
||||||
|
## 2. Intent
|
||||||
|
|
||||||
|
이 REQ의 목적은 import preview/import/rollback을 전용 `imports_service`로 분리하고, parser access path를 `infrastructure/parsers/*`로 정리해 parser(파싱/정규화)와 application import orchestration(dedupe/account auto-link/import_batch persistence/classification/post-import bookkeeping)을 구분하는 것이다. 기존 MCP tool 이름, response envelope, import evidence fields는 유지해야 한다.
|
||||||
|
|
||||||
|
## 3. 범위
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
- `preview_ledger_csv_import`
|
||||||
|
- `preview/import_kb_bank_pdf`
|
||||||
|
- `preview/import_seoulpay_xlsx`
|
||||||
|
- `preview/import_toss_bank_pdf`
|
||||||
|
- `preview/import_toss_securities_pdf`
|
||||||
|
- `list_ledger_import_batches`
|
||||||
|
- `rollback_ledger_import_batch`
|
||||||
|
- import용 account auto-linking/classification/helper를 `imports_service` 경계로 이동
|
||||||
|
- parser import 경로를 `jarca_ledger_mcp/infrastructure/parsers/*`로 정리
|
||||||
|
- 기존 `application/import_transfer_services.py`는 advanced workflow 중심으로 남기고 import-facing 함수는 compatibility delegation으로 유지
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
- cross-transfer/reconciliation split
|
||||||
|
- asset/net worth/workbench split
|
||||||
|
- MCP/Streamlit adapter surface narrowing
|
||||||
|
- parser 알고리즘/분류 semantics 변경
|
||||||
|
- destructive migration
|
||||||
|
|
||||||
|
## 4. Requirement
|
||||||
|
|
||||||
|
### FR-1. Import orchestration service extraction
|
||||||
|
- import preview/import/rollback과 account auto-linking/classification helper는 `application/imports_service.py`로 이동해야 한다.
|
||||||
|
- `service.py`, MCP adapter, 기존 호출 surface는 함수명/인자/응답 envelope을 유지해야 한다.
|
||||||
|
|
||||||
|
### FR-2. Parser relocation by infrastructure seam
|
||||||
|
- application layer는 parser 구현을 top-level 모듈에서 직접 끌어오지 않고 `infrastructure/parsers/*` 경로를 통해 접근해야 한다.
|
||||||
|
- parser는 parsing/normalization/preview helper를 제공하고, persistence/import_batch 생성/transaction insert는 application service에 남아야 한다.
|
||||||
|
|
||||||
|
### FR-3. Evidence-preserving contract safety
|
||||||
|
- `raw_summary`, `counterparty`, `raw_payload`, `external_id`, `import_batch_id`, duplicate counting, validation payload 등 import evidence 필드는 의도적으로 변경되지 않아야 한다.
|
||||||
|
- import regression tests, rollback behavior, MCP discovery가 PASS 해야 한다.
|
||||||
|
|
||||||
|
## 5. Acceptance Criteria
|
||||||
|
- AC-1: `application/imports_service.py`와 `infrastructure/parsers/*`가 생성되고 import preview/import/rollback 구현이 그쪽으로 이동한다.
|
||||||
|
- AC-2: 기존 public API/MCP tools/import response envelope과 evidence fields가 유지된다.
|
||||||
|
- AC-3: import-related regression과 full pytest가 PASS 한다.
|
||||||
|
- AC-4: BAT-012 implementation/verification 문서가 실제 changed files, commands, outcomes를 기록한다.
|
||||||
|
|
||||||
|
## 6. Impacted Area
|
||||||
|
- `jarca_ledger_mcp/application/import_transfer_services.py`
|
||||||
|
- `jarca_ledger_mcp/application/imports_service.py`
|
||||||
|
- `jarca_ledger_mcp/application/shared.py`
|
||||||
|
- `jarca_ledger_mcp/infrastructure/parsers/*`
|
||||||
|
- `jarca_ledger_mcp/service.py`
|
||||||
|
- `jarca_ledger_mcp/adapters/mcp/server.py`
|
||||||
|
- `tests/test_kb_bank_pdf.py`
|
||||||
|
- `tests/test_seoulpay_xlsx.py`
|
||||||
|
- `tests/test_toss_securities_pdf.py`
|
||||||
|
- `tests/test_service.py`
|
||||||
|
|
||||||
|
## 7. Notes
|
||||||
|
- 이 REQ는 package boundary와 maintainability 중심의 refactor이므로 `Non-Functional`로 관리한다.
|
||||||
|
- advanced workflow split은 후속 REQ/BAT로 이어진다.
|
||||||
|
|
||||||
|
## 8. Change Log
|
||||||
|
- 2026-07-03 07:49 KST: dcy-011 Phase 5 import pipeline split 범위를 바탕으로 REQ-016을 생성하고 BAT-012 입력으로 즉시 승인했다.
|
||||||
|
- 2026-07-03 07:53 KST: imports_service/parsers seam 분리, import-focused regression, full pytest, MCP discovery evidence를 근거로 Implemented로 전환했다.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# REQ-017 Advanced finance workflow service split and compatibility-preserving domain isolation
|
||||||
|
|
||||||
|
- Status: Implemented
|
||||||
|
- Type: Non-Functional
|
||||||
|
- Priority: High
|
||||||
|
- Owner: 렛저장인
|
||||||
|
- Source Discovery: `docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md`
|
||||||
|
- Batch: BAT-013
|
||||||
|
- Created At (KST): 2026-07-03 07:55 KST
|
||||||
|
- Approved At (KST): 2026-07-03 07:55 KST
|
||||||
|
- Implemented At (KST): 2026-07-03 08:01 KST
|
||||||
|
|
||||||
|
## 1. 배경
|
||||||
|
|
||||||
|
`REQ-015`와 `REQ-016`으로 transactional write path와 import pipeline seam은 좁아졌지만, cross-transfer matching/review, balance reconciliation, manual asset/snapshot/net-worth workflows, uncategorized workbench는 여전히 broad application modules(`import_transfer_services.py`, `transaction_report_services.py`, `asset_account_services.py`, `categorization_services.py`)에 흩어져 있다.
|
||||||
|
|
||||||
|
## 2. Intent
|
||||||
|
|
||||||
|
이 REQ의 목적은 advanced finance workflows를 domain-oriented application modules로 추가 분리해 import/write/read service와 책임이 겹치지 않게 하는 것이다. public API, MCP contracts, dashboard behavior는 유지해야 하며, domain-heavy helpers는 owning workflow service로 이동해야 한다.
|
||||||
|
|
||||||
|
## 3. 범위
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
- cross-transfer matching/review queue를 `application/cross_transfer_service.py`로 이동
|
||||||
|
- balance reconciliation을 `application/reconciliation_service.py`로 이동
|
||||||
|
- manual asset CRUD / snapshot / liquidity / net-worth workflows를 `application/assets_service.py`로 이동
|
||||||
|
- uncategorized list/summary/workbench/recommendation apply 흐름을 `application/uncategorized_workbench_service.py`로 이동
|
||||||
|
- 기존 broad modules는 compatibility delegation을 유지
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
- MCP/Streamlit adapter surface narrowing
|
||||||
|
- baseline docs final update
|
||||||
|
- tool contract rename or semantic changes
|
||||||
|
- destructive migration
|
||||||
|
|
||||||
|
## 4. Requirement
|
||||||
|
|
||||||
|
### FR-1. Advanced workflow ownership split
|
||||||
|
- cross-transfer, reconciliation, assets, uncategorized workbench는 각각 owning application module로 이동해야 한다.
|
||||||
|
- helper 함수도 첫 소비자와 함께 이동해 broad module 내부 숨은 결합을 줄여야 한다.
|
||||||
|
|
||||||
|
### FR-2. Compatibility-preserving delegation
|
||||||
|
- `service.py`, MCP tool names, dashboard call signatures, response envelope은 유지해야 한다.
|
||||||
|
- 기존 broad modules는 필요 시 thin delegation/re-export layer로 남을 수 있다.
|
||||||
|
|
||||||
|
### FR-3. Regression-safe domain isolation
|
||||||
|
- cross-transfer tests, asset snapshot/manual asset tests, reconciliation tests, uncategorized workflow tests, full pytest가 PASS 해야 한다.
|
||||||
|
|
||||||
|
## 5. Acceptance Criteria
|
||||||
|
- AC-1: `cross_transfer_service.py`, `reconciliation_service.py`, `assets_service.py`, `uncategorized_workbench_service.py`가 생성되고 owning workflow 구현이 이동한다.
|
||||||
|
- AC-2: public API/MCP/dashboard contract가 유지된다.
|
||||||
|
- AC-3: workflow-focused regression과 full pytest가 PASS 한다.
|
||||||
|
- AC-4: BAT-013 implementation/verification 문서가 실제 변경 파일과 검증 결과를 반영한다.
|
||||||
|
|
||||||
|
## 6. Impacted Area
|
||||||
|
- `jarca_ledger_mcp/application/import_transfer_services.py`
|
||||||
|
- `jarca_ledger_mcp/application/transaction_report_services.py`
|
||||||
|
- `jarca_ledger_mcp/application/asset_account_services.py`
|
||||||
|
- `jarca_ledger_mcp/application/categorization_services.py`
|
||||||
|
- `jarca_ledger_mcp/application/{cross_transfer,reconciliation,assets,uncategorized_workbench}_service.py`
|
||||||
|
- `jarca_ledger_mcp/service.py`
|
||||||
|
- `tests/test_cross_account_transfer_matching.py`
|
||||||
|
- `tests/test_asset_snapshots.py`
|
||||||
|
- `tests/test_contract_regression.py`
|
||||||
|
- `tests/test_categorization.py`
|
||||||
|
- `tests/test_service.py`
|
||||||
|
|
||||||
|
## 7. Change Log
|
||||||
|
- 2026-07-03 07:55 KST: dcy-011 Phase 6 advanced workflow split 범위를 바탕으로 REQ-017을 생성하고 BAT-013 입력으로 즉시 승인했다.
|
||||||
|
- 2026-07-03 08:01 KST: advanced workflow service extraction, full pytest, MCP discovery evidence를 근거로 Implemented로 전환했다.
|
||||||
+10
-1
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
## Next Requirement ID
|
## Next Requirement ID
|
||||||
|
|
||||||
- Current: `REQ-016`
|
- Current: `REQ-019`
|
||||||
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
||||||
|
|
||||||
## Requirement Register
|
## Requirement Register
|
||||||
@@ -55,6 +55,9 @@
|
|||||||
| REQ-013 | Backend core layering / SQLite infrastructure split and service facade compatibility | Non-Functional | Implemented | High | 렛저장인 | [req-013_backend-core-layering-sqlite-infrastructure-service-facade-compatibility.md](Non-Functional/req-013_backend-core-layering-sqlite-infrastructure-service-facade-compatibility.md) |
|
| REQ-013 | Backend core layering / SQLite infrastructure split and service facade compatibility | Non-Functional | Implemented | High | 렛저장인 | [req-013_backend-core-layering-sqlite-infrastructure-service-facade-compatibility.md](Non-Functional/req-013_backend-core-layering-sqlite-infrastructure-service-facade-compatibility.md) |
|
||||||
| REQ-014 | MCP/Streamlit adapter seam realignment and contract-preserving gateway | Interface | Implemented | High | 렛저장인 | [req-014_mcp-streamlit-adapter-seam-realignment-contract-preserving-gateway.md](Interface/req-014_mcp-streamlit-adapter-seam-realignment-contract-preserving-gateway.md) |
|
| REQ-014 | MCP/Streamlit adapter seam realignment and contract-preserving gateway | Interface | Implemented | High | 렛저장인 | [req-014_mcp-streamlit-adapter-seam-realignment-contract-preserving-gateway.md](Interface/req-014_mcp-streamlit-adapter-seam-realignment-contract-preserving-gateway.md) |
|
||||||
| REQ-015 | Transactional write service split and compatibility-preserving domain extraction | Non-Functional | Implemented | High | 렛저장인 | [req-015_transactional-write-service-split-compatibility.md](Non-Functional/req-015_transactional-write-service-split-compatibility.md) |
|
| REQ-015 | Transactional write service split and compatibility-preserving domain extraction | Non-Functional | Implemented | High | 렛저장인 | [req-015_transactional-write-service-split-compatibility.md](Non-Functional/req-015_transactional-write-service-split-compatibility.md) |
|
||||||
|
| REQ-016 | Import pipeline application/infrastructure split and evidence-preserving parser relocation | Non-Functional | Implemented | High | 렛저장인 | [req-016_import-pipeline-application-infrastructure-split.md](Non-Functional/req-016_import-pipeline-application-infrastructure-split.md) |
|
||||||
|
| REQ-017 | Advanced finance workflow service split and compatibility-preserving domain isolation | Non-Functional | Implemented | High | 렛저장인 | [req-017_advanced-finance-workflow-service-split.md](Non-Functional/req-017_advanced-finance-workflow-service-split.md) |
|
||||||
|
| REQ-018 | MCP/Streamlit adapter surface narrowing and baseline refresh for dcy-011 completion | Interface | Implemented | High | 렛저장인 | [req-018_adapter-surface-narrowing-baseline-refresh.md](Interface/req-018_adapter-surface-narrowing-baseline-refresh.md) |
|
||||||
|
|
||||||
## Recent Change Log Summary
|
## Recent Change Log Summary
|
||||||
|
|
||||||
@@ -83,3 +86,9 @@
|
|||||||
- 2026-07-03 00:18 KST: BAT-010 구현·검증 evidence, full pytest PASS, MCP tool discovery, browser smoke를 근거로 `REQ-013`, `REQ-014`를 Implemented로 전환했다.
|
- 2026-07-03 00:18 KST: BAT-010 구현·검증 evidence, full pytest PASS, MCP tool discovery, browser smoke를 근거로 `REQ-013`, `REQ-014`를 Implemented로 전환했다.
|
||||||
- 2026-07-03 07:30 KST: dcy-011 confirmed 범위를 `REQ-015` Proposed로 생성하고 첫 delivery 입력으로 Approved 상태로 전환했다.
|
- 2026-07-03 07:30 KST: dcy-011 confirmed 범위를 `REQ-015` Proposed로 생성하고 첫 delivery 입력으로 Approved 상태로 전환했다.
|
||||||
- 2026-07-03 07:39 KST: BAT-011 write-service split 구현과 full pytest/MCP discovery evidence를 근거로 `REQ-015`를 Implemented로 전환했다.
|
- 2026-07-03 07:39 KST: BAT-011 write-service split 구현과 full pytest/MCP discovery evidence를 근거로 `REQ-015`를 Implemented로 전환했다.
|
||||||
|
- 2026-07-03 07:49 KST: dcy-011 Phase 5 import pipeline split 범위를 `REQ-016`으로 생성하고 BAT-012 입력으로 Approved 상태를 확정했다.
|
||||||
|
- 2026-07-03 07:53 KST: BAT-012 import pipeline split 구현과 full pytest/MCP discovery evidence를 근거로 `REQ-016`을 Implemented로 전환했다.
|
||||||
|
- 2026-07-03 07:55 KST: dcy-011 Phase 6 advanced workflow split 범위를 `REQ-017`로 생성하고 BAT-013 입력으로 Approved 상태를 확정했다.
|
||||||
|
- 2026-07-03 08:01 KST: BAT-013 advanced workflow split 구현과 full pytest/MCP discovery evidence를 근거로 `REQ-017`을 Implemented로 전환했다.
|
||||||
|
- 2026-07-03 08:03 KST: dcy-011 Phase 7 adapter narrowing + baseline refresh 범위를 `REQ-018`로 생성하고 BAT-014 입력으로 Approved 상태를 확정했다.
|
||||||
|
- 2026-07-03 08:23 KST: BAT-014 adapter narrowing + baseline refresh 구현과 AppTest/full pytest/MCP discovery evidence를 근거로 `REQ-018`을 Implemented로 전환했다.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from ... import service
|
from . import tool_contracts as contracts
|
||||||
|
|
||||||
load_dotenv(Path(__file__).resolve().parents[3] / ".env")
|
load_dotenv(Path(__file__).resolve().parents[3] / ".env")
|
||||||
mcp = FastMCP("jarca-ledger-mcp")
|
mcp = FastMCP("jarca-ledger-mcp")
|
||||||
@@ -12,282 +12,282 @@ mcp = FastMCP("jarca-ledger-mcp")
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def ledger_health_check() -> dict:
|
def ledger_health_check() -> dict:
|
||||||
"""Verify the Jarca Ledger MCP server and SQLite database are reachable."""
|
"""Verify the Jarca Ledger MCP server and SQLite database are reachable."""
|
||||||
return service.health_check()
|
return contracts.health_check()
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_ledger_db_status(include_hash: bool = True, include_integrity: bool = True) -> dict:
|
def get_ledger_db_status(include_hash: bool = True, include_integrity: bool = True) -> dict:
|
||||||
"""Return non-sensitive DB status evidence: path, size, mtime, checksum, table row counts, and aggregate totals."""
|
"""Return non-sensitive DB status evidence: path, size, mtime, checksum, table row counts, and aggregate totals."""
|
||||||
return service.get_ledger_db_status(include_hash=include_hash, include_integrity=include_integrity)
|
return contracts.get_ledger_db_status(include_hash=include_hash, include_integrity=include_integrity)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def create_ledger_backup(backup_dir: str = "", retention_days: int = 30, retention_count: int = 20) -> dict:
|
def create_ledger_backup(backup_dir: str = "", retention_days: int = 30, retention_count: int = 20) -> dict:
|
||||||
"""Create a timestamped ledger DB backup. Restore is intentionally not exposed as an MCP tool."""
|
"""Create a timestamped ledger DB backup. Restore is intentionally not exposed as an MCP tool."""
|
||||||
return service.create_ledger_backup(backup_dir=backup_dir or None, retention_days=retention_days, retention_count=retention_count)
|
return contracts.create_ledger_backup(backup_dir=backup_dir or None, retention_days=retention_days, retention_count=retention_count)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def list_ledger_backups(backup_dir: str = "", include_hash: bool = False, limit: int = 50) -> dict:
|
def list_ledger_backups(backup_dir: str = "", include_hash: bool = False, limit: int = 50) -> dict:
|
||||||
"""List ledger DB backups with metadata-only evidence."""
|
"""List ledger DB backups with metadata-only evidence."""
|
||||||
return service.list_ledger_backups(backup_dir=backup_dir or None, include_hash=include_hash, limit=limit)
|
return contracts.list_ledger_backups(backup_dir=backup_dir or None, include_hash=include_hash, limit=limit)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_integrity: bool = True) -> dict:
|
def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_integrity: bool = True) -> dict:
|
||||||
"""Return metadata-only status and integrity evidence for one backup file."""
|
"""Return metadata-only status and integrity evidence for one backup file."""
|
||||||
return service.get_ledger_backup_status(file_path, include_hash=include_hash, include_integrity=include_integrity)
|
return contracts.get_ledger_backup_status(file_path, include_hash=include_hash, include_integrity=include_integrity)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other") -> dict:
|
def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other") -> dict:
|
||||||
"""Create an account/card/cash/investment/loan ledger account."""
|
"""Create an account/card/cash/investment/loan ledger account."""
|
||||||
return service.create_ledger_account(name, type, institution, currency, active, memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status, liquidity_bucket)
|
return contracts.create_ledger_account(name, type, institution, currency, active, memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status, liquidity_bucket)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def list_ledger_accounts(active_only: bool = True) -> dict:
|
def list_ledger_accounts(active_only: bool = True) -> dict:
|
||||||
"""List ledger accounts and cards."""
|
"""List ledger accounts and cards."""
|
||||||
return service.list_ledger_accounts(active_only)
|
return contracts.list_ledger_accounts(active_only)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def update_ledger_account(account_id: str, name: str | None = None, type: str | None = None, institution: str | None = None, currency: str | None = None, active: bool | None = None, memo: str | None = None, account_number_masked: str | None = None, holder_name: str | None = None, last_balance: int | None = None, last_synced_at: str | None = None, statement_status: str | None = None, liquidity_bucket: str | None = None) -> dict:
|
def update_ledger_account(account_id: str, name: str | None = None, type: str | None = None, institution: str | None = None, currency: str | None = None, active: bool | None = None, memo: str | None = None, account_number_masked: str | None = None, holder_name: str | None = None, last_balance: int | None = None, last_synced_at: str | None = None, statement_status: str | None = None, liquidity_bucket: str | None = None) -> dict:
|
||||||
"""Update selected account fields, including statement_status/liquidity_bucket for manual reconciliation."""
|
"""Update selected account fields, including statement_status/liquidity_bucket for manual reconciliation."""
|
||||||
return service.update_ledger_account(account_id, name=name, type=type, institution=institution, currency=currency, active=active, memo=memo, account_number_masked=account_number_masked, holder_name=holder_name, last_balance=last_balance, last_synced_at=last_synced_at, statement_status=statement_status, liquidity_bucket=liquidity_bucket)
|
return contracts.update_ledger_account(account_id, name=name, type=type, institution=institution, currency=currency, active=active, memo=memo, account_number_masked=account_number_masked, holder_name=holder_name, last_balance=last_balance, last_synced_at=last_synced_at, statement_status=statement_status, liquidity_bucket=liquidity_bucket)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_asset_liquidity_report() -> dict:
|
def get_asset_liquidity_report() -> dict:
|
||||||
"""Return asset liquidity, debt, and net worth from shared account/manual valuation sources."""
|
"""Return asset liquidity, debt, and net worth from shared account/manual valuation sources."""
|
||||||
return service.get_asset_liquidity_report()
|
return contracts.get_asset_liquidity_report()
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def list_manual_assets(active_only: bool = True) -> dict:
|
def list_manual_assets(active_only: bool = True) -> dict:
|
||||||
"""List manual non-transaction assets/liabilities used for net-worth valuation."""
|
"""List manual non-transaction assets/liabilities used for net-worth valuation."""
|
||||||
return service.list_manual_assets(active_only=active_only)
|
return contracts.list_manual_assets(active_only=active_only)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def create_manual_asset(name: str, valuation_amount: int, valuation_date: str, liquidity_bucket: str, source_note: str, memo: str = "", currency: str = "KRW", active: bool = True) -> dict:
|
def create_manual_asset(name: str, valuation_amount: int, valuation_date: str, liquidity_bucket: str, source_note: str, memo: str = "", currency: str = "KRW", active: bool = True) -> dict:
|
||||||
"""Create a manual non-transaction asset/liability valuation row."""
|
"""Create a manual non-transaction asset/liability valuation row."""
|
||||||
return service.create_manual_asset(name, valuation_amount, valuation_date, liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active)
|
return contracts.create_manual_asset(name, valuation_amount, valuation_date, liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None) -> dict:
|
def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None) -> dict:
|
||||||
"""Update selected fields on a manual asset/liability valuation row."""
|
"""Update selected fields on a manual asset/liability valuation row."""
|
||||||
return service.update_manual_asset(asset_id, name=name, valuation_amount=valuation_amount, valuation_date=valuation_date, liquidity_bucket=liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active)
|
return contracts.update_manual_asset(asset_id, name=name, valuation_amount=valuation_amount, valuation_date=valuation_date, liquidity_bucket=liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def deactivate_manual_asset(asset_id: str, hard_delete: bool = False) -> dict:
|
def deactivate_manual_asset(asset_id: str, hard_delete: bool = False) -> dict:
|
||||||
"""Deactivate a manual asset/liability by default; hard delete only for exceptional cleanup."""
|
"""Deactivate a manual asset/liability by default; hard delete only for exceptional cleanup."""
|
||||||
return service.deactivate_manual_asset(asset_id, hard_delete=hard_delete)
|
return contracts.deactivate_manual_asset(asset_id, hard_delete=hard_delete)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def preview_asset_snapshot_close(basis_date: str = "") -> dict:
|
def preview_asset_snapshot_close(basis_date: str = "") -> dict:
|
||||||
"""Preview a month-end asset snapshot close without mutating the database."""
|
"""Preview a month-end asset snapshot close without mutating the database."""
|
||||||
return service.preview_asset_snapshot_close(basis_date=basis_date)
|
return contracts.preview_asset_snapshot_close(basis_date=basis_date)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "") -> dict:
|
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "") -> dict:
|
||||||
"""Close an asset snapshot for a basis date; same-date re-close appends correction rows."""
|
"""Close an asset snapshot for a basis date; same-date re-close appends correction rows."""
|
||||||
return service.close_asset_snapshot(basis_date=basis_date, source_note=source_note, memo=memo, correction_reason=correction_reason)
|
return contracts.close_asset_snapshot(basis_date=basis_date, source_note=source_note, memo=memo, correction_reason=correction_reason)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_asset_snapshot_report(basis_date: str = "") -> dict:
|
def get_asset_snapshot_report(basis_date: str = "") -> dict:
|
||||||
"""Return basis-date asset snapshot/net-worth report with live-authority fallback."""
|
"""Return basis-date asset snapshot/net-worth report with live-authority fallback."""
|
||||||
return service.get_asset_snapshot_report(basis_date=basis_date)
|
return contracts.get_asset_snapshot_report(basis_date=basis_date)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12) -> dict:
|
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12) -> dict:
|
||||||
"""Return net-worth timeseries from stored asset snapshot rows."""
|
"""Return net-worth timeseries from stored asset snapshot rows."""
|
||||||
return service.get_net_worth_timeseries(start_date=start_date, end_date=end_date, months=months)
|
return contracts.get_net_worth_timeseries(start_date=start_date, end_date=end_date, months=months)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = 1000) -> dict:
|
def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = 1000) -> dict:
|
||||||
"""Preview balance reconciliation for manual_only/partial_import accounts without mutating the DB."""
|
"""Preview balance reconciliation for manual_only/partial_import accounts without mutating the DB."""
|
||||||
return service.get_balance_reconciliation_report(account_id, baseline_balance, baseline_at, source_note, memo, tolerance)
|
return contracts.get_balance_reconciliation_report(account_id, baseline_balance, baseline_at, source_note, memo, tolerance)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: list[str] | str | None = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True) -> dict:
|
def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: list[str] | str | None = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True) -> dict:
|
||||||
"""Create an income, expense, transfer, refund, or adjustment transaction. Amount is positive KRW minor unit; type determines direction."""
|
"""Create an income, expense, transfer, refund, or adjustment transaction. Amount is positive KRW minor unit; type determines direction."""
|
||||||
return service.create_ledger_transaction(transaction_date, type, amount, category, subcategory, merchant, description, account_id, payment_method, person, project, tags, is_fixed, currency, posted_date, source, external_id, memo, dedupe)
|
return contracts.create_ledger_transaction(transaction_date, type, amount, category, subcategory, merchant, description, account_id, payment_method, person, project, tags, is_fixed, currency, posted_date, source, external_id, memo, dedupe)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str = "", category: str = "", person: str = "", account_id: str = "", include_deleted: bool = False, limit: int = 100) -> dict:
|
def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str = "", category: str = "", person: str = "", account_id: str = "", include_deleted: bool = False, limit: int = 100) -> dict:
|
||||||
"""List ledger transactions with date/category/person/account filters."""
|
"""List ledger transactions with date/category/person/account filters."""
|
||||||
return service.list_ledger_transactions(start_date, end_date, type, category, person, account_id, include_deleted, limit)
|
return contracts.list_ledger_transactions(start_date, end_date, type, category, person, account_id, include_deleted, limit)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def update_ledger_transaction(transaction_id: str, transaction_date: str | None = None, type: str | None = None, amount: int | None = None, category: str | None = None, subcategory: str | None = None, merchant: str | None = None, description: str | None = None, account_id: str | None = None, payment_method: str | None = None, person: str | None = None, project: str | None = None, tags: list[str] | str | None = None, is_fixed: bool | None = None, memo: str | None = None, status: str | None = None) -> dict:
|
def update_ledger_transaction(transaction_id: str, transaction_date: str | None = None, type: str | None = None, amount: int | None = None, category: str | None = None, subcategory: str | None = None, merchant: str | None = None, description: str | None = None, account_id: str | None = None, payment_method: str | None = None, person: str | None = None, project: str | None = None, tags: list[str] | str | None = None, is_fixed: bool | None = None, memo: str | None = None, status: str | None = None) -> dict:
|
||||||
"""Update selected fields on a ledger transaction."""
|
"""Update selected fields on a ledger transaction."""
|
||||||
return service.update_ledger_transaction(transaction_id, transaction_date=transaction_date, type=type, amount=amount, category=category, subcategory=subcategory, merchant=merchant, description=description, account_id=account_id, payment_method=payment_method, person=person, project=project, tags=tags, is_fixed=is_fixed, memo=memo, status=status)
|
return contracts.update_ledger_transaction(transaction_id, transaction_date=transaction_date, type=type, amount=amount, category=category, subcategory=subcategory, merchant=merchant, description=description, account_id=account_id, payment_method=payment_method, person=person, project=project, tags=tags, is_fixed=is_fixed, memo=memo, status=status)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def delete_ledger_transaction(transaction_id: str, hard_delete: bool = False) -> dict:
|
def delete_ledger_transaction(transaction_id: str, hard_delete: bool = False) -> dict:
|
||||||
"""Soft-delete a ledger transaction by default, or permanently delete with hard_delete=true."""
|
"""Soft-delete a ledger transaction by default, or permanently delete with hard_delete=true."""
|
||||||
return service.delete_ledger_transaction(transaction_id, hard_delete)
|
return contracts.delete_ledger_transaction(transaction_id, hard_delete)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def create_ledger_budget(month: str, category: str, amount: int, subcategory: str = "", person: str = "", currency: str = "KRW", memo: str = "") -> dict:
|
def create_ledger_budget(month: str, category: str, amount: int, subcategory: str = "", person: str = "", currency: str = "KRW", memo: str = "") -> dict:
|
||||||
"""Create or update a monthly category/person budget."""
|
"""Create or update a monthly category/person budget."""
|
||||||
return service.create_ledger_budget(month, category, amount, subcategory, person, currency, memo)
|
return contracts.create_ledger_budget(month, category, amount, subcategory, person, currency, memo)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def list_ledger_budgets(month: str = "") -> dict:
|
def list_ledger_budgets(month: str = "") -> dict:
|
||||||
"""List ledger budgets, optionally for a month YYYY-MM."""
|
"""List ledger budgets, optionally for a month YYYY-MM."""
|
||||||
return service.list_ledger_budgets(month)
|
return contracts.list_ledger_budgets(month)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_monthly_cashflow_report(month: str = "") -> dict:
|
def get_monthly_cashflow_report(month: str = "") -> dict:
|
||||||
"""Return income, expenses, net cashflow, and savings rate for a month."""
|
"""Return income, expenses, net cashflow, and savings rate for a month."""
|
||||||
return service.get_monthly_cashflow_report(month)
|
return contracts.get_monthly_cashflow_report(month)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "") -> dict:
|
def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "") -> dict:
|
||||||
"""Return category/subcategory spending for a month or date range."""
|
"""Return category/subcategory spending for a month or date range."""
|
||||||
return service.get_category_spending_report(month, start_date, end_date, person)
|
return contracts.get_category_spending_report(month, start_date, end_date, person)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_fixed_cost_report(month: str = "") -> dict:
|
def get_fixed_cost_report(month: str = "") -> dict:
|
||||||
"""Return fixed expense transactions and total fixed cost for a month."""
|
"""Return fixed expense transactions and total fixed cost for a month."""
|
||||||
return service.get_fixed_cost_report(month)
|
return contracts.get_fixed_cost_report(month)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_budget_vs_actual_report(month: str = "") -> dict:
|
def get_budget_vs_actual_report(month: str = "") -> dict:
|
||||||
"""Compare monthly budgets against actual spending."""
|
"""Compare monthly budgets against actual spending."""
|
||||||
return service.get_budget_vs_actual_report(month)
|
return contracts.get_budget_vs_actual_report(month)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0) -> dict:
|
def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0) -> dict:
|
||||||
"""Estimate cashflow available for investing after buffer and planned extra expenses."""
|
"""Estimate cashflow available for investing after buffer and planned extra expenses."""
|
||||||
return service.get_investable_cashflow_report(month, minimum_cash_buffer, planned_extra_expense)
|
return contracts.get_investable_cashflow_report(month, minimum_cash_buffer, planned_extra_expense)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def preview_ledger_csv_import(file_path: str, source: str = "generic") -> dict:
|
def preview_ledger_csv_import(file_path: str, source: str = "generic") -> dict:
|
||||||
"""Preview a UTF-8/UTF-8-BOM CSV import before saving transactions."""
|
"""Preview a UTF-8/UTF-8-BOM CSV import before saving transactions."""
|
||||||
return service.preview_ledger_csv_import(file_path, source)
|
return contracts.preview_ledger_csv_import(file_path, source)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20) -> dict:
|
def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20) -> dict:
|
||||||
"""Preview transactions from a password-protected KB Kookmin Bank account-history PDF without saving them."""
|
"""Preview transactions from a password-protected KB Kookmin Bank account-history PDF without saving them."""
|
||||||
return service.preview_kb_bank_pdf_import(file_path, password, preview_limit)
|
return contracts.preview_kb_bank_pdf_import(file_path, password, preview_limit)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip_duplicates: bool = True, skip_validation: bool = False) -> dict:
|
def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip_duplicates: bool = True, skip_validation: bool = False) -> dict:
|
||||||
"""Import transactions from a KB Kookmin Bank account-history PDF into the ledger database."""
|
"""Import transactions from a KB Kookmin Bank account-history PDF into the ledger database."""
|
||||||
return service.import_kb_bank_pdf(file_path, password, account_id, skip_duplicates, skip_validation=skip_validation)
|
return contracts.import_kb_bank_pdf(file_path, password, account_id, skip_duplicates, skip_validation=skip_validation)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20) -> dict:
|
def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20) -> dict:
|
||||||
"""Preview transactions from a SeoulPay usage-history XLSX before saving them."""
|
"""Preview transactions from a SeoulPay usage-history XLSX before saving them."""
|
||||||
return service.preview_seoulpay_xlsx_import(file_path, password, preview_limit)
|
return contracts.preview_seoulpay_xlsx_import(file_path, password, preview_limit)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "", skip_duplicates: bool = True) -> dict:
|
def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "", skip_duplicates: bool = True) -> dict:
|
||||||
"""Import transactions from a SeoulPay usage-history XLSX into the ledger database."""
|
"""Import transactions from a SeoulPay usage-history XLSX into the ledger database."""
|
||||||
return service.import_seoulpay_xlsx(file_path, password, account_id, skip_duplicates)
|
return contracts.import_seoulpay_xlsx(file_path, password, account_id, skip_duplicates)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20) -> dict:
|
def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20) -> dict:
|
||||||
"""Preview transactions from a Toss Securities transaction-history PDF without saving them."""
|
"""Preview transactions from a Toss Securities transaction-history PDF without saving them."""
|
||||||
return service.preview_toss_securities_pdf_import(file_path, preview_limit)
|
return contracts.preview_toss_securities_pdf_import(file_path, preview_limit)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict:
|
def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict:
|
||||||
"""Import transactions from a Toss Securities transaction-history PDF into the ledger database."""
|
"""Import transactions from a Toss Securities transaction-history PDF into the ledger database."""
|
||||||
return service.import_toss_securities_pdf(file_path, account_id, skip_duplicates)
|
return contracts.import_toss_securities_pdf(file_path, account_id, skip_duplicates)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20) -> dict:
|
def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20) -> dict:
|
||||||
"""Preview transactions from a Toss Bank transaction-history PDF without saving them."""
|
"""Preview transactions from a Toss Bank transaction-history PDF without saving them."""
|
||||||
return service.preview_toss_bank_pdf_import(file_path, preview_limit)
|
return contracts.preview_toss_bank_pdf_import(file_path, preview_limit)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict:
|
def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict:
|
||||||
"""Import transactions from a Toss Bank transaction-history PDF into the ledger database."""
|
"""Import transactions from a Toss Bank transaction-history PDF into the ledger database."""
|
||||||
return service.import_toss_bank_pdf(file_path, account_id, skip_duplicates)
|
return contracts.import_toss_bank_pdf(file_path, account_id, skip_duplicates)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50) -> dict:
|
def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50) -> dict:
|
||||||
"""List CSV/PDF/API import batches."""
|
"""List CSV/PDF/API import batches."""
|
||||||
return service.list_ledger_import_batches(source, include_rolled_back, limit)
|
return contracts.list_ledger_import_batches(source, include_rolled_back, limit)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def rollback_ledger_import_batch(import_batch_id: str, hard_delete: bool = False) -> dict:
|
def rollback_ledger_import_batch(import_batch_id: str, hard_delete: bool = False) -> dict:
|
||||||
"""Roll back all transactions created by one import batch. Soft-deletes by default."""
|
"""Roll back all transactions created by one import batch. Soft-deletes by default."""
|
||||||
return service.rollback_ledger_import_batch(import_batch_id, hard_delete)
|
return contracts.rollback_ledger_import_batch(import_batch_id, hard_delete)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def create_category_rule(match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None) -> dict:
|
def create_category_rule(match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None) -> dict:
|
||||||
"""Create or update a ledger transaction categorization rule."""
|
"""Create or update a ledger transaction categorization rule."""
|
||||||
return service.create_category_rule(match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month)
|
return contracts.create_category_rule(match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def seed_default_category_rules(overwrite: bool = False) -> dict:
|
def seed_default_category_rules(overwrite: bool = False) -> dict:
|
||||||
"""Seed default Jarca category rules for common Korean household ledger patterns."""
|
"""Seed default Jarca category rules for common Korean household ledger patterns."""
|
||||||
return service.seed_default_category_rules(overwrite)
|
return contracts.seed_default_category_rules(overwrite)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def list_category_rules(active_only: bool = True) -> dict:
|
def list_category_rules(active_only: bool = True) -> dict:
|
||||||
"""List transaction categorization rules."""
|
"""List transaction categorization rules."""
|
||||||
return service.list_category_rules(active_only)
|
return contracts.list_category_rules(active_only)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def update_category_rule(rule_id: str, match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, active: bool = True) -> dict:
|
def update_category_rule(rule_id: str, match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, active: bool = True) -> dict:
|
||||||
"""Update an existing ledger transaction categorization rule."""
|
"""Update an existing ledger transaction categorization rule."""
|
||||||
return service.update_category_rule(rule_id, match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month, active)
|
return contracts.update_category_rule(rule_id, match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month, active)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def delete_category_rule(rule_id: str) -> dict:
|
def delete_category_rule(rule_id: str) -> dict:
|
||||||
"""Delete a transaction categorization rule."""
|
"""Delete a transaction categorization rule."""
|
||||||
return service.delete_category_rule(rule_id)
|
return contracts.delete_category_rule(rule_id)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def preview_categorization(limit: int = 1000, only_uncategorized: bool = True) -> dict:
|
def preview_categorization(limit: int = 1000, only_uncategorized: bool = True) -> dict:
|
||||||
"""Preview category-rule matches before writing categories to transactions."""
|
"""Preview category-rule matches before writing categories to transactions."""
|
||||||
return service.preview_categorization(limit, only_uncategorized)
|
return contracts.preview_categorization(limit, only_uncategorized)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True) -> dict:
|
def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True) -> dict:
|
||||||
"""Apply active category rules to matching ledger transactions."""
|
"""Apply active category rules to matching ledger transactions."""
|
||||||
return service.apply_categorization_rules(limit, only_uncategorized)
|
return contracts.apply_categorization_rules(limit, only_uncategorized)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0) -> dict:
|
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0) -> dict:
|
||||||
"""List active transactions that still have no category."""
|
"""List active transactions that still have no category."""
|
||||||
return service.list_uncategorized_transactions(limit, min_amount)
|
return contracts.list_uncategorized_transactions(limit, min_amount)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_uncategorized_merchant_summary(limit: int = 50) -> dict:
|
def get_uncategorized_merchant_summary(limit: int = 50) -> dict:
|
||||||
"""Summarize uncategorized transactions by merchant/raw_summary/type for user review."""
|
"""Summarize uncategorized transactions by merchant/raw_summary/type for user review."""
|
||||||
return service.get_uncategorized_merchant_summary(limit)
|
return contracts.get_uncategorized_merchant_summary(limit)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def create_ledger_category(category: str, subcategory: str = "", sort_order: int = 100, memo: str = "", cashflow_kind: str = "") -> dict:
|
def create_ledger_category(category: str, subcategory: str = "", sort_order: int = 100, memo: str = "", cashflow_kind: str = "") -> dict:
|
||||||
"""Create or reactivate a ledger category/subcategory used by the dashboard and transactions."""
|
"""Create or reactivate a ledger category/subcategory used by the dashboard and transactions."""
|
||||||
return service.create_ledger_category(category, subcategory, sort_order, memo, cashflow_kind)
|
return contracts.create_ledger_category(category, subcategory, sort_order, memo, cashflow_kind)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def seed_default_ledger_categories(overwrite: bool = False) -> dict:
|
def seed_default_ledger_categories(overwrite: bool = False) -> dict:
|
||||||
"""Seed default ledger categories and subcategories."""
|
"""Seed default ledger categories and subcategories."""
|
||||||
return service.seed_default_ledger_categories(overwrite)
|
return contracts.seed_default_ledger_categories(overwrite)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def list_ledger_categories(active_only: bool = True, include_usage: bool = True) -> dict:
|
def list_ledger_categories(active_only: bool = True, include_usage: bool = True) -> dict:
|
||||||
"""List ledger categories and subcategories, optionally with usage counts."""
|
"""List ledger categories and subcategories, optionally with usage counts."""
|
||||||
return service.list_ledger_categories(active_only, include_usage)
|
return contracts.list_ledger_categories(active_only, include_usage)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def list_cross_transfer_review_queue(limit: int = 300) -> dict:
|
def list_cross_transfer_review_queue(limit: int = 300) -> dict:
|
||||||
"""List KB↔토스 계좌 이체 확인필요 큐 항목."""
|
"""List KB↔토스 계좌 이체 확인필요 큐 항목."""
|
||||||
return service.list_cross_transfer_review_queue(limit)
|
return contracts.list_cross_transfer_review_queue(limit)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def preview_cross_account_transfers(limit: int = 500) -> dict:
|
def preview_cross_account_transfers(limit: int = 500) -> dict:
|
||||||
"""Preview KB↔토스은행 이체 매칭 후보를 반환합니다."""
|
"""Preview KB↔토스은행 이체 매칭 후보를 반환합니다."""
|
||||||
return service.preview_cross_account_transfers(limit)
|
return contracts.preview_cross_account_transfers(limit)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def run_cross_account_transfer_match(limit: int = 500) -> dict:
|
def run_cross_account_transfer_match(limit: int = 500) -> dict:
|
||||||
"""Run KB↔토스은행 자동 이체 매칭 및 태그/분류 반영을 수행합니다."""
|
"""Run KB↔토스은행 자동 이체 매칭 및 태그/분류 반영을 수행합니다."""
|
||||||
return service.run_cross_account_transfer_match(limit)
|
return contracts.run_cross_account_transfer_match(limit)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False) -> dict:
|
def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False) -> dict:
|
||||||
"""Deactivate or delete a ledger category/subcategory."""
|
"""Deactivate or delete a ledger category/subcategory."""
|
||||||
return service.delete_ledger_category(category, subcategory, hard_delete, force)
|
return contracts.delete_ledger_category(category, subcategory, hard_delete, force)
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
mcp.run()
|
mcp.run()
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...service import (
|
||||||
|
apply_categorization_rules,
|
||||||
|
close_asset_snapshot,
|
||||||
|
create_category_rule,
|
||||||
|
create_ledger_account,
|
||||||
|
create_ledger_backup,
|
||||||
|
create_ledger_budget,
|
||||||
|
create_ledger_category,
|
||||||
|
create_ledger_transaction,
|
||||||
|
create_manual_asset,
|
||||||
|
deactivate_manual_asset,
|
||||||
|
delete_category_rule,
|
||||||
|
delete_ledger_category,
|
||||||
|
delete_ledger_transaction,
|
||||||
|
get_asset_liquidity_report,
|
||||||
|
get_asset_snapshot_report,
|
||||||
|
get_balance_reconciliation_report,
|
||||||
|
get_budget_vs_actual_report,
|
||||||
|
get_category_spending_report,
|
||||||
|
get_fixed_cost_report,
|
||||||
|
get_investable_cashflow_report,
|
||||||
|
get_ledger_backup_status,
|
||||||
|
get_ledger_db_status,
|
||||||
|
get_monthly_cashflow_report,
|
||||||
|
get_net_worth_timeseries,
|
||||||
|
health_check,
|
||||||
|
import_kb_bank_pdf,
|
||||||
|
import_seoulpay_xlsx,
|
||||||
|
import_toss_bank_pdf,
|
||||||
|
import_toss_securities_pdf,
|
||||||
|
list_category_rules,
|
||||||
|
list_cross_transfer_review_queue,
|
||||||
|
list_ledger_accounts,
|
||||||
|
list_ledger_backups,
|
||||||
|
list_ledger_budgets,
|
||||||
|
list_ledger_categories,
|
||||||
|
list_ledger_import_batches,
|
||||||
|
list_ledger_transactions,
|
||||||
|
list_manual_assets,
|
||||||
|
list_uncategorized_transactions,
|
||||||
|
preview_asset_snapshot_close,
|
||||||
|
preview_categorization,
|
||||||
|
preview_cross_account_transfers,
|
||||||
|
preview_kb_bank_pdf_import,
|
||||||
|
preview_ledger_csv_import,
|
||||||
|
preview_seoulpay_xlsx_import,
|
||||||
|
preview_toss_bank_pdf_import,
|
||||||
|
preview_toss_securities_pdf_import,
|
||||||
|
rollback_ledger_import_batch,
|
||||||
|
run_cross_account_transfer_match,
|
||||||
|
seed_default_category_rules,
|
||||||
|
seed_default_ledger_categories,
|
||||||
|
update_category_rule,
|
||||||
|
update_ledger_account,
|
||||||
|
update_ledger_transaction,
|
||||||
|
update_manual_asset,
|
||||||
|
get_uncategorized_merchant_summary,
|
||||||
|
)
|
||||||
@@ -1 +1,11 @@
|
|||||||
from . import service_gateway
|
from . import (
|
||||||
|
accounts_gateway,
|
||||||
|
assets_gateway,
|
||||||
|
categorization_gateway,
|
||||||
|
imports_gateway,
|
||||||
|
overview_gateway,
|
||||||
|
reconciliation_gateway,
|
||||||
|
service_gateway,
|
||||||
|
transactions_gateway,
|
||||||
|
transfers_gateway,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...service import (
|
||||||
|
list_ledger_accounts,
|
||||||
|
update_ledger_account,
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...service import (
|
||||||
|
close_asset_snapshot,
|
||||||
|
create_manual_asset,
|
||||||
|
deactivate_manual_asset,
|
||||||
|
list_manual_assets,
|
||||||
|
preview_asset_snapshot_close,
|
||||||
|
update_manual_asset,
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...service import (
|
||||||
|
apply_categorization_rules,
|
||||||
|
apply_category_rule,
|
||||||
|
apply_uncategorized_group_recommendation,
|
||||||
|
apply_uncategorized_recommendation,
|
||||||
|
create_category_rule,
|
||||||
|
create_ledger_category,
|
||||||
|
delete_ledger_category,
|
||||||
|
get_uncategorized_merchant_summary,
|
||||||
|
get_uncategorized_workbench,
|
||||||
|
list_category_rules,
|
||||||
|
list_ledger_categories,
|
||||||
|
list_uncategorized_transactions,
|
||||||
|
preview_categorization,
|
||||||
|
preview_category_rule_matches,
|
||||||
|
seed_default_category_rules,
|
||||||
|
seed_default_ledger_categories,
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...service import (
|
||||||
|
import_toss_bank_pdf,
|
||||||
|
import_toss_securities_pdf,
|
||||||
|
list_ledger_import_batches,
|
||||||
|
preview_toss_bank_pdf_import,
|
||||||
|
preview_toss_securities_pdf_import,
|
||||||
|
rollback_ledger_import_batch,
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...service import (
|
||||||
|
get_asset_liquidity_report,
|
||||||
|
get_balance_reconciliation_report,
|
||||||
|
get_budget_vs_actual_report,
|
||||||
|
get_monthly_cashflow_report,
|
||||||
|
health_check,
|
||||||
|
list_cross_transfer_review_queue,
|
||||||
|
list_ledger_backups,
|
||||||
|
list_ledger_import_batches,
|
||||||
|
list_uncategorized_transactions,
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...service import get_balance_reconciliation_report
|
||||||
@@ -1,44 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ...service import (
|
from .accounts_gateway import *
|
||||||
apply_categorization_rules,
|
from .assets_gateway import *
|
||||||
apply_uncategorized_group_recommendation,
|
from .categorization_gateway import *
|
||||||
apply_uncategorized_recommendation,
|
from .imports_gateway import *
|
||||||
close_asset_snapshot,
|
from .overview_gateway import *
|
||||||
create_category_rule,
|
from .reconciliation_gateway import *
|
||||||
create_ledger_category,
|
from .transactions_gateway import *
|
||||||
create_manual_asset,
|
from .transfers_gateway import *
|
||||||
deactivate_manual_asset,
|
|
||||||
delete_ledger_category,
|
|
||||||
find_cancel_original_candidates,
|
|
||||||
find_self_transfer_candidates,
|
|
||||||
get_asset_liquidity_report,
|
|
||||||
get_balance_reconciliation_report,
|
|
||||||
get_budget_vs_actual_report,
|
|
||||||
get_monthly_cashflow_report,
|
|
||||||
health_check,
|
|
||||||
import_toss_bank_pdf,
|
|
||||||
import_toss_securities_pdf,
|
|
||||||
link_cancel_to_original,
|
|
||||||
link_self_transfer_pair,
|
|
||||||
list_category_rules,
|
|
||||||
list_cross_transfer_review_queue,
|
|
||||||
list_ledger_accounts,
|
|
||||||
list_ledger_backups,
|
|
||||||
list_ledger_categories,
|
|
||||||
list_ledger_import_batches,
|
|
||||||
list_manual_assets,
|
|
||||||
list_uncategorized_transactions,
|
|
||||||
preview_asset_snapshot_close,
|
|
||||||
preview_categorization,
|
|
||||||
preview_cross_account_transfers,
|
|
||||||
preview_toss_bank_pdf_import,
|
|
||||||
preview_toss_securities_pdf_import,
|
|
||||||
rollback_ledger_import_batch,
|
|
||||||
run_cross_account_transfer_match,
|
|
||||||
seed_default_category_rules,
|
|
||||||
seed_default_ledger_categories,
|
|
||||||
update_ledger_account,
|
|
||||||
update_ledger_transaction,
|
|
||||||
update_manual_asset,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...service import update_ledger_transaction
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...service import (
|
||||||
|
find_cancel_original_candidates,
|
||||||
|
find_self_transfer_candidates,
|
||||||
|
link_cancel_to_original,
|
||||||
|
link_self_transfer_pair,
|
||||||
|
list_cross_transfer_review_queue,
|
||||||
|
preview_cross_account_transfers,
|
||||||
|
run_cross_account_transfer_match,
|
||||||
|
)
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
from . import shared
|
from . import shared
|
||||||
from .accounts_write_services import *
|
from .accounts_write_services import *
|
||||||
from .asset_account_services import *
|
from .asset_account_services import *
|
||||||
|
from .assets_service import *
|
||||||
from .budgets_write_services import *
|
from .budgets_write_services import *
|
||||||
from .category_rules_write_services import *
|
from .category_rules_write_services import *
|
||||||
from .categorization_services import *
|
from .categorization_services import *
|
||||||
|
from .cross_transfer_service import *
|
||||||
|
from .imports_service import *
|
||||||
from .import_transfer_services import *
|
from .import_transfer_services import *
|
||||||
|
from .reconciliation_service import *
|
||||||
from .system_services import *
|
from .system_services import *
|
||||||
from .transactions_write_services import *
|
from .transactions_write_services import *
|
||||||
from .transaction_report_services import *
|
from .transaction_report_services import *
|
||||||
|
from .uncategorized_workbench_service import *
|
||||||
|
|||||||
@@ -1,493 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .assets_service import (
|
||||||
|
close_asset_snapshot,
|
||||||
def _valuation_policy() -> dict[str, Any]:
|
create_asset_snapshot,
|
||||||
return {
|
create_manual_asset,
|
||||||
"authority_sources": ["accounts.last_balance", "manual_assets"],
|
deactivate_manual_asset,
|
||||||
"snapshot_role": "historical_record_layer",
|
get_asset_liquidity_report,
|
||||||
"formula": "asset_buckets - loan",
|
get_asset_snapshot_report,
|
||||||
"asset_buckets": ASSET_BUCKETS,
|
get_net_worth_timeseries,
|
||||||
"liability_buckets": LIABILITY_BUCKETS,
|
list_manual_assets,
|
||||||
"missing_valuation_policy": "exclude_without_transaction_inference",
|
preview_asset_snapshot_close,
|
||||||
}
|
update_manual_asset,
|
||||||
|
)
|
||||||
|
|
||||||
def _account_valuation_items(conn: sqlite3.Connection) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
|
||||||
rows = conn.execute("SELECT * FROM accounts WHERE active=1 ORDER BY name").fetchall()
|
|
||||||
included: list[dict[str, Any]] = []
|
|
||||||
excluded: list[dict[str, Any]] = []
|
|
||||||
invalid: list[dict[str, Any]] = []
|
|
||||||
for row in rows:
|
|
||||||
account = row_to_dict(row)
|
|
||||||
raw_bucket = str(account.get("liquidity_bucket") or "other")
|
|
||||||
try:
|
|
||||||
bucket = normalize_liquidity_bucket(raw_bucket)
|
|
||||||
except ValueError:
|
|
||||||
bucket = "other"
|
|
||||||
invalid.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": raw_bucket})
|
|
||||||
base = {
|
|
||||||
"id": account.get("id", ""),
|
|
||||||
"source_id": account.get("id", ""),
|
|
||||||
"source_type": "account",
|
|
||||||
"authority_source": "accounts.last_balance",
|
|
||||||
"name": account.get("name", ""),
|
|
||||||
"type": account.get("type", ""),
|
|
||||||
"liquidity_bucket": bucket,
|
|
||||||
"valuation_date": account.get("last_synced_at", ""),
|
|
||||||
"last_synced_at": account.get("last_synced_at", ""),
|
|
||||||
"source_note": account.get("memo", ""),
|
|
||||||
"memo": account.get("memo", ""),
|
|
||||||
}
|
|
||||||
balance = account.get("last_balance")
|
|
||||||
if balance is None:
|
|
||||||
excluded.append({**base, "reason": "missing_last_balance"})
|
|
||||||
continue
|
|
||||||
amount = int(balance)
|
|
||||||
included.append({**base, "amount": amount, "last_balance": amount})
|
|
||||||
return included, excluded, invalid
|
|
||||||
|
|
||||||
|
|
||||||
def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = True) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
||||||
where = "WHERE active=1" if active_only else ""
|
|
||||||
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
|
|
||||||
included: list[dict[str, Any]] = []
|
|
||||||
excluded: list[dict[str, Any]] = []
|
|
||||||
for row in rows:
|
|
||||||
asset = row_to_dict(row)
|
|
||||||
try:
|
|
||||||
bucket = normalize_liquidity_bucket(asset.get("liquidity_bucket"))
|
|
||||||
except ValueError:
|
|
||||||
bucket = "other"
|
|
||||||
base = {
|
|
||||||
"id": asset.get("id", ""),
|
|
||||||
"source_id": asset.get("id", ""),
|
|
||||||
"source_type": "manual_asset",
|
|
||||||
"authority_source": "manual_assets",
|
|
||||||
"name": asset.get("name", ""),
|
|
||||||
"liquidity_bucket": bucket,
|
|
||||||
"valuation_date": asset.get("valuation_date", ""),
|
|
||||||
"source_note": asset.get("source_note", ""),
|
|
||||||
"memo": asset.get("memo", ""),
|
|
||||||
"active": asset.get("active", True),
|
|
||||||
}
|
|
||||||
value = asset.get("valuation_amount")
|
|
||||||
if value is None:
|
|
||||||
excluded.append({**base, "reason": "missing_valuation_amount"})
|
|
||||||
continue
|
|
||||||
included.append({**base, "amount": int(value), "valuation_amount": int(value)})
|
|
||||||
return included, excluded
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC",
|
|
||||||
(basis_date,),
|
|
||||||
).fetchall()
|
|
||||||
if not rows:
|
|
||||||
return [], {}
|
|
||||||
run_rows = [row for row in rows if "snapshot_run_id" in row.keys() and row["snapshot_run_id"]]
|
|
||||||
if not run_rows:
|
|
||||||
return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)}
|
|
||||||
latest = sorted(run_rows, key=lambda row: (int(row["_rowid"]), row["closed_at"] or row["created_at"] or "", row["snapshot_run_id"] or ""))[-1]
|
|
||||||
latest_run_id = latest["snapshot_run_id"]
|
|
||||||
selected = [row for row in run_rows if row["snapshot_run_id"] == latest_run_id]
|
|
||||||
metadata = {
|
|
||||||
"snapshot_run_id": latest_run_id,
|
|
||||||
"is_correction": bool(latest["is_correction"]),
|
|
||||||
"correction_of_run_id": latest["correction_of_run_id"] or "",
|
|
||||||
"correction_reason": latest["correction_reason"] or "",
|
|
||||||
"closed_at": latest["closed_at"] or latest["created_at"] or "",
|
|
||||||
"total_snapshot_rows_for_basis_date": len(rows),
|
|
||||||
}
|
|
||||||
return selected, metadata
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]:
|
|
||||||
rows, _metadata = _snapshot_rows_for_latest_run(conn, basis_date)
|
|
||||||
items: list[dict[str, Any]] = []
|
|
||||||
for row in rows:
|
|
||||||
snap = row_to_dict(row)
|
|
||||||
try:
|
|
||||||
bucket = normalize_liquidity_bucket(snap.get("liquidity_bucket"))
|
|
||||||
except ValueError:
|
|
||||||
bucket = "other"
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"id": snap.get("id", ""),
|
|
||||||
"source_id": snap.get("source_id", ""),
|
|
||||||
"source_type": snap.get("source_type", ""),
|
|
||||||
"authority_source": "asset_snapshots",
|
|
||||||
"valuation_source": snap.get("valuation_source", ""),
|
|
||||||
"name": snap.get("name", ""),
|
|
||||||
"liquidity_bucket": bucket,
|
|
||||||
"amount": int(snap.get("amount") or 0),
|
|
||||||
"valuation_date": snap.get("basis_date", ""),
|
|
||||||
"source_note": snap.get("source_note", ""),
|
|
||||||
"memo": snap.get("memo", ""),
|
|
||||||
"snapshot_run_id": snap.get("snapshot_run_id", ""),
|
|
||||||
"is_correction": bool(snap.get("is_correction", False)),
|
|
||||||
"correction_of_run_id": snap.get("correction_of_run_id", ""),
|
|
||||||
"correction_reason": snap.get("correction_reason", ""),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_items: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
||||||
bucket_totals = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
|
|
||||||
bucket_counts = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
|
|
||||||
for item in included_items:
|
|
||||||
bucket = normalize_liquidity_bucket(item.get("liquidity_bucket"))
|
|
||||||
amount = int(item.get("amount") or 0)
|
|
||||||
bucket_counts[bucket] += 1
|
|
||||||
bucket_totals[bucket] += amount
|
|
||||||
total_assets = sum(bucket_totals[bucket] for bucket in ASSET_BUCKETS)
|
|
||||||
total_liabilities = sum(abs(bucket_totals[bucket]) for bucket in LIABILITY_BUCKETS)
|
|
||||||
net_worth = total_assets - total_liabilities
|
|
||||||
buckets = []
|
|
||||||
for bucket in ASSET_BUCKET_ORDER:
|
|
||||||
amount = bucket_totals[bucket]
|
|
||||||
denominator = total_liabilities if bucket in LIABILITY_BUCKETS else total_assets
|
|
||||||
buckets.append(
|
|
||||||
{
|
|
||||||
"liquidity_bucket": bucket,
|
|
||||||
"amount": amount,
|
|
||||||
"account_count": sum(1 for item in included_items if item.get("source_type") == "account" and item.get("liquidity_bucket") == bucket),
|
|
||||||
"manual_asset_count": sum(1 for item in included_items if item.get("source_type") == "manual_asset" and item.get("liquidity_bucket") == bucket),
|
|
||||||
"item_count": bucket_counts[bucket],
|
|
||||||
"asset_share": round(amount / total_assets, 4) if bucket not in LIABILITY_BUCKETS and total_assets else None,
|
|
||||||
"liability_share": round(abs(amount) / total_liabilities, 4) if bucket in LIABILITY_BUCKETS and total_liabilities else None,
|
|
||||||
"share": round(abs(amount) / denominator, 4) if denominator else None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"buckets": buckets,
|
|
||||||
"bucket_totals": bucket_totals,
|
|
||||||
"total_assets": total_assets,
|
|
||||||
"total_liabilities": total_liabilities,
|
|
||||||
"net_worth": net_worth,
|
|
||||||
"included_items": included_items,
|
|
||||||
"excluded_items": excluded_items or [],
|
|
||||||
"valuation_policy": _valuation_policy(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def create_manual_asset(name: str, valuation_amount: int | None, valuation_date: str, liquidity_bucket: str, source_note: str = "", memo: str = "", currency: str = "KRW", active: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
|
||||||
if not name.strip():
|
|
||||||
return fail("validation_error", "manual asset name is required")
|
|
||||||
try:
|
|
||||||
bucket = normalize_liquidity_bucket(liquidity_bucket)
|
|
||||||
amount = int(valuation_amount) if valuation_amount is not None else None
|
|
||||||
vdate = normalize_date(valuation_date, default_today=True)
|
|
||||||
except Exception as e:
|
|
||||||
return fail("validation_error", str(e))
|
|
||||||
ts = now_iso()
|
|
||||||
asset_id = new_id("asset")
|
|
||||||
with db(db_path) as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""INSERT INTO manual_assets(id,name,liquidity_bucket,valuation_amount,valuation_date,currency,source_note,memo,active,created_at,updated_at)
|
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)""",
|
|
||||||
(asset_id, name.strip(), bucket, amount, vdate, currency, source_note, memo, int(active), ts, ts),
|
|
||||||
)
|
|
||||||
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
|
||||||
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
|
|
||||||
|
|
||||||
|
|
||||||
def list_manual_assets(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
|
||||||
where = "WHERE active=1" if active_only else ""
|
|
||||||
with db(db_path) as conn:
|
|
||||||
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
|
|
||||||
assets = [{**row_to_dict(row), "authority_source": "manual_assets"} for row in rows]
|
|
||||||
return ok(manual_assets=assets, count=len(assets), valuation_policy=_valuation_policy())
|
|
||||||
|
|
||||||
|
|
||||||
def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None, db_path: str | None = None) -> dict[str, Any]:
|
|
||||||
allowed_updates: list[str] = []
|
|
||||||
params: list[Any] = []
|
|
||||||
try:
|
|
||||||
if name is not None:
|
|
||||||
if not name.strip():
|
|
||||||
return fail("validation_error", "manual asset name is required")
|
|
||||||
allowed_updates.append("name=?")
|
|
||||||
params.append(name.strip())
|
|
||||||
if valuation_amount is not None:
|
|
||||||
allowed_updates.append("valuation_amount=?")
|
|
||||||
params.append(int(valuation_amount))
|
|
||||||
if valuation_date is not None:
|
|
||||||
allowed_updates.append("valuation_date=?")
|
|
||||||
params.append(normalize_date(valuation_date, default_today=True))
|
|
||||||
if liquidity_bucket is not None:
|
|
||||||
allowed_updates.append("liquidity_bucket=?")
|
|
||||||
params.append(normalize_liquidity_bucket(liquidity_bucket))
|
|
||||||
if source_note is not None:
|
|
||||||
allowed_updates.append("source_note=?")
|
|
||||||
params.append(source_note)
|
|
||||||
if memo is not None:
|
|
||||||
allowed_updates.append("memo=?")
|
|
||||||
params.append(memo)
|
|
||||||
if currency is not None:
|
|
||||||
allowed_updates.append("currency=?")
|
|
||||||
params.append(currency or "KRW")
|
|
||||||
if active is not None:
|
|
||||||
allowed_updates.append("active=?")
|
|
||||||
params.append(int(bool(active)))
|
|
||||||
except Exception as e:
|
|
||||||
return fail("validation_error", str(e))
|
|
||||||
if not allowed_updates:
|
|
||||||
return fail("validation_error", "no fields to update")
|
|
||||||
allowed_updates.append("updated_at=?")
|
|
||||||
params.extend([now_iso(), asset_id])
|
|
||||||
with db(db_path) as conn:
|
|
||||||
cur = conn.execute(f"UPDATE manual_assets SET {', '.join(allowed_updates)} WHERE id=?", params)
|
|
||||||
if cur.rowcount == 0:
|
|
||||||
return fail("not_found", "manual asset not found")
|
|
||||||
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
|
||||||
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
|
|
||||||
|
|
||||||
|
|
||||||
def deactivate_manual_asset(asset_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]:
|
|
||||||
with db(db_path) as conn:
|
|
||||||
if hard_delete:
|
|
||||||
cur = conn.execute("DELETE FROM manual_assets WHERE id=?", (asset_id,))
|
|
||||||
else:
|
|
||||||
cur = conn.execute("UPDATE manual_assets SET active=0, updated_at=? WHERE id=?", (now_iso(), asset_id))
|
|
||||||
if cur.rowcount == 0:
|
|
||||||
return fail("not_found", "manual asset not found")
|
|
||||||
row = None if hard_delete else conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
|
||||||
payload = {"manual_asset_id": asset_id, "deactivated": True, "hard_delete": hard_delete}
|
|
||||||
if row is not None:
|
|
||||||
payload["manual_asset"] = {**row_to_dict(row), "authority_source": "manual_assets"}
|
|
||||||
return ok(**payload)
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str) -> dict[str, Any]:
|
|
||||||
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
|
|
||||||
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
|
|
||||||
included = account_items + manual_items
|
|
||||||
excluded = account_excluded + manual_excluded
|
|
||||||
summary = _summarize_valuation_items(included, excluded)
|
|
||||||
existing_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis,)).fetchone()[0]
|
|
||||||
warnings: list[dict[str, Any]] = []
|
|
||||||
for item in excluded:
|
|
||||||
warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", "")})
|
|
||||||
for item in included:
|
|
||||||
item_date = str(item.get("valuation_date") or "")[:10]
|
|
||||||
if item_date and item_date < basis:
|
|
||||||
warnings.append({"code": "stale_valuation", "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", ""), "valuation_date": item_date, "basis_date": basis})
|
|
||||||
for item in invalid_bucket_accounts:
|
|
||||||
warnings.append({"code": "invalid_liquidity_bucket", **item})
|
|
||||||
if existing_rows:
|
|
||||||
warnings.append({"code": "existing_snapshot_rows", "basis_date": basis, "snapshot_row_count": int(existing_rows), "policy": "append_only_correction_required"})
|
|
||||||
return {
|
|
||||||
"basis_date": basis,
|
|
||||||
"source": "live_authority",
|
|
||||||
"is_closed_snapshot": False,
|
|
||||||
"snapshot_count": 0,
|
|
||||||
"existing_snapshot_row_count": int(existing_rows),
|
|
||||||
"would_be_correction": bool(existing_rows),
|
|
||||||
"warnings": warnings,
|
|
||||||
**summary,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
|
||||||
except Exception as e:
|
|
||||||
return fail("validation_error", str(e))
|
|
||||||
with db(db_path) as conn:
|
|
||||||
preview = _snapshot_preview_from_conn(conn, basis)
|
|
||||||
return ok(**preview)
|
|
||||||
|
|
||||||
|
|
||||||
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
|
||||||
except Exception as e:
|
|
||||||
return fail("validation_error", str(e))
|
|
||||||
ts = now_iso()
|
|
||||||
run_id = new_id("snaprun")
|
|
||||||
with db(db_path) as conn:
|
|
||||||
preview = _snapshot_preview_from_conn(conn, basis)
|
|
||||||
included = preview["included_items"]
|
|
||||||
latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis)
|
|
||||||
is_correction = bool(preview["existing_snapshot_row_count"])
|
|
||||||
if is_correction and not correction_reason.strip():
|
|
||||||
return fail("validation_error", "correction_reason is required when closing a basis_date that already has snapshot rows", basis_date=basis, existing_snapshot_row_count=preview["existing_snapshot_row_count"])
|
|
||||||
correction_of_run_id = latest_meta.get("snapshot_run_id", "") if latest_rows else ""
|
|
||||||
snapshot_ids: list[str] = []
|
|
||||||
for item in included:
|
|
||||||
snapshot_id = new_id("snap")
|
|
||||||
row_note = source_note or item.get("source_note", "") or "month-end close"
|
|
||||||
row_memo = memo or item.get("memo", "")
|
|
||||||
conn.execute(
|
|
||||||
"""INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at)
|
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
||||||
(
|
|
||||||
snapshot_id,
|
|
||||||
basis,
|
|
||||||
item.get("source_type", ""),
|
|
||||||
item.get("source_id", item.get("id", "")),
|
|
||||||
item.get("name", ""),
|
|
||||||
int(item.get("amount") or 0),
|
|
||||||
item.get("currency", "KRW"),
|
|
||||||
normalize_liquidity_bucket(item.get("liquidity_bucket")),
|
|
||||||
item.get("authority_source", item.get("source_type", "")),
|
|
||||||
row_note,
|
|
||||||
row_memo,
|
|
||||||
run_id,
|
|
||||||
correction_of_run_id,
|
|
||||||
correction_reason.strip(),
|
|
||||||
int(is_correction),
|
|
||||||
ts,
|
|
||||||
ts,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
snapshot_ids.append(snapshot_id)
|
|
||||||
report_items = _snapshot_items(conn, basis)
|
|
||||||
summary = _summarize_valuation_items(report_items, [])
|
|
||||||
return ok(
|
|
||||||
basis_date=basis,
|
|
||||||
source="snapshot",
|
|
||||||
is_closed_snapshot=True,
|
|
||||||
snapshot_run_id=run_id,
|
|
||||||
correction_of_run_id=correction_of_run_id,
|
|
||||||
correction_reason=correction_reason.strip(),
|
|
||||||
is_correction=is_correction,
|
|
||||||
closed_at=ts,
|
|
||||||
inserted_count=len(snapshot_ids),
|
|
||||||
inserted_snapshot_ids=snapshot_ids,
|
|
||||||
warnings=preview["warnings"],
|
|
||||||
**summary,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, name: str, amount: int | None, liquidity_bucket: str, valuation_source: str = "", source_note: str = "", memo: str = "", currency: str = "KRW", db_path: str | None = None) -> dict[str, Any]:
|
|
||||||
if source_type not in {"account", "manual_asset"}:
|
|
||||||
return fail("validation_error", "source_type must be one of ['account', 'manual_asset']")
|
|
||||||
if not name.strip():
|
|
||||||
return fail("validation_error", "snapshot item name is required")
|
|
||||||
try:
|
|
||||||
basis = normalize_date(basis_date, default_today=True)
|
|
||||||
bucket = normalize_liquidity_bucket(liquidity_bucket)
|
|
||||||
amount_int = int(amount) if amount is not None else None
|
|
||||||
except Exception as e:
|
|
||||||
return fail("validation_error", str(e))
|
|
||||||
ts = now_iso()
|
|
||||||
snapshot_id = new_id("snap")
|
|
||||||
with db(db_path) as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,created_at)
|
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
||||||
(snapshot_id, basis, source_type, source_id, name.strip(), amount_int, currency, bucket, valuation_source or source_type, source_note, memo, ts),
|
|
||||||
)
|
|
||||||
row = conn.execute("SELECT * FROM asset_snapshots WHERE id=?", (snapshot_id,)).fetchone()
|
|
||||||
return ok(asset_snapshot=row_to_dict(row))
|
|
||||||
|
|
||||||
|
|
||||||
def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]:
|
|
||||||
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
|
||||||
with db(db_path) as conn:
|
|
||||||
rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis)
|
|
||||||
snapshots = _snapshot_items(conn, basis) if rows else []
|
|
||||||
if snapshots:
|
|
||||||
summary = _summarize_valuation_items(snapshots, [])
|
|
||||||
return ok(
|
|
||||||
basis_date=basis,
|
|
||||||
source="asset_snapshots",
|
|
||||||
is_closed_snapshot=True,
|
|
||||||
snapshot_count=len(snapshots),
|
|
||||||
close_metadata=close_metadata,
|
|
||||||
**summary,
|
|
||||||
)
|
|
||||||
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
|
|
||||||
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
|
|
||||||
included = account_items + manual_items
|
|
||||||
excluded = account_excluded + manual_excluded
|
|
||||||
summary = _summarize_valuation_items(included, excluded)
|
|
||||||
return ok(
|
|
||||||
basis_date=basis,
|
|
||||||
source="live_authority",
|
|
||||||
is_closed_snapshot=False,
|
|
||||||
snapshot_count=0,
|
|
||||||
invalid_bucket_accounts=invalid_bucket_accounts,
|
|
||||||
**summary,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
months_int = max(1, min(int(months), 120))
|
|
||||||
end_norm = normalize_date(end_date, default_today=True) if end_date else current_kst_date().isoformat()
|
|
||||||
if start_date:
|
|
||||||
start_norm = normalize_date(start_date)
|
|
||||||
else:
|
|
||||||
end_dt = date.fromisoformat(end_norm)
|
|
||||||
start_month = end_dt.month - months_int + 1
|
|
||||||
year = end_dt.year + (start_month - 1) // 12
|
|
||||||
month = (start_month - 1) % 12 + 1
|
|
||||||
start_norm = date(year, month, 1).isoformat()
|
|
||||||
except Exception as e:
|
|
||||||
return fail("validation_error", str(e))
|
|
||||||
with db(db_path) as conn:
|
|
||||||
basis_rows = conn.execute(
|
|
||||||
"""SELECT DISTINCT basis_date FROM asset_snapshots
|
|
||||||
WHERE basis_date>=? AND basis_date<=?
|
|
||||||
ORDER BY basis_date ASC""",
|
|
||||||
(start_norm, end_norm),
|
|
||||||
).fetchall()
|
|
||||||
points = []
|
|
||||||
for row in basis_rows:
|
|
||||||
basis = row["basis_date"]
|
|
||||||
items = _snapshot_items(conn, basis)
|
|
||||||
summary = _summarize_valuation_items(items, [])
|
|
||||||
points.append(
|
|
||||||
{
|
|
||||||
"basis_date": basis,
|
|
||||||
"total_assets": summary["total_assets"],
|
|
||||||
"total_liabilities": summary["total_liabilities"],
|
|
||||||
"net_worth": summary["net_worth"],
|
|
||||||
"source": "asset_snapshots",
|
|
||||||
"item_count": len(items),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return ok(start_date=start_norm, end_date=end_norm, months=months_int, points=points, count=len(points), valuation_policy=_valuation_policy())
|
|
||||||
|
|
||||||
|
|
||||||
def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
|
|
||||||
"""Summarize assets/liabilities from shared valuation authority sources.
|
|
||||||
|
|
||||||
Existing REQ-005 keys are preserved. When manual_assets is empty, totals and
|
|
||||||
source remain equivalent to the original accounts.last_balance behavior.
|
|
||||||
"""
|
|
||||||
with db(db_path) as conn:
|
|
||||||
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
|
|
||||||
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
|
|
||||||
included = account_items + manual_items
|
|
||||||
excluded = account_excluded + manual_excluded
|
|
||||||
summary = _summarize_valuation_items(included, excluded)
|
|
||||||
source_accounts = [item for item in included if item.get("source_type") == "account"]
|
|
||||||
source = "accounts.last_balance" if not manual_items and not manual_excluded else "accounts.last_balance+manual_assets"
|
|
||||||
return ok(
|
|
||||||
buckets=summary["buckets"],
|
|
||||||
accounts=source_accounts,
|
|
||||||
manual_assets=manual_items,
|
|
||||||
total_assets=summary["total_assets"],
|
|
||||||
total_liabilities=summary["total_liabilities"],
|
|
||||||
net_worth=summary["net_worth"],
|
|
||||||
missing_balance_accounts=[
|
|
||||||
{"id": item.get("id", ""), "name": item.get("name", ""), "liquidity_bucket": item.get("liquidity_bucket", "")}
|
|
||||||
for item in account_excluded
|
|
||||||
],
|
|
||||||
missing_valuation_items=excluded,
|
|
||||||
invalid_bucket_accounts=invalid_bucket_accounts,
|
|
||||||
manual_asset_count=len(manual_items),
|
|
||||||
included_items=included,
|
|
||||||
excluded_items=excluded,
|
|
||||||
valuation_policy=summary["valuation_policy"],
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
from .accounts_write_services import update_ledger_account
|
from .accounts_write_services import update_ledger_account
|
||||||
|
|||||||
@@ -0,0 +1,490 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .shared import *
|
||||||
|
|
||||||
|
def _valuation_policy() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"authority_sources": ["accounts.last_balance", "manual_assets"],
|
||||||
|
"snapshot_role": "historical_record_layer",
|
||||||
|
"formula": "asset_buckets - loan",
|
||||||
|
"asset_buckets": ASSET_BUCKETS,
|
||||||
|
"liability_buckets": LIABILITY_BUCKETS,
|
||||||
|
"missing_valuation_policy": "exclude_without_transaction_inference",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _account_valuation_items(conn: sqlite3.Connection) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
rows = conn.execute("SELECT * FROM accounts WHERE active=1 ORDER BY name").fetchall()
|
||||||
|
included: list[dict[str, Any]] = []
|
||||||
|
excluded: list[dict[str, Any]] = []
|
||||||
|
invalid: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
account = row_to_dict(row)
|
||||||
|
raw_bucket = str(account.get("liquidity_bucket") or "other")
|
||||||
|
try:
|
||||||
|
bucket = normalize_liquidity_bucket(raw_bucket)
|
||||||
|
except ValueError:
|
||||||
|
bucket = "other"
|
||||||
|
invalid.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": raw_bucket})
|
||||||
|
base = {
|
||||||
|
"id": account.get("id", ""),
|
||||||
|
"source_id": account.get("id", ""),
|
||||||
|
"source_type": "account",
|
||||||
|
"authority_source": "accounts.last_balance",
|
||||||
|
"name": account.get("name", ""),
|
||||||
|
"type": account.get("type", ""),
|
||||||
|
"liquidity_bucket": bucket,
|
||||||
|
"valuation_date": account.get("last_synced_at", ""),
|
||||||
|
"last_synced_at": account.get("last_synced_at", ""),
|
||||||
|
"source_note": account.get("memo", ""),
|
||||||
|
"memo": account.get("memo", ""),
|
||||||
|
}
|
||||||
|
balance = account.get("last_balance")
|
||||||
|
if balance is None:
|
||||||
|
excluded.append({**base, "reason": "missing_last_balance"})
|
||||||
|
continue
|
||||||
|
amount = int(balance)
|
||||||
|
included.append({**base, "amount": amount, "last_balance": amount})
|
||||||
|
return included, excluded, invalid
|
||||||
|
|
||||||
|
|
||||||
|
def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = True) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
where = "WHERE active=1" if active_only else ""
|
||||||
|
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
|
||||||
|
included: list[dict[str, Any]] = []
|
||||||
|
excluded: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
asset = row_to_dict(row)
|
||||||
|
try:
|
||||||
|
bucket = normalize_liquidity_bucket(asset.get("liquidity_bucket"))
|
||||||
|
except ValueError:
|
||||||
|
bucket = "other"
|
||||||
|
base = {
|
||||||
|
"id": asset.get("id", ""),
|
||||||
|
"source_id": asset.get("id", ""),
|
||||||
|
"source_type": "manual_asset",
|
||||||
|
"authority_source": "manual_assets",
|
||||||
|
"name": asset.get("name", ""),
|
||||||
|
"liquidity_bucket": bucket,
|
||||||
|
"valuation_date": asset.get("valuation_date", ""),
|
||||||
|
"source_note": asset.get("source_note", ""),
|
||||||
|
"memo": asset.get("memo", ""),
|
||||||
|
"active": asset.get("active", True),
|
||||||
|
}
|
||||||
|
value = asset.get("valuation_amount")
|
||||||
|
if value is None:
|
||||||
|
excluded.append({**base, "reason": "missing_valuation_amount"})
|
||||||
|
continue
|
||||||
|
included.append({**base, "amount": int(value), "valuation_amount": int(value)})
|
||||||
|
return included, excluded
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC",
|
||||||
|
(basis_date,),
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
return [], {}
|
||||||
|
run_rows = [row for row in rows if "snapshot_run_id" in row.keys() and row["snapshot_run_id"]]
|
||||||
|
if not run_rows:
|
||||||
|
return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)}
|
||||||
|
latest = sorted(run_rows, key=lambda row: (int(row["_rowid"]), row["closed_at"] or row["created_at"] or "", row["snapshot_run_id"] or ""))[-1]
|
||||||
|
latest_run_id = latest["snapshot_run_id"]
|
||||||
|
selected = [row for row in run_rows if row["snapshot_run_id"] == latest_run_id]
|
||||||
|
metadata = {
|
||||||
|
"snapshot_run_id": latest_run_id,
|
||||||
|
"is_correction": bool(latest["is_correction"]),
|
||||||
|
"correction_of_run_id": latest["correction_of_run_id"] or "",
|
||||||
|
"correction_reason": latest["correction_reason"] or "",
|
||||||
|
"closed_at": latest["closed_at"] or latest["created_at"] or "",
|
||||||
|
"total_snapshot_rows_for_basis_date": len(rows),
|
||||||
|
}
|
||||||
|
return selected, metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]:
|
||||||
|
rows, _metadata = _snapshot_rows_for_latest_run(conn, basis_date)
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
snap = row_to_dict(row)
|
||||||
|
try:
|
||||||
|
bucket = normalize_liquidity_bucket(snap.get("liquidity_bucket"))
|
||||||
|
except ValueError:
|
||||||
|
bucket = "other"
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": snap.get("id", ""),
|
||||||
|
"source_id": snap.get("source_id", ""),
|
||||||
|
"source_type": snap.get("source_type", ""),
|
||||||
|
"authority_source": "asset_snapshots",
|
||||||
|
"valuation_source": snap.get("valuation_source", ""),
|
||||||
|
"name": snap.get("name", ""),
|
||||||
|
"liquidity_bucket": bucket,
|
||||||
|
"amount": int(snap.get("amount") or 0),
|
||||||
|
"valuation_date": snap.get("basis_date", ""),
|
||||||
|
"source_note": snap.get("source_note", ""),
|
||||||
|
"memo": snap.get("memo", ""),
|
||||||
|
"snapshot_run_id": snap.get("snapshot_run_id", ""),
|
||||||
|
"is_correction": bool(snap.get("is_correction", False)),
|
||||||
|
"correction_of_run_id": snap.get("correction_of_run_id", ""),
|
||||||
|
"correction_reason": snap.get("correction_reason", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_items: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||||
|
bucket_totals = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
|
||||||
|
bucket_counts = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
|
||||||
|
for item in included_items:
|
||||||
|
bucket = normalize_liquidity_bucket(item.get("liquidity_bucket"))
|
||||||
|
amount = int(item.get("amount") or 0)
|
||||||
|
bucket_counts[bucket] += 1
|
||||||
|
bucket_totals[bucket] += amount
|
||||||
|
total_assets = sum(bucket_totals[bucket] for bucket in ASSET_BUCKETS)
|
||||||
|
total_liabilities = sum(abs(bucket_totals[bucket]) for bucket in LIABILITY_BUCKETS)
|
||||||
|
net_worth = total_assets - total_liabilities
|
||||||
|
buckets = []
|
||||||
|
for bucket in ASSET_BUCKET_ORDER:
|
||||||
|
amount = bucket_totals[bucket]
|
||||||
|
denominator = total_liabilities if bucket in LIABILITY_BUCKETS else total_assets
|
||||||
|
buckets.append(
|
||||||
|
{
|
||||||
|
"liquidity_bucket": bucket,
|
||||||
|
"amount": amount,
|
||||||
|
"account_count": sum(1 for item in included_items if item.get("source_type") == "account" and item.get("liquidity_bucket") == bucket),
|
||||||
|
"manual_asset_count": sum(1 for item in included_items if item.get("source_type") == "manual_asset" and item.get("liquidity_bucket") == bucket),
|
||||||
|
"item_count": bucket_counts[bucket],
|
||||||
|
"asset_share": round(amount / total_assets, 4) if bucket not in LIABILITY_BUCKETS and total_assets else None,
|
||||||
|
"liability_share": round(abs(amount) / total_liabilities, 4) if bucket in LIABILITY_BUCKETS and total_liabilities else None,
|
||||||
|
"share": round(abs(amount) / denominator, 4) if denominator else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"buckets": buckets,
|
||||||
|
"bucket_totals": bucket_totals,
|
||||||
|
"total_assets": total_assets,
|
||||||
|
"total_liabilities": total_liabilities,
|
||||||
|
"net_worth": net_worth,
|
||||||
|
"included_items": included_items,
|
||||||
|
"excluded_items": excluded_items or [],
|
||||||
|
"valuation_policy": _valuation_policy(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_manual_asset(name: str, valuation_amount: int | None, valuation_date: str, liquidity_bucket: str, source_note: str = "", memo: str = "", currency: str = "KRW", active: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
if not name.strip():
|
||||||
|
return fail("validation_error", "manual asset name is required")
|
||||||
|
try:
|
||||||
|
bucket = normalize_liquidity_bucket(liquidity_bucket)
|
||||||
|
amount = int(valuation_amount) if valuation_amount is not None else None
|
||||||
|
vdate = normalize_date(valuation_date, default_today=True)
|
||||||
|
except Exception as e:
|
||||||
|
return fail("validation_error", str(e))
|
||||||
|
ts = now_iso()
|
||||||
|
asset_id = new_id("asset")
|
||||||
|
with db(db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO manual_assets(id,name,liquidity_bucket,valuation_amount,valuation_date,currency,source_note,memo,active,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(asset_id, name.strip(), bucket, amount, vdate, currency, source_note, memo, int(active), ts, ts),
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
||||||
|
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
|
||||||
|
|
||||||
|
|
||||||
|
def list_manual_assets(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
where = "WHERE active=1" if active_only else ""
|
||||||
|
with db(db_path) as conn:
|
||||||
|
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
|
||||||
|
assets = [{**row_to_dict(row), "authority_source": "manual_assets"} for row in rows]
|
||||||
|
return ok(manual_assets=assets, count=len(assets), valuation_policy=_valuation_policy())
|
||||||
|
|
||||||
|
|
||||||
|
def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
allowed_updates: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
try:
|
||||||
|
if name is not None:
|
||||||
|
if not name.strip():
|
||||||
|
return fail("validation_error", "manual asset name is required")
|
||||||
|
allowed_updates.append("name=?")
|
||||||
|
params.append(name.strip())
|
||||||
|
if valuation_amount is not None:
|
||||||
|
allowed_updates.append("valuation_amount=?")
|
||||||
|
params.append(int(valuation_amount))
|
||||||
|
if valuation_date is not None:
|
||||||
|
allowed_updates.append("valuation_date=?")
|
||||||
|
params.append(normalize_date(valuation_date, default_today=True))
|
||||||
|
if liquidity_bucket is not None:
|
||||||
|
allowed_updates.append("liquidity_bucket=?")
|
||||||
|
params.append(normalize_liquidity_bucket(liquidity_bucket))
|
||||||
|
if source_note is not None:
|
||||||
|
allowed_updates.append("source_note=?")
|
||||||
|
params.append(source_note)
|
||||||
|
if memo is not None:
|
||||||
|
allowed_updates.append("memo=?")
|
||||||
|
params.append(memo)
|
||||||
|
if currency is not None:
|
||||||
|
allowed_updates.append("currency=?")
|
||||||
|
params.append(currency or "KRW")
|
||||||
|
if active is not None:
|
||||||
|
allowed_updates.append("active=?")
|
||||||
|
params.append(int(bool(active)))
|
||||||
|
except Exception as e:
|
||||||
|
return fail("validation_error", str(e))
|
||||||
|
if not allowed_updates:
|
||||||
|
return fail("validation_error", "no fields to update")
|
||||||
|
allowed_updates.append("updated_at=?")
|
||||||
|
params.extend([now_iso(), asset_id])
|
||||||
|
with db(db_path) as conn:
|
||||||
|
cur = conn.execute(f"UPDATE manual_assets SET {', '.join(allowed_updates)} WHERE id=?", params)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
return fail("not_found", "manual asset not found")
|
||||||
|
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
||||||
|
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
|
||||||
|
|
||||||
|
|
||||||
|
def deactivate_manual_asset(asset_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
with db(db_path) as conn:
|
||||||
|
if hard_delete:
|
||||||
|
cur = conn.execute("DELETE FROM manual_assets WHERE id=?", (asset_id,))
|
||||||
|
else:
|
||||||
|
cur = conn.execute("UPDATE manual_assets SET active=0, updated_at=? WHERE id=?", (now_iso(), asset_id))
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
return fail("not_found", "manual asset not found")
|
||||||
|
row = None if hard_delete else conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
||||||
|
payload = {"manual_asset_id": asset_id, "deactivated": True, "hard_delete": hard_delete}
|
||||||
|
if row is not None:
|
||||||
|
payload["manual_asset"] = {**row_to_dict(row), "authority_source": "manual_assets"}
|
||||||
|
return ok(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str) -> dict[str, Any]:
|
||||||
|
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
|
||||||
|
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
|
||||||
|
included = account_items + manual_items
|
||||||
|
excluded = account_excluded + manual_excluded
|
||||||
|
summary = _summarize_valuation_items(included, excluded)
|
||||||
|
existing_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis,)).fetchone()[0]
|
||||||
|
warnings: list[dict[str, Any]] = []
|
||||||
|
for item in excluded:
|
||||||
|
warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", "")})
|
||||||
|
for item in included:
|
||||||
|
item_date = str(item.get("valuation_date") or "")[:10]
|
||||||
|
if item_date and item_date < basis:
|
||||||
|
warnings.append({"code": "stale_valuation", "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", ""), "valuation_date": item_date, "basis_date": basis})
|
||||||
|
for item in invalid_bucket_accounts:
|
||||||
|
warnings.append({"code": "invalid_liquidity_bucket", **item})
|
||||||
|
if existing_rows:
|
||||||
|
warnings.append({"code": "existing_snapshot_rows", "basis_date": basis, "snapshot_row_count": int(existing_rows), "policy": "append_only_correction_required"})
|
||||||
|
return {
|
||||||
|
"basis_date": basis,
|
||||||
|
"source": "live_authority",
|
||||||
|
"is_closed_snapshot": False,
|
||||||
|
"snapshot_count": 0,
|
||||||
|
"existing_snapshot_row_count": int(existing_rows),
|
||||||
|
"would_be_correction": bool(existing_rows),
|
||||||
|
"warnings": warnings,
|
||||||
|
**summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
||||||
|
except Exception as e:
|
||||||
|
return fail("validation_error", str(e))
|
||||||
|
with db(db_path) as conn:
|
||||||
|
preview = _snapshot_preview_from_conn(conn, basis)
|
||||||
|
return ok(**preview)
|
||||||
|
|
||||||
|
|
||||||
|
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
||||||
|
except Exception as e:
|
||||||
|
return fail("validation_error", str(e))
|
||||||
|
ts = now_iso()
|
||||||
|
run_id = new_id("snaprun")
|
||||||
|
with db(db_path) as conn:
|
||||||
|
preview = _snapshot_preview_from_conn(conn, basis)
|
||||||
|
included = preview["included_items"]
|
||||||
|
latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis)
|
||||||
|
is_correction = bool(preview["existing_snapshot_row_count"])
|
||||||
|
if is_correction and not correction_reason.strip():
|
||||||
|
return fail("validation_error", "correction_reason is required when closing a basis_date that already has snapshot rows", basis_date=basis, existing_snapshot_row_count=preview["existing_snapshot_row_count"])
|
||||||
|
correction_of_run_id = latest_meta.get("snapshot_run_id", "") if latest_rows else ""
|
||||||
|
snapshot_ids: list[str] = []
|
||||||
|
for item in included:
|
||||||
|
snapshot_id = new_id("snap")
|
||||||
|
row_note = source_note or item.get("source_note", "") or "month-end close"
|
||||||
|
row_memo = memo or item.get("memo", "")
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
snapshot_id,
|
||||||
|
basis,
|
||||||
|
item.get("source_type", ""),
|
||||||
|
item.get("source_id", item.get("id", "")),
|
||||||
|
item.get("name", ""),
|
||||||
|
int(item.get("amount") or 0),
|
||||||
|
item.get("currency", "KRW"),
|
||||||
|
normalize_liquidity_bucket(item.get("liquidity_bucket")),
|
||||||
|
item.get("authority_source", item.get("source_type", "")),
|
||||||
|
row_note,
|
||||||
|
row_memo,
|
||||||
|
run_id,
|
||||||
|
correction_of_run_id,
|
||||||
|
correction_reason.strip(),
|
||||||
|
int(is_correction),
|
||||||
|
ts,
|
||||||
|
ts,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
snapshot_ids.append(snapshot_id)
|
||||||
|
report_items = _snapshot_items(conn, basis)
|
||||||
|
summary = _summarize_valuation_items(report_items, [])
|
||||||
|
return ok(
|
||||||
|
basis_date=basis,
|
||||||
|
source="snapshot",
|
||||||
|
is_closed_snapshot=True,
|
||||||
|
snapshot_run_id=run_id,
|
||||||
|
correction_of_run_id=correction_of_run_id,
|
||||||
|
correction_reason=correction_reason.strip(),
|
||||||
|
is_correction=is_correction,
|
||||||
|
closed_at=ts,
|
||||||
|
inserted_count=len(snapshot_ids),
|
||||||
|
inserted_snapshot_ids=snapshot_ids,
|
||||||
|
warnings=preview["warnings"],
|
||||||
|
**summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, name: str, amount: int | None, liquidity_bucket: str, valuation_source: str = "", source_note: str = "", memo: str = "", currency: str = "KRW", db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
if source_type not in {"account", "manual_asset"}:
|
||||||
|
return fail("validation_error", "source_type must be one of ['account', 'manual_asset']")
|
||||||
|
if not name.strip():
|
||||||
|
return fail("validation_error", "snapshot item name is required")
|
||||||
|
try:
|
||||||
|
basis = normalize_date(basis_date, default_today=True)
|
||||||
|
bucket = normalize_liquidity_bucket(liquidity_bucket)
|
||||||
|
amount_int = int(amount) if amount is not None else None
|
||||||
|
except Exception as e:
|
||||||
|
return fail("validation_error", str(e))
|
||||||
|
ts = now_iso()
|
||||||
|
snapshot_id = new_id("snap")
|
||||||
|
with db(db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,created_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(snapshot_id, basis, source_type, source_id, name.strip(), amount_int, currency, bucket, valuation_source or source_type, source_note, memo, ts),
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT * FROM asset_snapshots WHERE id=?", (snapshot_id,)).fetchone()
|
||||||
|
return ok(asset_snapshot=row_to_dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
||||||
|
with db(db_path) as conn:
|
||||||
|
rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis)
|
||||||
|
snapshots = _snapshot_items(conn, basis) if rows else []
|
||||||
|
if snapshots:
|
||||||
|
summary = _summarize_valuation_items(snapshots, [])
|
||||||
|
return ok(
|
||||||
|
basis_date=basis,
|
||||||
|
source="asset_snapshots",
|
||||||
|
is_closed_snapshot=True,
|
||||||
|
snapshot_count=len(snapshots),
|
||||||
|
close_metadata=close_metadata,
|
||||||
|
**summary,
|
||||||
|
)
|
||||||
|
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
|
||||||
|
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
|
||||||
|
included = account_items + manual_items
|
||||||
|
excluded = account_excluded + manual_excluded
|
||||||
|
summary = _summarize_valuation_items(included, excluded)
|
||||||
|
return ok(
|
||||||
|
basis_date=basis,
|
||||||
|
source="live_authority",
|
||||||
|
is_closed_snapshot=False,
|
||||||
|
snapshot_count=0,
|
||||||
|
invalid_bucket_accounts=invalid_bucket_accounts,
|
||||||
|
**summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
months_int = max(1, min(int(months), 120))
|
||||||
|
end_norm = normalize_date(end_date, default_today=True) if end_date else current_kst_date().isoformat()
|
||||||
|
if start_date:
|
||||||
|
start_norm = normalize_date(start_date)
|
||||||
|
else:
|
||||||
|
end_dt = date.fromisoformat(end_norm)
|
||||||
|
start_month = end_dt.month - months_int + 1
|
||||||
|
year = end_dt.year + (start_month - 1) // 12
|
||||||
|
month = (start_month - 1) % 12 + 1
|
||||||
|
start_norm = date(year, month, 1).isoformat()
|
||||||
|
except Exception as e:
|
||||||
|
return fail("validation_error", str(e))
|
||||||
|
with db(db_path) as conn:
|
||||||
|
basis_rows = conn.execute(
|
||||||
|
"""SELECT DISTINCT basis_date FROM asset_snapshots
|
||||||
|
WHERE basis_date>=? AND basis_date<=?
|
||||||
|
ORDER BY basis_date ASC""",
|
||||||
|
(start_norm, end_norm),
|
||||||
|
).fetchall()
|
||||||
|
points = []
|
||||||
|
for row in basis_rows:
|
||||||
|
basis = row["basis_date"]
|
||||||
|
items = _snapshot_items(conn, basis)
|
||||||
|
summary = _summarize_valuation_items(items, [])
|
||||||
|
points.append(
|
||||||
|
{
|
||||||
|
"basis_date": basis,
|
||||||
|
"total_assets": summary["total_assets"],
|
||||||
|
"total_liabilities": summary["total_liabilities"],
|
||||||
|
"net_worth": summary["net_worth"],
|
||||||
|
"source": "asset_snapshots",
|
||||||
|
"item_count": len(items),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return ok(start_date=start_norm, end_date=end_norm, months=months_int, points=points, count=len(points), valuation_policy=_valuation_policy())
|
||||||
|
|
||||||
|
|
||||||
|
def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Summarize assets/liabilities from shared valuation authority sources.
|
||||||
|
|
||||||
|
Existing REQ-005 keys are preserved. When manual_assets is empty, totals and
|
||||||
|
source remain equivalent to the original accounts.last_balance behavior.
|
||||||
|
"""
|
||||||
|
with db(db_path) as conn:
|
||||||
|
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
|
||||||
|
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
|
||||||
|
included = account_items + manual_items
|
||||||
|
excluded = account_excluded + manual_excluded
|
||||||
|
summary = _summarize_valuation_items(included, excluded)
|
||||||
|
source_accounts = [item for item in included if item.get("source_type") == "account"]
|
||||||
|
source = "accounts.last_balance" if not manual_items and not manual_excluded else "accounts.last_balance+manual_assets"
|
||||||
|
return ok(
|
||||||
|
buckets=summary["buckets"],
|
||||||
|
accounts=source_accounts,
|
||||||
|
manual_assets=manual_items,
|
||||||
|
total_assets=summary["total_assets"],
|
||||||
|
total_liabilities=summary["total_liabilities"],
|
||||||
|
net_worth=summary["net_worth"],
|
||||||
|
missing_balance_accounts=[
|
||||||
|
{"id": item.get("id", ""), "name": item.get("name", ""), "liquidity_bucket": item.get("liquidity_bucket", "")}
|
||||||
|
for item in account_excluded
|
||||||
|
],
|
||||||
|
missing_valuation_items=excluded,
|
||||||
|
invalid_bucket_accounts=invalid_bucket_accounts,
|
||||||
|
manual_asset_count=len(manual_items),
|
||||||
|
included_items=included,
|
||||||
|
excluded_items=excluded,
|
||||||
|
valuation_policy=summary["valuation_policy"],
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
@@ -274,220 +274,13 @@ def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = Tru
|
|||||||
applied += 1
|
applied += 1
|
||||||
return ok(applied_count=applied, preview=preview)
|
return ok(applied_count=applied, preview=preview)
|
||||||
|
|
||||||
|
from .uncategorized_workbench_service import (
|
||||||
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0, db_path: str | None = None) -> dict[str, Any]:
|
apply_uncategorized_group_recommendation,
|
||||||
with db(db_path) as conn:
|
apply_uncategorized_recommendation,
|
||||||
rows=conn.execute("""SELECT * FROM transactions WHERE status='active' AND COALESCE(category,'')='' AND amount>=? ORDER BY amount DESC, transaction_datetime DESC LIMIT ?""", (int(min_amount), max(1,min(int(limit),1000)))).fetchall()
|
get_uncategorized_merchant_summary,
|
||||||
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
|
get_uncategorized_workbench,
|
||||||
|
list_uncategorized_transactions,
|
||||||
|
)
|
||||||
def get_uncategorized_merchant_summary(limit: int = 50, db_path: str | None = None) -> dict[str, Any]:
|
|
||||||
with db(db_path) as conn:
|
|
||||||
rows=conn.execute("""SELECT merchant, raw_summary, type, COUNT(*) count, SUM(amount) total_amount, MAX(amount) max_amount
|
|
||||||
FROM transactions WHERE status='active' AND COALESCE(category,'')=''
|
|
||||||
GROUP BY merchant, raw_summary, type ORDER BY total_amount DESC LIMIT ?""", (max(1,min(int(limit),500)),)).fetchall()
|
|
||||||
return ok(items=[row_to_dict(r) for r in rows], count=len(rows))
|
|
||||||
|
|
||||||
|
|
||||||
def _recommend_category_for_transaction(tx: dict[str, Any], rules: list[dict[str, Any]], history: dict[tuple[str, str], list[dict[str, Any]]]) -> dict[str, Any]:
|
|
||||||
"""Recommend a category for one transaction using deterministic, explainable signals."""
|
|
||||||
for rule in rules:
|
|
||||||
try:
|
|
||||||
if _rule_matches(rule, tx):
|
|
||||||
return {
|
|
||||||
"category": rule.get("category", ""),
|
|
||||||
"subcategory": rule.get("subcategory", ""),
|
|
||||||
"confidence": round(float(rule.get("confidence") or 1.0), 2),
|
|
||||||
"source": "rule",
|
|
||||||
"reason": f"활성 규칙 `{rule.get('pattern', '')}` 매칭",
|
|
||||||
"rule_id": rule.get("id", ""),
|
|
||||||
}
|
|
||||||
except re.error:
|
|
||||||
continue
|
|
||||||
|
|
||||||
key = (tx.get("merchant", "") or "", tx.get("type", "") or "")
|
|
||||||
candidates = history.get(key, [])
|
|
||||||
if candidates:
|
|
||||||
best = candidates[0]
|
|
||||||
count = int(best.get("count") or 0)
|
|
||||||
confidence = min(0.9, 0.55 + min(count, 5) * 0.07)
|
|
||||||
return {
|
|
||||||
"category": best.get("category", ""),
|
|
||||||
"subcategory": best.get("subcategory", ""),
|
|
||||||
"confidence": round(confidence, 2),
|
|
||||||
"source": "history",
|
|
||||||
"reason": f"같은 사용처의 기존 분류 {count}건",
|
|
||||||
"rule_id": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
return {"category": "", "subcategory": "", "confidence": 0.0, "source": "none", "reason": "추천 근거 없음", "rule_id": ""}
|
|
||||||
|
|
||||||
|
|
||||||
def get_uncategorized_workbench(
|
|
||||||
limit: int = 100,
|
|
||||||
min_amount: int = 0,
|
|
||||||
sort_by: str = "amount_desc",
|
|
||||||
db_path: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
row_limit = max(1, min(int(limit), 1000))
|
|
||||||
minimum_amount = int(min_amount)
|
|
||||||
order_by_map = {
|
|
||||||
"amount_desc": "amount DESC, transaction_datetime DESC, transaction_date DESC, created_at DESC",
|
|
||||||
"date_desc": "transaction_datetime DESC, transaction_date DESC, created_at DESC, amount DESC",
|
|
||||||
}
|
|
||||||
order_by = order_by_map.get(sort_by, order_by_map["amount_desc"])
|
|
||||||
with db(db_path) as conn:
|
|
||||||
tx_rows = [row_to_dict(r) for r in conn.execute(
|
|
||||||
f"""SELECT id, transaction_date, transaction_datetime, type, amount, currency, merchant, raw_summary,
|
|
||||||
counterparty, account_id, payment_method, person, is_fixed, source, memo, created_at
|
|
||||||
FROM transactions
|
|
||||||
WHERE status='active' AND COALESCE(category,'')='' AND amount>=?
|
|
||||||
ORDER BY {order_by}
|
|
||||||
LIMIT ?""",
|
|
||||||
(minimum_amount, row_limit),
|
|
||||||
).fetchall()]
|
|
||||||
total_row = conn.execute(
|
|
||||||
"""SELECT COUNT(*) AS count, COALESCE(SUM(amount),0) AS total_amount,
|
|
||||||
COUNT(DISTINCT COALESCE(NULLIF(merchant,''), raw_summary, counterparty, '')) AS merchant_group_count
|
|
||||||
FROM transactions
|
|
||||||
WHERE status='active' AND COALESCE(category,'')='' AND amount>=?""",
|
|
||||||
(minimum_amount,),
|
|
||||||
).fetchone()
|
|
||||||
history_rows = [row_to_dict(r) for r in conn.execute(
|
|
||||||
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count,
|
|
||||||
SUM(amount) AS total_amount, MAX(transaction_date) AS latest_date
|
|
||||||
FROM transactions
|
|
||||||
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
|
|
||||||
GROUP BY merchant, type, category, subcategory
|
|
||||||
ORDER BY merchant, type, count DESC, total_amount DESC"""
|
|
||||||
).fetchall()]
|
|
||||||
rules = [row_to_dict(r) for r in conn.execute(
|
|
||||||
"SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id"
|
|
||||||
).fetchall()]
|
|
||||||
|
|
||||||
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
|
||||||
for row in history_rows:
|
|
||||||
history.setdefault((row.get("merchant", "") or "", row.get("type", "") or ""), []).append(row)
|
|
||||||
|
|
||||||
txs: list[dict[str, Any]] = []
|
|
||||||
groups: dict[tuple[str, str, str], dict[str, Any]] = {}
|
|
||||||
for tx in tx_rows:
|
|
||||||
rec = _recommend_category_for_transaction(tx, rules, history)
|
|
||||||
public_tx = {
|
|
||||||
**tx,
|
|
||||||
"recommendation": rec,
|
|
||||||
"recommended_category": rec.get("category", ""),
|
|
||||||
"recommended_subcategory": rec.get("subcategory", ""),
|
|
||||||
"recommendation_confidence": rec.get("confidence", 0.0),
|
|
||||||
"recommendation_reason": rec.get("reason", ""),
|
|
||||||
"recommendation_source": rec.get("source", "none"),
|
|
||||||
}
|
|
||||||
txs.append(public_tx)
|
|
||||||
gkey = (tx.get("merchant", "") or "", tx.get("raw_summary", "") or "", tx.get("type", "") or "")
|
|
||||||
group = groups.setdefault(gkey, {
|
|
||||||
"merchant": gkey[0],
|
|
||||||
"raw_summary": gkey[1],
|
|
||||||
"type": gkey[2],
|
|
||||||
"count": 0,
|
|
||||||
"total_amount": 0,
|
|
||||||
"max_amount": 0,
|
|
||||||
"latest_date": "",
|
|
||||||
"sample_transaction_id": tx.get("id", ""),
|
|
||||||
"recommendation": rec,
|
|
||||||
})
|
|
||||||
group["count"] += 1
|
|
||||||
group["total_amount"] += int(tx.get("amount") or 0)
|
|
||||||
group["max_amount"] = max(int(group["max_amount"] or 0), int(tx.get("amount") or 0))
|
|
||||||
date_value = tx.get("transaction_datetime") or tx.get("transaction_date") or ""
|
|
||||||
if date_value > (group.get("latest_date") or ""):
|
|
||||||
group["latest_date"] = date_value
|
|
||||||
if not group["recommendation"].get("category") and rec.get("category"):
|
|
||||||
group["recommendation"] = rec
|
|
||||||
|
|
||||||
group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True)
|
|
||||||
recommendable_count = sum(1 for tx in txs if tx.get("recommended_category"))
|
|
||||||
repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2)
|
|
||||||
total = row_to_dict(total_row) if total_row else {"count": 0, "total_amount": 0, "merchant_group_count": 0}
|
|
||||||
return ok(
|
|
||||||
summary={
|
|
||||||
"uncategorized_count": int(total.get("count") or 0),
|
|
||||||
"uncategorized_total_amount": int(total.get("total_amount") or 0),
|
|
||||||
"merchant_group_count": int(total.get("merchant_group_count") or 0),
|
|
||||||
"recommendable_count": recommendable_count,
|
|
||||||
"repeated_group_count": repeated_group_count,
|
|
||||||
},
|
|
||||||
groups=group_items,
|
|
||||||
transactions=txs,
|
|
||||||
count=len(txs),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_uncategorized_recommendation(transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
|
|
||||||
if not transaction_id:
|
|
||||||
return fail("validation_error", "transaction_id is required")
|
|
||||||
with db(db_path) as conn:
|
|
||||||
row = conn.execute("SELECT * FROM transactions WHERE id=? AND status='active'", (transaction_id,)).fetchone()
|
|
||||||
if not row:
|
|
||||||
return fail("not_found", "transaction not found")
|
|
||||||
tx = row_to_dict(row)
|
|
||||||
if tx.get("category"):
|
|
||||||
return fail("validation_error", "transaction is already categorized")
|
|
||||||
rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
|
|
||||||
history_rows = [row_to_dict(r) for r in conn.execute(
|
|
||||||
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount
|
|
||||||
FROM transactions
|
|
||||||
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
|
|
||||||
GROUP BY merchant, type, category, subcategory
|
|
||||||
ORDER BY count DESC, total_amount DESC"""
|
|
||||||
).fetchall()]
|
|
||||||
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
|
||||||
for h in history_rows:
|
|
||||||
history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h)
|
|
||||||
rec = _recommend_category_for_transaction(tx, rules, history)
|
|
||||||
if not rec.get("category"):
|
|
||||||
return fail("no_recommendation", "추천 분류가 없습니다")
|
|
||||||
cur = conn.execute(
|
|
||||||
"""UPDATE transactions
|
|
||||||
SET category=?, subcategory=?, updated_at=?
|
|
||||||
WHERE id=? AND status='active' AND COALESCE(category,'')=''""",
|
|
||||||
(rec.get("category", ""), rec.get("subcategory", ""), now_iso(), transaction_id),
|
|
||||||
)
|
|
||||||
return ok(applied_count=cur.rowcount, transaction_id=transaction_id, recommendation=rec)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_uncategorized_group_recommendation(merchant: str, raw_summary: str = "", transaction_type: str = "", db_path: str | None = None) -> dict[str, Any]:
|
|
||||||
with db(db_path) as conn:
|
|
||||||
sample = conn.execute(
|
|
||||||
"""SELECT * FROM transactions
|
|
||||||
WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?
|
|
||||||
ORDER BY amount DESC, transaction_datetime DESC, transaction_date DESC LIMIT 1""",
|
|
||||||
(merchant, raw_summary, transaction_type),
|
|
||||||
).fetchone()
|
|
||||||
if not sample:
|
|
||||||
return fail("not_found", "uncategorized group not found")
|
|
||||||
tx = row_to_dict(sample)
|
|
||||||
rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
|
|
||||||
history_rows = [row_to_dict(r) for r in conn.execute(
|
|
||||||
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount
|
|
||||||
FROM transactions
|
|
||||||
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
|
|
||||||
GROUP BY merchant, type, category, subcategory
|
|
||||||
ORDER BY count DESC, total_amount DESC"""
|
|
||||||
).fetchall()]
|
|
||||||
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
|
||||||
for h in history_rows:
|
|
||||||
history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h)
|
|
||||||
rec = _recommend_category_for_transaction(tx, rules, history)
|
|
||||||
if not rec.get("category"):
|
|
||||||
return fail("no_recommendation", "추천 분류가 없습니다")
|
|
||||||
cur = conn.execute(
|
|
||||||
"""UPDATE transactions
|
|
||||||
SET category=?, subcategory=?, updated_at=?
|
|
||||||
WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?""",
|
|
||||||
(rec.get("category", ""), rec.get("subcategory", ""), now_iso(), merchant, raw_summary, transaction_type),
|
|
||||||
)
|
|
||||||
return ok(applied_count=cur.rowcount, merchant=merchant, raw_summary=raw_summary, type=transaction_type, recommendation=rec)
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_LEDGER_CATEGORIES: list[dict[str, Any]] = [
|
DEFAULT_LEDGER_CATEGORIES: list[dict[str, Any]] = [
|
||||||
{"category": "수입", "subcategory": "급여", "sort_order": 10},
|
{"category": "수입", "subcategory": "급여", "sort_order": 10},
|
||||||
@@ -599,6 +392,3 @@ def list_ledger_categories(active_only: bool = True, include_usage: bool = True,
|
|||||||
if item["subcategory"] and item["subcategory"] not in grouped[item["category"]]:
|
if item["subcategory"] and item["subcategory"] not in grouped[item["category"]]:
|
||||||
grouped[item["category"]].append(item["subcategory"])
|
grouped[item["category"]].append(item["subcategory"])
|
||||||
return ok(categories=items, grouped=grouped, count=len(items))
|
return ok(categories=items, grouped=grouped, count=len(items))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,593 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .shared import *
|
||||||
|
from .shared import _append_memo_tag, _memo_has_tag, _normalize_counterparty_for_match, _party_match_score
|
||||||
|
|
||||||
|
def _build_cross_transfer_index(transactions: list[dict[str, Any]]) -> dict[tuple[str, int, str], list[dict[str, Any]]]:
|
||||||
|
index: dict[tuple[str, int, str], list[dict[str, Any]]] = {}
|
||||||
|
for tx in transactions:
|
||||||
|
if tx.get("status") != "active":
|
||||||
|
continue
|
||||||
|
key = (tx["transaction_date"], int(tx["amount"]), tx["type"])
|
||||||
|
index.setdefault(key, []).append(tx)
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_transfer_candidates(db_conn: Any, source: str) -> list[dict[str, Any]]:
|
||||||
|
rows = db_conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT t.id, t.transaction_datetime, t.transaction_date, t.type, t.amount, t.category, t.subcategory,
|
||||||
|
t.counterparty, t.memo, t.person, t.account_id, t.payment_method, t.status,
|
||||||
|
a.holder_name, t.merchant
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN accounts a ON a.id = t.account_id
|
||||||
|
WHERE t.source=?
|
||||||
|
AND t.type IN ('income','expense')
|
||||||
|
AND (
|
||||||
|
t.category='이체'
|
||||||
|
OR t.subcategory IN ('확인필요', '본인계좌이동', '투자이동')
|
||||||
|
OR t.payment_method IN ('내계좌간자동이전', '내계좌간자동이체', '오픈뱅킹', '펌뱅킹출금', '펌뱅킹입금', '입금', '출금', 'ATM출금', '적금개설')
|
||||||
|
OR t.merchant LIKE '%이체%'
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(source,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tx_datetime(value: str | None) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
text = value.strip()
|
||||||
|
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(text, fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _self_transfer_direction_score(source_type: str, candidate_type: str) -> int:
|
||||||
|
if source_type == "expense":
|
||||||
|
if candidate_type == "income":
|
||||||
|
return 3
|
||||||
|
if candidate_type == "transfer":
|
||||||
|
return 2
|
||||||
|
elif source_type == "income":
|
||||||
|
if candidate_type == "expense":
|
||||||
|
return 3
|
||||||
|
if candidate_type == "transfer":
|
||||||
|
return 2
|
||||||
|
elif source_type == "transfer":
|
||||||
|
if candidate_type in {"income", "expense"}:
|
||||||
|
return 2
|
||||||
|
if candidate_type == "transfer":
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_cancel_lookup_text(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
text = value.lower().strip()
|
||||||
|
replacements = [
|
||||||
|
"kb체크취소", "승인취소", "취소", "환불", "환급", "refund", "reversal",
|
||||||
|
"카드입금", "카드환급", "매출취소", "입금", "출금", "체크카드", "신용카드",
|
||||||
|
]
|
||||||
|
for token in replacements:
|
||||||
|
text = text.replace(token, " ")
|
||||||
|
text = re.sub(r"\s+", " ", text)
|
||||||
|
return _normalize_counterparty_for_match(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _cancel_channel(value: str) -> str:
|
||||||
|
text = (value or "").lower()
|
||||||
|
if any(token in text for token in ("카드", "check", "credit")):
|
||||||
|
return "card"
|
||||||
|
if any(token in text for token in ("이체", "오픈뱅킹", "출금", "입금", "bank")):
|
||||||
|
return "bank"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_cancel_like_transaction(tx: dict[str, Any]) -> bool:
|
||||||
|
if str(tx.get("type") or "") == "refund":
|
||||||
|
return True
|
||||||
|
combined = " ".join(
|
||||||
|
str(tx.get(key) or "") for key in ("merchant", "raw_summary", "payment_method", "memo", "description")
|
||||||
|
).lower()
|
||||||
|
keywords = ("취소", "환불", "환급", "refund", "reversal", "카드입금", "매출취소")
|
||||||
|
return str(tx.get("type") or "") == "income" and any(token in combined for token in keywords)
|
||||||
|
|
||||||
|
|
||||||
|
def find_cancel_original_candidates(transaction_id: str, limit: int = 20, lookback_days: int = 365, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
limit = max(1, min(int(limit), 50))
|
||||||
|
lookback_days = max(1, min(int(lookback_days), 730))
|
||||||
|
with db(db_path) as conn:
|
||||||
|
source_row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN accounts a ON a.id = t.account_id
|
||||||
|
WHERE t.id=?
|
||||||
|
""",
|
||||||
|
(transaction_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not source_row:
|
||||||
|
return fail("not_found", f"transaction not found: {transaction_id}")
|
||||||
|
source = row_to_dict(source_row)
|
||||||
|
explicit_cancel_like = _is_cancel_like_transaction(source)
|
||||||
|
source_type = str(source.get("type") or "")
|
||||||
|
if source_type not in {"income", "refund"} and not explicit_cancel_like:
|
||||||
|
return fail("not_cancel_like", "cancel/original lookup is intended for 취소·환급성 거래")
|
||||||
|
|
||||||
|
source_date = normalize_date(source.get("transaction_date"), default_today=False)
|
||||||
|
source_dt = _parse_tx_datetime(source.get("transaction_datetime"))
|
||||||
|
source_day = date.fromisoformat(source_date)
|
||||||
|
start_date = date.fromordinal(source_day.toordinal() - lookback_days).isoformat()
|
||||||
|
source_text = " ".join(str(source.get(key) or "") for key in ("merchant", "counterparty", "raw_summary", "payment_method"))
|
||||||
|
source_norm_text = _normalize_cancel_lookup_text(source_text)
|
||||||
|
source_channel = _cancel_channel(str(source.get("payment_method") or "") + " " + str(source.get("raw_summary") or "") + " " + str(source.get("merchant") or ""))
|
||||||
|
candidate_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN accounts a ON a.id = t.account_id
|
||||||
|
WHERE t.status='active'
|
||||||
|
AND t.id<>?
|
||||||
|
AND t.amount=?
|
||||||
|
AND t.transaction_date BETWEEN ? AND ?
|
||||||
|
AND t.type IN ('expense', 'adjustment')
|
||||||
|
ORDER BY t.transaction_date DESC, t.transaction_datetime DESC, t.created_at DESC
|
||||||
|
""",
|
||||||
|
(transaction_id, int(source.get("amount") or 0), start_date, source_date),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
candidates: list[dict[str, Any]] = []
|
||||||
|
for row in candidate_rows:
|
||||||
|
candidate = row_to_dict(row)
|
||||||
|
candidate_date = normalize_date(candidate.get("transaction_date"), default_today=False)
|
||||||
|
candidate_day = date.fromisoformat(candidate_date)
|
||||||
|
day_delta = (source_day - candidate_day).days
|
||||||
|
if day_delta < 0:
|
||||||
|
continue
|
||||||
|
candidate_dt = _parse_tx_datetime(candidate.get("transaction_datetime"))
|
||||||
|
time_delta_seconds = abs(int((source_dt - candidate_dt).total_seconds())) if source_dt and candidate_dt else None
|
||||||
|
same_account = bool(source.get("account_id") and source.get("account_id") == candidate.get("account_id"))
|
||||||
|
source_same = bool(source.get("source") and source.get("source") == candidate.get("source"))
|
||||||
|
candidate_text = " ".join(str(candidate.get(key) or "") for key in ("merchant", "counterparty", "raw_summary", "payment_method"))
|
||||||
|
candidate_norm_text = _normalize_cancel_lookup_text(candidate_text)
|
||||||
|
merchant_score = 0
|
||||||
|
if source_norm_text and candidate_norm_text:
|
||||||
|
if source_norm_text == candidate_norm_text:
|
||||||
|
merchant_score = 4
|
||||||
|
elif source_norm_text in candidate_norm_text or candidate_norm_text in source_norm_text:
|
||||||
|
merchant_score = 2
|
||||||
|
channel_score = 0
|
||||||
|
candidate_channel = _cancel_channel(str(candidate.get("payment_method") or "") + " " + str(candidate.get("raw_summary") or "") + " " + str(candidate.get("merchant") or ""))
|
||||||
|
if source_channel != "other" and source_channel == candidate_channel:
|
||||||
|
channel_score = 2
|
||||||
|
if not explicit_cancel_like and not (same_account and merchant_score >= 2 and channel_score >= 1):
|
||||||
|
continue
|
||||||
|
recency_score = 0
|
||||||
|
if day_delta == 0:
|
||||||
|
recency_score = 5
|
||||||
|
elif day_delta <= 7:
|
||||||
|
recency_score = 4
|
||||||
|
elif day_delta <= 30:
|
||||||
|
recency_score = 3
|
||||||
|
elif day_delta <= 90:
|
||||||
|
recency_score = 2
|
||||||
|
else:
|
||||||
|
recency_score = 1
|
||||||
|
time_score = 0
|
||||||
|
if time_delta_seconds is not None:
|
||||||
|
if time_delta_seconds <= 3600:
|
||||||
|
time_score = 2
|
||||||
|
elif time_delta_seconds <= 86400:
|
||||||
|
time_score = 1
|
||||||
|
score = merchant_score + channel_score + recency_score + time_score + (3 if same_account else 0) + (1 if source_same else 0)
|
||||||
|
reason_bits = [
|
||||||
|
f"same_account={int(same_account)}",
|
||||||
|
f"merchant={merchant_score}",
|
||||||
|
f"channel={channel_score}",
|
||||||
|
f"days={day_delta}",
|
||||||
|
]
|
||||||
|
if time_delta_seconds is not None:
|
||||||
|
reason_bits.append(f"time_delta={time_delta_seconds}s")
|
||||||
|
candidates.append({
|
||||||
|
"id": candidate.get("id"),
|
||||||
|
"transaction_date": candidate.get("transaction_date"),
|
||||||
|
"transaction_datetime": candidate.get("transaction_datetime"),
|
||||||
|
"source": candidate.get("source"),
|
||||||
|
"type": candidate.get("type"),
|
||||||
|
"amount": candidate.get("amount"),
|
||||||
|
"category": candidate.get("category"),
|
||||||
|
"subcategory": candidate.get("subcategory"),
|
||||||
|
"merchant": candidate.get("merchant"),
|
||||||
|
"raw_summary": candidate.get("raw_summary"),
|
||||||
|
"counterparty": candidate.get("counterparty"),
|
||||||
|
"account_id": candidate.get("account_id"),
|
||||||
|
"account_name": candidate.get("account_name"),
|
||||||
|
"institution": candidate.get("institution"),
|
||||||
|
"payment_method": candidate.get("payment_method"),
|
||||||
|
"memo": candidate.get("memo"),
|
||||||
|
"score": score,
|
||||||
|
"day_delta": day_delta,
|
||||||
|
"time_delta_seconds": time_delta_seconds,
|
||||||
|
"reason": ", ".join(reason_bits),
|
||||||
|
})
|
||||||
|
|
||||||
|
candidates.sort(key=lambda item: (-int(item.get("score", 0)), int(item.get("day_delta", 9999)), int(item.get("time_delta_seconds") or 99999999), str(item.get("transaction_datetime") or "")))
|
||||||
|
return ok(source_tx=source, count=len(candidates[:limit]), candidates=candidates[:limit], source="cancel_original_candidate_search")
|
||||||
|
|
||||||
|
|
||||||
|
def find_self_transfer_candidates(transaction_id: str, limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
limit = max(1, min(int(limit), 50))
|
||||||
|
with db(db_path) as conn:
|
||||||
|
source_row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN accounts a ON a.id = t.account_id
|
||||||
|
WHERE t.id=?
|
||||||
|
""",
|
||||||
|
(transaction_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not source_row:
|
||||||
|
return fail("not_found", f"transaction not found: {transaction_id}")
|
||||||
|
|
||||||
|
source = row_to_dict(source_row)
|
||||||
|
source_date = normalize_date(source.get("transaction_date"), default_today=False)
|
||||||
|
source_dt = _parse_tx_datetime(source.get("transaction_datetime"))
|
||||||
|
source_holder = _normalize_counterparty_for_match(source.get("holder_name", ""))
|
||||||
|
anchor_date = date.fromisoformat(source_date) if source_date else current_kst_date()
|
||||||
|
window_start = anchor_date.toordinal() - 1
|
||||||
|
window_end = anchor_date.toordinal() + 1
|
||||||
|
candidate_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN accounts a ON a.id = t.account_id
|
||||||
|
WHERE t.status='active'
|
||||||
|
AND t.id<>?
|
||||||
|
AND COALESCE(t.account_id, '')<>COALESCE(?, '')
|
||||||
|
AND t.amount=?
|
||||||
|
AND t.transaction_date BETWEEN ? AND ?
|
||||||
|
ORDER BY t.transaction_date, t.transaction_datetime, t.created_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
transaction_id,
|
||||||
|
source.get("account_id", ""),
|
||||||
|
int(source.get("amount") or 0),
|
||||||
|
date.fromordinal(window_start).isoformat(),
|
||||||
|
date.fromordinal(window_end).isoformat(),
|
||||||
|
),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
candidates: list[dict[str, Any]] = []
|
||||||
|
for row in candidate_rows:
|
||||||
|
candidate = row_to_dict(row)
|
||||||
|
direction_score = _self_transfer_direction_score(str(source.get("type", "")), str(candidate.get("type", "")))
|
||||||
|
if direction_score == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidate_dt = _parse_tx_datetime(candidate.get("transaction_datetime"))
|
||||||
|
day_delta = abs((date.fromisoformat(candidate.get("transaction_date", source_date)) - date.fromisoformat(source_date)).days)
|
||||||
|
time_delta_seconds = abs(int((candidate_dt - source_dt).total_seconds())) if candidate_dt and source_dt else None
|
||||||
|
same_owner = bool(source_holder and source_holder == _normalize_counterparty_for_match(candidate.get("holder_name", "")))
|
||||||
|
party_score = max(
|
||||||
|
_party_match_score(str(source.get("counterparty", "")), str(candidate.get("counterparty", ""))),
|
||||||
|
_party_match_score(str(source.get("counterparty", "")), str(candidate.get("holder_name", ""))),
|
||||||
|
_party_match_score(str(source.get("merchant", "")), str(candidate.get("counterparty", ""))),
|
||||||
|
_party_match_score(str(source.get("merchant", "")), str(candidate.get("holder_name", ""))),
|
||||||
|
)
|
||||||
|
source_gap_score = 1 if source.get("source") != candidate.get("source") else 0
|
||||||
|
day_score = 3 if day_delta == 0 else 1
|
||||||
|
time_score = 0
|
||||||
|
if time_delta_seconds is not None:
|
||||||
|
if time_delta_seconds <= 120:
|
||||||
|
time_score = 3
|
||||||
|
elif time_delta_seconds <= 3600:
|
||||||
|
time_score = 2
|
||||||
|
elif time_delta_seconds <= 86400:
|
||||||
|
time_score = 1
|
||||||
|
score = direction_score + party_score + source_gap_score + day_score + time_score + (3 if same_owner else 0)
|
||||||
|
reason_bits = [
|
||||||
|
f"direction={direction_score}",
|
||||||
|
f"party={party_score}",
|
||||||
|
f"same_owner={int(same_owner)}",
|
||||||
|
f"same_source={int(source.get('source') == candidate.get('source'))}",
|
||||||
|
f"day_delta={day_delta}",
|
||||||
|
]
|
||||||
|
if time_delta_seconds is not None:
|
||||||
|
reason_bits.append(f"time_delta={time_delta_seconds}s")
|
||||||
|
candidates.append(
|
||||||
|
{
|
||||||
|
"id": candidate.get("id"),
|
||||||
|
"transaction_date": candidate.get("transaction_date"),
|
||||||
|
"transaction_datetime": candidate.get("transaction_datetime"),
|
||||||
|
"source": candidate.get("source"),
|
||||||
|
"type": candidate.get("type"),
|
||||||
|
"amount": candidate.get("amount"),
|
||||||
|
"category": candidate.get("category"),
|
||||||
|
"subcategory": candidate.get("subcategory"),
|
||||||
|
"merchant": candidate.get("merchant"),
|
||||||
|
"counterparty": candidate.get("counterparty"),
|
||||||
|
"account_id": candidate.get("account_id"),
|
||||||
|
"account_name": candidate.get("account_name"),
|
||||||
|
"institution": candidate.get("institution"),
|
||||||
|
"holder_name": candidate.get("holder_name"),
|
||||||
|
"memo": candidate.get("memo"),
|
||||||
|
"score": score,
|
||||||
|
"same_owner": same_owner,
|
||||||
|
"day_delta": day_delta,
|
||||||
|
"time_delta_seconds": time_delta_seconds,
|
||||||
|
"reason": ", ".join(reason_bits),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates.sort(key=lambda item: (-int(item.get("score", 0)), int(item.get("day_delta", 99)), int(item.get("time_delta_seconds") or 999999), str(item.get("transaction_datetime") or "")))
|
||||||
|
return ok(source_tx=source, count=len(candidates[:limit]), candidates=candidates[:limit], source="self_transfer_candidate_search")
|
||||||
|
|
||||||
|
|
||||||
|
def link_self_transfer_pair(source_transaction_id: str, candidate_transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
if not source_transaction_id or not candidate_transaction_id:
|
||||||
|
return fail("validation_error", "both transaction ids are required")
|
||||||
|
if source_transaction_id == candidate_transaction_id:
|
||||||
|
return fail("validation_error", "source and candidate transaction must differ")
|
||||||
|
|
||||||
|
preview = find_self_transfer_candidates(source_transaction_id, limit=50, db_path=db_path)
|
||||||
|
if not preview.get("success"):
|
||||||
|
return preview
|
||||||
|
candidates = preview.get("candidates") or []
|
||||||
|
candidate = next((item for item in candidates if item.get("id") == candidate_transaction_id), None)
|
||||||
|
if candidate is None:
|
||||||
|
return fail("candidate_not_found", f"candidate not found for source transaction: {candidate_transaction_id}")
|
||||||
|
|
||||||
|
link_tag = f"manual_self_transfer_link:{source_transaction_id}|{candidate_transaction_id}"
|
||||||
|
ts = now_iso()
|
||||||
|
with db(db_path) as conn:
|
||||||
|
source_row = conn.execute("SELECT * FROM transactions WHERE id=?", (source_transaction_id,)).fetchone()
|
||||||
|
candidate_row = conn.execute("SELECT * FROM transactions WHERE id=?", (candidate_transaction_id,)).fetchone()
|
||||||
|
if not source_row or not candidate_row:
|
||||||
|
return fail("not_found", "source or candidate transaction not found")
|
||||||
|
source_memo = _append_memo_tag(source_row["memo"], link_tag)
|
||||||
|
candidate_memo = _append_memo_tag(candidate_row["memo"], link_tag)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE transactions SET category='이체', subcategory='본인계좌이동', memo=?, updated_at=? WHERE id=?",
|
||||||
|
(source_memo, ts, source_transaction_id),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE transactions SET category='이체', subcategory='본인계좌이동', memo=?, updated_at=? WHERE id=?",
|
||||||
|
(candidate_memo, ts, candidate_transaction_id),
|
||||||
|
)
|
||||||
|
updated_source = conn.execute("SELECT * FROM transactions WHERE id=?", (source_transaction_id,)).fetchone()
|
||||||
|
updated_candidate = conn.execute("SELECT * FROM transactions WHERE id=?", (candidate_transaction_id,)).fetchone()
|
||||||
|
|
||||||
|
return ok(
|
||||||
|
source_transaction=row_to_dict(updated_source),
|
||||||
|
candidate_transaction=row_to_dict(updated_candidate),
|
||||||
|
link_tag=link_tag,
|
||||||
|
score=candidate.get("score"),
|
||||||
|
reason=candidate.get("reason"),
|
||||||
|
source="manual_self_transfer_link",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def link_cancel_to_original(cancel_transaction_id: str, original_transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
if not cancel_transaction_id or not original_transaction_id:
|
||||||
|
return fail("validation_error", "both transaction ids are required")
|
||||||
|
if cancel_transaction_id == original_transaction_id:
|
||||||
|
return fail("validation_error", "cancel and original transaction must differ")
|
||||||
|
|
||||||
|
preview = find_cancel_original_candidates(cancel_transaction_id, limit=50, db_path=db_path)
|
||||||
|
if not preview.get("success"):
|
||||||
|
return preview
|
||||||
|
candidates = preview.get("candidates") or []
|
||||||
|
original = next((item for item in candidates if item.get("id") == original_transaction_id), None)
|
||||||
|
if original is None:
|
||||||
|
return fail("candidate_not_found", f"original transaction candidate not found: {original_transaction_id}")
|
||||||
|
|
||||||
|
link_tag = f"manual_cancel_link:cancel={cancel_transaction_id}|original={original_transaction_id}"
|
||||||
|
ts = now_iso()
|
||||||
|
with db(db_path) as conn:
|
||||||
|
cancel_row = conn.execute("SELECT * FROM transactions WHERE id=?", (cancel_transaction_id,)).fetchone()
|
||||||
|
original_row = conn.execute("SELECT * FROM transactions WHERE id=?", (original_transaction_id,)).fetchone()
|
||||||
|
if not cancel_row or not original_row:
|
||||||
|
return fail("not_found", "cancel or original transaction not found")
|
||||||
|
|
||||||
|
cancel_memo = _append_memo_tag(cancel_row["memo"], link_tag)
|
||||||
|
original_memo = _append_memo_tag(original_row["memo"], link_tag)
|
||||||
|
original_category = original_row["category"] or "수입"
|
||||||
|
original_subcategory = original_row["subcategory"] or "환급"
|
||||||
|
original_person = original_row["person"] or ""
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE transactions SET type='refund', category=?, subcategory=?, person=?, memo=?, updated_at=? WHERE id=?",
|
||||||
|
(original_category, original_subcategory, original_person, cancel_memo, ts, cancel_transaction_id),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE transactions SET memo=?, updated_at=? WHERE id=?",
|
||||||
|
(original_memo, ts, original_transaction_id),
|
||||||
|
)
|
||||||
|
updated_cancel = conn.execute("SELECT * FROM transactions WHERE id=?", (cancel_transaction_id,)).fetchone()
|
||||||
|
updated_original = conn.execute("SELECT * FROM transactions WHERE id=?", (original_transaction_id,)).fetchone()
|
||||||
|
|
||||||
|
return ok(
|
||||||
|
cancel_transaction=row_to_dict(updated_cancel),
|
||||||
|
original_transaction=row_to_dict(updated_original),
|
||||||
|
link_tag=link_tag,
|
||||||
|
score=original.get("score"),
|
||||||
|
reason=original.get("reason"),
|
||||||
|
source="manual_cancel_link",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_memos(conn: Any, tx_id: str, tag: str) -> None:
|
||||||
|
existing = conn.execute("SELECT memo FROM transactions WHERE id=?", (tx_id,)).fetchone()
|
||||||
|
if not existing:
|
||||||
|
return
|
||||||
|
memo = _append_memo_tag(existing["memo"], tag)
|
||||||
|
conn.execute("UPDATE transactions SET memo=?, updated_at=? WHERE id=?", (memo, now_iso(), tx_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cross_account_transfer_match(limit: int = 500, dry_run: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
limit = max(1, min(int(limit), 1000))
|
||||||
|
matched_pairs: list[dict[str, Any]] = []
|
||||||
|
review_items: list[dict[str, Any]] = []
|
||||||
|
matched_tx_ids: set[str] = set()
|
||||||
|
|
||||||
|
with db(db_path) as conn:
|
||||||
|
kb_rows = _fetch_transfer_candidates(conn, "kb_bank_pdf")
|
||||||
|
toss_rows = _fetch_transfer_candidates(conn, "toss_bank_pdf")
|
||||||
|
toss_index = _build_cross_transfer_index(toss_rows)
|
||||||
|
|
||||||
|
for kb in kb_rows:
|
||||||
|
if kb["status"] != "active":
|
||||||
|
continue
|
||||||
|
if _memo_has_tag(kb.get("memo", ""), CROSS_TRANSFER_MATCH_TAG):
|
||||||
|
continue
|
||||||
|
required_type = "income" if kb["type"] == "expense" else "expense"
|
||||||
|
candidates = [
|
||||||
|
t
|
||||||
|
for t in toss_index.get((kb["transaction_date"], int(kb["amount"]), required_type), [])
|
||||||
|
if t.get("status") == "active" and not _memo_has_tag(t.get("memo", ""), CROSS_TRANSFER_MATCH_TAG)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
review_items.append(
|
||||||
|
{
|
||||||
|
"source_tx_id": kb["id"],
|
||||||
|
"source": "kb_bank_pdf",
|
||||||
|
"target_tx_id": "",
|
||||||
|
"reason": "no_candidate_found",
|
||||||
|
"type": kb["type"],
|
||||||
|
"amount": kb["amount"],
|
||||||
|
"transaction_date": kb["transaction_date"],
|
||||||
|
"counterparty": kb["counterparty"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
scored = []
|
||||||
|
for candidate in candidates:
|
||||||
|
score = _party_match_score(kb.get("counterparty", ""), candidate.get("counterparty", ""))
|
||||||
|
scored.append((score, candidate["transaction_datetime"], candidate))
|
||||||
|
scored.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
||||||
|
best_score = scored[0][0]
|
||||||
|
top = [c for c in scored if c[0] == best_score]
|
||||||
|
|
||||||
|
if best_score == 0 or len(top) != 1:
|
||||||
|
review_items.append(
|
||||||
|
{
|
||||||
|
"source_tx_id": kb["id"],
|
||||||
|
"source": "kb_bank_pdf",
|
||||||
|
"target_tx_id": "",
|
||||||
|
"reason": "ambiguous_or_name_mismatch",
|
||||||
|
"candidate_count": len(candidates),
|
||||||
|
"best_name_score": best_score,
|
||||||
|
"type": kb["type"],
|
||||||
|
"amount": kb["amount"],
|
||||||
|
"transaction_date": kb["transaction_date"],
|
||||||
|
"counterparty": kb["counterparty"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
toss = top[0][2]
|
||||||
|
if toss["id"] in matched_tx_ids:
|
||||||
|
review_items.append(
|
||||||
|
{
|
||||||
|
"source_tx_id": kb["id"],
|
||||||
|
"source": "kb_bank_pdf",
|
||||||
|
"target_tx_id": toss["id"],
|
||||||
|
"reason": "target_already_matched",
|
||||||
|
"type": kb["type"],
|
||||||
|
"amount": kb["amount"],
|
||||||
|
"transaction_date": kb["transaction_date"],
|
||||||
|
"counterparty": kb["counterparty"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
kb_party = _normalize_counterparty_for_match(kb.get("counterparty", ""))
|
||||||
|
toss_party = _normalize_counterparty_for_match(toss.get("counterparty", ""))
|
||||||
|
owner_candidates = {
|
||||||
|
_normalize_counterparty_for_match(kb.get("holder_name", "")),
|
||||||
|
_normalize_counterparty_for_match(toss.get("holder_name", "")),
|
||||||
|
}
|
||||||
|
if (kb_party and kb_party in owner_candidates) or (toss_party and toss_party in owner_candidates):
|
||||||
|
subcategory = "투자이동"
|
||||||
|
else:
|
||||||
|
subcategory = "확인필요"
|
||||||
|
|
||||||
|
matched_pairs.append(
|
||||||
|
{
|
||||||
|
"kb_tx_id": kb["id"],
|
||||||
|
"toss_tx_id": toss["id"],
|
||||||
|
"transaction_date": kb["transaction_date"],
|
||||||
|
"amount": kb["amount"],
|
||||||
|
"counterparty_kb": kb["counterparty"],
|
||||||
|
"counterparty_toss": toss["counterparty"],
|
||||||
|
"subcategory": subcategory,
|
||||||
|
"name_score": best_score,
|
||||||
|
"reason": "paired_by_date_amount_party",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
review_tag = f"{CROSS_TRANSFER_MATCH_TAG}kb={kb['id']}|toss={toss['id']}"
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE transactions SET category='이체', subcategory=?, memo=?, updated_at=? WHERE id=?",
|
||||||
|
(subcategory, _append_memo_tag(kb.get("memo", ""), review_tag), now_iso(), kb["id"]),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE transactions SET category='이체', subcategory=?, memo=?, updated_at=? WHERE id=?",
|
||||||
|
(subcategory, _append_memo_tag(toss.get("memo", ""), review_tag), now_iso(), toss["id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
_tag_memos(conn, kb["id"], f"{CROSS_TRANSFER_REVIEW_TAG}paired")
|
||||||
|
_tag_memos(conn, toss["id"], f"{CROSS_TRANSFER_REVIEW_TAG}paired")
|
||||||
|
matched_tx_ids.add(kb["id"])
|
||||||
|
matched_tx_ids.add(toss["id"])
|
||||||
|
|
||||||
|
if len(matched_pairs) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
for review in review_items:
|
||||||
|
if review["source_tx_id"] in matched_tx_ids:
|
||||||
|
continue
|
||||||
|
if not dry_run:
|
||||||
|
_tag_memos(conn, review["source_tx_id"], f"{CROSS_TRANSFER_REVIEW_TAG}{review['reason']}")
|
||||||
|
|
||||||
|
|
||||||
|
return ok(
|
||||||
|
dry_run=dry_run,
|
||||||
|
matched_count=len(matched_pairs),
|
||||||
|
review_count=len(review_items),
|
||||||
|
matched_pairs=matched_pairs[:limit],
|
||||||
|
review_items=review_items[:limit],
|
||||||
|
source="kb_toss_cross_transfer_match",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def preview_cross_account_transfers(limit: int = 500, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
return _run_cross_account_transfer_match(limit=limit, dry_run=True, db_path=db_path)
|
||||||
|
|
||||||
|
|
||||||
|
def run_cross_account_transfer_match(limit: int = 500, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
return _run_cross_account_transfer_match(limit=limit, dry_run=False, db_path=db_path)
|
||||||
|
|
||||||
|
|
||||||
|
def list_cross_transfer_review_queue(limit: int = 300, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
clauses = ["t.status='active'", "t.source IN ('kb_bank_pdf','toss_bank_pdf')"]
|
||||||
|
clauses.append("t.memo LIKE '%cross_transfer_review:%'")
|
||||||
|
where = " WHERE " + " AND ".join(clauses)
|
||||||
|
with db(db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM transactions t{where} ORDER BY t.transaction_date DESC, t.created_at DESC LIMIT ?",
|
||||||
|
(max(1, min(int(limit), 1000)),),
|
||||||
|
).fetchall()
|
||||||
|
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows), source="cross_transfer_review_queue")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,922 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .shared import *
|
||||||
|
from .shared import _append_memo_tag, _memo_has_tag, _normalize_counterparty_for_match, _party_match_score
|
||||||
|
|
||||||
|
def preview_ledger_csv_import(file_path: str, source: str = "generic", db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
path=Path(file_path).expanduser()
|
||||||
|
if not path.exists():
|
||||||
|
return fail("not_found", f"CSV file not found: {path}")
|
||||||
|
with path.open("r", encoding="utf-8-sig", newline="") as f:
|
||||||
|
rows=list(csv.DictReader(f))
|
||||||
|
preview=[]
|
||||||
|
for r in rows[:20]:
|
||||||
|
preview.append(r)
|
||||||
|
return ok(source=source, filename=str(path), row_count=len(rows), preview=preview, required_columns=["transaction_date/date", "amount", "type optional", "category optional", "merchant/description optional"])
|
||||||
|
|
||||||
|
|
||||||
|
def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
parsed = parse_kb_bank_pdf(file_path, password)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
return fail("not_found", str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
return fail("pdf_password_error", str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return fail("pdf_parse_error", str(e))
|
||||||
|
transactions = parsed["transactions"]
|
||||||
|
validation = parsed["validation"]
|
||||||
|
source_hash = parsed["source_hash"]
|
||||||
|
duplicate_count = 0
|
||||||
|
with db(db_path) as conn:
|
||||||
|
for tx in transactions:
|
||||||
|
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, tx.counterparty, "", tx.external_id(source_hash))
|
||||||
|
found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone()
|
||||||
|
duplicate_count += 1 if found else 0
|
||||||
|
preview = [transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]]
|
||||||
|
return ok(
|
||||||
|
source="kb_bank_pdf",
|
||||||
|
file_path=parsed["file_path"],
|
||||||
|
source_hash=source_hash,
|
||||||
|
page_count=parsed["page_count"],
|
||||||
|
metadata=parsed["metadata"],
|
||||||
|
row_count=len(transactions),
|
||||||
|
duplicate_count=duplicate_count,
|
||||||
|
validation=validation,
|
||||||
|
preview=preview,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_or_create_kb_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str:
|
||||||
|
if account_id:
|
||||||
|
row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||||
|
if row:
|
||||||
|
return account_id
|
||||||
|
raise ValueError(f"account_id not found: {account_id}")
|
||||||
|
account_number_masked = metadata.get("account_number_masked") or ""
|
||||||
|
row = None
|
||||||
|
if account_number_masked:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1",
|
||||||
|
("KB국민은행", account_number_masked),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return row["id"]
|
||||||
|
ts = now_iso()
|
||||||
|
new_account_id = new_id("acct")
|
||||||
|
account_name = metadata.get("account_name") or "KB국민은행 계좌"
|
||||||
|
display_name = f"KB국민은행 {account_name} {account_number_masked}".strip()
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
new_account_id,
|
||||||
|
display_name,
|
||||||
|
"bank",
|
||||||
|
"KB국민은행",
|
||||||
|
"KRW",
|
||||||
|
1,
|
||||||
|
"국민은행 거래내역 PDF import로 자동 생성",
|
||||||
|
account_number_masked,
|
||||||
|
metadata.get("holder_name") or "",
|
||||||
|
metadata.get("final_balance"),
|
||||||
|
ts,
|
||||||
|
ts,
|
||||||
|
ts,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return new_account_id
|
||||||
|
|
||||||
|
|
||||||
|
def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None, skip_validation: bool = False) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
parsed = parse_kb_bank_pdf(file_path, password)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
return fail("not_found", str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
return fail("pdf_password_error", str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return fail("pdf_parse_error", str(e))
|
||||||
|
transactions = parsed["transactions"]
|
||||||
|
validation = parsed["validation"]
|
||||||
|
if not validation.get("ok") and not skip_validation:
|
||||||
|
return fail("balance_validation_failed", "KB Bank PDF balance flow validation failed", validation=validation)
|
||||||
|
source_hash = parsed["source_hash"]
|
||||||
|
metadata = parsed["metadata"]
|
||||||
|
ts = now_iso()
|
||||||
|
batch_id = new_id("imp")
|
||||||
|
inserted = 0
|
||||||
|
skipped_duplicates = 0
|
||||||
|
inserted_ids: list[str] = []
|
||||||
|
try:
|
||||||
|
with db(db_path) as conn:
|
||||||
|
linked_account_id = _find_or_create_kb_account(conn, metadata, account_id)
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo)
|
||||||
|
VALUES(?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
batch_id,
|
||||||
|
"kb_bank_pdf",
|
||||||
|
parsed["file_path"],
|
||||||
|
ts,
|
||||||
|
0,
|
||||||
|
"active",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"source_hash": source_hash,
|
||||||
|
"page_count": parsed["page_count"],
|
||||||
|
"metadata": metadata,
|
||||||
|
"validation": validation,
|
||||||
|
"parsed_row_count": len(transactions),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for tx in transactions:
|
||||||
|
tx_id = new_id("tx")
|
||||||
|
external_id = tx.external_id(source_hash)
|
||||||
|
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, tx.counterparty, linked_account_id, external_id)
|
||||||
|
payload = tx.raw_payload()
|
||||||
|
description = f"국민은행 PDF import; 적요={tx.raw_summary}; 거래점={tx.branch}; 잔액={tx.balance_after}"
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
tx_id,
|
||||||
|
tx.transaction_date,
|
||||||
|
"",
|
||||||
|
tx.type,
|
||||||
|
tx.amount,
|
||||||
|
"KRW",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
tx.counterparty,
|
||||||
|
description,
|
||||||
|
linked_account_id,
|
||||||
|
tx.raw_summary,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
normalize_tags(["kb_bank_pdf", tx.raw_summary] if tx.raw_summary else ["kb_bank_pdf"]),
|
||||||
|
0,
|
||||||
|
"kb_bank_pdf",
|
||||||
|
batch_id,
|
||||||
|
external_id,
|
||||||
|
dedupe_key,
|
||||||
|
tx.remittance_memo,
|
||||||
|
tx.transaction_datetime,
|
||||||
|
tx.counterparty,
|
||||||
|
tx.balance_after,
|
||||||
|
tx.branch,
|
||||||
|
tx.raw_summary,
|
||||||
|
payload,
|
||||||
|
ts,
|
||||||
|
ts,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
inserted_ids.append(tx_id)
|
||||||
|
except Exception as e:
|
||||||
|
if "UNIQUE constraint failed" in str(e) and skip_duplicates:
|
||||||
|
skipped_duplicates += 1
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
|
||||||
|
if transactions:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?",
|
||||||
|
(transactions[-1].balance_after, ts, ts, linked_account_id),
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return fail("validation_error", str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return fail("db_error", str(e))
|
||||||
|
return ok(
|
||||||
|
import_batch_id=batch_id,
|
||||||
|
account_id=linked_account_id,
|
||||||
|
source="kb_bank_pdf",
|
||||||
|
parsed_row_count=len(transactions),
|
||||||
|
inserted_count=inserted,
|
||||||
|
skipped_duplicate_count=skipped_duplicates,
|
||||||
|
inserted_transaction_ids=inserted_ids[:20],
|
||||||
|
validation=validation,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_or_create_seoulpay_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str:
|
||||||
|
if account_id:
|
||||||
|
row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||||
|
if row:
|
||||||
|
return account_id
|
||||||
|
raise ValueError(f"account_id not found: {account_id}")
|
||||||
|
holder_name = metadata.get("holder_name") or ""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM accounts WHERE institution=? AND name=? AND active=1 ORDER BY created_at LIMIT 1",
|
||||||
|
("서울페이", f"서울페이 이용내역 {holder_name}".strip()),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return row["id"]
|
||||||
|
ts = now_iso()
|
||||||
|
new_account_id = new_id("acct")
|
||||||
|
display_name = f"서울페이 이용내역 {holder_name}".strip()
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
new_account_id,
|
||||||
|
display_name,
|
||||||
|
"cash",
|
||||||
|
"서울페이",
|
||||||
|
"KRW",
|
||||||
|
1,
|
||||||
|
"서울페이 이용내역 XLSX import로 자동 생성",
|
||||||
|
"",
|
||||||
|
holder_name,
|
||||||
|
None,
|
||||||
|
ts,
|
||||||
|
ts,
|
||||||
|
ts,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return new_account_id
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_seoulpay_transaction(tx: Any) -> tuple[str, str, str, bool]:
|
||||||
|
merchant = (tx.merchant or "").strip()
|
||||||
|
text = f"{merchant} {tx.transaction_kind} {tx.payment_kind}"
|
||||||
|
if tx.type == "refund":
|
||||||
|
return "수입", "환급", merchant, False
|
||||||
|
if "땡겨요" in text or "배달" in text:
|
||||||
|
return "식비", "배달", merchant, False
|
||||||
|
if any(keyword in text for keyword in ("교습소", "학원", "바이올린", "피아노")):
|
||||||
|
return "교육", "학원", merchant, False
|
||||||
|
if any(keyword in text for keyword in ("김영모", "베이커리", "제과", "카페", "커피")):
|
||||||
|
return "식비", "카페", merchant, False
|
||||||
|
if "온라인" in text:
|
||||||
|
return "쇼핑", "온라인", merchant, False
|
||||||
|
return "쇼핑", "생활용품", merchant, False
|
||||||
|
|
||||||
|
|
||||||
|
def _find_or_create_toss_bank_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str:
|
||||||
|
if account_id:
|
||||||
|
row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||||
|
if row:
|
||||||
|
return account_id
|
||||||
|
raise ValueError(f"account_id not found: {account_id}")
|
||||||
|
account_number_masked = metadata.get("account_number_masked") or ""
|
||||||
|
row = None
|
||||||
|
if account_number_masked:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1",
|
||||||
|
("토스뱅크", account_number_masked),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return row["id"]
|
||||||
|
ts = now_iso()
|
||||||
|
new_account_id = new_id("acct")
|
||||||
|
account_name = metadata.get("account_name") or "토스뱅크 계좌"
|
||||||
|
display_name = f"토스뱅크 {account_name} {account_number_masked}".strip()
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
new_account_id,
|
||||||
|
display_name,
|
||||||
|
"bank",
|
||||||
|
"토스뱅크",
|
||||||
|
"KRW",
|
||||||
|
1,
|
||||||
|
"토스뱅크 거래내역 PDF import로 자동 생성",
|
||||||
|
account_number_masked,
|
||||||
|
metadata.get("holder_name") or "",
|
||||||
|
metadata.get("final_balance"),
|
||||||
|
ts,
|
||||||
|
ts,
|
||||||
|
ts,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return new_account_id
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_toss_bank_transaction(tx: Any, owner_name: str = "") -> tuple[str, str, str, bool]:
|
||||||
|
action = (tx.action or "").strip()
|
||||||
|
counterparty = (tx.counterparty or "").strip()
|
||||||
|
owner_name = (owner_name or "").strip()
|
||||||
|
if "이자" in action:
|
||||||
|
return "수입", "이자", counterparty, False
|
||||||
|
if "캐시백" in action:
|
||||||
|
return "수입", "캐시백", counterparty, False
|
||||||
|
if "체크카드결제" in action:
|
||||||
|
return "식비", "카드결제", counterparty, False
|
||||||
|
transfer_like = {
|
||||||
|
"이체",
|
||||||
|
"내계좌간자동이체",
|
||||||
|
"오픈뱅킹",
|
||||||
|
"펌뱅킹출금",
|
||||||
|
"펌뱅킹입금",
|
||||||
|
"적금 개설",
|
||||||
|
"출금",
|
||||||
|
"입금",
|
||||||
|
"ATM출금",
|
||||||
|
}
|
||||||
|
if any(keyword in action for keyword in transfer_like):
|
||||||
|
if counterparty and owner_name and counterparty != owner_name:
|
||||||
|
return "이체", "확인필요", counterparty, False
|
||||||
|
return "이체", "본인계좌이동", counterparty, False
|
||||||
|
if "캐시백" in action:
|
||||||
|
return "수입", "캐시백", counterparty, False
|
||||||
|
return "이체", "확인필요", counterparty, False
|
||||||
|
|
||||||
|
def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
parsed = parse_seoulpay_xlsx(file_path, password=password)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
return fail("not_found", str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
return fail("xlsx_parse_error", str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return fail("xlsx_parse_error", str(e))
|
||||||
|
|
||||||
|
transactions = parsed["transactions"]
|
||||||
|
source_hash = parsed["source_hash"]
|
||||||
|
duplicate_count = 0
|
||||||
|
by_category: dict[str, dict[str, int]] = {}
|
||||||
|
with db(db_path) as conn:
|
||||||
|
linked_account_id = ""
|
||||||
|
metadata = parsed["metadata"]
|
||||||
|
holder_name = metadata.get("holder_name") or ""
|
||||||
|
if holder_name:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM accounts WHERE institution=? AND name=? AND active=1 ORDER BY created_at LIMIT 1",
|
||||||
|
("서울페이", f"서울페이 이용내역 {holder_name}".strip()),
|
||||||
|
).fetchone()
|
||||||
|
linked_account_id = row["id"] if row else ""
|
||||||
|
for tx in transactions:
|
||||||
|
category, subcategory, _, _ = _classify_seoulpay_transaction(tx)
|
||||||
|
key = f"{category}/{subcategory}"
|
||||||
|
item = by_category.setdefault(key, {"count": 0, "amount": 0})
|
||||||
|
item["count"] += 1
|
||||||
|
item["amount"] += int(tx.amount)
|
||||||
|
merchant = f"서울페이 {tx.merchant}".strip()
|
||||||
|
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash))
|
||||||
|
found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone()
|
||||||
|
duplicate_count += 1 if found else 0
|
||||||
|
|
||||||
|
preview = [seoulpay_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]]
|
||||||
|
return ok(
|
||||||
|
source="seoulpay_xlsx",
|
||||||
|
file_path=parsed["file_path"],
|
||||||
|
source_hash=source_hash,
|
||||||
|
sheet_name=parsed["sheet_name"],
|
||||||
|
metadata=parsed["metadata"],
|
||||||
|
row_count=len(transactions),
|
||||||
|
duplicate_count=duplicate_count,
|
||||||
|
validation=parsed["validation"],
|
||||||
|
by_category=by_category,
|
||||||
|
preview=preview,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
parsed = parse_seoulpay_xlsx(file_path, password=password)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
return fail("not_found", str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
return fail("xlsx_parse_error", str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return fail("xlsx_parse_error", str(e))
|
||||||
|
|
||||||
|
transactions = parsed["transactions"]
|
||||||
|
validation = parsed["validation"]
|
||||||
|
if not validation.get("ok"):
|
||||||
|
return fail("xlsx_parse_error", "SeoulPay XLSX parsing produced invalid transactions", validation=validation)
|
||||||
|
|
||||||
|
source_hash = parsed["source_hash"]
|
||||||
|
metadata = parsed["metadata"]
|
||||||
|
ts = now_iso()
|
||||||
|
batch_id = new_id("imp")
|
||||||
|
inserted = 0
|
||||||
|
skipped_duplicates = 0
|
||||||
|
inserted_ids: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db(db_path) as conn:
|
||||||
|
linked_account_id = _find_or_create_seoulpay_account(conn, metadata, account_id)
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo)
|
||||||
|
VALUES(?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
batch_id,
|
||||||
|
"seoulpay_xlsx",
|
||||||
|
parsed["file_path"],
|
||||||
|
ts,
|
||||||
|
0,
|
||||||
|
"active",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"source_hash": source_hash,
|
||||||
|
"sheet_name": parsed["sheet_name"],
|
||||||
|
"metadata": metadata,
|
||||||
|
"validation": validation,
|
||||||
|
"parsed_row_count": len(transactions),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for tx in transactions:
|
||||||
|
tx_id = new_id("tx")
|
||||||
|
external_id = tx.external_id(source_hash)
|
||||||
|
merchant = f"서울페이 {tx.merchant}".strip()
|
||||||
|
category, subcategory, person, is_fixed = _classify_seoulpay_transaction(tx)
|
||||||
|
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id)
|
||||||
|
description = f"서울페이 XLSX import; 거래유형={tx.transaction_kind}; 결제구분={tx.payment_kind}; 거래번호={tx.transaction_no}; 가맹점ID={tx.merchant_id}"
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
tx_id,
|
||||||
|
tx.transaction_date,
|
||||||
|
"",
|
||||||
|
tx.type,
|
||||||
|
tx.amount,
|
||||||
|
"KRW",
|
||||||
|
category,
|
||||||
|
subcategory,
|
||||||
|
merchant,
|
||||||
|
description,
|
||||||
|
linked_account_id,
|
||||||
|
tx.payment_kind,
|
||||||
|
person,
|
||||||
|
"",
|
||||||
|
normalize_tags(["seoulpay_xlsx", tx.channel, tx.payment_kind, tx.transaction_kind]),
|
||||||
|
int(is_fixed),
|
||||||
|
"seoulpay_xlsx",
|
||||||
|
batch_id,
|
||||||
|
external_id,
|
||||||
|
dedupe_key,
|
||||||
|
tx.payment_kind,
|
||||||
|
tx.transaction_datetime,
|
||||||
|
tx.merchant,
|
||||||
|
None,
|
||||||
|
tx.channel,
|
||||||
|
tx.transaction_kind,
|
||||||
|
tx.raw_payload(),
|
||||||
|
ts,
|
||||||
|
ts,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
inserted_ids.append(tx_id)
|
||||||
|
except Exception as e:
|
||||||
|
if "UNIQUE constraint failed" in str(e) and skip_duplicates:
|
||||||
|
skipped_duplicates += 1
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
|
||||||
|
except ValueError as e:
|
||||||
|
return fail("validation_error", str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return fail("db_error", str(e))
|
||||||
|
|
||||||
|
return ok(
|
||||||
|
import_batch_id=batch_id,
|
||||||
|
account_id=linked_account_id,
|
||||||
|
source="seoulpay_xlsx",
|
||||||
|
parsed_row_count=len(transactions),
|
||||||
|
inserted_count=inserted,
|
||||||
|
skipped_duplicate_count=skipped_duplicates,
|
||||||
|
inserted_transaction_ids=inserted_ids[:20],
|
||||||
|
validation=validation,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
parsed = parse_toss_bank_pdf(file_path)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
return fail("not_found", str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
return fail("pdf_parse_error", str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return fail("pdf_parse_error", str(e))
|
||||||
|
|
||||||
|
transactions = parsed["transactions"]
|
||||||
|
source_hash = parsed["source_hash"]
|
||||||
|
duplicate_count = 0
|
||||||
|
with db(db_path) as conn:
|
||||||
|
metadata = parsed["metadata"]
|
||||||
|
account_number_masked = metadata.get("account_number_masked") or ""
|
||||||
|
linked_account_id = ""
|
||||||
|
if account_number_masked:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1",
|
||||||
|
("토스뱅크", account_number_masked),
|
||||||
|
).fetchone()
|
||||||
|
linked_account_id = row["id"] if row else ""
|
||||||
|
for tx in transactions:
|
||||||
|
merchant = f"토스뱅크 {tx.action}" + (f" {tx.counterparty}" if tx.counterparty else "")
|
||||||
|
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash))
|
||||||
|
found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone()
|
||||||
|
duplicate_count += 1 if found else 0
|
||||||
|
|
||||||
|
preview = [toss_bank_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]]
|
||||||
|
by_action: dict[str, dict[str, int]] = {}
|
||||||
|
for tx in transactions:
|
||||||
|
item = by_action.setdefault(tx.action, {"count": 0, "amount": 0})
|
||||||
|
item["count"] += 1
|
||||||
|
item["amount"] += int(tx.amount)
|
||||||
|
return ok(
|
||||||
|
source="toss_bank_pdf",
|
||||||
|
file_path=parsed["file_path"],
|
||||||
|
source_hash=source_hash,
|
||||||
|
page_count=parsed["page_count"],
|
||||||
|
metadata=parsed["metadata"],
|
||||||
|
row_count=len(transactions),
|
||||||
|
duplicate_count=duplicate_count,
|
||||||
|
validation=parsed["validation"],
|
||||||
|
by_action=by_action,
|
||||||
|
preview=preview,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
parsed = parse_toss_bank_pdf(file_path)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
return fail("not_found", str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
return fail("pdf_parse_error", str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return fail("pdf_parse_error", str(e))
|
||||||
|
|
||||||
|
transactions = parsed["transactions"]
|
||||||
|
validation = parsed["validation"]
|
||||||
|
if not validation.get("ok"):
|
||||||
|
return fail("pdf_parse_error", "Toss Bank PDF parsing produced no valid transactions", validation=validation)
|
||||||
|
|
||||||
|
source_hash = parsed["source_hash"]
|
||||||
|
metadata = parsed["metadata"]
|
||||||
|
ts = now_iso()
|
||||||
|
batch_id = new_id("imp")
|
||||||
|
inserted = 0
|
||||||
|
skipped_duplicates = 0
|
||||||
|
inserted_ids: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db(db_path) as conn:
|
||||||
|
linked_account_id = _find_or_create_toss_bank_account(conn, metadata, account_id)
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo)
|
||||||
|
VALUES(?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
batch_id,
|
||||||
|
"toss_bank_pdf",
|
||||||
|
parsed["file_path"],
|
||||||
|
ts,
|
||||||
|
0,
|
||||||
|
"active",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"source_hash": source_hash,
|
||||||
|
"page_count": parsed["page_count"],
|
||||||
|
"metadata": metadata,
|
||||||
|
"validation": validation,
|
||||||
|
"parsed_row_count": len(transactions),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for tx in transactions:
|
||||||
|
tx_id = new_id("tx")
|
||||||
|
external_id = tx.external_id(source_hash)
|
||||||
|
merchant = f"토스뱅크 {tx.action}" + (f" {tx.counterparty}" if tx.counterparty else "")
|
||||||
|
category, subcategory, person, is_fixed = _classify_toss_bank_transaction(tx, metadata.get("holder_name") or "")
|
||||||
|
if category == "이체":
|
||||||
|
tx_type = "transfer"
|
||||||
|
else:
|
||||||
|
tx_type = tx.type
|
||||||
|
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id)
|
||||||
|
description = f"토스뱅크 PDF import; 구분={tx.action}; 상대={tx.counterparty}; 잔액={tx.balance_after}"
|
||||||
|
payload = tx.raw_payload()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
tx_id,
|
||||||
|
tx.transaction_date,
|
||||||
|
"",
|
||||||
|
tx_type,
|
||||||
|
tx.amount,
|
||||||
|
"KRW",
|
||||||
|
category,
|
||||||
|
subcategory,
|
||||||
|
merchant,
|
||||||
|
description,
|
||||||
|
linked_account_id,
|
||||||
|
tx.action,
|
||||||
|
person,
|
||||||
|
"",
|
||||||
|
normalize_tags(["toss_bank_pdf", tx.action] + ([tx.counterparty] if tx.counterparty else [])),
|
||||||
|
int(is_fixed),
|
||||||
|
"toss_bank_pdf",
|
||||||
|
batch_id,
|
||||||
|
external_id,
|
||||||
|
dedupe_key,
|
||||||
|
tx.action,
|
||||||
|
tx.transaction_datetime,
|
||||||
|
tx.counterparty,
|
||||||
|
tx.balance_after,
|
||||||
|
"토스뱅크",
|
||||||
|
tx.raw_summary,
|
||||||
|
payload,
|
||||||
|
ts,
|
||||||
|
ts,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
inserted_ids.append(tx_id)
|
||||||
|
except Exception as e:
|
||||||
|
if "UNIQUE constraint failed" in str(e) and skip_duplicates:
|
||||||
|
skipped_duplicates += 1
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
|
||||||
|
if transactions:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?",
|
||||||
|
(transactions[-1].balance_after, ts, ts, linked_account_id),
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return fail("validation_error", str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return fail("db_error", str(e))
|
||||||
|
|
||||||
|
return ok(
|
||||||
|
import_batch_id=batch_id,
|
||||||
|
account_id=linked_account_id,
|
||||||
|
source="toss_bank_pdf",
|
||||||
|
parsed_row_count=len(transactions),
|
||||||
|
inserted_count=inserted,
|
||||||
|
skipped_duplicate_count=skipped_duplicates,
|
||||||
|
inserted_transaction_ids=inserted_ids[:20],
|
||||||
|
validation=validation,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_or_create_toss_securities_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str:
|
||||||
|
if account_id:
|
||||||
|
row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||||
|
if row:
|
||||||
|
return account_id
|
||||||
|
raise ValueError(f"account_id not found: {account_id}")
|
||||||
|
account_number_masked = metadata.get("account_number_masked") or ""
|
||||||
|
row = None
|
||||||
|
if account_number_masked:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1",
|
||||||
|
("토스증권", account_number_masked),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return row["id"]
|
||||||
|
ts = now_iso()
|
||||||
|
new_account_id = new_id("acct")
|
||||||
|
display_name = f"토스증권 {metadata.get('account_name') or '계좌'} {account_number_masked}".strip()
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
new_account_id,
|
||||||
|
display_name,
|
||||||
|
"investment",
|
||||||
|
"토스증권",
|
||||||
|
"KRW",
|
||||||
|
1,
|
||||||
|
"토스증권 거래내역 PDF import로 자동 생성",
|
||||||
|
account_number_masked,
|
||||||
|
metadata.get("holder_name") or "",
|
||||||
|
None,
|
||||||
|
ts,
|
||||||
|
ts,
|
||||||
|
ts,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return new_account_id
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_toss_transaction(tx: Any, owner_name: str = "") -> tuple[str, str, str, bool]:
|
||||||
|
action = tx.action
|
||||||
|
counterparty = (tx.name or "").strip()
|
||||||
|
owner_name = (owner_name or "").strip()
|
||||||
|
if "배당" in action:
|
||||||
|
return "수입", "배당", counterparty, False
|
||||||
|
if "이자" in action:
|
||||||
|
return "수입", "이자", counterparty, False
|
||||||
|
if "환급" in action:
|
||||||
|
return "수입", "환급", counterparty, False
|
||||||
|
if "이체" in action:
|
||||||
|
# 토스증권 입출금에서 상대가 계좌 소유자와 다르면 본인 투자 이동으로 확정하지 않는다.
|
||||||
|
# 예: 이체출금(국민은행) 조상문 -> 외부/타인 송금 가능성이 있어 확인필요.
|
||||||
|
if counterparty and owner_name and counterparty != owner_name:
|
||||||
|
return "이체", "확인필요", counterparty, False
|
||||||
|
return "이체", "투자이동", counterparty, False
|
||||||
|
return "이체", "투자이동", counterparty, False
|
||||||
|
|
||||||
|
|
||||||
|
def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
parsed = parse_toss_securities_pdf(file_path)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
return fail("not_found", str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
return fail("pdf_parse_error", str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return fail("pdf_parse_error", str(e))
|
||||||
|
transactions = parsed["transactions"]
|
||||||
|
source_hash = parsed["source_hash"]
|
||||||
|
duplicate_count = 0
|
||||||
|
with db(db_path) as conn:
|
||||||
|
metadata = parsed["metadata"]
|
||||||
|
account_number_masked = metadata.get("account_number_masked") or ""
|
||||||
|
linked_account_id = ""
|
||||||
|
if account_number_masked:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1",
|
||||||
|
("토스증권", account_number_masked),
|
||||||
|
).fetchone()
|
||||||
|
linked_account_id = row["id"] if row else ""
|
||||||
|
for tx in transactions:
|
||||||
|
merchant = f"토스증권 {tx.action}" + (f" {tx.name}" if tx.name else "")
|
||||||
|
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash))
|
||||||
|
found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone()
|
||||||
|
duplicate_count += 1 if found else 0
|
||||||
|
preview = [toss_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]]
|
||||||
|
by_action: dict[str, dict[str, int]] = {}
|
||||||
|
for tx in transactions:
|
||||||
|
item = by_action.setdefault(tx.action, {"count": 0, "amount": 0})
|
||||||
|
item["count"] += 1
|
||||||
|
item["amount"] += int(tx.amount)
|
||||||
|
return ok(
|
||||||
|
source="toss_securities_pdf",
|
||||||
|
file_path=parsed["file_path"],
|
||||||
|
source_hash=source_hash,
|
||||||
|
page_count=parsed["page_count"],
|
||||||
|
metadata=parsed["metadata"],
|
||||||
|
row_count=len(transactions),
|
||||||
|
duplicate_count=duplicate_count,
|
||||||
|
validation=parsed["validation"],
|
||||||
|
by_action=by_action,
|
||||||
|
preview=preview,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
parsed = parse_toss_securities_pdf(file_path)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
return fail("not_found", str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
return fail("pdf_parse_error", str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return fail("pdf_parse_error", str(e))
|
||||||
|
transactions = parsed["transactions"]
|
||||||
|
validation = parsed["validation"]
|
||||||
|
if not validation.get("ok"):
|
||||||
|
return fail("pdf_parse_error", "Toss Securities PDF parsing produced no valid transactions", validation=validation)
|
||||||
|
source_hash = parsed["source_hash"]
|
||||||
|
metadata = parsed["metadata"]
|
||||||
|
ts = now_iso()
|
||||||
|
batch_id = new_id("imp")
|
||||||
|
inserted = 0
|
||||||
|
skipped_duplicates = 0
|
||||||
|
inserted_ids: list[str] = []
|
||||||
|
try:
|
||||||
|
with db(db_path) as conn:
|
||||||
|
linked_account_id = _find_or_create_toss_securities_account(conn, metadata, account_id)
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo)
|
||||||
|
VALUES(?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
batch_id,
|
||||||
|
"toss_securities_pdf",
|
||||||
|
parsed["file_path"],
|
||||||
|
ts,
|
||||||
|
0,
|
||||||
|
"active",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"source_hash": source_hash,
|
||||||
|
"page_count": parsed["page_count"],
|
||||||
|
"metadata": metadata,
|
||||||
|
"validation": validation,
|
||||||
|
"parsed_row_count": len(transactions),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for tx in transactions:
|
||||||
|
tx_id = new_id("tx")
|
||||||
|
external_id = tx.external_id(source_hash)
|
||||||
|
merchant = f"토스증권 {tx.action}" + (f" {tx.name}" if tx.name else "")
|
||||||
|
category, subcategory, person, is_fixed = _classify_toss_transaction(tx, metadata.get("holder_name") or "")
|
||||||
|
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id)
|
||||||
|
description = f"토스증권 PDF import; 구분={tx.action}; 종목/상대={tx.name}; 수량={tx.quantity}; 단가={tx.unit_price}; 수수료={tx.fee}; 세금={tx.tax}; 잔액={tx.balance_after}"
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
tx_id,
|
||||||
|
tx.transaction_date,
|
||||||
|
"",
|
||||||
|
tx.type,
|
||||||
|
tx.amount,
|
||||||
|
"KRW",
|
||||||
|
category,
|
||||||
|
subcategory,
|
||||||
|
merchant,
|
||||||
|
description,
|
||||||
|
linked_account_id,
|
||||||
|
tx.action,
|
||||||
|
person,
|
||||||
|
"",
|
||||||
|
normalize_tags(["toss_securities_pdf", tx.section, tx.action, tx.code] if tx.code else ["toss_securities_pdf", tx.section, tx.action]),
|
||||||
|
int(is_fixed),
|
||||||
|
"toss_securities_pdf",
|
||||||
|
batch_id,
|
||||||
|
external_id,
|
||||||
|
dedupe_key,
|
||||||
|
tx.code,
|
||||||
|
tx.transaction_date,
|
||||||
|
tx.name,
|
||||||
|
tx.balance_after,
|
||||||
|
"토스증권",
|
||||||
|
tx.action,
|
||||||
|
tx.raw_payload(),
|
||||||
|
ts,
|
||||||
|
ts,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
inserted_ids.append(tx_id)
|
||||||
|
except Exception as e:
|
||||||
|
if "UNIQUE constraint failed" in str(e) and skip_duplicates:
|
||||||
|
skipped_duplicates += 1
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
|
||||||
|
if transactions:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?",
|
||||||
|
(transactions[-1].balance_after, ts, ts, linked_account_id),
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return fail("validation_error", str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return fail("db_error", str(e))
|
||||||
|
return ok(
|
||||||
|
import_batch_id=batch_id,
|
||||||
|
account_id=linked_account_id,
|
||||||
|
source="toss_securities_pdf",
|
||||||
|
parsed_row_count=len(transactions),
|
||||||
|
inserted_count=inserted,
|
||||||
|
skipped_duplicate_count=skipped_duplicates,
|
||||||
|
inserted_transaction_ids=inserted_ids[:20],
|
||||||
|
validation=validation,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
clauses=[]; params=[]
|
||||||
|
if source:
|
||||||
|
clauses.append("source=?"); params.append(source)
|
||||||
|
if not include_rolled_back:
|
||||||
|
clauses.append("status='active'")
|
||||||
|
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
||||||
|
params.append(max(1, min(int(limit), 500)))
|
||||||
|
with db(db_path) as conn:
|
||||||
|
rows=conn.execute(f"SELECT * FROM import_batches{where} ORDER BY imported_at DESC LIMIT ?", params).fetchall()
|
||||||
|
return ok(import_batches=[row_to_dict(r) for r in rows], count=len(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_ledger_import_batch(import_batch_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
with db(db_path) as conn:
|
||||||
|
batch = conn.execute("SELECT * FROM import_batches WHERE id=?", (import_batch_id,)).fetchone()
|
||||||
|
if not batch:
|
||||||
|
return fail("not_found", "import batch not found")
|
||||||
|
if hard_delete:
|
||||||
|
cur = conn.execute("DELETE FROM transactions WHERE import_batch_id=?", (import_batch_id,))
|
||||||
|
else:
|
||||||
|
cur = conn.execute("UPDATE transactions SET status='deleted', updated_at=? WHERE import_batch_id=? AND status='active'", (now_iso(), import_batch_id))
|
||||||
|
conn.execute("UPDATE import_batches SET status='rolled_back' WHERE id=?", (import_batch_id,))
|
||||||
|
return ok(import_batch_id=import_batch_id, affected_transactions=cur.rowcount, hard_delete=hard_delete)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .shared import *
|
||||||
|
|
||||||
|
|
||||||
|
def _transaction_signed_amount(tx_type: str, amount: int) -> int:
|
||||||
|
if tx_type in {"income", "refund", "adjustment"}:
|
||||||
|
return int(amount)
|
||||||
|
if tx_type in {"expense", "transfer"}:
|
||||||
|
return -int(amount)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at: str) -> dict[str, Any]:
|
||||||
|
date_cutoff = normalize_date(baseline_at[:10])
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT type, amount, balance_after, transaction_date, transaction_datetime, created_at
|
||||||
|
FROM transactions
|
||||||
|
WHERE status='active' AND account_id=? AND transaction_date<=?
|
||||||
|
ORDER BY transaction_date ASC, transaction_datetime ASC, created_at ASC""",
|
||||||
|
(account_id, date_cutoff),
|
||||||
|
).fetchall()
|
||||||
|
signed_total = sum(_transaction_signed_amount(row["type"], int(row["amount"] or 0)) for row in rows)
|
||||||
|
latest_balance_row = next((row for row in reversed(rows) if row["balance_after"] is not None), None)
|
||||||
|
if latest_balance_row is not None:
|
||||||
|
calculated_balance = int(latest_balance_row["balance_after"])
|
||||||
|
basis = "latest_transaction_balance_after"
|
||||||
|
basis_at = latest_balance_row["transaction_datetime"] or latest_balance_row["transaction_date"]
|
||||||
|
else:
|
||||||
|
calculated_balance = int(signed_total)
|
||||||
|
basis = "net_transaction_delta_without_opening_balance"
|
||||||
|
basis_at = baseline_at
|
||||||
|
return {
|
||||||
|
"calculated_balance": calculated_balance,
|
||||||
|
"calculation_basis": basis,
|
||||||
|
"calculation_basis_at": basis_at,
|
||||||
|
"transaction_count": len(rows),
|
||||||
|
"signed_transaction_total": int(signed_total),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = BALANCE_RECONCILIATION_TOLERANCE_KRW, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Preview manual/partial-import account balance reconciliation without mutating transactions."""
|
||||||
|
try:
|
||||||
|
baseline_at_norm = normalize_snapshot_at(baseline_at.strip(), default_now=False) if baseline_at and baseline_at.strip() else None
|
||||||
|
tolerance_int = int(tolerance)
|
||||||
|
if tolerance_int < 0:
|
||||||
|
raise ValueError("tolerance must be non-negative")
|
||||||
|
baseline_balance_int = int(baseline_balance) if baseline_balance is not None else None
|
||||||
|
except Exception as e:
|
||||||
|
return fail("validation_error", str(e))
|
||||||
|
with db(db_path) as conn:
|
||||||
|
clauses = ["active=1"]
|
||||||
|
params: list[Any] = []
|
||||||
|
if account_id:
|
||||||
|
clauses.append("id=?")
|
||||||
|
params.append(account_id)
|
||||||
|
else:
|
||||||
|
placeholders = ",".join("?" for _ in RECONCILIATION_TARGET_STATUSES)
|
||||||
|
clauses.append(f"statement_status IN ({placeholders})")
|
||||||
|
params.extend(sorted(RECONCILIATION_TARGET_STATUSES))
|
||||||
|
accounts = conn.execute(f"SELECT * FROM accounts WHERE {' AND '.join(clauses)} ORDER BY name", params).fetchall()
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for account in accounts:
|
||||||
|
effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"]
|
||||||
|
effective_at = baseline_at_norm or account["last_synced_at"] or now_iso()
|
||||||
|
calc = _account_balance_row(conn, account["id"], effective_at)
|
||||||
|
if effective_baseline is None:
|
||||||
|
difference = None
|
||||||
|
ratio = None
|
||||||
|
status = "BASELINE_REQUIRED"
|
||||||
|
else:
|
||||||
|
difference = int(effective_baseline) - int(calc["calculated_balance"])
|
||||||
|
ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None
|
||||||
|
status = "OK" if abs(difference) <= tolerance_int else "REVIEW_NEEDED"
|
||||||
|
items.append({
|
||||||
|
"account_id": account["id"],
|
||||||
|
"account_name": account["name"],
|
||||||
|
"statement_status": account["statement_status"],
|
||||||
|
"is_reconciliation_target": account["statement_status"] in RECONCILIATION_TARGET_STATUSES,
|
||||||
|
"baseline_balance": effective_baseline,
|
||||||
|
"baseline_at": effective_at,
|
||||||
|
"calculated_balance": calc["calculated_balance"],
|
||||||
|
"difference": difference,
|
||||||
|
"tolerance": tolerance_int,
|
||||||
|
"reference_ratio_threshold": BALANCE_RECONCILIATION_REFERENCE_RATIO,
|
||||||
|
"difference_ratio": ratio,
|
||||||
|
"status": status,
|
||||||
|
"recommended_decisions": ["계좌이동", "실지출", "실수입", "투자이동", "분석제외", "확인필요"] if status == "REVIEW_NEEDED" else [],
|
||||||
|
"source_note": source_note,
|
||||||
|
"memo": memo,
|
||||||
|
**calc,
|
||||||
|
})
|
||||||
|
return ok(items=items, count=len(items), tolerance=tolerance_int, baseline_at=baseline_at_norm, routing="preview_only_manual_confirmation_required")
|
||||||
@@ -11,11 +11,17 @@ from typing import Any
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from ..infrastructure.parsers import (
|
||||||
|
parse_kb_bank_pdf,
|
||||||
|
parse_seoulpay_xlsx,
|
||||||
|
parse_toss_bank_pdf,
|
||||||
|
parse_toss_securities_pdf,
|
||||||
|
seoulpay_transaction_preview,
|
||||||
|
toss_bank_transaction_preview,
|
||||||
|
toss_transaction_preview,
|
||||||
|
transaction_preview,
|
||||||
|
)
|
||||||
from ..infrastructure.sqlite import db, init_db, resolve_db_path
|
from ..infrastructure.sqlite import db, init_db, resolve_db_path
|
||||||
from ..kb_bank_pdf import parse_kb_bank_pdf, transaction_preview
|
|
||||||
from ..toss_bank_pdf import parse_toss_bank_pdf, transaction_preview as toss_bank_transaction_preview
|
|
||||||
from ..toss_securities_pdf import parse_toss_securities_pdf, transaction_preview as toss_transaction_preview
|
|
||||||
from ..seoulpay_xlsx import parse_seoulpay_xlsx, transaction_preview as seoulpay_transaction_preview
|
|
||||||
|
|
||||||
VALID_TRANSACTION_TYPES = {"income", "expense", "transfer", "refund", "adjustment"}
|
VALID_TRANSACTION_TYPES = {"income", "expense", "transfer", "refund", "adjustment"}
|
||||||
VALID_ACCOUNT_TYPES = {"bank", "card", "cash", "investment", "loan", "other"}
|
VALID_ACCOUNT_TYPES = {"bank", "card", "cash", "investment", "loan", "other"}
|
||||||
|
|||||||
@@ -23,98 +23,7 @@ def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str
|
|||||||
rows = conn.execute(sql, params).fetchall()
|
rows = conn.execute(sql, params).fetchall()
|
||||||
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
|
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
|
||||||
|
|
||||||
|
from .reconciliation_service import get_balance_reconciliation_report
|
||||||
def _transaction_signed_amount(tx_type: str, amount: int) -> int:
|
|
||||||
if tx_type in {"income", "refund", "adjustment"}:
|
|
||||||
return int(amount)
|
|
||||||
if tx_type in {"expense", "transfer"}:
|
|
||||||
return -int(amount)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at: str) -> dict[str, Any]:
|
|
||||||
date_cutoff = normalize_date(baseline_at[:10])
|
|
||||||
rows = conn.execute(
|
|
||||||
"""SELECT type, amount, balance_after, transaction_date, transaction_datetime, created_at
|
|
||||||
FROM transactions
|
|
||||||
WHERE status='active' AND account_id=? AND transaction_date<=?
|
|
||||||
ORDER BY transaction_date ASC, transaction_datetime ASC, created_at ASC""",
|
|
||||||
(account_id, date_cutoff),
|
|
||||||
).fetchall()
|
|
||||||
signed_total = sum(_transaction_signed_amount(row["type"], int(row["amount"] or 0)) for row in rows)
|
|
||||||
latest_balance_row = next((row for row in reversed(rows) if row["balance_after"] is not None), None)
|
|
||||||
if latest_balance_row is not None:
|
|
||||||
calculated_balance = int(latest_balance_row["balance_after"])
|
|
||||||
basis = "latest_transaction_balance_after"
|
|
||||||
basis_at = latest_balance_row["transaction_datetime"] or latest_balance_row["transaction_date"]
|
|
||||||
else:
|
|
||||||
calculated_balance = int(signed_total)
|
|
||||||
basis = "net_transaction_delta_without_opening_balance"
|
|
||||||
basis_at = baseline_at
|
|
||||||
return {
|
|
||||||
"calculated_balance": calculated_balance,
|
|
||||||
"calculation_basis": basis,
|
|
||||||
"calculation_basis_at": basis_at,
|
|
||||||
"transaction_count": len(rows),
|
|
||||||
"signed_transaction_total": int(signed_total),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = BALANCE_RECONCILIATION_TOLERANCE_KRW, db_path: str | None = None) -> dict[str, Any]:
|
|
||||||
"""Preview manual/partial-import account balance reconciliation without mutating transactions."""
|
|
||||||
try:
|
|
||||||
baseline_at_norm = normalize_snapshot_at(baseline_at.strip(), default_now=False) if baseline_at and baseline_at.strip() else None
|
|
||||||
tolerance_int = int(tolerance)
|
|
||||||
if tolerance_int < 0:
|
|
||||||
raise ValueError("tolerance must be non-negative")
|
|
||||||
baseline_balance_int = int(baseline_balance) if baseline_balance is not None else None
|
|
||||||
except Exception as e:
|
|
||||||
return fail("validation_error", str(e))
|
|
||||||
with db(db_path) as conn:
|
|
||||||
clauses = ["active=1"]
|
|
||||||
params: list[Any] = []
|
|
||||||
if account_id:
|
|
||||||
clauses.append("id=?")
|
|
||||||
params.append(account_id)
|
|
||||||
else:
|
|
||||||
placeholders = ",".join("?" for _ in RECONCILIATION_TARGET_STATUSES)
|
|
||||||
clauses.append(f"statement_status IN ({placeholders})")
|
|
||||||
params.extend(sorted(RECONCILIATION_TARGET_STATUSES))
|
|
||||||
accounts = conn.execute(f"SELECT * FROM accounts WHERE {' AND '.join(clauses)} ORDER BY name", params).fetchall()
|
|
||||||
items: list[dict[str, Any]] = []
|
|
||||||
for account in accounts:
|
|
||||||
effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"]
|
|
||||||
effective_at = baseline_at_norm or account["last_synced_at"] or now_iso()
|
|
||||||
calc = _account_balance_row(conn, account["id"], effective_at)
|
|
||||||
if effective_baseline is None:
|
|
||||||
difference = None
|
|
||||||
ratio = None
|
|
||||||
status = "BASELINE_REQUIRED"
|
|
||||||
else:
|
|
||||||
difference = int(effective_baseline) - int(calc["calculated_balance"])
|
|
||||||
ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None
|
|
||||||
status = "OK" if abs(difference) <= tolerance_int else "REVIEW_NEEDED"
|
|
||||||
items.append({
|
|
||||||
"account_id": account["id"],
|
|
||||||
"account_name": account["name"],
|
|
||||||
"statement_status": account["statement_status"],
|
|
||||||
"is_reconciliation_target": account["statement_status"] in RECONCILIATION_TARGET_STATUSES,
|
|
||||||
"baseline_balance": effective_baseline,
|
|
||||||
"baseline_at": effective_at,
|
|
||||||
"calculated_balance": calc["calculated_balance"],
|
|
||||||
"difference": difference,
|
|
||||||
"tolerance": tolerance_int,
|
|
||||||
"reference_ratio_threshold": BALANCE_RECONCILIATION_REFERENCE_RATIO,
|
|
||||||
"difference_ratio": ratio,
|
|
||||||
"status": status,
|
|
||||||
"recommended_decisions": ["계좌이동", "실지출", "실수입", "투자이동", "분석제외", "확인필요"] if status == "REVIEW_NEEDED" else [],
|
|
||||||
"source_note": source_note,
|
|
||||||
"memo": memo,
|
|
||||||
**calc,
|
|
||||||
})
|
|
||||||
return ok(items=items, count=len(items), tolerance=tolerance_int, baseline_at=baseline_at_norm, routing="preview_only_manual_confirmation_required")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def list_ledger_budgets(month: str = "", db_path: str | None = None) -> dict[str, Any]:
|
def list_ledger_budgets(month: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||||
clauses=[]; params=[]
|
clauses=[]; params=[]
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .shared import *
|
||||||
|
|
||||||
|
def _rule_matches(rule: dict[str, Any], tx: dict[str, Any]) -> bool:
|
||||||
|
if rule.get("transaction_type") and rule.get("transaction_type") != tx.get("type", ""):
|
||||||
|
return False
|
||||||
|
amount = int(tx.get("amount") or 0)
|
||||||
|
if rule.get("amount_min") is not None and amount < int(rule["amount_min"]):
|
||||||
|
return False
|
||||||
|
if rule.get("amount_max") is not None and amount > int(rule["amount_max"]):
|
||||||
|
return False
|
||||||
|
if rule.get("day_of_month") is not None:
|
||||||
|
tx_date = tx.get("transaction_datetime") or tx.get("transaction_date") or ""
|
||||||
|
try:
|
||||||
|
if datetime.fromisoformat(str(tx_date)[:10]).day != int(rule["day_of_month"]):
|
||||||
|
return False
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
value = str(tx.get(rule["match_field"], "") or "")
|
||||||
|
pattern = str(rule.get("pattern", ""))
|
||||||
|
match_type = rule.get("match_type") or "contains"
|
||||||
|
if match_type == "contains":
|
||||||
|
return pattern.lower() in value.lower()
|
||||||
|
if match_type == "equals":
|
||||||
|
return value == pattern
|
||||||
|
if match_type == "starts_with":
|
||||||
|
return value.startswith(pattern)
|
||||||
|
if match_type == "ends_with":
|
||||||
|
return value.endswith(pattern)
|
||||||
|
if match_type == "regex":
|
||||||
|
return re.search(pattern, value) is not None
|
||||||
|
return False
|
||||||
|
|
||||||
|
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
with db(db_path) as conn:
|
||||||
|
rows=conn.execute("""SELECT * FROM transactions WHERE status='active' AND COALESCE(category,'')='' AND amount>=? ORDER BY amount DESC, transaction_datetime DESC LIMIT ?""", (int(min_amount), max(1,min(int(limit),1000)))).fetchall()
|
||||||
|
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def get_uncategorized_merchant_summary(limit: int = 50, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
with db(db_path) as conn:
|
||||||
|
rows=conn.execute("""SELECT merchant, raw_summary, type, COUNT(*) count, SUM(amount) total_amount, MAX(amount) max_amount
|
||||||
|
FROM transactions WHERE status='active' AND COALESCE(category,'')=''
|
||||||
|
GROUP BY merchant, raw_summary, type ORDER BY total_amount DESC LIMIT ?""", (max(1,min(int(limit),500)),)).fetchall()
|
||||||
|
return ok(items=[row_to_dict(r) for r in rows], count=len(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def _recommend_category_for_transaction(tx: dict[str, Any], rules: list[dict[str, Any]], history: dict[tuple[str, str], list[dict[str, Any]]]) -> dict[str, Any]:
|
||||||
|
"""Recommend a category for one transaction using deterministic, explainable signals."""
|
||||||
|
for rule in rules:
|
||||||
|
try:
|
||||||
|
if _rule_matches(rule, tx):
|
||||||
|
return {
|
||||||
|
"category": rule.get("category", ""),
|
||||||
|
"subcategory": rule.get("subcategory", ""),
|
||||||
|
"confidence": round(float(rule.get("confidence") or 1.0), 2),
|
||||||
|
"source": "rule",
|
||||||
|
"reason": f"활성 규칙 `{rule.get('pattern', '')}` 매칭",
|
||||||
|
"rule_id": rule.get("id", ""),
|
||||||
|
}
|
||||||
|
except re.error:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = (tx.get("merchant", "") or "", tx.get("type", "") or "")
|
||||||
|
candidates = history.get(key, [])
|
||||||
|
if candidates:
|
||||||
|
best = candidates[0]
|
||||||
|
count = int(best.get("count") or 0)
|
||||||
|
confidence = min(0.9, 0.55 + min(count, 5) * 0.07)
|
||||||
|
return {
|
||||||
|
"category": best.get("category", ""),
|
||||||
|
"subcategory": best.get("subcategory", ""),
|
||||||
|
"confidence": round(confidence, 2),
|
||||||
|
"source": "history",
|
||||||
|
"reason": f"같은 사용처의 기존 분류 {count}건",
|
||||||
|
"rule_id": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"category": "", "subcategory": "", "confidence": 0.0, "source": "none", "reason": "추천 근거 없음", "rule_id": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def get_uncategorized_workbench(
|
||||||
|
limit: int = 100,
|
||||||
|
min_amount: int = 0,
|
||||||
|
sort_by: str = "amount_desc",
|
||||||
|
db_path: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row_limit = max(1, min(int(limit), 1000))
|
||||||
|
minimum_amount = int(min_amount)
|
||||||
|
order_by_map = {
|
||||||
|
"amount_desc": "amount DESC, transaction_datetime DESC, transaction_date DESC, created_at DESC",
|
||||||
|
"date_desc": "transaction_datetime DESC, transaction_date DESC, created_at DESC, amount DESC",
|
||||||
|
}
|
||||||
|
order_by = order_by_map.get(sort_by, order_by_map["amount_desc"])
|
||||||
|
with db(db_path) as conn:
|
||||||
|
tx_rows = [row_to_dict(r) for r in conn.execute(
|
||||||
|
f"""SELECT id, transaction_date, transaction_datetime, type, amount, currency, merchant, raw_summary,
|
||||||
|
counterparty, account_id, payment_method, person, is_fixed, source, memo, created_at
|
||||||
|
FROM transactions
|
||||||
|
WHERE status='active' AND COALESCE(category,'')='' AND amount>=?
|
||||||
|
ORDER BY {order_by}
|
||||||
|
LIMIT ?""",
|
||||||
|
(minimum_amount, row_limit),
|
||||||
|
).fetchall()]
|
||||||
|
total_row = conn.execute(
|
||||||
|
"""SELECT COUNT(*) AS count, COALESCE(SUM(amount),0) AS total_amount,
|
||||||
|
COUNT(DISTINCT COALESCE(NULLIF(merchant,''), raw_summary, counterparty, '')) AS merchant_group_count
|
||||||
|
FROM transactions
|
||||||
|
WHERE status='active' AND COALESCE(category,'')='' AND amount>=?""",
|
||||||
|
(minimum_amount,),
|
||||||
|
).fetchone()
|
||||||
|
history_rows = [row_to_dict(r) for r in conn.execute(
|
||||||
|
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count,
|
||||||
|
SUM(amount) AS total_amount, MAX(transaction_date) AS latest_date
|
||||||
|
FROM transactions
|
||||||
|
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
|
||||||
|
GROUP BY merchant, type, category, subcategory
|
||||||
|
ORDER BY merchant, type, count DESC, total_amount DESC"""
|
||||||
|
).fetchall()]
|
||||||
|
rules = [row_to_dict(r) for r in conn.execute(
|
||||||
|
"SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id"
|
||||||
|
).fetchall()]
|
||||||
|
|
||||||
|
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||||
|
for row in history_rows:
|
||||||
|
history.setdefault((row.get("merchant", "") or "", row.get("type", "") or ""), []).append(row)
|
||||||
|
|
||||||
|
txs: list[dict[str, Any]] = []
|
||||||
|
groups: dict[tuple[str, str, str], dict[str, Any]] = {}
|
||||||
|
for tx in tx_rows:
|
||||||
|
rec = _recommend_category_for_transaction(tx, rules, history)
|
||||||
|
public_tx = {
|
||||||
|
**tx,
|
||||||
|
"recommendation": rec,
|
||||||
|
"recommended_category": rec.get("category", ""),
|
||||||
|
"recommended_subcategory": rec.get("subcategory", ""),
|
||||||
|
"recommendation_confidence": rec.get("confidence", 0.0),
|
||||||
|
"recommendation_reason": rec.get("reason", ""),
|
||||||
|
"recommendation_source": rec.get("source", "none"),
|
||||||
|
}
|
||||||
|
txs.append(public_tx)
|
||||||
|
gkey = (tx.get("merchant", "") or "", tx.get("raw_summary", "") or "", tx.get("type", "") or "")
|
||||||
|
group = groups.setdefault(gkey, {
|
||||||
|
"merchant": gkey[0],
|
||||||
|
"raw_summary": gkey[1],
|
||||||
|
"type": gkey[2],
|
||||||
|
"count": 0,
|
||||||
|
"total_amount": 0,
|
||||||
|
"max_amount": 0,
|
||||||
|
"latest_date": "",
|
||||||
|
"sample_transaction_id": tx.get("id", ""),
|
||||||
|
"recommendation": rec,
|
||||||
|
})
|
||||||
|
group["count"] += 1
|
||||||
|
group["total_amount"] += int(tx.get("amount") or 0)
|
||||||
|
group["max_amount"] = max(int(group["max_amount"] or 0), int(tx.get("amount") or 0))
|
||||||
|
date_value = tx.get("transaction_datetime") or tx.get("transaction_date") or ""
|
||||||
|
if date_value > (group.get("latest_date") or ""):
|
||||||
|
group["latest_date"] = date_value
|
||||||
|
if not group["recommendation"].get("category") and rec.get("category"):
|
||||||
|
group["recommendation"] = rec
|
||||||
|
|
||||||
|
group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True)
|
||||||
|
recommendable_count = sum(1 for tx in txs if tx.get("recommended_category"))
|
||||||
|
repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2)
|
||||||
|
total = row_to_dict(total_row) if total_row else {"count": 0, "total_amount": 0, "merchant_group_count": 0}
|
||||||
|
return ok(
|
||||||
|
summary={
|
||||||
|
"uncategorized_count": int(total.get("count") or 0),
|
||||||
|
"uncategorized_total_amount": int(total.get("total_amount") or 0),
|
||||||
|
"merchant_group_count": int(total.get("merchant_group_count") or 0),
|
||||||
|
"recommendable_count": recommendable_count,
|
||||||
|
"repeated_group_count": repeated_group_count,
|
||||||
|
},
|
||||||
|
groups=group_items,
|
||||||
|
transactions=txs,
|
||||||
|
count=len(txs),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_uncategorized_recommendation(transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
if not transaction_id:
|
||||||
|
return fail("validation_error", "transaction_id is required")
|
||||||
|
with db(db_path) as conn:
|
||||||
|
row = conn.execute("SELECT * FROM transactions WHERE id=? AND status='active'", (transaction_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return fail("not_found", "transaction not found")
|
||||||
|
tx = row_to_dict(row)
|
||||||
|
if tx.get("category"):
|
||||||
|
return fail("validation_error", "transaction is already categorized")
|
||||||
|
rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
|
||||||
|
history_rows = [row_to_dict(r) for r in conn.execute(
|
||||||
|
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount
|
||||||
|
FROM transactions
|
||||||
|
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
|
||||||
|
GROUP BY merchant, type, category, subcategory
|
||||||
|
ORDER BY count DESC, total_amount DESC"""
|
||||||
|
).fetchall()]
|
||||||
|
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||||
|
for h in history_rows:
|
||||||
|
history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h)
|
||||||
|
rec = _recommend_category_for_transaction(tx, rules, history)
|
||||||
|
if not rec.get("category"):
|
||||||
|
return fail("no_recommendation", "추천 분류가 없습니다")
|
||||||
|
cur = conn.execute(
|
||||||
|
"""UPDATE transactions
|
||||||
|
SET category=?, subcategory=?, updated_at=?
|
||||||
|
WHERE id=? AND status='active' AND COALESCE(category,'')=''""",
|
||||||
|
(rec.get("category", ""), rec.get("subcategory", ""), now_iso(), transaction_id),
|
||||||
|
)
|
||||||
|
return ok(applied_count=cur.rowcount, transaction_id=transaction_id, recommendation=rec)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_uncategorized_group_recommendation(merchant: str, raw_summary: str = "", transaction_type: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
with db(db_path) as conn:
|
||||||
|
sample = conn.execute(
|
||||||
|
"""SELECT * FROM transactions
|
||||||
|
WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?
|
||||||
|
ORDER BY amount DESC, transaction_datetime DESC, transaction_date DESC LIMIT 1""",
|
||||||
|
(merchant, raw_summary, transaction_type),
|
||||||
|
).fetchone()
|
||||||
|
if not sample:
|
||||||
|
return fail("not_found", "uncategorized group not found")
|
||||||
|
tx = row_to_dict(sample)
|
||||||
|
rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
|
||||||
|
history_rows = [row_to_dict(r) for r in conn.execute(
|
||||||
|
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount
|
||||||
|
FROM transactions
|
||||||
|
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
|
||||||
|
GROUP BY merchant, type, category, subcategory
|
||||||
|
ORDER BY count DESC, total_amount DESC"""
|
||||||
|
).fetchall()]
|
||||||
|
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||||
|
for h in history_rows:
|
||||||
|
history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h)
|
||||||
|
rec = _recommend_category_for_transaction(tx, rules, history)
|
||||||
|
if not rec.get("category"):
|
||||||
|
return fail("no_recommendation", "추천 분류가 없습니다")
|
||||||
|
cur = conn.execute(
|
||||||
|
"""UPDATE transactions
|
||||||
|
SET category=?, subcategory=?, updated_at=?
|
||||||
|
WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?""",
|
||||||
|
(rec.get("category", ""), rec.get("subcategory", ""), now_iso(), merchant, raw_summary, transaction_type),
|
||||||
|
)
|
||||||
|
return ok(applied_count=cur.rowcount, merchant=merchant, raw_summary=raw_summary, type=transaction_type, recommendation=rec)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .kb_bank_pdf import parse_kb_bank_pdf, transaction_preview
|
||||||
|
from .toss_bank_pdf import parse_toss_bank_pdf, transaction_preview as toss_bank_transaction_preview
|
||||||
|
from .toss_securities_pdf import parse_toss_securities_pdf, transaction_preview as toss_transaction_preview
|
||||||
|
from .seoulpay_xlsx import parse_seoulpay_xlsx, transaction_preview as seoulpay_transaction_preview
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from ...kb_bank_pdf import parse_kb_bank_pdf, transaction_preview
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from ...seoulpay_xlsx import parse_seoulpay_xlsx, transaction_preview
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from ...toss_bank_pdf import parse_toss_bank_pdf, transaction_preview
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from ...toss_securities_pdf import parse_toss_securities_pdf, transaction_preview
|
||||||
@@ -5,20 +5,30 @@ from types import ModuleType
|
|||||||
|
|
||||||
from .application import accounts_write_services as _accounts_write_services
|
from .application import accounts_write_services as _accounts_write_services
|
||||||
from .application import asset_account_services as _asset_account_services
|
from .application import asset_account_services as _asset_account_services
|
||||||
|
from .application import assets_service as _assets_service
|
||||||
from .application import budgets_write_services as _budgets_write_services
|
from .application import budgets_write_services as _budgets_write_services
|
||||||
from .application import category_rules_write_services as _category_rules_write_services
|
from .application import category_rules_write_services as _category_rules_write_services
|
||||||
from .application import categorization_services as _categorization_services
|
from .application import categorization_services as _categorization_services
|
||||||
|
from .application import cross_transfer_service as _cross_transfer_service
|
||||||
|
from .application import imports_service as _imports_service
|
||||||
from .application import import_transfer_services as _import_transfer_services
|
from .application import import_transfer_services as _import_transfer_services
|
||||||
|
from .application import reconciliation_service as _reconciliation_service
|
||||||
from .application import shared as _shared
|
from .application import shared as _shared
|
||||||
from .application import system_services as _system_services
|
from .application import system_services as _system_services
|
||||||
from .application import transactions_write_services as _transactions_write_services
|
from .application import transactions_write_services as _transactions_write_services
|
||||||
from .application import transaction_report_services as _transaction_report_services
|
from .application import transaction_report_services as _transaction_report_services
|
||||||
|
from .application import uncategorized_workbench_service as _uncategorized_workbench_service
|
||||||
from .application.asset_account_services import *
|
from .application.asset_account_services import *
|
||||||
|
from .application.assets_service import *
|
||||||
from .application.categorization_services import *
|
from .application.categorization_services import *
|
||||||
|
from .application.cross_transfer_service import *
|
||||||
|
from .application.imports_service import *
|
||||||
from .application.import_transfer_services import *
|
from .application.import_transfer_services import *
|
||||||
|
from .application.reconciliation_service import *
|
||||||
from .application.shared import *
|
from .application.shared import *
|
||||||
from .application.system_services import *
|
from .application.system_services import *
|
||||||
from .application.transaction_report_services import *
|
from .application.transaction_report_services import *
|
||||||
|
from .application.uncategorized_workbench_service import *
|
||||||
|
|
||||||
# Keep compatibility for tests/monkeypatches that reach into service internals.
|
# Keep compatibility for tests/monkeypatches that reach into service internals.
|
||||||
_current_kst_datetime = _shared._current_kst_datetime
|
_current_kst_datetime = _shared._current_kst_datetime
|
||||||
@@ -28,11 +38,16 @@ _PROXY_MODULES = (
|
|||||||
_accounts_write_services,
|
_accounts_write_services,
|
||||||
_system_services,
|
_system_services,
|
||||||
_asset_account_services,
|
_asset_account_services,
|
||||||
|
_assets_service,
|
||||||
_budgets_write_services,
|
_budgets_write_services,
|
||||||
_category_rules_write_services,
|
_category_rules_write_services,
|
||||||
_transaction_report_services,
|
_transaction_report_services,
|
||||||
|
_reconciliation_service,
|
||||||
|
_imports_service,
|
||||||
_import_transfer_services,
|
_import_transfer_services,
|
||||||
|
_cross_transfer_service,
|
||||||
_categorization_services,
|
_categorization_services,
|
||||||
|
_uncategorized_workbench_service,
|
||||||
_transactions_write_services,
|
_transactions_write_services,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user