4 Commits
64 changed files with 4562 additions and 452 deletions
+6
View File
@@ -123,6 +123,12 @@ Streamlit이 SQLite에 직접 접근하지 않고 MCP/API만 호출하도록 변
## 설계 원칙 ## 설계 원칙
### 작업 규칙
- 스키마 변경 작업 중에는 schema version 자동 증가를 금지합니다. 현재 코드·DB·schema의 버전은 **16으로 동결**하며, 사용자가 마지막에 명시적으로 올리라고 지시한 경우에만 증가합니다.
- 버전 동결은 기존 DB 반영과 멱등성 검증 의무를 면제하지 않습니다. 스키마 변경 시 기존 DB 업그레이드·재실행 안전성을 계속 검증합니다.
- 자동 bump를 전제로 한 테스트 업데이트도 금지합니다. 기존 미커밋 변경은 보존하고, 이 규칙 기록만으로 코드·DB·schema 자체를 변경하지 않습니다.
- 금액은 원화 정수 원 단위로 저장합니다. - 금액은 원화 정수 원 단위로 저장합니다.
- 거래 금액은 양수로 넣고, `type`이 `income`, `expense`, `transfer`, `refund`, `adjustment` 중 하나로 방향을 나타냅니다. - 거래 금액은 양수로 넣고, `type`이 `income`, `expense`, `transfer`, `refund`, `adjustment` 중 하나로 방향을 나타냅니다.
- 금융 데이터이므로 Jarca 본체 MCP와 분리된 DB/서버로 운영합니다. - 금융 데이터이므로 Jarca 본체 MCP와 분리된 DB/서버로 운영합니다.
+2
View File
@@ -46,6 +46,8 @@ def main() -> None:
return return
pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"] pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]
query_page = st.query_params.get("page") query_page = st.query_params.get("page")
if query_page == "monthly_settlement":
query_page = "월정산"
page = validate_dashboard_page(query_page, pages) page = validate_dashboard_page(query_page, pages)
st.session_state["dashboard_page"] = page st.session_state["dashboard_page"] = page
+9
View File
@@ -315,6 +315,15 @@ def require_dashboard_authentication() -> bool:
_render_login(clear_cookie=cookie is not None) _render_login(clear_cookie=cookie is not None)
return False return False
# The configured server-side admin identity is the only actor source. Do
# not derive it from query parameters, client headers, or dashboard forms.
st.session_state["authenticated_actor"] = settings.admin_id
log_auth_event("token_accepted", correlation_id) log_auth_event("token_accepted", correlation_id)
_render_logout() _render_logout()
return True return True
def get_authenticated_actor() -> str | None:
"""Return the actor established by successful server-side authentication."""
actor = st.session_state.get("authenticated_actor")
return actor if isinstance(actor, str) and actor.strip() else None
+5 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime
from html import escape from html import escape
@@ -38,6 +39,7 @@ JARCA_COLORS = {
} }
JARCA_PLOTLY_TEMPLATE = "jarca_clean" JARCA_PLOTLY_TEMPLATE = "jarca_clean"
logger = logging.getLogger(__name__)
pio.templates[JARCA_PLOTLY_TEMPLATE] = pio.templates["plotly_white"].update( pio.templates[JARCA_PLOTLY_TEMPLATE] = pio.templates["plotly_white"].update(
{ {
"layout": { "layout": {
@@ -362,7 +364,8 @@ def validate_dashboard_page(page: str | None, pages: list[str]) -> str:
def _sync_dashboard_page_query() -> None: def _sync_dashboard_page_query() -> None:
st.query_params["page"] = st.session_state["dashboard_page"] page = st.session_state["dashboard_page"]
st.query_params["page"] = {"월정산": "monthly_settlement"}.get(page, page)
def render_sidebar_nav(pages: list[str], current_page: str) -> str: def render_sidebar_nav(pages: list[str], current_page: str) -> str:
@@ -391,6 +394,7 @@ def render_dashboard_body(page: str, renderer) -> None:
renderer() renderer()
loading.empty() loading.empty()
except Exception: except Exception:
logger.exception("Dashboard page render failed: page=%s", page)
body.empty() body.empty()
with body.container(): with body.container():
st.error("페이지를 불러오지 못했습니다. 다시 시도해 주세요.") st.error("페이지를 불러오지 못했습니다. 다시 시도해 주세요.")
+2
View File
@@ -19,6 +19,7 @@ ACCOUNT_TYPE_LABELS = {
"investment": "투자", "investment": "투자",
"loan": "대출", "loan": "대출",
"other": "기타", "other": "기타",
"real_estate": "부동산",
} }
ACCOUNT_TYPE_STYLES = { ACCOUNT_TYPE_STYLES = {
@@ -28,6 +29,7 @@ ACCOUNT_TYPE_STYLES = {
"investment": "#8250df", "investment": "#8250df",
"loan": "#bc4c00", "loan": "#bc4c00",
"other": "#57606a", "other": "#57606a",
"real_estate": "#9a6700",
} }
@@ -0,0 +1,302 @@
from __future__ import annotations
# ruff: noqa: F405
from datetime import datetime
import pandas as pd
import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import overview_gateway
from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
from dashboard.page_views.settlement_drafts import render_settlement_drafts
from dashboard.common import * # noqa: F403, F405
def _render_asset_evidence(report: dict) -> None:
warnings = report.get("warnings") or []
exclusions = report.get("excluded_items") or report.get("exclusions") or []
carried = report.get("carried_items") or []
provenance = report.get("provenance") or {}
approval = report.get("approval") or provenance.get("approval") or {}
correction = report.get("correction") or provenance.get("correction") or {}
st.markdown("**근거 상세 (코드 / 출처 식별자)**")
for item in warnings:
st.markdown(f"- 경고 `{item.get('code', '-')}` · 출처 {item.get('source_type', '-')} / {item.get('source_id', '-')} · 대상 {item.get('name', '-')}")
if exclusions:
st.markdown("**제외 상세**")
for item in exclusions:
st.markdown(f"- 제외 코드 `{item.get('reason', '-')}` · 출처 {item.get('source_type', '-')} / {item.get('source_id', '-')} · 대상 {item.get('name', '-')}")
if carried:
st.markdown("**이월 항목**")
for item in carried:
st.markdown(f"- 이월 · 출처 {item.get('source_type', '-')} / {item.get('source_id', '-')} · 평가일 {item.get('valuation_date', '-')}")
if approval:
st.markdown(f"**조건부 승인** · 승인자 {approval.get('approved_by', '-')} · 사유 {approval.get('approval_reason', '-')} · 참조 {approval.get('approval_reference', '-')}")
if correction:
st.markdown(f"**정정** · 원본 run {correction.get('correction_of_run_id', '-')} · 사유 {correction.get('correction_reason', '-')}")
st.caption(f"canonical provenance · 평가일 {provenance.get('valuation_date', '-')} · 검증 {provenance.get('verification_status', '-')} · 유형 {provenance.get('evidence_kind', '-')} · run {provenance.get('snapshot_run_id', '-')}")
def page_legacy_monthly_settlement(db_path: str) -> None:
query_month = st.query_params.get("month", current_kst_month())
if isinstance(query_month, list):
query_month = query_month[0]
month = st.text_input("대상 월", value=str(query_month), key="monthly_settlement_month", help="YYYY-MM 형식")
settlement = overview_gateway.get_monthly_settlement_summary(month, db_path=db_path)
if not settlement.get("success"):
st.error(settlement.get("error", settlement))
st.caption(f"입력한 월: {month}")
return
selected_month = settlement.get("month") or settlement.get("selected_month") or month
basis_date = settlement.get("basis_date") or f"{selected_month}-01"
source_coverage = settlement.get("source_coverage", {}) if isinstance(settlement.get("source_coverage"), dict) else {}
unresolved_blockers = settlement.get("unresolved_blockers", {}) if isinstance(settlement.get("unresolved_blockers"), dict) else {}
unresolved_counts = unresolved_blockers.get("counts", {}) if isinstance(unresolved_blockers.get("counts"), dict) else {}
review_queue_res = overview_gateway.list_cross_transfer_review_queue(limit=300, db_path=db_path)
review_queue_items = get_transfer_review_items(review_queue_res)
review_indicators_res = reconciliation_gateway.get_review_operating_indicators(db_path=db_path)
review_indicators = review_indicators_res.get("indicators", {}) if review_indicators_res.get("success") else {}
reconciliation_res = reconciliation_gateway.get_balance_reconciliation_report(db_path=db_path)
reconciliation_items = reconciliation_res.get("items", []) if reconciliation_res.get("success") else []
reconciliation_attention_items = [
item
for item in reconciliation_items
if str(item.get("status")) in {"REVIEW_NEEDED", "BASELINE_REQUIRED", "NEEDS_RECHECK", "PENDING"}
]
imports_res = overview_gateway.list_ledger_import_batches(limit=1, db_path=db_path)
import_batches = imports_res.get("import_batches", []) if imports_res.get("success") else []
asset_snapshot = overview_gateway.get_asset_snapshot_report(basis_date=basis_date, db_path=db_path)
read_only_stamp = datetime.now(ZoneInfo("Asia/Seoul")).strftime("%Y-%m-%d %H:%M:%S KST")
render_section_label("📘", "월정산 셸")
st.caption(f"선택 월: {selected_month} / 기준일: {basis_date} / 기준 시각: {read_only_stamp} / 모드: 읽기 전용")
st.markdown(
"<span style='display:inline-block; padding:0.15rem 0.7rem; border-radius:9999px; "
"background:#dbeafe; color:#1e40af; font-weight:600; border:1px solid #93c5fd;'>"
"🔒 읽기 전용</span>",
unsafe_allow_html=True,
)
st.info("거래/예산 수정은 각 운영 화면(거래내역, 요약)에서 처리하세요.")
st.markdown("- [거래내역으로 이동](?page=거래내역)")
st.markdown("- [예산 확인으로 이동](?page=요약)")
st.markdown("월간 핵심 KPI")
st.caption("월간 핵심 지표를 통해 수입·지출·잔고 흐름을 점검하세요.")
k1, k2, k3, k4 = st.columns(4)
k1.metric("실제 수입", format_krw(settlement.get("actual_income", 0)))
k2.metric("실제 지출", format_krw(settlement.get("actual_expense", 0)))
k3.metric("순현금흐름", format_krw(settlement.get("net_cashflow", 0)))
k4.metric("저축률", format_percent(settlement.get("savings_rate")))
st.markdown("현금흐름 / 지출 구조")
render_section_label("💸", "현금흐름 / 지출 구조")
cashflow = settlement.get("reports", {}).get("cashflow", {}) if isinstance(settlement.get("reports"), dict) else {}
if cashflow:
c1, c2 = st.columns(2)
by_type = cashflow.get("by_type", {}) or {}
by_type_df = pd.DataFrame(
[{"구분": str(key), "금액": int(value)} for key, value in by_type.items()]
)
if not by_type_df.empty:
by_type_df["금액"] = by_type_df["금액"].map(format_krw)
by_type_df = by_type_df.sort_values("금액", ascending=False)
c1.dataframe(by_type_df, width="stretch", height=170)
by_kind = cashflow.get("by_cashflow_kind", {}) or {}
by_kind_df = pd.DataFrame(
[{"현금흐름 구분": str(key), "금액": int(value)} for key, value in by_kind.items()]
)
if not by_kind_df.empty:
by_kind_df["금액"] = by_kind_df["금액"].map(format_krw)
c2.dataframe(by_kind_df, width="stretch", height=170)
else:
st.info("해당 월 현금흐름 정보가 아직 없습니다.")
st.markdown("예산 대비 점검")
render_section_label("🧮", "예산 대비 점검")
budget_report = settlement.get("reports", {}).get("budget", {}) if isinstance(settlement.get("reports"), dict) else {}
b1, b2, b3 = st.columns(3)
b1.metric("예산 항목 수", f"{int(budget_report.get('count', 0))}건")
b2.metric("월 예산", format_krw(budget_report.get("total_budget", 0)))
b3.metric("예산 초과 항목", f"{int(settlement.get('budget_overrun_count', 0))}건")
budget_items = budget_report.get("items", []) if isinstance(budget_report, dict) else []
if budget_items:
budget_df = pd.DataFrame(budget_items)
budget_df["예산"] = budget_df["budget"].map(format_krw)
budget_df["실제"] = budget_df["actual"].map(format_krw)
budget_df["잔여"] = budget_df["remaining"].map(format_krw)
budget_df["사용률"] = budget_df["usage_rate"].map(format_percent)
st.dataframe(
budget_df[["category", "subcategory", "person", "예산", "실제", "잔여", "사용률", "status"]],
width="stretch",
height=220,
)
else:
st.info(f"{selected_month} 예산 항목이 없습니다.")
render_section_label("⚠️", "정산 전 운영 블로커 점검")
unresolved_blocker_count = int(settlement.get("unresolved_blocker_count", 0))
unresolved_detail_count = unresolved_counts.get("unresolved_count", unresolved_blocker_count)
st.caption(f"미해결 블로커: {int(unresolved_detail_count)}건")
render_section_label("🧯", "정산 전 검토")
review_count = int(unresolved_counts.get("pending_review_count", len(review_queue_items) if isinstance(review_queue_items, list) else 0))
unreconciled_count = int(unresolved_counts.get("unreconciled_account_count", len(reconciliation_attention_items)) if isinstance(unresolved_counts, dict) else len(reconciliation_attention_items))
uncategorized_backlog = int(unresolved_counts.get("uncategorized_recommendation_backlog", 0)) if isinstance(unresolved_counts, dict) else 0
x1, x2, x3 = st.columns(3)
x1.metric("미처리 review", f"{review_count}건")
x2.metric("미대조 계좌", f"{unreconciled_count}건")
x3.metric("미분류 추천 대기", f"{uncategorized_backlog}건")
if review_queue_items:
st.markdown("확인필요 큐 최근 항목")
for item in review_queue_items[:5]:
tx_date = item.get("transaction_date") or item.get("reviewed_at") or "-"
merchant = item.get("merchant") or item.get("counterparty") or "-"
amount = format_krw(item.get("amount") if item.get("amount") is not None else item.get("difference"))
status = item.get("review_status") or item.get("status") or "pending"
st.markdown(f"- {tx_date} · {merchant} · {amount} · {status}")
else:
st.caption("확인필요 큐는 비어 있습니다.")
st.markdown("- [확인필요 큐로 이동](?page=확인필요 큐)")
render_section_label("📥", "거래 반영 / 계좌 상태")
source_imported_at = source_coverage.get("imports", {}).get("latest_imported_at")
t1, t2, t3 = st.columns(3)
t1.metric("최근 Import 건수", f"{int(source_coverage.get('imports', {}).get('recent_batch_count', 0))}건")
t2.metric("최근 Import 시각", source_imported_at or "없음")
t3.metric("보류 후 처리", str(review_indicators.get("held_recheck_count", "-") or "0") + "건")
if import_batches:
latest_import = import_batches[0]
st.caption(f"최근 Import: 출처 {latest_import.get('source', '알 수 없음')} / 수집일 {latest_import.get('imported_at', '미기록')}")
else:
st.info("최근 Import 배치가 없습니다.")
st.markdown("- [계좌 화면에서 잔액대조 확인](?page=계좌)")
st.markdown("- [Import 화면에서 이력 확인](?page=Import)")
representative_items = list(reconciliation_items[:3]) if isinstance(reconciliation_items, list) else []
st.markdown("대표 계좌 반영 상태")
if representative_items:
reconciliation_status_label = {
"OK": "정상",
"REVIEW_NEEDED": "미대조",
"BASELINE_REQUIRED": "기준 잔액 미입력",
"NEEDS_RECHECK": "재확인 필요",
"PENDING": "대기",
}
for idx, item in enumerate(representative_items, start=1):
raw_status = str(item.get("status", "-") or "-")
synced_at = item.get("last_synced_at") or item.get("baseline_at") or item.get("calculation_basis_at") or "-"
baseline_balance = item.get("baseline_balance")
display_balance = baseline_balance if baseline_balance is not None else item.get("calculated_balance")
item_line = (
f"- [{idx}] 계좌명: {item.get('account_name', '-')}, "
f"기관: {item.get('institution', item.get('bank', '미기록'))}, "
f"유형: {item.get('account_type', item.get('type', '미기록'))}, "
f"최근 동기화: {synced_at}, "
f"잔액: {format_krw(display_balance)}, "
f"상태: {reconciliation_status_label.get(raw_status, raw_status)}"
)
st.markdown(
item_line,
)
st.markdown(f"상태: {reconciliation_status_label.get(raw_status, '정상' if idx == 1 else raw_status)}")
else:
st.caption("잔액대조 대상 계좌가 현재 없습니다.")
st.markdown("- [미분류 페이지](?page=미분류)")
render_section_label("🏦", "자산 / 순자산 마감 상태")
latest_asset_status = "미확인"
if asset_snapshot.get("success"):
latest_asset_status = "마감 완료" if bool(asset_snapshot.get("is_closed_snapshot")) else "미마감"
warnings = asset_snapshot.get("warnings") if isinstance(asset_snapshot, dict) else []
missing_valuation_items = (
asset_snapshot.get("excluded_items", asset_snapshot.get("missing_valuation_items", []))
if isinstance(asset_snapshot, dict)
else []
)
warning_text = "경고 없음"
if warnings or missing_valuation_items:
warning_text = f"경고 {len(warnings or [])}건 / 제외 {len(missing_valuation_items or [])}건"
m1, m2, m3, m4 = st.columns(4)
m1.metric("자산 합계", format_krw(asset_snapshot.get("total_assets") if isinstance(asset_snapshot, dict) else source_coverage.get("assets", {}).get("total_assets")))
m2.metric("총부채", format_krw(asset_snapshot.get("total_liabilities") if isinstance(asset_snapshot, dict) else source_coverage.get("assets", {}).get("total_liabilities")))
m3.metric("순자산", format_krw(
asset_snapshot.get("net_worth")
if isinstance(asset_snapshot, dict)
else source_coverage.get("assets", {}).get("net_worth")
))
m4.metric("자산 마감 상태", latest_asset_status)
st.caption(f"자산 상세: {warning_text}")
st.caption(f"마감 기준일: {asset_snapshot.get('basis_date', basis_date)} · 선택 정책: {asset_snapshot.get('selection_policy', 'exact')} · close_status: {asset_snapshot.get('close_status', '미확인')}")
_render_asset_evidence(asset_snapshot)
close_metadata = asset_snapshot.get("close_metadata", {}) if isinstance(asset_snapshot, dict) else {}
if close_metadata.get("approved_by") or close_metadata.get("approval_reason"):
st.caption(f"조건부 승인: {close_metadata.get('approved_by', '-')} · {close_metadata.get('approval_reason', '-')} · {close_metadata.get('approval_reference', '-')}")
st.markdown("- [자산/마감 화면에서 스냅샷 확인](?page=자산/마감)")
render_section_label("📜", "월정산 결과 / Evidence")
st.markdown("### 읽기 전용 근거 요약")
evidence_close_status = "마감 완료" if bool(asset_snapshot.get("is_closed_snapshot")) else "미마감"
import_summary = source_coverage.get("imports", {}) if isinstance(source_coverage, dict) else {}
evidence_rows = [
f"- close 준비 상태: {evidence_close_status}",
f"- source coverage: 최근 Import 배치 {int(import_summary.get('recent_batch_count', 0))}건",
f"- 최근 Import: {import_summary.get('latest_imported_at') or '없음'}",
f"- 자산/마감: {evidence_close_status} ({asset_snapshot.get('basis_date', basis_date)})",
f"- 미해결 블로커: {int(unresolved_detail_count)}건",
]
for row in evidence_rows:
st.markdown(row)
st.markdown("### 다음 액션")
st.caption("이번 범위에는 월정산 close 저장/실행이 포함되지 않습니다")
action_candidates = [
"예산 조정 후보: 월말 예산 초과/부족 항목이 있는지 점검하고 요약 페이지에서 예산을 갱신하세요.",
"분류/검토 규칙 후보: 미분류 추천 및 확인필요 큐에서 규칙 후보를 먼저 점검하세요.",
"대조/Import/백업 후속 작업: 계좌 잔액대조, 최근 Import 이력, 백업 최신화 상태를 함께 확인하세요.",
"수기 자산/마감 화면에서 기준일·평가 상태를 정합성 확인 후 운영 승인 여부를 결정하세요.",
]
for action in action_candidates:
st.markdown(f"- {action}")
st.divider()
st.markdown("### 저장된 월정산 스냅샷")
st.caption("아래는 저장 시점의 불변 기록이며 위의 live KPI/리포트와 별도입니다.")
exact_run = st.text_input("정확한 run ID (선택)", key="saved_settlement_run_id")
saved = settlement_gateway.get_monthly_settlement_snapshot(month=selected_month, run_id=exact_run, db_path=db_path)
if not saved.get("success"):
st.info("저장된 스냅샷이 없거나 조회할 수 없습니다.")
else:
snapshot = saved.get("snapshot") if isinstance(saved.get("snapshot"), dict) else saved
st.caption(f"run {snapshot.get('run_id', snapshot.get('snapshot_run_id', '미보존'))} · 버전 {snapshot.get('aggregation_version') or 'legacy'} · 출처 {snapshot.get('source', '미보존')}")
st.caption(f"close_status: {snapshot.get('close_status', '미보존')} · legacy: {snapshot.get('legacy', '미보존')} · comprehensive: {snapshot.get('is_comprehensive', '미보존')}")
if snapshot.get("close_status") == "conditional":
st.caption("조건부 마감 상태 (부분 승인 조건 적용)")
for key, label in (("income_total", "수입"), ("expense_total", "지출"), ("net_cashflow", "순현금흐름"), ("total_assets", "자산"), ("total_liabilities", "부채"), ("net_worth", "순자산")):
st.write(label, snapshot.get(key) if snapshot.get(key) is not None else "미보존")
rows = snapshot.get("cashflow_items", [])
if isinstance(rows, list): st.dataframe([{k: row.get(k) for k in ("cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency")} for row in rows if isinstance(row, dict)])
st.markdown("**저장된 헤더 근거**")
_render_asset_evidence({key: snapshot.get(key) for key in ("excluded_items", "carried_items", "approval")})
source_snapshot = snapshot.get("source_snapshot") if isinstance(snapshot.get("source_snapshot"), dict) else {}
asset_evidence = snapshot.get("items") if isinstance(snapshot.get("items"), list) else []
source_assets = source_snapshot.get("assets") if isinstance(source_snapshot.get("assets"), list) else []
safe_asset_fields = ("source_type", "source_id", "status", "valuation_id", "valuation_date", "amount", "applied_amount", "verification_status", "authority_source", "exclusion_reason", "currency", "name", "evidence_kind", "valuation_source", "selection_mode", "selection_policy")
st.markdown("**저장 자산 근거**")
st.caption("누락(missing)·제외(excluded) 항목과 저장된 사유를 표시합니다. 미보존 근거는 추정하지 않습니다.")
asset_rows = list(asset_evidence)
asset_rows.extend(source_assets)
st.dataframe([{k: row.get(k) for k in safe_asset_fields} for row in asset_rows if isinstance(row, dict)])
st.divider()
render_settlement_drafts(db_path=db_path, month=selected_month)
st.divider()
st.markdown("- [요약 페이지로 이동](?page=요약)")
+643 -268
View File
@@ -2,301 +2,676 @@ from __future__ import annotations
# ruff: noqa: F405 # ruff: noqa: F405
from datetime import datetime from datetime import date, datetime
import pandas as pd
import streamlit as st import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import overview_gateway from jarca_ledger_mcp.adapters.streamlit import overview_gateway
from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
from dashboard.page_views.settlement_drafts import render_settlement_drafts
from dashboard.common import * # noqa: F403, F405 from dashboard.common import * # noqa: F403, F405
def _render_asset_evidence(report: dict) -> None: def _query_value(name: str, default: str = "") -> str:
warnings = report.get("warnings") or [] value = st.query_params.get(name, default)
exclusions = report.get("excluded_items") or report.get("exclusions") or [] if isinstance(value, list):
carried = report.get("carried_items") or [] value = value[0] if value else default
provenance = report.get("provenance") or {} return str(value or default)
approval = report.get("approval") or provenance.get("approval") or {}
correction = report.get("correction") or provenance.get("correction") or {}
st.markdown("**근거 상세 (코드 / 출처 식별자)**") def _valid_month(value: str) -> bool:
for item in warnings: try:
st.markdown(f"- 경고 `{item.get('code', '-')}` · 출처 {item.get('source_type', '-')} / {item.get('source_id', '-')} · 대상 {item.get('name', '-')}") datetime.strptime(value, "%Y-%m")
if exclusions: return len(value) == 7
st.markdown("**제외 상세**") except (TypeError, ValueError):
for item in exclusions: return False
st.markdown(f"- 제외 코드 `{item.get('reason', '-')}` · 출처 {item.get('source_type', '-')} / {item.get('source_id', '-')} · 대상 {item.get('name', '-')}")
if carried:
st.markdown("**이월 항목**") def _monthly_list_url(month: str | None, year: str, status: str) -> str:
for item in carried: params = ["page=월정산"]
st.markdown(f"- 이월 · 출처 {item.get('source_type', '-')} / {item.get('source_id', '-')} · 평가일 {item.get('valuation_date', '-')}") if month:
if approval: params.append(f"month={month}")
st.markdown(f"**조건부 승인** · 승인자 {approval.get('approved_by', '-')} · 사유 {approval.get('approval_reason', '-')} · 참조 {approval.get('approval_reference', '-')}") if year and year != "전체":
if correction: params.append(f"year={year}")
st.markdown(f"**정정** · 원본 run {correction.get('correction_of_run_id', '-')} · 사유 {correction.get('correction_reason', '-')}") if status and status != "전체":
st.caption(f"canonical provenance · 평가일 {provenance.get('valuation_date', '-')} · 검증 {provenance.get('verification_status', '-')} · 유형 {provenance.get('evidence_kind', '-')} · run {provenance.get('snapshot_run_id', '-')}") params.append(f"status={status}")
return "?" + "&".join(params)
def _set_monthly_detail(settlement_id: str, year: str, status: str) -> None:
# The sidebar radio owns this widget key and may already have been
# instantiated in the current run. Mutating it here raises
# StreamlitAPIException during a real sidebar-backed navigation event.
if "dashboard_page" not in st.session_state:
st.session_state["dashboard_page"] = "월정산"
st.query_params["page"] = "monthly_settlement"
st.query_params["id"] = settlement_id
st.query_params.pop("month", None)
if year and year != "전체":
st.query_params["year"] = year
else:
st.query_params.pop("year", None)
if status and status != "전체":
st.query_params["status"] = status
else:
st.query_params.pop("status", None)
def _clear_monthly_detail(year: str, status: str) -> None:
# Keep the sidebar-owned widget state untouched; query params drive the
# rerun and the existing radio value is already 월정산.
if "dashboard_page" not in st.session_state:
st.session_state["dashboard_page"] = "월정산"
# Clear any detail selection before rerunning; otherwise the app's
# query-to-session restore can resurrect the stale detail on this run.
st.session_state.pop("monthly_settlement_detail_id", None)
st.session_state.pop("monthly_settlement_detail_month", None)
# Do not let the previous overview selection fire again when the list
# widget is recreated after returning from detail.
st.session_state.pop("monthly_settlement_overview_table", None)
st.query_params["page"] = "monthly_settlement"
st.query_params.pop("id", None)
st.query_params.pop("month", None)
if year and year != "전체":
st.query_params["year"] = year
else:
st.query_params.pop("year", None)
if status and status != "전체":
st.query_params["status"] = status
else:
st.query_params.pop("status", None)
st.rerun()
def _open_monthly_settlement_dialog(db_path: str) -> None:
@st.dialog("월정산 추가")
def dialog() -> None:
target_month = st.text_input("대상 월", placeholder="YYYY-MM", key="monthly_settlement_dialog_month")
if st.button("추가", type="primary", key="monthly_settlement_dialog_submit"):
result = settlement_gateway.create_monthly_settlement(target_month, db_path=db_path)
settlement_id = result.get("settlement_id")
if result.get("success") and settlement_id:
st.query_params["page"] = "monthly_settlement"
st.query_params["id"] = settlement_id
st.query_params.pop("month", None)
st.rerun()
elif result.get("error_code") == "duplicate_month" and settlement_id:
st.error("이미 생성된 월정산입니다. 기존 상세로 이동합니다.")
st.query_params["page"] = "monthly_settlement"
st.query_params["id"] = settlement_id
st.rerun()
else:
st.error(result.get("message", "월정산을 추가할 수 없습니다."))
dialog()
def _render_monthly_list(db_path: str) -> None:
overview = overview_gateway.list_monthly_settlement_overview(db_path=db_path)
title_col, action_col = st.columns([7, 1])
with title_col:
st.header("월정산")
with action_col:
if st.button("+", type="primary", key="monthly_settlement_add", help="월정산 추가"):
_open_monthly_settlement_dialog(db_path)
st.caption("월을 선택하면 해당 월의 독립 상세 화면으로 이동합니다. 조회만으로 초안을 만들지 않습니다.")
years = sorted({str(r.get("month", ""))[:4] for r in overview.get("months", []) if r.get("month")}, reverse=True)
year = st.selectbox("연도", ["전체"] + years, index=(["전체"] + years).index(_query_value("year", "전체")) if _query_value("year", "전체") in (["전체"] + years) else 0, key="settlement_overview_year")
statuses = ["전체", "마감완료", "미마감", "정정진행중"]
status = st.selectbox("상태", statuses, index=statuses.index(_query_value("status", "전체")) if _query_value("status", "전체") in statuses else 0, key="settlement_overview_status")
rows = overview.get("months", []) if overview.get("success") else []
if year != "전체":
rows = [r for r in rows if str(r.get("month", "")).startswith(year)]
if status != "전체":
rows = [r for r in rows if status in str(r.get("status_label", ""))]
if not rows:
st.info("표시할 월정산이 없습니다.")
return
# Keep the seven-column table shape while using an in-app button only for
# the month cell. A markdown href reloads the parent document and flashes
# the authenticated iframe; the callback changes query params in-place.
st.caption("월 텍스트를 클릭하면 해당 월정산 상세 화면으로 이동합니다.")
st.markdown(
"""<style>
.st-key-monthly-settlement-table [data-testid="stHorizontalBlock"] {
align-items: stretch; gap: 0 !important; border-bottom: 1px solid var(--secondary-background-color);
}
.st-key-monthly-settlement-table [data-testid="column"] {
min-width: 0; padding: 0.52rem 0.65rem; display: flex; align-items: center;
}
.st-key-monthly-settlement-table [data-testid="column"]:not(:first-child) { justify-content: flex-end; text-align: right; }
.st-key-monthly-settlement-table [data-testid="stHorizontalBlock"]:first-child {
background: var(--secondary-background-color); border-top: 1px solid var(--border-color);
font-weight: 700; white-space: nowrap;
}
.st-key-monthly-settlement-table [data-testid="stButton"] { width: 100%; }
.st-key-monthly-settlement-table [data-testid="stButton"] button {
width: 100%; justify-content: flex-start; padding: 0; min-height: 0;
font-weight: 700; color: var(--text-color);
}
.st-key-monthly-settlement-table [data-testid="stMarkdownContainer"] p { margin: 0; line-height: 1.35; }
</style>""",
unsafe_allow_html=True,
)
widths = [1.25, 1.55, 1.25, 1.25, 1.5, 1.45, 1.65]
headers = ["월", "정산상태", "수입", "지출", "순현금흐름", "순자산", "최종마감일"]
with st.container(key="monthly_settlement_table"):
header_cells = st.columns(widths)
for cell, label in zip(header_cells, headers):
with cell:
st.markdown(label)
for row_index, row in enumerate(rows):
month = str(row.get("month", ""))
settlement_id = row.get("settlement_id")
cells = st.columns(widths)
with cells[0]:
if settlement_id:
st.button(
month,
type="tertiary",
key=f"monthly_settlement_month_{row_index}_{settlement_id}",
on_click=_set_monthly_detail,
args=(str(settlement_id), year, status),
)
else:
st.markdown(month)
values = [
row.get("status_label", ""),
format_krw(row.get("income")) if row.get("income") is not None else "미확정",
format_krw(row.get("expense")) if row.get("expense") is not None else "미확정",
format_krw(row.get("net_cashflow_display")) if row.get("net_cashflow_display") is not None else "미확정",
format_krw(row.get("net_worth_display")) if row.get("net_worth_display") is not None else "미확정",
row.get("closed_at") or "미마감",
]
for cell, value in zip(cells[1:], values):
with cell:
st.markdown(str(value))
def page_monthly_settlement(db_path: str) -> None: def page_monthly_settlement(db_path: str) -> None:
query_month = st.query_params.get("month", current_kst_month()) if _query_value("legacy") == "1":
if isinstance(query_month, list): from .legacy_monthly_settlement import page_legacy_monthly_settlement
query_month = query_month[0] page_legacy_monthly_settlement(db_path)
month = st.text_input("대상 월", value=str(query_month), key="monthly_settlement_month", help="YYYY-MM 형식") return
settlement_id = _query_value("id")
legacy_month = _query_value("month")
if not settlement_id and legacy_month:
overview = overview_gateway.list_monthly_settlement_overview(db_path=db_path)
settlement_id = next((str(row.get("settlement_id")) for row in overview.get("months", []) if row.get("month") == legacy_month and row.get("settlement_id")), "")
if settlement_id:
st.query_params["page"] = "monthly_settlement"
st.query_params["id"] = settlement_id
st.query_params.pop("month", None)
else:
from .legacy_monthly_settlement import page_legacy_monthly_settlement
page_legacy_monthly_settlement(db_path)
return
if settlement_id:
year = _query_value("year", "전체")
status = _query_value("status", "전체")
parent = settlement_gateway.get_monthly_settlement_record(settlement_id, db_path=db_path)
if not parent.get("success"):
st.error("월정산을 찾을 수 없습니다.")
return
month = str(parent["settlement"]["month"])
settlement_row = parent.get("settlement") or {}
settlement_status = settlement_row.get("status")
settlement_version = settlement_row.get("version")
# Fetch the review payload once and feed the same read-only result to
# both the header action and the cashflow renderer.
review_payload = None
if settlement_status in {"draft", "in_review"}:
review_payload = settlement_gateway.review_monthly_settlement(settlement_id, db_path=db_path)
with st.container(key="monthly-settlement-detail-header"):
st.markdown(
"""<style>
.st-key-monthly-settlement-detail-header h1 { margin: 0; padding: 0; }
.st-key-monthly-settlement-detail-header [data-testid="stPopover"] svg { display: none; }
</style>""",
unsafe_allow_html=True,
)
try:
back_col, title_col, menu_col = st.columns([1.5, 6, 1], vertical_alignment="center")
except TypeError:
# Keep lightweight navigation fakes compatible; real Streamlit
# supports vertical_alignment (verified in the active env).
back_col, title_col, menu_col = st.columns([1.5, 6, 1])
with back_col:
st.button(
"← 월정산 목록",
key="monthly_settlement_back",
on_click=_clear_monthly_detail,
args=(year, status),
)
with title_col:
st.title(f"월정산 상세 · {month}")
with menu_col:
with st.popover("⋯", type="tertiary", help="월정산 메뉴"):
if settlement_status in {"draft", "in_review"}:
approve_disabled = not bool((review_payload or {}).get("success"))
if st.button("검토승인", key=f"monthly_approve_{settlement_id}", disabled=approve_disabled):
fingerprint = (review_payload or {}).get("review_fingerprint")
if settlement_version is None or not fingerprint:
st.error("검토 승인에 필요한 정보가 없습니다.")
result = {"success": False}
else:
result = settlement_gateway.approve_monthly_settlement(
settlement_id, int(settlement_version), str(fingerprint), db_path=db_path,
)
if result.get("success"):
st.success("승인되었습니다.")
st.rerun()
else:
st.error(result.get("error", "승인에 실패했습니다."))
elif settlement_status == "approved":
if st.button("승인 취소", key=f"monthly_cancel_approval_{settlement_id}"):
_open_monthly_approval_cancel_dialog(db_path, settlement_id, month, settlement_version)
if st.button("마감", key=f"monthly_close_{settlement_id}"):
_open_monthly_close_dialog(db_path, settlement_id, month, settlement_version)
try:
_render_monthly_detail(db_path, month, settlement_id, review_payload=review_payload)
except TypeError as exc:
# Keep lightweight navigation fakes that predate the payload arg usable.
if "review_payload" not in str(exc):
raise
_render_monthly_detail(db_path, month)
return
_render_monthly_list(db_path)
st.link_button("기존 자산 마감 · 초안 및 이력", "?page=monthly_settlement&legacy=1")
def _account_evaluation_totals(rows: list[dict]) -> dict:
"""Aggregate linked snapshots into signed assets, debt, and net assets.
Unknown categories stay out of the net calculation; NULL amounts stay unknown.
Debt follows the existing signed debt-positive contract (no unconditional abs()).
"""
registered = [row for row in rows if row.get("snapshot_id")]
registered_accounts = {row.get("account_id") or row.get("source_id") for row in registered}
all_accounts = {row.get("account_id") or row.get("source_id") for row in rows}
unregistered_accounts = all_accounts - registered_accounts
metrics: dict[str, dict[str, int]] = {}
legacy_totals: dict[str, int] = {}
unknown_count = 0
unknown_by_currency: dict[str, int] = {}
for row in registered:
currency = str(row.get("currency") or "미지정")
amount = row.get("amount")
if amount is None:
unknown_count += 1
continue
legacy_totals[currency] = legacy_totals.get(currency, 0) + int(amount)
account_type = str(row.get("account_type") or "").lower()
category = str(row.get("snapshot_account_category") or row.get("account_liquidity_bucket") or "").lower()
is_debt = account_type in {"loan", "card", "liability", "debt", "credit_card"} or category in {"loan", "card", "liability", "debt"}
is_asset = account_type in {"asset", "bank", "cash", "checking", "savings", "investment", "securities"} or category in {"asset", "cash", "bank", "investment"}
bucket = metrics.setdefault(currency, {"assets": 0, "liabilities": 0, "net_assets": 0})
if is_debt:
bucket["liabilities"] += int(amount)
elif is_asset:
bucket["assets"] += int(amount)
else:
if account_type or category:
unknown_by_currency[currency] = unknown_by_currency.get(currency, 0) + 1
continue
bucket["net_assets"] = bucket["assets"] - bucket["liabilities"]
return {
"metrics": metrics,
"totals": legacy_totals,
"unknown_count": unknown_count + sum(unknown_by_currency.values()),
"unknown_by_currency": unknown_by_currency,
"registered_count": len(registered_accounts),
"unregistered_count": len(unregistered_accounts),
"total_count": len(all_accounts),
"has_amount": bool(metrics),
}
def _format_evaluation_total(currency: str, amount: int) -> str:
return f"{amount:,}원" if currency == "KRW" else f"{amount:,} {currency}"
_ACCOUNT_EVALUATION_BUCKETS = (
("cash", "현금(cash)"),
("deposit", "예금(deposit)"),
("investment", "투자(investment)"),
("real_estate", "부동산(real_estate)"),
("loan", "대출·부채(loan)"),
("other", "기타(other)"),
)
_ACCOUNT_EVALUATION_BUCKET_CODES = {code for code, _ in _ACCOUNT_EVALUATION_BUCKETS}
def _snapshot_display_bucket(item: dict) -> str:
"""Use frozen snapshot classification; unknown/missing values are other."""
raw = item.get("snapshot_account_category")
if raw is None:
raw = item.get("account_liquidity_bucket")
value = str(raw or "").strip().lower()
return value if value in _ACCOUNT_EVALUATION_BUCKET_CODES else "other"
def _open_monthly_approval_cancel_dialog(db_path: str, settlement_id: str, month: str, expected_version: int | None) -> None:
@st.dialog("승인 취소")
def dialog() -> None:
st.markdown(f"**대상 월:** {month}")
st.warning("승인을 취소하면 검토중으로 돌아가며 재승인이 필요합니다.")
cancel_col, confirm_col = st.columns(2)
with cancel_col:
st.button("취소", key=f"monthly_approval_cancel_abort_{settlement_id}")
with confirm_col:
if st.button("승인 취소", key=f"monthly_approval_cancel_confirm_{settlement_id}", type="primary"):
if expected_version is None:
st.error("승인 취소에 필요한 버전 정보가 없습니다.")
return
result = settlement_gateway.cancel_monthly_settlement_approval(settlement_id, int(expected_version), db_path=db_path)
if result.get("success"):
st.success("승인이 취소되었습니다. 검토 후 다시 승인해야 합니다.")
st.rerun()
else:
st.error(result.get("error", "승인 취소에 실패했습니다."))
dialog()
def _open_monthly_close_dialog(db_path: str, settlement_id: str, month: str, expected_version: int | None) -> None:
@st.dialog("월정산 마감")
def dialog() -> None:
st.markdown(f"**대상 월:** {month}")
st.warning("마감 후에는 이 월정산의 평가 등록·분류 수정·연결 해제 등을 변경할 수 없습니다.")
cancel_col, confirm_col = st.columns(2)
with cancel_col:
st.button("취소", key=f"monthly_close_cancel_{settlement_id}")
with confirm_col:
if st.button("마감", key=f"monthly_close_confirm_{settlement_id}", type="primary"):
if expected_version is None:
st.error("마감에 필요한 버전 정보가 없습니다.")
return
result = settlement_gateway.close_monthly_settlement(settlement_id, int(expected_version), db_path=db_path)
if result.get("success"):
st.success("마감되었습니다.")
st.rerun()
else:
st.error(result.get("error", "마감에 실패했습니다."))
dialog()
def _render_monthly_detail(db_path: str, month: str, settlement_id: str | None = None, *, review_payload: dict | None = None) -> None:
settlement_id = settlement_id or _query_value("id")
settlement = overview_gateway.get_monthly_settlement_summary(month, db_path=db_path) settlement = overview_gateway.get_monthly_settlement_summary(month, db_path=db_path)
if not settlement.get("success"): if not settlement.get("success"):
st.error(settlement.get("error", settlement)) st.error(settlement.get("error", settlement))
st.caption(f"입력한 월: {month}") st.caption(f"입력한 월: {month}")
return return
st.subheader("계정별 평가 내역")
# The summary is a month-level report and does not carry lifecycle fields.
# Read the canonical parent row by the selected settlement id instead of
# guessing status/version from the nested report payload.
settlement_record = settlement_gateway.get_monthly_settlement_record(settlement_id, db_path=db_path) if settlement_id else {}
parent = settlement_record.get("settlement") or {}
settlement_status = parent.get("status")
settlement_version = parent.get("version")
snapshots = settlement_gateway.list_account_snapshots_for_settlement(settlement_id, db_path=db_path)
snapshot_rows = snapshots.get("snapshots", []) if snapshots.get("success") else []
rows = []
for item in snapshot_rows:
amount = item.get("amount")
status = item.get("display_status") or ("미등록" if not item.get("snapshot_id") else ("미확인" if amount is None else "확인"))
rows.append({
"snapshot_id": item.get("snapshot_id"),
"account_id": item.get("account_id") or item.get("source_id"),
"account_name": item.get("account_name") or item.get("name") or "",
"currency": item.get("currency") or "",
"amount": amount,
"basis_date": item.get("basis_date") or "",
"previous_ledger": item.get("previous_ledger"),
"next_ledger": item.get("next_ledger"),
"unlinked_candidates": item.get("unlinked_candidates") or [],
"account_type": item.get("account_type"),
"account_liquidity_bucket": item.get("account_liquidity_bucket"),
"snapshot_account_category": item.get("snapshot_account_category"),
"display_bucket": _snapshot_display_bucket(item),
"projection": item.get("projection"),
"계정명": item.get("account_name") or item.get("name") or "",
"평가금액": "—" if status == "미등록" else ("미확인" if amount is None else f"{amount:,}"),
"통화": item.get("currency") or "",
"기준일": item.get("basis_date") or "",
"확인상태": status,
"등록가능": not snapshots.get("closed", False) and status == "미등록",
"연결해제가능": snapshots.get("status") in {"draft", "in_review"} and status != "미등록" and bool(item.get("snapshot_id")),
})
registered = [r for r in rows if r.get("snapshot_id")]
unregistered = [r for r in rows if not r.get("snapshot_id")]
totals = _account_evaluation_totals(rows)
if snapshots.get("success"):
st.caption(f"평가등록 {totals['registered_count']} / 전체 {totals['total_count']} · 금액 미확인 {totals['unknown_count']} · 미등록 {totals['unregistered_count']}")
st.caption("미검증 평가금액이 포함될 수 있습니다.")
for currency, values in sorted(totals["metrics"].items()):
suffix = "원" if currency == "KRW" else currency
metric_columns = st.columns(3)
with metric_columns[0]:
st.metric(f"자산 합계 ({currency})", f"{values['assets']:,}{suffix}")
with metric_columns[1]:
st.metric(f"부채 합계 ({currency})", f"{values['liabilities']:,}{suffix}")
with metric_columns[2]:
st.metric(f"순자산 ({currency})", f"{values['net_assets']:,}{suffix}")
selected_month = settlement.get("month") or settlement.get("selected_month") or month st.markdown("### 평가 등록 계좌")
basis_date = settlement.get("basis_date") or f"{selected_month}-01" if not registered:
source_coverage = settlement.get("source_coverage", {}) if isinstance(settlement.get("source_coverage"), dict) else {} st.caption("등록된 평가 내역이 없습니다.")
unresolved_blockers = settlement.get("unresolved_blockers", {}) if isinstance(settlement.get("unresolved_blockers"), dict) else {}
unresolved_counts = unresolved_blockers.get("counts", {}) if isinstance(unresolved_blockers.get("counts"), dict) else {}
review_queue_res = overview_gateway.list_cross_transfer_review_queue(limit=300, db_path=db_path)
review_queue_items = get_transfer_review_items(review_queue_res)
review_indicators_res = reconciliation_gateway.get_review_operating_indicators(db_path=db_path)
review_indicators = review_indicators_res.get("indicators", {}) if review_indicators_res.get("success") else {}
reconciliation_res = reconciliation_gateway.get_balance_reconciliation_report(db_path=db_path)
reconciliation_items = reconciliation_res.get("items", []) if reconciliation_res.get("success") else []
reconciliation_attention_items = [
item
for item in reconciliation_items
if str(item.get("status")) in {"REVIEW_NEEDED", "BASELINE_REQUIRED", "NEEDS_RECHECK", "PENDING"}
]
imports_res = overview_gateway.list_ledger_import_batches(limit=1, db_path=db_path)
import_batches = imports_res.get("import_batches", []) if imports_res.get("success") else []
asset_snapshot = overview_gateway.get_asset_snapshot_report(basis_date=basis_date, db_path=db_path)
read_only_stamp = datetime.now(ZoneInfo("Asia/Seoul")).strftime("%Y-%m-%d %H:%M:%S KST")
render_section_label("📘", "월정산 셸")
st.caption(f"선택 월: {selected_month} / 기준일: {basis_date} / 기준 시각: {read_only_stamp} / 모드: 읽기 전용")
st.markdown(
"<span style='display:inline-block; padding:0.15rem 0.7rem; border-radius:9999px; "
"background:#dbeafe; color:#1e40af; font-weight:600; border:1px solid #93c5fd;'>"
"🔒 읽기 전용</span>",
unsafe_allow_html=True,
)
st.info("거래/예산 수정은 각 운영 화면(거래내역, 요약)에서 처리하세요.")
st.markdown("- [거래내역으로 이동](?page=거래내역)")
st.markdown("- [예산 확인으로 이동](?page=요약)")
st.markdown("월간 핵심 KPI")
st.caption("월간 핵심 지표를 통해 수입·지출·잔고 흐름을 점검하세요.")
k1, k2, k3, k4 = st.columns(4)
k1.metric("실제 수입", format_krw(settlement.get("actual_income", 0)))
k2.metric("실제 지출", format_krw(settlement.get("actual_expense", 0)))
k3.metric("순현금흐름", format_krw(settlement.get("net_cashflow", 0)))
k4.metric("저축률", format_percent(settlement.get("savings_rate")))
st.markdown("현금흐름 / 지출 구조")
render_section_label("💸", "현금흐름 / 지출 구조")
cashflow = settlement.get("reports", {}).get("cashflow", {}) if isinstance(settlement.get("reports"), dict) else {}
if cashflow:
c1, c2 = st.columns(2)
by_type = cashflow.get("by_type", {}) or {}
by_type_df = pd.DataFrame(
[{"구분": str(key), "금액": int(value)} for key, value in by_type.items()]
)
if not by_type_df.empty:
by_type_df["금액"] = by_type_df["금액"].map(format_krw)
by_type_df = by_type_df.sort_values("금액", ascending=False)
c1.dataframe(by_type_df, width="stretch", height=170)
by_kind = cashflow.get("by_cashflow_kind", {}) or {}
by_kind_df = pd.DataFrame(
[{"현금흐름 구분": str(key), "금액": int(value)} for key, value in by_kind.items()]
)
if not by_kind_df.empty:
by_kind_df["금액"] = by_kind_df["금액"].map(format_krw)
c2.dataframe(by_kind_df, width="stretch", height=170)
else: else:
st.info("해당 월 현금흐름 정보가 아직 없습니다.") # Keep the group header and every row on the same four-column grid.
widths = [2.6, 1.5, 0.45, 0.45]
st.markdown("""<style>
[class*="monthly-account-evaluation-group"] [data-testid="stHorizontalBlock"] { align-items: stretch; gap: 0 !important; border-bottom: 1px solid var(--border-color); border-left: 1px solid var(--border-color); border-right: 1px solid var(--border-color); }
[class*="monthly-account-evaluation-group"] [data-testid="stHorizontalBlock"] [data-testid="column"] { min-width: 0; padding: 0.42rem 0.55rem; display: flex; align-items: center; border-right: 1px solid var(--border-color); }
[class*="monthly-account-evaluation-group"] [data-testid="column"]:last-child { border-right: 0; }
[class*="monthly-account-evaluation-group"] [data-testid="stHorizontalBlock"]:first-child { background: var(--secondary-background-color); border-top: 1px solid var(--border-color); font-weight: 700; white-space: nowrap; }
[class*="monthly-account-evaluation-group"] [data-testid="stHorizontalBlock"]:not(:first-child) [data-testid="column"]:nth-child(2) { justify-content: flex-end; text-align: right; }
[class*="monthly-account-evaluation-group"] [data-testid="stHorizontalBlock"]:not(:first-child) [data-testid="column"]:nth-child(3),
[class*="monthly-account-evaluation-group"] [data-testid="stHorizontalBlock"]:not(:first-child) [data-testid="column"]:nth-child(4) { justify-content: center; }
[class*="monthly-account-evaluation-group"] .monthly-account-name { padding-left: 1.1rem; }
</style>""", unsafe_allow_html=True)
for bucket, label in _ACCOUNT_EVALUATION_BUCKETS:
group_rows = [item for item in registered if item["display_bucket"] == bucket]
if not group_rows:
continue
group_totals = _account_evaluation_totals(group_rows)["totals"]
subtotal = " · ".join(_format_evaluation_total(currency, amount) for currency, amount in sorted(group_totals.items())) or "—"
with st.container(key=f"monthly-account-evaluation-group-{bucket}"):
header_cells = st.columns(widths)
with header_cells[0]: st.markdown(label)
with header_cells[1]: st.markdown(f"**{subtotal}**")
for cell in header_cells[2:]:
with cell: st.markdown("")
for item in group_rows:
cells = st.columns(widths)
with cells[0]: st.markdown(f'<div class="monthly-account-name">{item["계정명"]}</div>', unsafe_allow_html=True)
amount = item["amount"]
amount_display = "미확인" if amount is None else f'{amount:,}{"원" if item["currency"] == "KRW" else " " + str(item["currency"] or "")}'
with cells[1]: st.markdown(amount_display)
with cells[2]:
if item["연결해제가능"] and st.button("×", key=f"monthly_unlink_{settlement_id}_{item['snapshot_id']}", help="연결 해제", type="tertiary", use_container_width=True):
_render_snapshot_unlink_dialog(db_path, settlement_id, item)
with cells[3]:
if item["연결해제가능"] and st.button("✎", key=f"monthly_category_{settlement_id}_{item['snapshot_id']}", help="분류 수정", type="tertiary", use_container_width=True):
_render_snapshot_category_dialog(db_path, settlement_id, item)
st.markdown("예산 대비 점검") st.markdown("### 평가 미등록 계좌")
render_section_label("🧮", "예산 대비 점검") if not unregistered:
budget_report = settlement.get("reports", {}).get("budget", {}) if isinstance(settlement.get("reports"), dict) else {} st.caption("모든 계좌의 평가가 등록되었습니다.")
b1, b2, b3 = st.columns(3) else:
b1.metric("예산 항목 수", f"{int(budget_report.get('count', 0))}건") for item in unregistered:
b2.metric("월 예산", format_krw(budget_report.get("total_budget", 0))) if item["등록가능"]:
b3.metric("예산 초과 항목", f"{int(settlement.get('budget_overrun_count', 0))}건") if st.button(item["계정명"], type="tertiary", key=f"monthly_evaluation_account_{settlement_id}_{item['account_id']}"):
_render_account_registration_dialog(db_path, settlement_id, settlement, item)
else:
st.markdown(item["계정명"])
budget_items = budget_report.get("items", []) if isinstance(budget_report, dict) else [] _render_monthly_cashflow(db_path, month, settlement_id, bool(snapshots.get("closed", False)), settlement_status, settlement_version, review_payload=review_payload)
if budget_items:
budget_df = pd.DataFrame(budget_items)
budget_df["예산"] = budget_df["budget"].map(format_krw) def _render_monthly_cashflow(db_path: str, month: str, settlement_id: str, closed: bool, settlement_status: str | None = None, settlement_version: int | None = None, *, review_payload: dict | None = None) -> None:
budget_df["실제"] = budget_df["actual"].map(format_krw) report = settlement_gateway.get_monthly_cashflow(month, settlement_id=settlement_id, closed=closed, db_path=db_path)
budget_df["잔여"] = budget_df["remaining"].map(format_krw) if not report.get("success"):
budget_df["사용률"] = budget_df["usage_rate"].map(format_percent) st.warning("현금흐름을 불러오지 못했습니다.")
st.dataframe( return
budget_df[["category", "subcategory", "person", "예산", "실제", "잔여", "사용률", "status"]], st.subheader("수입·지출")
width="stretch", st.caption("정산 스냅샷 기준" if closed else "현재 원장 기준")
height=220, if not report.get("saved", True):
st.info(report.get("message", "저장된 현금흐름 스냅샷이 없습니다."))
return
cols = st.columns(3)
for col, label, key in zip(cols, ("수입", "지출", "수지"), ("income", "expenses", "net_cashflow")):
with col:
value = report.get(key)
st.metric(label, "통화별" if value is None and report.get("by_currency") else ("미확정" if value is None else f"{int(value):,}"))
if report.get("by_currency"):
for currency, totals in report["by_currency"].items():
st.caption(f"{currency}: 수입 {totals['income']:,} · 지출 {totals['expenses']:,} · 순 {totals['net_cashflow']:,}")
unknown_rows = report.get("unknown_category_items") or [r for r in (report.get("categories") or []) if r.get("cashflow_kind") == "unknown"]
if unknown_rows:
unknown_count = sum(int(r.get("count") or 0) for r in unknown_rows)
st.warning(f"분류가 필요한 거래가 {unknown_count}건 있습니다. 미분류 금액은 수입·지출 합계에 포함되지 않습니다.")
st.markdown("### 미분류 내역")
st.dataframe([{"카테고리": r.get("category") or "미지정", "하위카테고리": r.get("subcategory") or "미지정", "금액": r.get("amount"), "거래수": int(r.get("count") or 0), "통화": r.get("currency") or "미지정"} for r in unknown_rows], hide_index=True, use_container_width=True)
st.markdown("### 카테고리별 내역")
categories = report.get("categories") or []
for kind, title in (("income", "수입"), ("expense", "지출")):
rows = [r for r in categories if r.get("cashflow_kind") == kind]
st.markdown(f"**{title}**")
if not rows:
st.caption("내역이 없습니다.")
continue
st.dataframe([{"카테고리": r.get("category") or "미분류", "하위카테고리": r.get("subcategory") or "미분류", "금액": int(r.get("amount") or 0), "거래수": int(r.get("count") or 0), "통화": r.get("currency") or "미지정"} for r in rows], hide_index=True, use_container_width=True)
def _render_snapshot_unlink_dialog(db_path: str, settlement_id: str, item: dict) -> None:
@st.dialog("평가 연결 해제")
def dialog() -> None:
account_name = str(item.get("account_name") or item.get("name") or "")
amount = item.get("amount")
amount_display = "미확인" if amount is None else f"{amount:,}"
st.markdown(f"**계좌명:** {account_name}")
st.markdown(f"**평가금액:** {amount_display}")
st.info("스냅샷은 보존되고 이 월정산과의 연결만 해제됩니다.")
confirm, cancel = st.columns(2)
if cancel.button("닫기", key=f"monthly_unlink_cancel_{settlement_id}_{item['snapshot_id']}"):
st.rerun()
if confirm.button("연결해제", key=f"monthly_unlink_confirm_{settlement_id}_{item['snapshot_id']}"):
result = settlement_gateway.unlink_account_snapshot(
snapshot_id=str(item["snapshot_id"]), monthly_settlement_id=str(settlement_id), db_path=db_path,
) )
if result.get("success"):
st.rerun()
else: else:
st.info(f"{selected_month} 예산 항목이 없습니다.") st.error(result.get("error", "연결 해제에 실패했습니다."))
dialog()
render_section_label("⚠️", "정산 전 운영 블로커 점검")
unresolved_blocker_count = int(settlement.get("unresolved_blocker_count", 0))
unresolved_detail_count = unresolved_counts.get("unresolved_count", unresolved_blocker_count)
st.caption(f"미해결 블로커: {int(unresolved_detail_count)}건")
render_section_label("🧯", "정산 전 검토") def _render_snapshot_category_dialog(db_path: str, settlement_id: str, item: dict) -> None:
review_count = int(unresolved_counts.get("pending_review_count", len(review_queue_items) if isinstance(review_queue_items, list) else 0)) @st.dialog("평가 분류 수정")
unreconciled_count = int(unresolved_counts.get("unreconciled_account_count", len(reconciliation_attention_items)) if isinstance(unresolved_counts, dict) else len(reconciliation_attention_items)) def dialog() -> None:
uncategorized_backlog = int(unresolved_counts.get("uncategorized_recommendation_backlog", 0)) if isinstance(unresolved_counts, dict) else 0 buckets = ["cash", "deposit", "investment", "real_estate", "loan", "other"]
x1, x2, x3 = st.columns(3) current = item.get("snapshot_account_category") or item.get("account_liquidity_bucket") or "other"
x1.metric("미처리 review", f"{review_count}건") value = st.selectbox("분류", buckets, index=buckets.index(current) if current in buckets else 5, key=f"monthly_category_value_{settlement_id}_{item['snapshot_id']}")
x2.metric("미대조 계좌", f"{unreconciled_count}건") if st.button("저장", key=f"monthly_category_save_{settlement_id}_{item['snapshot_id']}"):
x3.metric("미분류 추천 대기", f"{uncategorized_backlog}건") result = settlement_gateway.update_account_snapshot_category(str(item["snapshot_id"]), str(settlement_id), value, db_path=db_path)
if review_queue_items: if result.get("success"):
st.markdown("확인필요 큐 최근 항목") st.rerun()
for item in review_queue_items[:5]:
tx_date = item.get("transaction_date") or item.get("reviewed_at") or "-"
merchant = item.get("merchant") or item.get("counterparty") or "-"
amount = format_krw(item.get("amount") if item.get("amount") is not None else item.get("difference"))
status = item.get("review_status") or item.get("status") or "pending"
st.markdown(f"- {tx_date} · {merchant} · {amount} · {status}")
else: else:
st.caption("확인필요 큐는 비어 있습니다.") st.error(result.get("error", "분류 수정에 실패했습니다."))
st.markdown("- [확인필요 큐로 이동](?page=확인필요 큐)") dialog()
render_section_label("📥", "거래 반영 / 계좌 상태")
source_imported_at = source_coverage.get("imports", {}).get("latest_imported_at")
t1, t2, t3 = st.columns(3)
t1.metric("최근 Import 건수", f"{int(source_coverage.get('imports', {}).get('recent_batch_count', 0))}건")
t2.metric("최근 Import 시각", source_imported_at or "없음")
t3.metric("보류 후 처리", str(review_indicators.get("held_recheck_count", "-") or "0") + "건")
if import_batches: def _render_account_registration_dialog(db_path: str, settlement_id: str, settlement: dict, item: dict) -> None:
latest_import = import_batches[0] @st.dialog("미등록 계정 평가 등록")
st.caption(f"최근 Import: 출처 {latest_import.get('source', '알 수 없음')} / 수집일 {latest_import.get('imported_at', '미기록')}") def dialog() -> None:
account_id = str(item.get("account_id") or item.get("source_id"))
account_name = str(item.get("account_name") or item.get("name") or account_id)
prefix = f"monthly_evaluation_{settlement_id}_{account_id}"
st.text_input("계정", value=account_name, disabled=True, key=f"{prefix}_account")
candidates = item.get("unlinked_candidates") or []
modes = ["기존 스냅샷 연결", "새 평가 등록"] if candidates else ["새 평가 등록"]
mode = st.radio("등록 방식", modes, index=0, horizontal=True, key=f"{prefix}_mode")
if mode == "기존 스냅샷 연결":
labels = []
by_label = {}
for candidate in candidates:
amount = "미확인" if candidate.get("amount") is None else f"{candidate['amount']:,}"
label = f"기준일 {candidate.get('basis_date','')} · {amount} {candidate.get('currency','')} · {candidate.get('verification_status') or '미확인'}"
labels.append(label); by_label[label] = candidate
selected_label = st.selectbox("기존 스냅샷", labels, key=f"{prefix}_candidate")
selected = by_label[selected_label]
st.text_input("선택 금액", value="미확인" if selected.get("amount") is None else f"{selected['amount']:,}", disabled=True, key=f"{prefix}_candidate_amount")
mismatch = str(selected.get("basis_date")) != str(settlement.get("basis_date"))
allow_mismatch = False
if mismatch:
st.warning("기준일이 다른 스냅샷을 연결합니다")
allow_mismatch = st.checkbox("기준일 불일치를 확인하고 연결", key=f"{prefix}_mismatch")
connect, cancel = st.columns(2)
if cancel.button("취소", key=f"{prefix}_candidate_cancel"):
st.rerun()
if connect.button("연결", key=f"{prefix}_connect"):
if mismatch and not allow_mismatch:
st.error("기준일 불일치 동의가 필요합니다.")
return
result = settlement_gateway.link_account_snapshot(str(selected["snapshot_id"]), str(settlement_id), allow_basis_date_mismatch=allow_mismatch, db_path=db_path)
if result.get("success"):
st.rerun()
else: else:
st.info("최근 Import 배치가 없습니다.") st.error(result.get("error", "스냅샷 연결에 실패했습니다."))
return
st.markdown("- [계좌 화면에서 잔액대조 확인](?page=계좌)") settlement_basis_date = settlement.get("basis_date") or settlement.get("selected_basis_date")
st.markdown("- [Import 화면에서 이력 확인](?page=Import)") if not settlement_basis_date:
settlement_basis_date = f"{settlement.get('month') or settlement.get('selected_month')}-01"
representative_items = list(reconciliation_items[:3]) if isinstance(reconciliation_items, list) else [] basis_date = st.date_input("기준일", value=date.fromisoformat(str(settlement_basis_date)[:10]), key=f"{prefix}_basis_date")
st.markdown("대표 계좌 반영 상태") currency = st.text_input("통화", value=str(item.get("currency") or ""), disabled=True, key=f"{prefix}_currency")
if representative_items: previous = item.get("previous_ledger")
reconciliation_status_label = { following = item.get("next_ledger")
"OK": "정상", choices = []
"REVIEW_NEEDED": "미대조", choice_map = {}
"BASELINE_REQUIRED": "기준 잔액 미입력", if previous:
"NEEDS_RECHECK": "재확인 필요", label = f"이전 기록 · {previous['transaction_date']} · 잔액 {previous['balance_after']} {previous['currency']}"
"PENDING": "대기", choices.append(label); choice_map[label] = ("previous", previous)
} if following:
for idx, item in enumerate(representative_items, start=1): label = f"이후 기록 · {following['transaction_date']} · 잔액 {following['balance_after']} {following['currency']}"
raw_status = str(item.get("status", "-") or "-") choices.append(label); choice_map[label] = ("next", following)
synced_at = item.get("last_synced_at") or item.get("baseline_at") or item.get("calculation_basis_at") or "-" choices.append("수동 기입")
baseline_balance = item.get("baseline_balance") selected = st.selectbox("평가 출처", choices, index=0 if previous else len(choices)-1, key=f"{prefix}_source")
display_balance = baseline_balance if baseline_balance is not None else item.get("calculated_balance") policy, ledger = choice_map.get(selected, ("manual", None))
item_line = ( if ledger:
f"- [{idx}] 계좌명: {item.get('account_name', '-')}, " amount_text = str(ledger["balance_after"])
f"기관: {item.get('institution', item.get('bank', '미기록'))}, " st.text_input("평가금액 (정수)", value=amount_text, disabled=True, key=f"{prefix}_amount_ledger")
f"유형: {item.get('account_type', item.get('type', '미기록'))}, " else:
f"최근 동기화: {synced_at}, " amount_text = st.text_input("평가금액 (정수)", key=f"{prefix}_amount_manual")
f"잔액: {format_krw(display_balance)}, " if policy == "next":
f"상태: {reconciliation_status_label.get(raw_status, raw_status)}" st.caption("기준일 이후 기록입니다.")
buckets = ["cash", "deposit", "investment", "real_estate", "loan", "other"]
account_bucket = item.get("account_liquidity_bucket") or "other"
liquidity_bucket = st.selectbox("분류", buckets, index=buckets.index(account_bucket) if account_bucket in buckets else 5, key=f"{prefix}_liquidity_bucket")
memo = st.text_area("메모 (선택)", key=f"{prefix}_memo")
st.caption("초기 등록은 draft이며 검증 완료가 아닙니다. 금액이 있어도 확인 상태로 바뀌지 않습니다.")
save, cancel = st.columns(2)
if cancel.button("취소", key=f"{prefix}_cancel"):
st.rerun()
if save.button("저장", key=f"{prefix}_save"):
if not amount_text.strip():
st.error("평가금액을 입력하세요.")
return
try:
amount = int(amount_text.strip())
except ValueError:
st.error("평가금액은 정수여야 합니다.")
return
result = settlement_gateway.register_account_valuation(
account_id=account_id, monthly_settlement_id=settlement_id,
basis_date=basis_date.isoformat(), amount=amount, currency=currency,
memo=memo, selection_policy=policy, valuation_source="ledger" if ledger else "manual",
source_note=ledger["id"] if ledger else "", db_path=db_path,
liquidity_bucket=liquidity_bucket,
) )
st.markdown( if result.get("success"):
item_line, st.rerun()
)
st.markdown(f"상태: {reconciliation_status_label.get(raw_status, '정상' if idx == 1 else raw_status)}")
else: else:
st.caption("잔액대조 대상 계좌가 현재 없습니다.") st.error(result.get("error", "평가 등록에 실패했습니다."))
dialog()
st.markdown("- [미분류 페이지](?page=미분류)")
render_section_label("🏦", "자산 / 순자산 마감 상태")
latest_asset_status = "미확인"
if asset_snapshot.get("success"):
latest_asset_status = "마감 완료" if bool(asset_snapshot.get("is_closed_snapshot")) else "미마감"
warnings = asset_snapshot.get("warnings") if isinstance(asset_snapshot, dict) else []
missing_valuation_items = (
asset_snapshot.get("excluded_items", asset_snapshot.get("missing_valuation_items", []))
if isinstance(asset_snapshot, dict)
else []
)
warning_text = "경고 없음"
if warnings or missing_valuation_items:
warning_text = f"경고 {len(warnings or [])}건 / 제외 {len(missing_valuation_items or [])}건"
m1, m2, m3, m4 = st.columns(4)
m1.metric("자산 합계", format_krw(asset_snapshot.get("total_assets") if isinstance(asset_snapshot, dict) else source_coverage.get("assets", {}).get("total_assets")))
m2.metric("총부채", format_krw(asset_snapshot.get("total_liabilities") if isinstance(asset_snapshot, dict) else source_coverage.get("assets", {}).get("total_liabilities")))
m3.metric("순자산", format_krw(
asset_snapshot.get("net_worth")
if isinstance(asset_snapshot, dict)
else source_coverage.get("assets", {}).get("net_worth")
))
m4.metric("자산 마감 상태", latest_asset_status)
st.caption(f"자산 상세: {warning_text}")
st.caption(f"마감 기준일: {asset_snapshot.get('basis_date', basis_date)} · 선택 정책: {asset_snapshot.get('selection_policy', 'exact')} · close_status: {asset_snapshot.get('close_status', '미확인')}")
_render_asset_evidence(asset_snapshot)
close_metadata = asset_snapshot.get("close_metadata", {}) if isinstance(asset_snapshot, dict) else {}
if close_metadata.get("approved_by") or close_metadata.get("approval_reason"):
st.caption(f"조건부 승인: {close_metadata.get('approved_by', '-')} · {close_metadata.get('approval_reason', '-')} · {close_metadata.get('approval_reference', '-')}")
st.markdown("- [자산/마감 화면에서 스냅샷 확인](?page=자산/마감)")
render_section_label("📜", "월정산 결과 / Evidence")
st.markdown("### 읽기 전용 근거 요약")
evidence_close_status = "마감 완료" if bool(asset_snapshot.get("is_closed_snapshot")) else "미마감"
import_summary = source_coverage.get("imports", {}) if isinstance(source_coverage, dict) else {}
evidence_rows = [
f"- close 준비 상태: {evidence_close_status}",
f"- source coverage: 최근 Import 배치 {int(import_summary.get('recent_batch_count', 0))}건",
f"- 최근 Import: {import_summary.get('latest_imported_at') or '없음'}",
f"- 자산/마감: {evidence_close_status} ({asset_snapshot.get('basis_date', basis_date)})",
f"- 미해결 블로커: {int(unresolved_detail_count)}건",
]
for row in evidence_rows:
st.markdown(row)
st.markdown("### 다음 액션")
st.caption("이번 범위에는 월정산 close 저장/실행이 포함되지 않습니다")
action_candidates = [
"예산 조정 후보: 월말 예산 초과/부족 항목이 있는지 점검하고 요약 페이지에서 예산을 갱신하세요.",
"분류/검토 규칙 후보: 미분류 추천 및 확인필요 큐에서 규칙 후보를 먼저 점검하세요.",
"대조/Import/백업 후속 작업: 계좌 잔액대조, 최근 Import 이력, 백업 최신화 상태를 함께 확인하세요.",
"수기 자산/마감 화면에서 기준일·평가 상태를 정합성 확인 후 운영 승인 여부를 결정하세요.",
]
for action in action_candidates:
st.markdown(f"- {action}")
st.divider()
st.markdown("### 저장된 월정산 스냅샷")
st.caption("아래는 저장 시점의 불변 기록이며 위의 live KPI/리포트와 별도입니다.")
exact_run = st.text_input("정확한 run ID (선택)", key="saved_settlement_run_id")
saved = settlement_gateway.get_monthly_settlement_snapshot(month=selected_month, run_id=exact_run, db_path=db_path)
if not saved.get("success"):
st.info("저장된 스냅샷이 없거나 조회할 수 없습니다.")
else:
snapshot = saved.get("snapshot") if isinstance(saved.get("snapshot"), dict) else saved
st.caption(f"run {snapshot.get('run_id', snapshot.get('snapshot_run_id', '미보존'))} · 버전 {snapshot.get('aggregation_version') or 'legacy'} · 출처 {snapshot.get('source', '미보존')}")
st.caption(f"close_status: {snapshot.get('close_status', '미보존')} · legacy: {snapshot.get('legacy', '미보존')} · comprehensive: {snapshot.get('is_comprehensive', '미보존')}")
if snapshot.get("close_status") == "conditional":
st.caption("조건부 마감 상태 (부분 승인 조건 적용)")
for key, label in (("income_total", "수입"), ("expense_total", "지출"), ("net_cashflow", "순현금흐름"), ("total_assets", "자산"), ("total_liabilities", "부채"), ("net_worth", "순자산")):
st.write(label, snapshot.get(key) if snapshot.get(key) is not None else "미보존")
rows = snapshot.get("cashflow_items", [])
if isinstance(rows, list): st.dataframe([{k: row.get(k) for k in ("cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency")} for row in rows if isinstance(row, dict)])
st.markdown("**저장된 헤더 근거**")
_render_asset_evidence({key: snapshot.get(key) for key in ("excluded_items", "carried_items", "approval")})
source_snapshot = snapshot.get("source_snapshot") if isinstance(snapshot.get("source_snapshot"), dict) else {}
asset_evidence = snapshot.get("items") if isinstance(snapshot.get("items"), list) else []
source_assets = source_snapshot.get("assets") if isinstance(source_snapshot.get("assets"), list) else []
safe_asset_fields = ("source_type", "source_id", "status", "valuation_id", "valuation_date", "amount", "applied_amount", "verification_status", "authority_source", "exclusion_reason", "currency", "name", "evidence_kind", "valuation_source", "selection_mode", "selection_policy")
st.markdown("**저장 자산 근거**")
st.caption("누락(missing)·제외(excluded) 항목과 저장된 사유를 표시합니다. 미보존 근거는 추정하지 않습니다.")
asset_rows = list(asset_evidence)
asset_rows.extend(source_assets)
st.dataframe([{k: row.get(k) for k in safe_asset_fields} for row in asset_rows if isinstance(row, dict)])
st.divider()
render_settlement_drafts(db_path=db_path, month=selected_month)
st.divider()
st.markdown("- [요약 페이지로 이동](?page=요약)")
+1 -7
View File
@@ -35,7 +35,7 @@ def page_overview(db_path: str) -> None:
c4.metric("저축률", format_percent(cashflow.get("savings_rate"))) c4.metric("저축률", format_percent(cashflow.get("savings_rate")))
render_section_label("🏦", "자산 유동성 현황") render_section_label("🏦", "자산 유동성 현황")
st.caption("기준: service 공통 평가 헬퍼(accounts.last_balance + manual_assets 보강). 거래 합계로 비거래 자산을 추정하지 않습니다.") st.caption("기준: service 공통 평가 헬퍼(accounts.last_balance). 거래 합계로 비거래 자산을 추정하지 않습니다.")
a1, a2, a3 = st.columns(3) a1, a2, a3 = st.columns(3)
a1.metric("자산 합계(부채 제외)", format_krw(asset_report.get("total_assets", 0))) a1.metric("자산 합계(부채 제외)", format_krw(asset_report.get("total_assets", 0)))
a2.metric("총부채", format_krw(asset_report.get("total_liabilities", 0))) a2.metric("총부채", format_krw(asset_report.get("total_liabilities", 0)))
@@ -52,7 +52,6 @@ def page_overview(db_path: str) -> None:
render_section_label("🧭", "자산 구성") render_section_label("🧭", "자산 구성")
source_accounts = pd.DataFrame(asset_report.get("accounts", [])) source_accounts = pd.DataFrame(asset_report.get("accounts", []))
manual_assets = pd.DataFrame(asset_report.get("manual_assets", []))
if not source_accounts.empty: if not source_accounts.empty:
source_accounts["bucket_label"] = source_accounts["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(source_accounts["liquidity_bucket"]) source_accounts["bucket_label"] = source_accounts["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(source_accounts["liquidity_bucket"])
source_accounts["last_balance_display"] = source_accounts["last_balance"].map(format_krw) source_accounts["last_balance_display"] = source_accounts["last_balance"].map(format_krw)
@@ -60,11 +59,6 @@ def page_overview(db_path: str) -> None:
st.dataframe(source_accounts[show_cols], width="stretch", height=220) st.dataframe(source_accounts[show_cols], width="stretch", height=220)
else: else:
st.info("last_balance가 입력된 활성 계좌/자산 항목이 없습니다.") st.info("last_balance가 입력된 활성 계좌/자산 항목이 없습니다.")
if not manual_assets.empty:
manual_assets["bucket_label"] = manual_assets["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(manual_assets["liquidity_bucket"])
manual_assets["valuation_display"] = manual_assets["amount"].map(format_krw)
st.caption("수기 자산/부채(manual_assets) — 비거래 자산 권위 소스")
st.dataframe(manual_assets[["name", "bucket_label", "valuation_display", "valuation_date", "source_note", "memo"]], width="stretch", height=180)
render_section_label("📉", "지출 구조/예산") render_section_label("📉", "지출 구조/예산")
budgets = pd.DataFrame(budget_report.get("items", [])) budgets = pd.DataFrame(budget_report.get("items", []))
+132 -36
View File
@@ -26,20 +26,102 @@ def _invalidate_and_rerun(draft_id: str, result: dict) -> None:
st.rerun() st.rerun()
def _authenticated_actor() -> str | None:
from dashboard.authentication import get_authenticated_actor
return get_authenticated_actor()
def _open_settlement_history(draft_id: str, run_id: str = "") -> None:
"""Open the read-only history panel and prefer the completed draft's run."""
st.session_state["settlement_history_open_draft"] = draft_id
if run_id:
st.session_state[f"settlement_history_run_{draft_id}"] = run_id
def _render_settlement_history(db_path: str | None, month: str, draft_id: str) -> None:
"""List immutable runs and fetch the exact record selected by the operator."""
listed = gateway.list_monthly_settlement_history(month, db_path=db_path)
if not listed.get("success"):
st.error(f"마감 이력을 불러오지 못했습니다 ({listed.get('error_code', 'operation_failed')}).")
return
history = listed.get("history", [])
if not isinstance(history, list):
history = []
runs = [str(item.get("run_id")) for item in history if isinstance(item, dict) and item.get("run_id")]
if not runs:
st.info("저장된 불변 마감 이력이 없습니다.")
return
selection_key = f"settlement_history_run_{draft_id}"
if st.session_state.get(selection_key) not in runs:
st.session_state[selection_key] = runs[0]
selected_run = st.selectbox("불변 마감 run 선택", runs, key=selection_key)
st.dataframe(history, hide_index=True, use_container_width=True)
record = gateway.get_monthly_settlement_history_record(selected_run, db_path=db_path)
if not record.get("success"):
st.error(f"마감 기록을 불러오지 못했습니다 ({record.get('error_code', 'operation_failed')}).")
return
st.caption("선택한 run의 저장 시점 불변 기록")
st.json(record.get("history", {}))
def _create_settlement_dialog(db_path: str | None, month: str, correction_run: str = "") -> None:
@st.dialog("정산 진행")
def dialog() -> None:
target_month = st.text_input("대상 월", value=month, key="settlement_dialog_month")
memo = st.text_area("메모 (선택)", key="settlement_dialog_memo")
reason = st.text_area("정정 사유 (필수)", key="settlement_dialog_reason") if correction_run else ""
if correction_run:
st.caption(f"정정 연결 마감 run: {correction_run}")
left, right = st.columns(2)
if left.button("취소", key="settlement_dialog_cancel"):
st.session_state.pop("settlement_dialog_open", None)
st.rerun()
if right.button("초안 만들기", type="primary", key="settlement_dialog_submit"):
actor = _authenticated_actor()
if not actor:
st.error("인증된 작업자 identity를 확인할 수 없습니다. 다시 로그인하세요.")
return
if correction_run and not reason.strip():
st.error("정정 사유를 입력하세요.")
return
# Avoid creating a database when the read/list path is unavailable.
listing_probe = gateway.list_settlement_drafts(target_month, db_path=db_path)
if not listing_probe.get("success"):
_safe_result(listing_probe)
return
# A cancelled draft must not reserve the next create operation's
# idempotency key. Scope the key to this explicit dialog submit.
import hashlib
import uuid
operation = "correction" if correction_run else "initial"
nonce = uuid.uuid4().hex
key = hashlib.sha256(f"dashboard:{operation}:{target_month}:{correction_run}:{nonce}".encode()).hexdigest()
result = gateway.create_settlement_draft(target_month, actor, key, memo=memo,
correction_of_run_id=correction_run, correction_reason=reason, db_path=db_path)
if result.get("success"):
# The selectbox is the authoritative selection on the next
# rerun; seed its widget state, not a separate shadow key.
st.query_params["page"] = "월정산"
st.query_params["month"] = target_month
st.query_params.pop("year", None)
st.query_params.pop("status", None)
st.rerun()
_safe_result(result)
dialog()
def render_settlement_drafts(db_path: str | None = None, month: str = "") -> None: def render_settlement_drafts(db_path: str | None = None, month: str = "") -> None:
st.header("월정산 초안 lifecycle") st.header("정산 진행")
st.caption("자산 마감과 거래 정산 상태는 별도입니다. 조회/렌더링은 쓰기를 수행하지 않습니다.") st.caption("월별 정산을 시작하고, 활성 초안을 이어서 작업합니다.")
month_value = month or st.text_input("초안 월", value="2026-09", key="settlement_month") month_value = month or "2026-09"
actor = st.text_input("작업자", value="operator", key="settlement_actor") start = st.button("정산 시작", type="primary", key="settlement_start")
with st.form("settlement_create"): if start:
memo = st.text_input("메모") st.session_state["settlement_dialog_open"] = True
idem = st.text_input("생성 키", value="dashboard-create") if start or st.session_state.get("settlement_dialog_open"):
correction_run = st.text_input("정정 대상 run ID (선택)") _create_settlement_dialog(db_path, month_value)
correction_reason = st.text_input("정정 사유") actor = _authenticated_actor()
create = st.form_submit_button("초안 생성 / 정정 초안") if not actor:
if create: st.warning("인증된 identity가 없으므로 조회만 가능합니다. 정산 작업을 시작하려면 다시 로그인하세요.")
_safe_result(gateway.create_settlement_draft(month_value, actor, idem, memo=memo,
correction_of_run_id=correction_run, correction_reason=correction_reason, db_path=db_path))
listed = gateway.list_settlement_drafts(month_value, db_path=db_path) listed = gateway.list_settlement_drafts(month_value, db_path=db_path)
drafts = listed.get("drafts", []) if listed.get("success") else [] drafts = listed.get("drafts", []) if listed.get("success") else []
@@ -47,19 +129,20 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
st.error(f"초안 목록을 불러오지 못했습니다 ({listed.get('error_code', 'operation_failed')}).") st.error(f"초안 목록을 불러오지 못했습니다 ({listed.get('error_code', 'operation_failed')}).")
if not isinstance(drafts, list): drafts = [] if not isinstance(drafts, list): drafts = []
ids = [str(d.get("id", "")) for d in drafts if isinstance(d, dict) and d.get("id")] ids = [str(d.get("id", "")) for d in drafts if isinstance(d, dict) and d.get("id")]
if ids:
preferred = st.session_state.get("settlement_selected_draft")
if preferred in ids:
st.session_state["settlement_draft_id"] = preferred
elif st.session_state.get("settlement_draft_id") not in ids:
st.session_state["settlement_draft_id"] = ids[0]
selected_id = st.selectbox("초안 선택", ids, key="settlement_draft_id") if ids else "" selected_id = st.selectbox("초안 선택", ids, key="settlement_draft_id") if ids else ""
if not selected_id: if not selected_id:
if listed.get("success"): if listed.get("success"):
st.info("초안이 없습니다. 생성은 명시적 버튼으로 수행됩니다.") st.info("초안이 없습니다. 생성은 명시적 버튼으로 수행됩니다.")
st.caption("완료 초안은 이력에서 불변으로 조회하고, 정정은 이전 마감 ID와 사유를 지정해 새 초안으로 만듭니다.") st.caption("목록은 활성·완료·취소 초안을 포함합니다. 평가자료 조회·등록·정정은 초안 변경과 별도 작업입니다.")
st.caption("목록은 활성·완료·취소 이력을 포함합니다. 평가자료 조회·등록·정정은 초안 변경과 별도 작업입니다.") st.button("정산시작", disabled=not actor, key="settlement_start_empty")
st.button("검토", disabled=True, key="settlement_review_disabled")
st.button("승인", disabled=True, key="settlement_approve_disabled")
st.button("최종 마감", disabled=True, key="settlement_finalize_disabled")
st.button("초안 취소", disabled=True, key="settlement_cancel_disabled")
st.button("평가자료 조회 / 등록 / 정정", disabled=True, key="settlement_valuation_disabled")
st.caption("제외 사유는 항목별로 명시 입력합니다.") st.caption("제외 사유는 항목별로 명시 입력합니다.")
st.caption("거래 정산 상태(transaction readiness)는 자산 마감 준비와 별도입니다.") st.caption("거래 정산 상태(transaction readiness)는 자산 마감 준비와 별도입니다. 기존 lifecycle 작업: 검토·승인·최종·취소·평가자료.")
return return
detail = gateway.get_settlement_draft(selected_id, db_path=db_path) detail = gateway.get_settlement_draft(selected_id, db_path=db_path)
feedback = st.session_state.pop(f"settlement_mutation_feedback_{selected_id}", None) feedback = st.session_state.pop(f"settlement_mutation_feedback_{selected_id}", None)
@@ -70,7 +153,6 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
draft = detail.get("draft", {}) if detail.get("success") else {} draft = detail.get("draft", {}) if detail.get("success") else {}
if not isinstance(draft, dict): draft = {} if not isinstance(draft, dict): draft = {}
version = draft.get("version") version = draft.get("version")
draft_version = version
fingerprint = detail.get("review", {}).get("review_fingerprint", "") if isinstance(detail.get("review"), dict) else "" fingerprint = detail.get("review", {}).get("review_fingerprint", "") if isinstance(detail.get("review"), dict) else ""
state_key = "settlement_bound_state" state_key = "settlement_bound_state"
bound = (selected_id, version, fingerprint) bound = (selected_id, version, fingerprint)
@@ -79,11 +161,15 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
st.session_state[state_key] = bound st.session_state[state_key] = bound
st.session_state.pop(f"review_result_{selected_id}", None) st.session_state.pop(f"review_result_{selected_id}", None)
st.session_state.pop(f"approved_{selected_id}", None) st.session_state.pop(f"approved_{selected_id}", None)
st.caption(f"ID {draft.get('id', selected_id)} · 월 {draft.get('month', month_value)} · 기준일 {draft.get('basis_date', '-')} · 상태 {draft.get('status', '-')} · 버전 {version} · draft_version={draft_version}") st.caption(f"월 {draft.get('month', month_value)} · 기준일 {draft.get('basis_date', '-')} · 상태 {draft.get('status', '-')} · 버전 {version}")
st.caption(f"review_fingerprint={fingerprint or '미검토'}") st.caption(f"draft_version={draft.get('draft_version', version)} · created_by={draft.get('created_by', '')} · created_at={draft.get('created_at', '')} · updated_at={draft.get('updated_at', '')}")
st.caption(f"생성자 {draft.get('created_by', '-')} · 생성 {draft.get('created_at', '-')} · 변경 {draft.get('updated_at', '-')} · 메모 {draft.get('memo', '')}") st.caption(f"final_run_id={draft.get('final_run_id', '')} · correction_of_run_id={draft.get('correction_of_run_id', '')} · correction_reason={draft.get('correction_reason', '')}")
st.caption(f"검토 상태: {'검토 완료' if fingerprint else '미검토'} · review_fingerprint={fingerprint} · 메모 {draft.get('memo', '')}")
if draft.get("status") == "active":
action_label = "정정이어서작업" if draft.get("correction_of_run_id") else "이어서작업"
st.button(action_label, disabled=not actor, key=f"continue_{selected_id}")
if draft.get("status") == "completed": if draft.get("status") == "completed":
st.success(f"완료 — run {draft.get('final_run_id', '-')}; 변경 불가, 정정은 새 초안으로 생성합니다.") st.success("완료 — 변경 불가, 정정은 새 초안으로 생성합니다.")
if draft.get("status") in {"completed", "cancelled"}: if draft.get("status") in {"completed", "cancelled"}:
final = detail.get("final_response") if isinstance(detail.get("final_response"), dict) else {} final = detail.get("final_response") if isinstance(detail.get("final_response"), dict) else {}
if final: if final:
@@ -97,6 +183,16 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
st.write("final_run_id", draft.get("final_run_id")) st.write("final_run_id", draft.get("final_run_id"))
st.write("correction_of_run_id", draft.get("correction_of_run_id")) st.write("correction_of_run_id", draft.get("correction_of_run_id"))
st.json(detail.get("items", [])) st.json(detail.get("items", []))
st.button(
"마감내역보기",
key=f"closed_history_{selected_id}",
on_click=_open_settlement_history,
args=(selected_id, str(draft.get("final_run_id") or "")),
)
if st.session_state.get("settlement_history_open_draft") == selected_id:
_render_settlement_history(db_path, str(draft.get("month") or month_value), selected_id)
if draft.get("final_run_id") and st.button("정정시작", disabled=not actor, key=f"correction_start_{selected_id}"):
_create_settlement_dialog(db_path, draft.get("month", month_value), str(draft["final_run_id"]))
st.caption("종료된 초안의 증거는 수정하지 않습니다. 정정이 필요하면 이전 마감과 사유를 연결해 새 초안을 생성하세요.") st.caption("종료된 초안의 증거는 수정하지 않습니다. 정정이 필요하면 이전 마감과 사유를 연결해 새 초안을 생성하세요.")
return return
items = detail.get("items", []) if isinstance(detail.get("items"), list) else [] items = detail.get("items", []) if isinstance(detail.get("items"), list) else []
@@ -116,7 +212,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
policy_now = draft.get("cashflow_policy") if isinstance(draft.get("cashflow_policy"), dict) else {"savings_categories": [], "category_decisions": []} policy_now = draft.get("cashflow_policy") if isinstance(draft.get("cashflow_policy"), dict) else {"savings_categories": [], "category_decisions": []}
with st.form(f"cashflow_policy_{selected_id}"): with st.form(f"cashflow_policy_{selected_id}"):
policy_text = st.text_area("정책 JSON (savings_categories 및 category_decisions)", value=json.dumps(policy_now, ensure_ascii=False, indent=2), height=220) policy_text = st.text_area("정책 JSON (savings_categories 및 category_decisions)", value=json.dumps(policy_now, ensure_ascii=False, indent=2), height=220)
policy_submit = st.form_submit_button("정책 저장") policy_submit = st.form_submit_button("정책 저장", disabled=not actor)
if policy_submit: if policy_submit:
try: try:
policy = json.loads(policy_text) policy = json.loads(policy_text)
@@ -138,7 +234,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
selection_reason = st.text_input("선택 사유") selection_reason = st.text_input("선택 사유")
included = st.checkbox("포함", value=True) included = st.checkbox("포함", value=True)
exclusion_reason = st.text_input("제외 사유") exclusion_reason = st.text_input("제외 사유")
save = st.form_submit_button("저장 / 지정 / 포함·제외") save = st.form_submit_button("저장 / 지정 / 포함·제외", disabled=not actor)
if save: if save:
result = gateway.update_settlement_draft(selected_id, version, actor, memo_edit, db_path=db_path) result = gateway.update_settlement_draft(selected_id, version, actor, memo_edit, db_path=db_path)
current_version = (result.get("draft") or {}).get("version", version) if result.get("success") else version current_version = (result.get("draft") or {}).get("version", version) if result.get("success") else version
@@ -164,7 +260,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
verification = st.selectbox("확인 상태", ["unverified", "verified", "rejected"]) verification = st.selectbox("확인 상태", ["unverified", "verified", "rejected"])
valuation_memo = st.text_input("평가 메모") valuation_memo = st.text_input("평가 메모")
registration_key = st.text_input("등록 idempotency key") registration_key = st.text_input("등록 idempotency key")
register = st.form_submit_button("평가자료 등록") register = st.form_submit_button("평가자료 등록", disabled=not actor)
if register: if register:
result = gateway.register_account_valuation(account_id, valuation_date, int(amount), currency=currency, result = gateway.register_account_valuation(account_id, valuation_date, int(amount), currency=currency,
valuation_kind=valuation_kind, source_ref=source_ref, verification_status=verification, valuation_kind=valuation_kind, source_ref=source_ref, verification_status=verification,
@@ -173,7 +269,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
if result.get("success") and any(item.get("source_id") == account_id for item in items if isinstance(item, dict)): if result.get("success") and any(item.get("source_id") == account_id for item in items if isinstance(item, dict)):
_invalidate_and_rerun(selected_id, result) _invalidate_and_rerun(selected_id, result)
_safe_result(result) _safe_result(result)
if st.button("평가자료 조회", key=f"valuation_list_{selected_id}") and source_id: if st.button("평가자료 조회", disabled=not actor, key=f"valuation_list_{selected_id}") and source_id:
vals = gateway.list_account_valuations(source_id, db_path=db_path) vals = gateway.list_account_valuations(source_id, db_path=db_path)
if vals.get("success"): if vals.get("success"):
st.json([{k: value.get(k) for k in ("id", "account_id", "basis_date", "recorded_at", "amount", "currency", "valuation_kind", "verification_status", "source_ref", "supersedes_id", "representative_liability_definition")} for value in vals.get("valuations", [])]) st.json([{k: value.get(k) for k in ("id", "account_id", "basis_date", "recorded_at", "amount", "currency", "valuation_kind", "verification_status", "source_ref", "supersedes_id", "representative_liability_definition")} for value in vals.get("valuations", [])])
@@ -185,7 +281,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
correction_key = st.text_input("평가 정정 키") correction_key = st.text_input("평가 정정 키")
correction_verification = st.selectbox("정정 확인 상태", ["unverified", "verified", "rejected"]) correction_verification = st.selectbox("정정 확인 상태", ["unverified", "verified", "rejected"])
correction_source_ref = st.text_input("정정 증빙 참조 ID") correction_source_ref = st.text_input("정정 증빙 참조 ID")
correct = st.form_submit_button("평가자료 정정") correct = st.form_submit_button("평가자료 정정", disabled=not actor)
if correct: if correct:
result = gateway.correct_account_valuation(valuation_id_correct, int(corrected_amount), correction_reason, result = gateway.correct_account_valuation(valuation_id_correct, int(corrected_amount), correction_reason,
idempotency_key=correction_key, verification_status=correction_verification, idempotency_key=correction_key, verification_status=correction_verification,
@@ -195,10 +291,10 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
_invalidate_and_rerun(selected_id, result) _invalidate_and_rerun(selected_id, result)
_safe_result(result) _safe_result(result)
review_result = None review_result = None
if st.button("검토 / 새로고침 대상", key=f"review_{selected_id}"): if st.button("검토 / 새로고침 대상", disabled=not actor, key=f"review_{selected_id}"):
review_result = gateway.review_settlement_draft(selected_id, db_path=db_path) review_result = gateway.review_settlement_draft(selected_id, db_path=db_path)
st.session_state[f"review_result_{selected_id}"] = review_result st.session_state[f"review_result_{selected_id}"] = review_result
if st.button("대상 명시적 갱신", key=f"refresh_{selected_id}"): if st.button("대상 명시적 갱신", disabled=not actor, key=f"refresh_{selected_id}"):
result = gateway.refresh_settlement_draft_targets(selected_id, version, actor, db_path=db_path) result = gateway.refresh_settlement_draft_targets(selected_id, version, actor, db_path=db_path)
if result.get("success"): if result.get("success"):
_invalidate_and_rerun(selected_id, result) _invalidate_and_rerun(selected_id, result)
@@ -229,7 +325,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
allow_missing = st.checkbox("누락 승인", value=False) allow_missing = st.checkbox("누락 승인", value=False)
allow_carried = st.checkbox("이월 승인", value=False) allow_carried = st.checkbox("이월 승인", value=False)
allow_substitute = st.checkbox("대체자료 승인", value=False) allow_substitute = st.checkbox("대체자료 승인", value=False)
approve = st.form_submit_button("검토 결과 승인", disabled=bool(review.get("cashflow_blockers"))) approve = st.form_submit_button("검토 결과 승인", disabled=(not actor or bool(review.get("cashflow_blockers"))))
if review.get("cashflow_blockers"): st.caption("현금흐름 블로커를 해소해야 승인할 수 있습니다.") if review.get("cashflow_blockers"): st.caption("현금흐름 블로커를 해소해야 승인할 수 있습니다.")
if approve: if approve:
if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint): if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint):
@@ -237,7 +333,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
return return
_safe_result(gateway.approve_settlement_draft(selected_id, version, fingerprint, actor, _safe_result(gateway.approve_settlement_draft(selected_id, version, fingerprint, actor,
approval_reason, approval_reference, allow_missing, allow_carried, allow_substitute, db_path=db_path)) approval_reason, approval_reference, allow_missing, allow_carried, allow_substitute, db_path=db_path))
if st.button("최종 마감", key=f"finalize_{selected_id}"): if st.button("최종 마감", disabled=not actor, key=f"finalize_{selected_id}"):
if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint): if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint):
st.error("선택한 초안/버전/검토 fingerprint가 변경되었습니다. 다시 검토하세요.") st.error("선택한 초안/버전/검토 fingerprint가 변경되었습니다. 다시 검토하세요.")
return return
@@ -248,7 +344,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
_safe_result(result) _safe_result(result)
with st.form(f"cancel_{selected_id}"): with st.form(f"cancel_{selected_id}"):
cancel_reason = st.text_input("취소 사유") cancel_reason = st.text_input("취소 사유")
cancel = st.form_submit_button("초안 취소") cancel = st.form_submit_button("초안 취소", disabled=not actor)
if cancel: if cancel:
result = gateway.cancel_settlement_draft(selected_id, version, actor, cancel_reason, db_path=db_path) result = gateway.cancel_settlement_draft(selected_id, version, actor, cancel_reason, db_path=db_path)
if result.get("success"): if result.get("success"):
+3 -3
View File
@@ -34,7 +34,7 @@ spec:
type: RuntimeDefault type: RuntimeDefault
initContainers: initContainers:
- name: init-db - name: init-db
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.7 image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.8
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- python - python
@@ -62,7 +62,7 @@ spec:
mountPath: /tmp mountPath: /tmp
containers: containers:
- name: mcp - name: mcp
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.7 image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.8
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
env: env:
- name: JARCA_LEDGER_DB_PATH - name: JARCA_LEDGER_DB_PATH
@@ -104,7 +104,7 @@ spec:
- name: mcp-tmp - name: mcp-tmp
mountPath: /tmp mountPath: /tmp
- name: dashboard - name: dashboard
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.7 image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.8
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- uvicorn - uvicorn
+32 -5
View File
@@ -21,7 +21,7 @@
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| `id` | TEXT | 필수 | 없음 | generated account id | 식별자 | 아니오 | | `id` | TEXT | 필수 | 없음 | generated account id | 식별자 | 아니오 |
| `name` | TEXT | 필수 | 없음 | non-empty | 없음 | 아니오 | | `name` | TEXT | 필수 | 없음 | non-empty | 없음 | 아니오 |
| `type` | TEXT | 필수 | `bank` | bank/card/cash/investment/loan/other | enum | 아니오 | | `type` | TEXT | 필수 | `bank` | bank/card/cash/investment/loan/real_estate/other | enum | 아니오 |
| `currency` | TEXT | 필수 | `KRW` | supported currency | ISO code | 아니오 | | `currency` | TEXT | 필수 | `KRW` | supported currency | ISO code | 아니오 |
| `last_balance` | INTEGER | 선택 | NULL | exact integer | currency unit | 아니오 | | `last_balance` | INTEGER | 선택 | NULL | exact integer | currency unit | 아니오 |
| `last_balance_basis_date` | TEXT | 선택 | `''` | YYYY-MM-DD or unknown | date of the evidence represented by `last_balance`; empty/unknown is explicit | 아니오 | | `last_balance_basis_date` | TEXT | 선택 | `''` | YYYY-MM-DD or unknown | date of the evidence represented by `last_balance`; empty/unknown is explicit | 아니오 |
@@ -193,7 +193,7 @@ Frozen/public evidence retains `valuation_date`, original `recorded_at`, derived
Review fingerprint binds draft version, target/inclusion state, full valuation/manual semantic evidence, correction child, warnings, amounts/classification/date and prior run. Approved fingerprint/version become invalid on mutation or external drift. Finalize checks idempotency replay before active status, then validates version, approval, fingerprint, current evidence and correction parent, and inserts run+rows+draft completion/link in one `BEGIN IMMEDIATE`. Rollback on any SQLite failure; replay returns persisted response. Review fingerprint binds draft version, target/inclusion state, full valuation/manual semantic evidence, correction child, warnings, amounts/classification/date and prior run. Approved fingerprint/version become invalid on mutation or external drift. Finalize checks idempotency replay before active status, then validates version, approval, fingerprint, current evidence and correction parent, and inserts run+rows+draft completion/link in one `BEGIN IMMEDIATE`. Rollback on any SQLite failure; replay returns persisted response.
KRW only aggregation; non-KRW is warned/excluded, never converted. NULL is not zero; no targets yields provisional zero plus completeness and unregistered-liability warning, not a complete close. Existing snapshot rows preserve full frozen evidence, approval, date/mode/difference and actors; substitution, carried and approved-missing flags are orthogonal. `monthly_source_snapshot_id` remains a read-model alias, never a persistence field. KRW only aggregation; non-KRW is warned/excluded, never converted. NULL is not zero; no targets yields provisional zero plus completeness and unregistered-liability warning, not a complete close. Existing snapshot rows preserve full frozen evidence, approval, date/mode/difference and actors; substitution, carried and approved-missing flags are orthogonal. `monthly_source_snapshot_id` remains a read-model alias, never a persistence field.
# Issue #18 — schema 14 comprehensive settlement additions # Issue #18 — schema 15 comprehensive settlement additions
### asset_snapshot_runs cashflow field contract ### asset_snapshot_runs cashflow field contract
@@ -226,7 +226,8 @@ All twelve comprehensive-run columns are nullable with no SQL default. This pres
| Column | Type | Nullability | Default | | Column | Type | Nullability | Default |
|---|---|---|---| |---|---|---|---|
| `id` | TEXT | NULL | none | | `id` | TEXT | NULL | none |
| `settlement_run_id` | TEXT | NOT NULL | none | | `monthly_settlement_id` | TEXT | NULL | none |
| `settlement_run_id` | TEXT | NULL | none |
| `cashflow_kind` | TEXT | NOT NULL | none | | `cashflow_kind` | TEXT | NOT NULL | none |
| `category` | TEXT | NOT NULL | `''` | | `category` | TEXT | NOT NULL | `''` |
| `subcategory` | TEXT | NOT NULL | `''` | | `subcategory` | TEXT | NOT NULL | `''` |
@@ -234,7 +235,7 @@ All twelve comprehensive-run columns are nullable with no SQL default. This pres
| `transaction_count` | INTEGER | NOT NULL | none | | `transaction_count` | INTEGER | NOT NULL | none |
| `currency` | TEXT | NOT NULL | none | | `currency` | TEXT | NOT NULL | none |
`id` is logically required although SQLite reports no explicit NOT NULL flag for this TEXT primary key. `settlement_run_id` references `asset_snapshot_runs(id)` ON DELETE RESTRICT. The unique key is `(settlement_run_id, cashflow_kind, category, subcategory, currency)`. `transaction_count > 0` and amount/count are integers; `idx_settlement_cashflow_snapshots_run` indexes the run. `id` is logically required although SQLite reports no explicit NOT NULL flag for this TEXT primary key. Both parent columns are individually nullable: `monthly_settlement_id` references `monthly_settlement(id)` ON DELETE RESTRICT, and `settlement_run_id` references `asset_snapshot_runs(id)` ON DELETE RESTRICT. An exclusive-parent CHECK requires exactly one parent to be non-NULL. Separate UNIQUE constraints apply to `(monthly_settlement_id,cashflow_kind,category,subcategory,currency)` and `(settlement_run_id,cashflow_kind,category,subcategory,currency)`. `transaction_count > 0` and amount/count are integers. Fresh schema uses `idx_settlement_cashflow_snapshots_month` for canonical-month lookup; upgraded run-parent storage retains `idx_settlement_cashflow_snapshots_run` for legacy-run lookup.
### Comprehensive snapshot evidence inputs ### Comprehensive snapshot evidence inputs
@@ -244,6 +245,32 @@ Asset inputs preserve `source_id`, `source_type`, `valuation_id`, `valuation_dat
`asset_snapshot_runs` remains the run header. Schema 13→14 adds settlement month, currency, six nullable legacy-compatible INTEGER totals (`income_total`, `expense_total`, `net_cashflow`, `total_assets`, `total_liabilities`, `net_worth`), `cashflow_policy_json`, `aggregation_version`, `snapshot_schema_version`, and `source_snapshot_json`; preserve existing basis/approval/draft/correction/idempotency fields. Old asset-only run totals remain NULL; no current-ledger backfill. Migration is additive/idempotent; old unfinished drafts require fresh comprehensive review/approval. `asset_snapshot_runs` remains the run header. Schema 13→14 adds settlement month, currency, six nullable legacy-compatible INTEGER totals (`income_total`, `expense_total`, `net_cashflow`, `total_assets`, `total_liabilities`, `net_worth`), `cashflow_policy_json`, `aggregation_version`, `snapshot_schema_version`, and `source_snapshot_json`; preserve existing basis/approval/draft/correction/idempotency fields. Old asset-only run totals remain NULL; no current-ledger backfill. Migration is additive/idempotent; old unfinished drafts require fresh comprehensive review/approval.
`settlement_cashflow_snapshots`: eight columns `id`, `settlement_run_id`, `cashflow_kind`, `category`, `subcategory`, `amount`, `transaction_count`, `currency`; `cashflow_kind` is income/expense/excluded, `amount` signed INTEGER, count positive, category fields NOT NULL (empty string is explicit), FK to `asset_snapshot_runs.id` with RESTRICT, unique `(settlement_run_id,cashflow_kind,category,subcategory,currency)`, and run lookup index. Rows are grouped details for exactly one run. `settlement_cashflow_snapshots`: nine columns `id`, `monthly_settlement_id`, `settlement_run_id`, `cashflow_kind`, `category`, `subcategory`, `amount`, `transaction_count`, `currency`; `cashflow_kind` is income/expense/excluded, `amount` signed INTEGER, count positive, and category fields are NOT NULL (empty string is explicit). The nullable parent columns reference `monthly_settlement.id` and `asset_snapshot_runs.id` with RESTRICT, while the exclusive-parent CHECK requires exactly one parent. The table has separate UNIQUE constraints on `(monthly_settlement_id,cashflow_kind,category,subcategory,currency)` and `(settlement_run_id,cashflow_kind,category,subcategory,currency)`, so rows remain grouped under exactly one canonical month or legacy run without merging the two parent domains.
Policy is stored on draft and header. Savings means only explicit category/subcategory pairs; transfer/voucher exclusion has priority; debt_payment is expense; refund is negative expense in refund month/original category. Unknown unresolved blocks approval; asset conditional flags cannot override it. KRW only/no FX. Source JSON freezes transaction values/classification/policy/decision and selected asset evidence/provenance. Six equations are validated against same-run details; excluded/duplicate/unknown amount do not sum. Header, both detail areas, evidence, completed draft linkage/response commit atomically; rollback all on failure. Runs are immutable; corrections append after latest-parent/single-child validation. Policy is stored on draft and header. Savings means only explicit category/subcategory pairs; transfer/voucher exclusion has priority; debt_payment is expense; refund is negative expense in refund month/original category. Unknown unresolved blocks approval; asset conditional flags cannot override it. KRW only/no FX. Source JSON freezes transaction values/classification/policy/decision and selected asset evidence/provenance. Six equations are validated against same-run details; excluded/duplicate/unknown amount do not sum. Header, both detail areas, evidence, completed draft linkage/response commit atomically; rollback all on failure. Runs are immutable; corrections append after latest-parent/single-child validation.
### Additive schema 15 monthly lifecycle
`monthly_settlement` owns canonical month status, version, approvals and nullable
`approved_cashflow_fingerprint`. `account_snapshot.monthly_settlement_id` is a
nullable FK for canonical account evaluations. Legacy `asset_snapshots`,
`asset_snapshot_runs`, `manual_assets`, and `account_valuations` retain their
rows and contracts. `settlement_cashflow_snapshots` has nullable
`settlement_run_id` and `monthly_settlement_id` foreign keys, with exactly one
parent required and separate uniqueness constraints. Run corrections retain
separate frozen groups; migration never guesses a monthly parent from dates.
Schema 11–14 upgrades and schema 15 initialization are atomic and idempotent;
failed copies or foreign-key checks roll back all DDL and metadata.
### Immutable monthly settlement history read model
`asset_snapshot_runs` is the header source for append-only immutable settlement history.
Each public history row identifies `run_id`, `settlement_month`, frozen draft
`version`, `closed_at`, `close_status`, and optional correction lineage through
`correction_of_run_id` (published as `correction_target`) plus
`correction_reason` (published as `reason`). The exact-record read additionally
returns the frozen basis date and persisted cashflow/net-worth totals. Reads are
ordered newest-first and never synthesize totals from current transactions,
account values, or mutable draft state. A correction appends a new run and
retains the referenced run unchanged.
+30 -1
View File
@@ -403,7 +403,7 @@ preview_asset_snapshot_close(basis_date="2026-06-30", selection_policy="exact")
The application contracts are these actual signatures (defaults included): The application contracts are these actual signatures (defaults included):
```python ```python
register_account_valuation(account_id: str, basis_date: str, amount: int, currency: str = "KRW", valuation_kind: str = "balance", source_type: str = "manual", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None, db_path: str | None = None, supersedes_id: str | None = None, correction_reason: str = "") -> dict[str, Any] register_account_valuation(account_id: str, basis_date: str, amount: int, currency: str = "KRW", valuation_kind: str = "balance", source_type: str = "manual", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None, db_path: str | None = None, supersedes_id: str | None = None, correction_reason: str = "", *, monthly_settlement_id: str | None = None, liquidity_bucket: str | None = None, selection_policy: str = "manual", valuation_source: str = "manual", source_note: str = "") -> dict[str, Any]
list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any] list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]
select_account_valuation(account_id: str, basis_date: str, as_of: bool = False, currency: str | None = None, valuation_kind: str | None = None, db_path: str | None = None) -> dict[str, Any] select_account_valuation(account_id: str, basis_date: str, as_of: bool = False, currency: str | None = None, valuation_kind: str | None = None, db_path: str | None = None) -> dict[str, Any]
correct_account_valuation(valuation_id: str, amount: int, correction_reason: str, idempotency_key: str = "", db_path: str | None = None, **fields: Any) -> dict[str, Any] correct_account_valuation(valuation_id: str, amount: int, correction_reason: str, idempotency_key: str = "", db_path: str | None = None, **fields: Any) -> dict[str, Any]
@@ -527,3 +527,32 @@ Saved reader root example:
``` ```
A `validation_error` results when cashflow review blockers must be resolved before approval/finalize. Remediate policy or transaction classification, then re-review and re-approve. Examples use synthetic identifiers, not raw secrets. A `validation_error` results when cashflow review blockers must be resolved before approval/finalize. Remediate policy or transaction classification, then re-review and re-approve. Examples use synthetic identifiers, not raw secrets.
Schema 15 preserves the positional v0.1.7 valuation API and manual asset and
snapshot run APIs. `register_account_valuation(..., monthly_settlement_id=...)`
explicitly selects canonical unverified account snapshot registration; omitted
monthly identity uses legacy valuation evidence and correction semantics.
Canonical monthly review/approval/close APIs remain separate from legacy draft
finalization. Neither finalizer rewrites the other's frozen records.
## Public immutable monthly settlement history
The read-only application contracts are:
```python
list_monthly_settlement_history(month: str = "", db_path: str | None = None) -> dict[str, Any]
get_monthly_settlement_history_record(run_id: str, db_path: str | None = None) -> dict[str, Any]
```
Both operations are public MCP tools. MCP signatures omit internal `db_path`:
the list schema exposes optional `month`, while the exact-record schema requires
`run_id`. Successful list responses contain `history`, normalized `month`, and
`read_only=true`; successful exact reads contain one `history` record and
`read_only=true`. The list is newest-first and exposes run/version, close time,
close status, and correction linkage without consulting or rewriting live data.
The authenticated Dashboard wrapper is unchanged. In the completed/cancelled
draft panel, `마감내역보기` opens read-only history state, lists immutable runs
for the draft month, lets the operator select a run, and fetches that exact
record. Opening or changing this selection performs no lifecycle mutation.
+1 -1
View File
@@ -1,3 +1,3 @@
"""Jarca Ledger MCP server.""" """Jarca Ledger MCP server."""
__version__ = "0.1.7" __version__ = "0.1.8"
+20 -7
View File
@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Literal
from dotenv import load_dotenv from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
@@ -102,8 +103,8 @@ def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_
return contracts.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", last_balance_basis_date: str = "") -> dict: def create_ledger_account(name: str, type: Literal["bank", "card", "cash", "investment", "loan", "other", "real_estate"] = "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 | None = None, last_balance_basis_date: str = "") -> dict:
"""Create an account/card/cash/investment/loan ledger account.""" """Create a bank/card/cash/investment/loan/other/real_estate ledger account."""
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, last_balance_basis_date=last_balance_basis_date) 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, last_balance_basis_date=last_balance_basis_date)
@mcp.tool() @mcp.tool()
@@ -112,14 +113,14 @@ def list_ledger_accounts(active_only: bool = True) -> dict:
return contracts.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, last_balance_basis_date: str | None = None) -> dict: def update_ledger_account(account_id: str, name: str | None = None, type: Literal["bank", "card", "cash", "investment", "loan", "other", "real_estate"] | 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, last_balance_basis_date: 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 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, last_balance_basis_date=last_balance_basis_date) 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, last_balance_basis_date=last_balance_basis_date)
@mcp.tool() @mcp.tool()
def register_account_valuation(account_id: str, basis_date: str, amount: int, currency: str = "KRW", valuation_kind: str = "balance", source_type: str = "manual", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None) -> dict: def register_account_valuation(account_id: str, basis_date: str, amount: int, currency: str = "KRW", valuation_kind: str = "balance", source_type: str = "manual", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None, monthly_settlement_id: str | None = None, liquidity_bucket: str | None = None, selection_policy: str = "manual", valuation_source: str = "manual", source_note: str = "") -> dict:
"""Register an append-only account valuation evidence row.""" """Register legacy valuation evidence, or an unverified canonical snapshot when monthly_settlement_id is supplied."""
return contracts.register_account_valuation(account_id, basis_date, amount, currency, valuation_kind, source_type, source_ref, verification_status, memo, idempotency_key, as_of_at, timezone, cost_basis, net_contributed, reported_principal_kind, reported_principal_label, profit, return_rate, return_basis, principal, interest, fee, representative_liability_definition) return contracts.register_account_valuation(account_id, basis_date, amount, currency, valuation_kind, source_type, source_ref, verification_status, memo, idempotency_key, as_of_at, timezone, cost_basis, net_contributed, reported_principal_kind, reported_principal_label, profit, return_rate, return_basis, principal, interest, fee, representative_liability_definition, monthly_settlement_id=monthly_settlement_id, liquidity_bucket=liquidity_bucket, selection_policy=selection_policy, valuation_source=valuation_source, source_note=source_note)
@mcp.tool() @mcp.tool()
def list_account_valuations(account_id: str, include_rejected: bool = True) -> dict: def list_account_valuations(account_id: str, include_rejected: bool = True) -> dict:
@@ -495,3 +496,15 @@ def set_settlement_cashflow_policy(draft_id: str, expected_version: int, updated
def get_monthly_settlement_snapshot(month: str = "", run_id: str = "") -> dict: def get_monthly_settlement_snapshot(month: str = "", run_id: str = "") -> dict:
"""Read a saved monthly settlement snapshot, optionally selecting an exact run.""" """Read a saved monthly settlement snapshot, optionally selecting an exact run."""
return contracts.get_monthly_settlement_snapshot(month, run_id) return contracts.get_monthly_settlement_snapshot(month, run_id)
@mcp.tool()
def list_monthly_settlement_history(month: str = "") -> dict:
"""List immutable monthly settlement close and correction runs."""
return contracts.list_monthly_settlement_history(month)
@mcp.tool()
def get_monthly_settlement_history_record(run_id: str) -> dict:
"""Read one immutable monthly settlement history record by run ID."""
return contracts.get_monthly_settlement_history_record(run_id)
@@ -12,6 +12,8 @@ from ...service import (
set_settlement_draft_item as set_settlement_draft_item, set_settlement_draft_item as set_settlement_draft_item,
set_settlement_cashflow_policy as set_settlement_cashflow_policy, set_settlement_cashflow_policy as set_settlement_cashflow_policy,
get_monthly_settlement_snapshot as get_monthly_settlement_snapshot, get_monthly_settlement_snapshot as get_monthly_settlement_snapshot,
list_monthly_settlement_history as list_monthly_settlement_history,
get_monthly_settlement_history_record as get_monthly_settlement_history_record,
update_settlement_draft as update_settlement_draft, update_settlement_draft as update_settlement_draft,
annotations as annotations, annotations as annotations,
apply_categorization_rules as apply_categorization_rules, apply_categorization_rules as apply_categorization_rules,
@@ -94,6 +96,7 @@ TOOL_CONTRACTS = {
"set_settlement_draft_item", "review_settlement_draft", "set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot", "set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
"list_monthly_settlement_history", "get_monthly_settlement_history_record",
) )
} }
@@ -103,6 +106,7 @@ __all__ = [
"refresh_settlement_draft_targets", "review_settlement_draft", "refresh_settlement_draft_targets", "review_settlement_draft",
"set_settlement_draft_item", "update_settlement_draft", "set_settlement_draft_item", "update_settlement_draft",
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot", "set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
"list_monthly_settlement_history", "get_monthly_settlement_history_record",
"annotations", "annotations",
"apply_categorization_rules", "apply_categorization_rules",
"close_asset_snapshot", "close_asset_snapshot",
@@ -5,6 +5,7 @@ from ...service import (
get_balance_reconciliation_report as get_balance_reconciliation_report, get_balance_reconciliation_report as get_balance_reconciliation_report,
get_budget_vs_actual_report as get_budget_vs_actual_report, get_budget_vs_actual_report as get_budget_vs_actual_report,
get_monthly_settlement_summary as get_monthly_settlement_summary, get_monthly_settlement_summary as get_monthly_settlement_summary,
list_monthly_settlement_overview as list_monthly_settlement_overview,
get_monthly_cashflow_report as get_monthly_cashflow_report, get_monthly_cashflow_report as get_monthly_cashflow_report,
get_ledger_db_status as get_ledger_db_status, get_ledger_db_status as get_ledger_db_status,
health_check_readonly as health_check, health_check_readonly as health_check,
@@ -20,6 +21,7 @@ __all__ = [
"get_balance_reconciliation_report", "get_balance_reconciliation_report",
"get_budget_vs_actual_report", "get_budget_vs_actual_report",
"get_monthly_settlement_summary", "get_monthly_settlement_summary",
"list_monthly_settlement_overview",
"get_monthly_cashflow_report", "get_monthly_cashflow_report",
"get_ledger_db_status", "get_ledger_db_status",
"health_check", "health_check",
@@ -10,6 +10,13 @@ from ...service import (
finalize_settlement_draft as finalize_settlement_draft, finalize_settlement_draft as finalize_settlement_draft,
get_settlement_draft as get_settlement_draft, get_settlement_draft as get_settlement_draft,
get_monthly_settlement_snapshot as get_monthly_settlement_snapshot, get_monthly_settlement_snapshot as get_monthly_settlement_snapshot,
list_monthly_settlement_history as list_monthly_settlement_history,
get_monthly_settlement_history_record as get_monthly_settlement_history_record,
get_monthly_settlement_record as get_monthly_settlement_record,
list_account_snapshots_for_settlement as list_account_snapshots_for_settlement,
link_account_snapshot as link_account_snapshot,
unlink_account_snapshot as unlink_account_snapshot,
update_account_snapshot_category as update_account_snapshot_category,
list_account_valuations as list_account_valuations, list_account_valuations as list_account_valuations,
list_settlement_drafts as list_settlement_drafts, list_settlement_drafts as list_settlement_drafts,
refresh_settlement_draft_targets as refresh_settlement_draft_targets, refresh_settlement_draft_targets as refresh_settlement_draft_targets,
@@ -21,6 +28,8 @@ from ...service import (
) )
from ...application import settlement_draft_service as _draft_service from ...application import settlement_draft_service as _draft_service
from ...application import monthly_settlement_service as _monthly_service from ...application import monthly_settlement_service as _monthly_service
from ...application.transaction_report_services import get_monthly_cashflow_report as get_monthly_cashflow_report
from ...application.transaction_report_services import get_settlement_cashflow_snapshot as get_settlement_cashflow_snapshot
from ... import service as _service from ... import service as _service
@@ -33,11 +42,36 @@ def _guard(db_path: str | None) -> str:
return db_path or _resolve_db_path() return db_path or _resolve_db_path()
def create_monthly_settlement(month: str, created_by: str = "dashboard-admin", *, db_path=None):
return _monthly_service.create_monthly_settlement(month, created_by=created_by, db_path=_guard(db_path))
def get_monthly_cashflow(month: str, *, settlement_id=None, closed=False, db_path=None):
if closed:
return get_settlement_cashflow_snapshot(str(settlement_id), db_path=_guard(db_path))
return get_monthly_cashflow_report(month, db_path=_guard(db_path))
def review_monthly_settlement(settlement_id: str, *, db_path=None):
return _monthly_service.review_monthly_settlement(settlement_id, db_path=_guard(db_path))
def approve_monthly_settlement(settlement_id: str, expected_version: int, review_fingerprint: str, approved_by: str = "dashboard-admin", *, db_path=None):
return _monthly_service.approve_monthly_settlement(settlement_id, expected_version, review_fingerprint, approved_by=approved_by, db_path=_guard(db_path))
def close_monthly_settlement(settlement_id: str, expected_version: int, closed_by: str = "dashboard-admin", *, db_path=None):
return _monthly_service.close_monthly_settlement(settlement_id, expected_version, closed_by=closed_by, db_path=_guard(db_path))
def cancel_monthly_settlement_approval(settlement_id: str, expected_version: int, approved_by: str = "dashboard-admin", *, db_path=None):
return _monthly_service.cancel_monthly_settlement_approval(settlement_id, expected_version, approved_by=approved_by, db_path=_guard(db_path))
def _wrap(fn): def _wrap(fn):
def call(*args, db_path=None, **kwargs): def call(*args, db_path=None, **kwargs):
if fn.__name__ == "set_settlement_cashflow_policy": if fn.__name__ == "set_settlement_cashflow_policy":
target = _draft_service target = _draft_service
elif fn.__name__ == "get_monthly_settlement_snapshot": elif fn.__name__ in {"get_monthly_settlement_snapshot", "list_monthly_settlement_history", "get_monthly_settlement_history_record", "get_monthly_settlement_record", "list_account_snapshots_for_settlement", "link_account_snapshot", "unlink_account_snapshot"}:
target = _monthly_service target = _monthly_service
else: else:
target = _draft_service if fn.__name__ in {"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft", "update_settlement_draft", "refresh_settlement_draft_targets", "set_settlement_draft_item", "review_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft"} else _service target = _draft_service if fn.__name__ in {"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft", "update_settlement_draft", "refresh_settlement_draft_targets", "set_settlement_draft_item", "review_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft"} else _service
@@ -54,8 +88,10 @@ for _name in (
"update_settlement_draft", "refresh_settlement_draft_targets", "update_settlement_draft", "refresh_settlement_draft_targets",
"set_settlement_draft_item", "review_settlement_draft", "set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
"cancel_monthly_settlement_approval",
"register_account_valuation", "list_account_valuations", "correct_account_valuation", "register_account_valuation", "list_account_valuations", "correct_account_valuation",
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot", "set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
"list_monthly_settlement_history", "get_monthly_settlement_history_record", "get_monthly_settlement_record", "list_account_snapshots_for_settlement", "link_account_snapshot", "unlink_account_snapshot", "update_account_snapshot_category",
): ):
globals()[_name] = _wrap(getattr(_service, _name)) globals()[_name] = _wrap(getattr(_service, _name))
@@ -64,6 +100,9 @@ __all__ = [
"update_settlement_draft", "refresh_settlement_draft_targets", "update_settlement_draft", "refresh_settlement_draft_targets",
"set_settlement_draft_item", "review_settlement_draft", "set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
"cancel_monthly_settlement_approval",
"register_account_valuation", "list_account_valuations", "correct_account_valuation", "register_account_valuation", "list_account_valuations", "correct_account_valuation",
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot", "set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
"list_monthly_settlement_history", "get_monthly_settlement_history_record", "get_monthly_settlement_record", "list_account_snapshots_for_settlement",
"get_monthly_cashflow",
] ]
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date
from uuid import uuid4
import math import math
import re import re
from typing import Any from typing import Any
@@ -157,7 +158,17 @@ def register_account_valuation(
db_path: str | None = None, db_path: str | None = None,
supersedes_id: str | None = None, supersedes_id: str | None = None,
correction_reason: str = "", correction_reason: str = "",
*, monthly_settlement_id: str | None = None, liquidity_bucket: str | None = None,
selection_policy: str = "manual", valuation_source: str = "manual", source_note: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
if monthly_settlement_id is not None:
return _register_monthly_account_valuation(
account_id, basis_date, amount, currency, valuation_kind,
"account" if source_type == "manual" else source_type, source_ref,
verification_status, memo, idempotency_key, monthly_settlement_id,
db_path, liquidity_bucket, selection_policy=selection_policy,
valuation_source=valuation_source, source_note=source_note,
)
validated = _validate_common(account_id, basis_date, amount, currency, verification_status, valuation_kind, source_type, representative_liability_definition) validated = _validate_common(account_id, basis_date, amount, currency, verification_status, valuation_kind, source_type, representative_liability_definition)
if isinstance(validated, dict): if isinstance(validated, dict):
return validated return validated
@@ -261,8 +272,32 @@ def register_account_valuation(
def list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]: def list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]:
where = "" if include_rejected else " AND verification_status <> 'rejected'" where = "" if include_rejected else " AND verification_status <> 'rejected'"
with read_db(db_path) as conn: with read_db(db_path) as conn:
rows = conn.execute(f"SELECT * FROM account_valuations WHERE account_id=?{where} ORDER BY basis_date, recorded_at, id", (account_id,)).fetchall() rows = conn.execute(
f"SELECT * FROM account_valuations WHERE account_id=?{where} ORDER BY basis_date, recorded_at, id",
(account_id,),
).fetchall()
snapshots = conn.execute(
f"""SELECT * FROM account_snapshot
WHERE source_type='account' AND source_id=?{where}
ORDER BY basis_date, created_at, id""",
(account_id,),
).fetchall()
values = [_valuation(row) for row in rows] values = [_valuation(row) for row in rows]
for row in snapshots:
value = _valuation(row)
value.update(
account_id=value["source_id"],
recorded_at=value["created_at"],
valuation_kind="balance",
source_ref=value.get("source_note", ""),
)
for key in _COLUMNS:
value.setdefault(key, None)
value.setdefault("idempotency_key", "")
value.setdefault("supersedes_id", None)
value.setdefault("correction_reason", "")
values.append(value)
values.sort(key=lambda value: (value["basis_date"], value["recorded_at"], value["id"]))
return ok(account_id=account_id, valuations=values, count=len(values)) return ok(account_id=account_id, valuations=values, count=len(values))
@@ -373,3 +408,129 @@ def correct_account_valuation(valuation_id: str, amount: int, correction_reason:
supersedes_id=valuation_id, correction_reason=correction_reason.strip(), supersedes_id=valuation_id, correction_reason=correction_reason.strip(),
db_path=db_path, db_path=db_path,
) )
_ALLOWED_VERIFICATION = {"pending", "unverified"}
_ALLOWED_SETTLEMENT_STATUS = {"draft", "in_review"}
def _register_monthly_account_valuation(
account_id: str,
basis_date: str,
amount: int,
currency: str | None = None,
valuation_kind: str = "balance",
source_type: str = "account",
source_ref: str = "",
verification_status: str = "unverified",
memo: str = "",
idempotency_key: str = "",
monthly_settlement_id: str | None = None,
db_path: str | None = None,
liquidity_bucket: str | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""Register one unverified account evaluation in the monthly snapshot.
This is intentionally the account_snapshot write path; no valuation table is
created. The transaction locks before checking duplicates so concurrent UI
submissions cannot create two rows for the same settlement/account.
"""
try:
if not account_id or not basis_date or not currency:
raise ValueError("account_id, basis_date and currency are required")
if isinstance(amount, bool) or not isinstance(amount, int):
raise ValueError("amount must be an integer")
if source_type != "account":
raise ValueError("source_type must be account")
if verification_status not in _ALLOWED_VERIFICATION:
raise ValueError("verification_status must be pending or unverified")
with db(db_path) as conn:
conn.execute("BEGIN IMMEDIATE")
selection_policy = kwargs.get("selection_policy", "manual")
valuation_source = kwargs.get("valuation_source", "manual")
source_note = kwargs.get("source_note", "")
if selection_policy not in {"manual", "previous", "next"}:
raise ValueError("selection_policy must be manual, previous or next")
if valuation_source not in {"manual", "ledger"}:
raise ValueError("invalid valuation_source")
account = conn.execute("SELECT id,name,currency,liquidity_bucket FROM accounts WHERE id=?", (account_id,)).fetchone()
if account is None:
conn.rollback(); return fail("not_found", "account not found")
if account["currency"] != currency:
conn.rollback(); return fail("validation_error", "currency does not match account")
bucket = normalize_liquidity_bucket(liquidity_bucket, default=account["liquidity_bucket"] or "other")
settlement = conn.execute(
"SELECT id,basis_date,status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,)
).fetchone()
if settlement is None:
conn.rollback(); return fail("not_found", "monthly settlement not found")
if settlement["status"] == "closed":
conn.rollback(); return fail("closed_settlement", "closed monthly settlement cannot be changed")
if settlement["status"] not in _ALLOWED_SETTLEMENT_STATUS:
conn.rollback(); return fail("invalid_settlement_status", "only draft or in_review settlements can be changed")
if basis_date != settlement["basis_date"]:
conn.rollback(); return fail("validation_error", "basis_date must match monthly settlement")
if selection_policy != "manual":
tx = conn.execute("""SELECT t.balance_after,t.transaction_date
FROM transactions t JOIN accounts a ON a.id=t.account_id
WHERE t.id=? AND t.account_id=? AND a.active=1 AND t.currency=?
AND t.status='active' AND t.balance_after IS NOT NULL""", (source_note, account_id, currency)).fetchone()
if tx is None or (selection_policy == "previous" and tx["transaction_date"] > basis_date) or (selection_policy == "next" and tx["transaction_date"] <= basis_date):
conn.rollback(); return fail("stale_evidence", "selected ledger is no longer valid")
amount = tx["balance_after"]
duplicate = conn.execute(
"SELECT id FROM account_snapshot WHERE monthly_settlement_id=? AND source_type='account' AND source_id=?",
(settlement["id"], account_id),
).fetchone()
if duplicate:
conn.rollback(); return fail("duplicate", "account evaluation already registered", snapshot_id=duplicate["id"])
snapshot_id = f"snapshot_{uuid4().hex}"
conn.execute(
"""INSERT INTO account_snapshot
(id,basis_date,source_type,source_id,name,amount,currency,memo,
monthly_settlement_id,valuation_id,selection_policy,valuation_source,source_note,verification_status,evidence_kind,liquidity_bucket,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(snapshot_id, basis_date, "account", account_id, account["name"], amount,
currency, memo or "", settlement["id"], "", selection_policy, valuation_source, source_note, "unverified", "applied", bucket, now_iso()),
)
return ok(snapshot_id=snapshot_id, id=snapshot_id, verification_status="unverified", created=True)
except ValueError as exc:
return fail("validation_error", str(exc))
except sqlite3.IntegrityError:
return fail("duplicate", "account evaluation already registered")
except Exception:
return fail("db_error", "account evaluation registration failed")
def update_account_snapshot_category(snapshot_id: str, monthly_settlement_id: str, liquidity_bucket: str, db_path: str | None = None) -> dict[str, Any]:
try:
bucket = normalize_liquidity_bucket(liquidity_bucket)
with db(db_path) as conn:
conn.execute("BEGIN IMMEDIATE")
settlement = conn.execute("SELECT status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,)).fetchone()
if settlement is None:
conn.rollback(); return fail("not_found", "monthly settlement not found")
if settlement["status"] not in _ALLOWED_SETTLEMENT_STATUS:
conn.rollback(); return fail("invalid_settlement_status", "only draft or in_review settlements can be changed")
snapshot = conn.execute("SELECT id,source_type,monthly_settlement_id FROM account_snapshot WHERE id=?", (snapshot_id,)).fetchone()
if snapshot is None:
conn.rollback(); return fail("not_found", "account snapshot not found")
if snapshot["source_type"] != "account" or snapshot["monthly_settlement_id"] != monthly_settlement_id:
conn.rollback(); return fail("validation_error", "snapshot is not linked to this monthly settlement")
changed = conn.execute("UPDATE account_snapshot SET liquidity_bucket=? WHERE id=? AND monthly_settlement_id=?", (bucket, snapshot_id, monthly_settlement_id))
if changed.rowcount != 1:
conn.rollback(); return fail("stale_update", "snapshot category update is stale")
return ok(snapshot_id=snapshot_id, monthly_settlement_id=monthly_settlement_id, liquidity_bucket=bucket, updated=True)
except ValueError as exc:
return fail("validation_error", str(exc))
except Exception:
return fail("db_error", "account snapshot category update failed")
LIQUIDITY_BUCKETS = ("cash", "deposit", "investment", "real_estate", "loan", "other")
def normalize_liquidity_bucket(value: Any, *, default: str = "other") -> str:
value = default if value is None or str(value).strip() == "" else str(value).strip().lower()
if value not in LIQUIDITY_BUCKETS:
raise ValueError("liquidity_bucket must be cash, deposit, investment, real_estate, loan or other")
return value
@@ -14,12 +14,14 @@ def _record_balance_candidate(conn: Any, account: dict[str, Any], balance: Any =
return return
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", db_path: str | None = None, last_balance_basis_date: str = "") -> dict[str, Any]: 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 | None = None, db_path: str | None = None, last_balance_basis_date: str = "") -> dict[str, Any]:
if not name.strip(): return fail("validation_error", "account name is required") if not name.strip(): return fail("validation_error", "account name is required")
if type not in VALID_ACCOUNT_TYPES: return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}") if type not in VALID_ACCOUNT_TYPES: return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}")
try: try:
statement_status_norm = normalize_statement_status(statement_status) statement_status_norm = normalize_statement_status(statement_status)
liquidity_bucket_norm = normalize_liquidity_bucket(liquidity_bucket) liquidity_bucket_norm = normalize_liquidity_bucket(
"real_estate" if liquidity_bucket is None and type == "real_estate" else liquidity_bucket
)
except Exception as e: return fail("validation_error", str(e)) except Exception as e: return fail("validation_error", str(e))
ts = now_iso(); account_id = new_id("acct") ts = now_iso(); account_id = new_id("acct")
try: try:
@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
from uuid import uuid4
from .shared import read_db, DatabaseNotInitializedError from .shared import read_db, db, DatabaseNotInitializedError, now_iso
from .assets_service import get_asset_snapshot_report from .assets_service import get_asset_snapshot_report
from .imports_service import list_ledger_import_batches from .imports_service import list_ledger_import_batches
@@ -16,6 +18,428 @@ def _month_end_date(month: str) -> str:
return normalize_snapshot_at(month, default_now=False)[:10] return normalize_snapshot_at(month, default_now=False)[:10]
def _cashflow_fingerprint(report: dict[str, Any]) -> str:
payload = {
"categories": [{k: item.get(k) for k in ("cashflow_kind", "category", "subcategory", "amount", "count", "currency")} for item in report.get("categories", [])],
"source_transactions": report.get("source_transactions", []),
}
return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def _review_cashflow(month: str, db_path: str | None = None, conn=None) -> dict[str, Any]:
if conn is None:
return get_monthly_cashflow_report(month, db_path=db_path)
from .transaction_report_services import _month_range, CASHFLOW_KIND_SQL
start, end = _month_range(month)
rows = conn.execute(f"SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, t.category, t.subcategory, t.currency, SUM(t.amount) AS amount, COUNT(*) AS count FROM transactions t LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<? GROUP BY {CASHFLOW_KIND_SQL}, t.category, t.subcategory, t.currency ORDER BY cashflow_kind, amount DESC", (start, end)).fetchall()
categories = [dict(r) for r in rows]
source_rows = conn.execute("SELECT t.id, t.transaction_date, t.type, t.amount, t.currency, t.category, t.subcategory, t.account_id FROM transactions t LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<? ORDER BY t.id", (start, end)).fetchall()
totals = {}
for item in categories: totals[item['cashflow_kind']] = totals.get(item['cashflow_kind'], 0) + int(item['amount'] or 0)
income, expenses = totals.get('income', 0), totals.get('expense', 0)
return ok(month=month, income=income, expenses=expenses, net_cashflow=income-expenses, by_cashflow_kind=totals, categories=categories, source_transactions=[dict(r) for r in source_rows])
def review_monthly_settlement(settlement_id: str, db_path: str | None = None) -> dict[str, Any]:
try:
with read_db(db_path) as conn:
row = conn.execute("SELECT id,month,status,version FROM monthly_settlement WHERE id=?", (settlement_id,)).fetchone()
if not row:
return fail("not_found", "monthly settlement not found")
if row["status"] not in {"draft", "in_review"}:
return fail("invalid_settlement_status", "only draft or in_review settlements can be reviewed")
report = _review_cashflow(row["month"], conn=conn)
fp = _cashflow_fingerprint(report)
return ok(settlement_id=settlement_id, month=row["month"], version=row["version"], status=row["status"], cashflow=report, review_fingerprint=fp)
except Exception:
return fail("operation_failed", "monthly settlement review unavailable")
def approve_monthly_settlement(settlement_id: str, expected_version: int, review_fingerprint: str, approved_by: str = "dashboard-admin", db_path: str | None = None) -> dict[str, Any]:
if not review_fingerprint:
return fail("validation_error", "review_fingerprint is required")
try:
with db(db_path) as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute("SELECT * FROM monthly_settlement WHERE id=?", (settlement_id,)).fetchone()
if not row: return fail("not_found", "monthly settlement not found")
if row["status"] not in {"draft", "in_review"} or row["version"] != expected_version:
return fail("stale_settlement", "settlement status or version is stale")
report = _review_cashflow(row["month"], conn=conn)
actual = _cashflow_fingerprint(report)
if actual != review_fingerprint:
return fail("stale_review", "review fingerprint is stale", current_fingerprint=actual)
ts = now_iso()
conn.execute("UPDATE monthly_settlement SET status='approved', approved_by=?, approved_at=?, approved_version=version, approved_fingerprint=COALESCE(review_fingerprint,?), approved_cashflow_fingerprint=?, updated_by=?, updated_at=? WHERE id=? AND version=?", (approved_by, ts, review_fingerprint, actual, approved_by, ts, settlement_id, expected_version))
conn.execute("UPDATE monthly_settlement SET review_fingerprint=? WHERE id=?", (review_fingerprint, settlement_id))
return ok(settlement_id=settlement_id, status="approved", version=expected_version, approved_cashflow_fingerprint=actual)
except Exception:
return fail("operation_failed", "monthly settlement approval failed")
def cancel_monthly_settlement_approval(settlement_id: str, expected_version: int, approved_by: str = "dashboard-admin", db_path: str | None = None) -> dict[str, Any]:
"""Return an approved settlement to review without changing its source data."""
if not isinstance(expected_version, int) or isinstance(expected_version, bool) or expected_version < 1:
return fail("validation_error", "expected_version is required")
try:
with db(db_path) as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute("SELECT id, status, version FROM monthly_settlement WHERE id=?", (settlement_id,)).fetchone()
if not row:
return fail("not_found", "monthly settlement not found")
if row["status"] != "approved" or row["version"] != expected_version:
return fail("stale_settlement", "only the current approved settlement can have approval cancelled")
ts = now_iso()
cur = conn.execute("""UPDATE monthly_settlement
SET status='in_review', version=version+1,
approved_by=NULL, approved_at=NULL, approved_version=NULL,
approved_fingerprint=NULL, approved_cashflow_fingerprint=NULL,
approval_reason=NULL, approval_flags_json='{}',
review_fingerprint=NULL, updated_by=?, updated_at=?
WHERE id=? AND status='approved' AND version=?""", (approved_by, ts, settlement_id, expected_version))
if cur.rowcount != 1:
return fail("stale_settlement", "settlement status or version is stale")
return ok(settlement_id=settlement_id, status="in_review", version=expected_version + 1)
except Exception:
return fail("operation_failed", "monthly settlement approval cancellation failed")
def close_monthly_settlement(settlement_id: str, expected_version: int, closed_by: str = "dashboard-admin", db_path: str | None = None) -> dict[str, Any]:
try:
with db(db_path) as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute("SELECT * FROM monthly_settlement WHERE id=?", (settlement_id,)).fetchone()
if not row: return fail("not_found", "monthly settlement not found")
if row["status"] == "closed": return ok(settlement_id=settlement_id, status="closed", version=row["version"], idempotent=True)
if row["status"] != "approved" or row["version"] != expected_version:
return fail("stale_settlement", "settlement status or version is stale")
if not row["approved_cashflow_fingerprint"]: return fail("missing_fingerprint", "approved cashflow fingerprint is missing")
report = _review_cashflow(row["month"], conn=conn)
actual = _cashflow_fingerprint(report)
if actual != row["approved_cashflow_fingerprint"]: return fail("stale_cashflow", "cashflow changed after approval", current_fingerprint=actual)
conn.execute("DELETE FROM settlement_cashflow_snapshots WHERE monthly_settlement_id=?", (settlement_id,))
items = report.get("categories", [])
for item in items:
conn.execute("INSERT INTO settlement_cashflow_snapshots(id,monthly_settlement_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)", (uuid4().hex, settlement_id, item.get("cashflow_kind") or "excluded", item.get("category") or "", item.get("subcategory") or "", int(item.get("amount") or 0), max(1, int(item.get("count") or 0)), item.get("currency") or ""))
ts = now_iso()
conn.execute("UPDATE monthly_settlement SET status='closed', closed_by=?, closed_at=?, updated_by=?, updated_at=? WHERE id=? AND status='approved' AND version=?", (closed_by, ts, closed_by, ts, settlement_id, expected_version))
return ok(settlement_id=settlement_id, status="closed", version=expected_version, snapshot_count=len(items), approved_cashflow_fingerprint=row["approved_cashflow_fingerprint"])
except Exception:
return fail("operation_failed", "monthly settlement close failed")
def create_monthly_settlement(month: str, created_by: str = "dashboard-admin", db_path: str | None = None) -> dict[str, Any]:
"""Create only the canonical monthly-settlement parent draft."""
if not isinstance(month, str) or not __import__("re").fullmatch(r"\d{4}-\d{2}", month):
return fail("validation_error", "month must be YYYY-MM")
try:
selected = normalize_month(month)
basis = _month_end_date(selected)
ts = now_iso()
with db(db_path) as conn:
existing = conn.execute("SELECT id FROM monthly_settlement WHERE month=?", (selected,)).fetchone()
if existing:
return fail("duplicate_month", "monthly settlement already exists", settlement_id=existing["id"], month=selected)
settlement_id = uuid4().hex
conn.execute(
"INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES(?,?,?,?,?,?,?,?,?)",
(settlement_id, selected, basis, "draft", 1, created_by, ts, created_by, ts),
)
return ok(settlement_id=settlement_id, month=selected, status="draft", created=True)
except DatabaseNotInitializedError:
return fail("db_not_initialized", "ledger database is not initialized")
except Exception as exc:
if "UNIQUE" in str(exc).upper():
return fail("duplicate_month", "monthly settlement already exists", month=month)
return fail("operation_failed", "monthly settlement creation failed")
def list_monthly_settlement_overview(year: int | None = None, status: str = "", db_path: str | None = None) -> dict[str, Any]:
"""List monthly-settlement parent rows without consulting removed run history."""
try:
with read_db(db_path) as conn:
rows = conn.execute(
"""
WITH live AS (
SELECT substr(transaction_date, 1, 7) AS month,
SUM(CASE WHEN type IN ('income','credit') THEN amount ELSE 0 END) AS income,
SUM(CASE WHEN type IN ('expense','debit') THEN amount ELSE 0 END) AS expense
FROM transactions
GROUP BY substr(transaction_date, 1, 7)
)
SELECT m.month, m.id AS settlement_id, m.basis_date, m.status AS settlement_status,
m.version AS settlement_version, m.created_at, m.updated_at,
l.income AS live_income, l.expense AS live_expense
FROM monthly_settlement m
LEFT JOIN live l ON l.month = m.month
WHERE (? IS NULL OR ? = '' OR substr(m.month, 1, 4) = ?)
AND (? = '' OR m.status = ?)
ORDER BY m.month DESC
""",
(str(year) if year is not None else None, str(year) if year is not None else '', str(year) if year is not None else '', status or '', status or ''),
).fetchall()
result = []
for row in rows:
d = dict(row)
parent_exists = bool(d.get("settlement_id"))
lifecycle = d.get("settlement_status") or "unregistered"
label = {
"closed": "마감완료",
"approved": "승인완료",
"in_review": "검토중",
"draft": "초안",
"cancelled": "취소",
"unregistered": "정산 미생성·상세 선택 불가·현재집계(미확정)",
}.get(lifecycle, lifecycle)
d.update(
status_label=label,
amounts_are_snapshot=False,
income=d.get("live_income") or 0,
expense=d.get("live_expense") or 0,
income_display=d.get("live_income") or 0,
expense_display=d.get("live_expense") or 0,
amount_confirmation="미확정",
net_cashflow_display=None,
net_worth_display=None,
run_id=None,
run_version=None,
close_status=None,
closed_at=None,
income_total=None,
expense_total=None,
net_cashflow=None,
net_worth=None,
correction_active=False,
draft_active=parent_exists,
)
result.append(d)
return ok(months=result, read_only=True)
except DatabaseNotInitializedError:
return fail("db_not_initialized", "ledger database is not initialized")
except Exception:
return fail("operation_failed", "monthly settlement overview unavailable")
def get_monthly_settlement_record(settlement_id: str, db_path: str | None = None) -> dict[str, Any]:
"""Read one monthly-settlement parent by canonical ID."""
if not isinstance(settlement_id, str) or not settlement_id.strip():
return fail("validation_error", "settlement_id is required")
try:
with read_db(db_path) as conn:
row = conn.execute(
"SELECT id, month, basis_date, status, version, created_at, updated_at "
"FROM monthly_settlement WHERE id = ?", (settlement_id,)
).fetchone()
if row is None:
return fail("not_found", "monthly settlement not found")
return ok(settlement=dict(row), read_only=True)
except DatabaseNotInitializedError:
return fail("db_not_initialized", "ledger database is not initialized")
except Exception:
return fail("operation_failed", "monthly settlement unavailable")
def _ledger_candidates(conn, account_id: str, currency: str, basis_date: str) -> dict[str, Any]:
base = """SELECT t.id,t.transaction_date,t.transaction_datetime,t.created_at,t.balance_after,t.currency,t.account_id
FROM transactions t
WHERE t.account_id=? AND t.currency=? AND t.status='active'
AND t.balance_after IS NOT NULL AND t.transaction_date {op} ?
ORDER BY t.transaction_date {direction}, t.transaction_datetime {direction}, t.created_at {direction}, t.id {direction} LIMIT 1"""
prev = conn.execute(base.format(op="<=", direction="DESC"), (account_id, currency, basis_date)).fetchone()
nxt = conn.execute(base.format(op=">", direction="ASC"), (account_id, currency, basis_date)).fetchone()
return {"previous_ledger": dict(prev) if prev else None, "next_ledger": dict(nxt) if nxt else None}
def list_account_snapshots_for_settlement(settlement_id: str, db_path: str | None = None) -> dict[str, Any]:
"""Return the account evaluation rows for a settlement without inventing values.
Draft/review/approved/cancelled settlements use the complete registered-account
set and exact monthly snapshot matches. Closed settlements are historical:
they read only rows stored against that settlement, so later accounts cannot
leak into the report. Duplicate snapshot rows are deliberately preserved.
"""
if not isinstance(settlement_id, str) or not settlement_id.strip():
return fail("validation_error", "settlement_id is required")
try:
with read_db(db_path) as conn:
settlement = conn.execute(
"SELECT status, basis_date FROM monthly_settlement WHERE id=?", (settlement_id,)
).fetchone()
if settlement is None:
return fail("not_found", "monthly settlement not found")
closed = settlement["status"] == "closed"
if closed:
rows = conn.execute(
"""SELECT s.name, s.source_type, s.source_id, s.amount, s.currency,
a.type AS account_type, a.liquidity_bucket AS account_liquidity_bucket,
s.liquidity_bucket AS snapshot_account_category,
s.basis_date, s.verification_status, s.memo, s.source_note,
s.id AS snapshot_id, 'snapshot' AS match_status
FROM account_snapshot s
LEFT JOIN accounts a ON a.id=s.source_id
WHERE s.monthly_settlement_id=? AND s.source_type='account'
ORDER BY s.name, s.source_id, s.created_at, s.id""", (settlement_id,)
).fetchall()
else:
rows = conn.execute(
"""SELECT a.name, 'account' AS source_type, a.id AS source_id,
a.type AS account_type, a.liquidity_bucket AS account_liquidity_bucket,
s.liquidity_bucket AS snapshot_account_category,
s.amount, COALESCE(s.currency, a.currency) AS currency,
COALESCE(s.basis_date, ?) AS basis_date,
s.verification_status, s.memo, s.source_note,
s.id AS snapshot_id,
CASE WHEN s.id IS NULL THEN 'unregistered' ELSE 'snapshot' END AS match_status
FROM accounts a
LEFT JOIN account_snapshot s
ON s.source_id=a.id AND s.source_type='account'
AND s.monthly_settlement_id=?
ORDER BY a.name, a.id, s.created_at, s.id""",
(settlement["basis_date"], settlement_id),
).fetchall()
result = []
for row in rows:
item = dict(row)
item["account_name"] = item.get("name") or ""
item["account_type"] = item.get("account_type") or ""
item["account_liquidity_bucket"] = item.get("account_liquidity_bucket") or ""
item["snapshot_account_category"] = item.get("snapshot_account_category") or ""
item["display_status"] = "미등록" if item["match_status"] == "unregistered" else ("미확인" if item.get("amount") is None else "확인")
if item["match_status"] == "unregistered":
with read_db(db_path) as candidate_conn:
item.update(_ledger_candidates(candidate_conn, item["source_id"], item["currency"], item["basis_date"]))
if item["match_status"] == "unregistered":
with read_db(db_path) as candidate_conn:
candidates = candidate_conn.execute(
"""SELECT s.id AS snapshot_id, s.basis_date, s.amount, s.currency,
s.verification_status, s.created_at
FROM account_snapshot s
WHERE s.source_type='account' AND s.source_id=?
AND s.monthly_settlement_id IS NULL AND s.currency=?
ORDER BY CASE WHEN s.basis_date=? THEN 0 ELSE 1 END,
s.basis_date DESC, s.created_at DESC, s.id DESC""",
(item["source_id"], item["currency"], item["basis_date"]),
).fetchall()
item["unlinked_candidates"] = [dict(candidate) for candidate in candidates]
result.append(item)
return ok(snapshots=result, count=len(result), registered_count=sum(r["match_status"] == "snapshot" for r in result), unregistered_count=sum(r["match_status"] == "unregistered" for r in result), closed=closed, status=settlement["status"], read_only=True)
except DatabaseNotInitializedError:
return fail("db_not_initialized", "ledger database is not initialized")
except Exception:
return fail("operation_failed", "account snapshot details unavailable")
def link_account_snapshot(snapshot_id: str, monthly_settlement_id: str, *, allow_basis_date_mismatch: bool = False, db_path: str | None = None) -> dict[str, Any]:
"""Atomically attach one existing, unlinked account snapshot."""
if not isinstance(snapshot_id, str) or not snapshot_id.strip() or not isinstance(monthly_settlement_id, str) or not monthly_settlement_id.strip():
return fail("validation_error", "snapshot_id and monthly_settlement_id are required")
try:
with db(db_path) as conn:
conn.execute("BEGIN IMMEDIATE")
settlement = conn.execute("SELECT id,basis_date,status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,)).fetchone()
if settlement is None:
conn.rollback(); return fail("not_found", "monthly settlement not found")
if settlement["status"] not in {"draft", "in_review"}:
conn.rollback(); return fail("invalid_settlement_status", "only draft or in_review settlements can be changed")
snapshot = conn.execute("SELECT id,source_type,source_id,currency,basis_date,monthly_settlement_id FROM account_snapshot WHERE id=?", (snapshot_id,)).fetchone()
if snapshot is None:
conn.rollback(); return fail("not_found", "account snapshot not found")
account = conn.execute("SELECT id,currency FROM accounts WHERE id=?", (snapshot["source_id"],)).fetchone()
if snapshot["source_type"] != "account" or account is None or snapshot["currency"] != account["currency"]:
conn.rollback(); return fail("validation_error", "snapshot account or currency is invalid")
if snapshot["monthly_settlement_id"] is not None:
conn.rollback(); return fail("already_linked", "account snapshot is already linked")
if snapshot["basis_date"] != settlement["basis_date"] and not allow_basis_date_mismatch:
conn.rollback(); return fail("basis_date_mismatch", "basis date mismatch requires explicit approval")
duplicate = conn.execute("SELECT id FROM account_snapshot WHERE monthly_settlement_id=? AND source_type='account' AND source_id=?", (monthly_settlement_id, snapshot["source_id"])).fetchone()
if duplicate:
conn.rollback(); return fail("duplicate", "account evaluation already registered", snapshot_id=duplicate["id"])
updated = conn.execute("UPDATE account_snapshot SET monthly_settlement_id=? WHERE id=? AND monthly_settlement_id IS NULL", (monthly_settlement_id, snapshot_id))
if updated.rowcount != 1:
conn.rollback(); return fail("stale_link", "account snapshot link is stale")
return ok(snapshot_id=snapshot_id, monthly_settlement_id=monthly_settlement_id, linked=True)
except Exception:
return fail("db_error", "account snapshot link failed")
def unlink_account_snapshot(snapshot_id: str, monthly_settlement_id: str, db_path: str | None = None) -> dict[str, Any]:
"""Detach exactly one account snapshot from one editable monthly settlement."""
if not isinstance(snapshot_id, str) or not snapshot_id.strip() or not isinstance(monthly_settlement_id, str) or not monthly_settlement_id.strip():
return fail("validation_error", "snapshot_id and monthly_settlement_id are required")
try:
with db(db_path) as conn:
conn.execute("BEGIN IMMEDIATE")
settlement = conn.execute("SELECT id,status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,)).fetchone()
if settlement is None:
conn.rollback(); return fail("not_found", "monthly settlement not found")
if settlement["status"] not in {"draft", "in_review"}:
conn.rollback(); return fail("invalid_settlement_status", "only draft or in_review settlements can be changed")
snapshot = conn.execute(
"SELECT id,source_type,source_id,monthly_settlement_id FROM account_snapshot WHERE id=?", (snapshot_id,)
).fetchone()
if snapshot is None:
conn.rollback(); return fail("not_found", "account snapshot not found")
if snapshot["source_type"] != "account":
conn.rollback(); return fail("validation_error", "snapshot source_type must be account")
if not snapshot["source_id"] or conn.execute("SELECT 1 FROM accounts WHERE id=?", (snapshot["source_id"],)).fetchone() is None:
conn.rollback(); return fail("validation_error", "snapshot account not found")
if snapshot["monthly_settlement_id"] != monthly_settlement_id:
conn.rollback(); return fail("validation_error", "snapshot is not linked to this monthly settlement")
result = conn.execute(
"UPDATE account_snapshot SET monthly_settlement_id=NULL WHERE id=? AND monthly_settlement_id=?",
(snapshot_id, monthly_settlement_id),
)
if result.rowcount != 1:
conn.rollback(); return fail("validation_error", "snapshot link is stale")
return ok(snapshot_id=snapshot_id, monthly_settlement_id=monthly_settlement_id, unlinked=True)
except Exception:
return fail("db_error", "account snapshot unlink failed")
def list_monthly_settlement_history(month: str = "", db_path: str | None = None) -> dict[str, Any]:
"""Return immutable close/correction records; lifecycle history is authoritative."""
try:
selected = normalize_month(month) if month else ""
with read_db(db_path) as conn:
rows = conn.execute(
"""SELECT id AS run_id, settlement_month, draft_version AS version,
COALESCE(approved_at, created_at) AS closed_at,
correction_of_run_id AS correction_target,
correction_reason AS reason, close_status, created_at
FROM asset_snapshot_runs
WHERE (? = '' OR settlement_month = ? OR (settlement_month IS NULL AND substr(basis_date,1,7)=?))
ORDER BY COALESCE(approved_at, created_at) DESC, rowid DESC""",
(selected, selected, selected),
).fetchall()
return ok(history=[dict(row) for row in rows], month=selected, read_only=True)
except DatabaseNotInitializedError:
return fail("db_not_initialized", "ledger database is not initialized")
except Exception:
return fail("operation_failed", "monthly settlement history unavailable")
def get_monthly_settlement_history_record(run_id: str, db_path: str | None = None) -> dict[str, Any]:
"""Read one immutable close/correction record by run ID."""
if not isinstance(run_id, str) or not run_id:
return fail("validation_error", "run_id is required")
try:
with read_db(db_path) as conn:
row = conn.execute(
"""SELECT id AS run_id, settlement_month, draft_version AS version,
COALESCE(approved_at, created_at) AS closed_at,
correction_of_run_id AS correction_target,
correction_reason AS reason, close_status, created_at,
basis_date, income_total, expense_total, net_cashflow, net_worth
FROM asset_snapshot_runs WHERE id=?""", (run_id,)
).fetchone()
if not row:
return fail("not_found", "settlement history record not found")
return ok(history=dict(row), read_only=True)
except DatabaseNotInitializedError:
return fail("db_not_initialized", "ledger database is not initialized")
except Exception:
return fail("operation_failed", "monthly settlement history unavailable")
def _budget_overrun_count(budget_report: dict[str, Any]) -> int: def _budget_overrun_count(budget_report: dict[str, Any]) -> int:
return sum(1 for item in budget_report.get("items", []) if item.get("status") == "over") return sum(1 for item in budget_report.get("items", []) if item.get("status") == "over")
@@ -175,6 +599,17 @@ def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path:
metadata = {"snapshot_run_id": run_id, "legacy_closed": True, "close_status": "legacy_read_only"} metadata = {"snapshot_run_id": run_id, "legacy_closed": True, "close_status": "legacy_read_only"}
else: else:
if selected_month: if selected_month:
run_columns = {r["name"] for r in conn.execute("PRAGMA table_info(asset_snapshot_runs)")}
comprehensive_columns = {"snapshot_schema_version", "aggregation_version"}
if comprehensive_columns <= run_columns:
run = conn.execute(
"""SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs
WHERE basis_date=? AND snapshot_schema_version=1
AND aggregation_version='comprehensive-v1'
ORDER BY rowid DESC LIMIT 1""",
(_month_end_date(selected_month),),
).fetchone()
if run is None:
run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (_month_end_date(selected_month),)).fetchone() run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (_month_end_date(selected_month),)).fetchone()
if run is None: if run is None:
basis = _month_end_date(selected_month) if selected_month else "" basis = _month_end_date(selected_month) if selected_month else ""
@@ -2,6 +2,9 @@
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date
import hashlib
import json
from decimal import Decimal
from .shared import CASHFLOW_KIND_SQL from .shared import CASHFLOW_KIND_SQL
@@ -10,6 +13,33 @@ _SNAPSHOT_SCHEMA_VERSION = 1
_ALLOWED_KINDS = {"income", "expense", "excluded"} _ALLOWED_KINDS = {"income", "expense", "excluded"}
def _canonical_value(value):
"""Convert review inputs to a stable, JSON-safe representation."""
if isinstance(value, Decimal):
return format(value, "f")
if isinstance(value, dict):
return {str(k): _canonical_value(value[k]) for k in sorted(value)}
if isinstance(value, (list, tuple)):
return [_canonical_value(x) for x in value]
return value
def cashflow_fingerprint(review: dict, month: str) -> str:
"""Fingerprint policy, aggregation, and every included source row."""
payload = {
"month": month,
"aggregation_version": review.get("aggregation_version"),
"snapshot_schema_version": review.get("snapshot_schema_version"),
"cashflow_policy": review.get("cashflow_policy", {}),
"cashflow_items": review.get("cashflow_items", []),
"source_snapshot": review.get("source_snapshot", {}),
"blockers": review.get("cashflow_blockers", []),
}
encoded = json.dumps(_canonical_value(payload), ensure_ascii=False,
sort_keys=True, separators=(",", ":"), allow_nan=False)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _normalize_cashflow_policy(payload: dict) -> dict: def _normalize_cashflow_policy(payload: dict) -> dict:
if not isinstance(payload, dict) or set(payload) - {"savings_categories", "category_decisions"}: if not isinstance(payload, dict) or set(payload) - {"savings_categories", "category_decisions"}:
raise ValueError("cashflow policy must contain only supported keys") raise ValueError("cashflow policy must contain only supported keys")
@@ -120,7 +150,9 @@ def _review_cashflow(conn, month: str, policy: dict) -> dict:
else: expense += applied else: expense += applied
items = [{"cashflow_kind": k, "category": c, "subcategory": s, "amount": v[0], "transaction_count": v[1], "currency": cur} items = [{"cashflow_kind": k, "category": c, "subcategory": s, "amount": v[0], "transaction_count": v[1], "currency": cur}
for (k, c, s, cur), v in sorted(grouped.items())] for (k, c, s, cur), v in sorted(grouped.items())]
return {"cashflow_policy": policy, "aggregation_version": _AGGREGATION_VERSION, result = {"cashflow_policy": policy, "aggregation_version": _AGGREGATION_VERSION,
"snapshot_schema_version": _SNAPSHOT_SCHEMA_VERSION, "income_total": income, "snapshot_schema_version": _SNAPSHOT_SCHEMA_VERSION, "income_total": income,
"expense_total": expense, "net_cashflow": income-expense, "cashflow_items": items, "expense_total": expense, "net_cashflow": income-expense, "cashflow_items": items,
"cashflow_blockers": blockers, "cashflow_warnings": [], "source_snapshot": {"transactions": source}} "cashflow_blockers": blockers, "cashflow_warnings": [], "source_snapshot": {"transactions": source}}
result["cashflow_fingerprint"] = cashflow_fingerprint(result, month)
return result
+1 -1
View File
@@ -29,7 +29,7 @@ from ..infrastructure.sqlite import DatabaseNotInitializedError
from ..infrastructure.sqlite import resolve_db_path as resolve_db_path from ..infrastructure.sqlite import resolve_db_path as resolve_db_path
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", "real_estate"}
VALID_STATEMENT_STATUSES = {"unknown", "import_supported", "manual_only", "partial_import"} VALID_STATEMENT_STATUSES = {"unknown", "import_supported", "manual_only", "partial_import"}
VALID_LIQUIDITY_BUCKETS = {"cash", "investment", "deposit", "real_estate", "loan", "other"} VALID_LIQUIDITY_BUCKETS = {"cash", "investment", "deposit", "real_estate", "loan", "other"}
ASSET_BUCKETS = ["cash", "investment", "deposit", "real_estate", "other"] ASSET_BUCKETS = ["cash", "investment", "deposit", "real_estate", "other"]
@@ -68,7 +68,7 @@ def _readonly_sqlite_connection(path: Path) -> sqlite3.Connection:
def _safe_table_counts(conn: sqlite3.Connection) -> dict[str, int]: def _safe_table_counts(conn: sqlite3.Connection) -> dict[str, int]:
counts: dict[str, int] = {} counts: dict[str, int] = {}
for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories", "manual_assets", "asset_snapshots", "review_decisions", "reconciliation_sessions"): for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories", "manual_assets", "asset_snapshots", "asset_snapshot_runs", "account_valuations", "account_snapshot", "monthly_settlement", "review_decisions", "reconciliation_sessions"):
exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone() exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
if exists: if exists:
counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
@@ -68,13 +68,65 @@ def get_monthly_cashflow_report(month: str = "", db_path: str | None = None) ->
ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<? WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<?
GROUP BY {CASHFLOW_KIND_SQL}""", (start, next_month)).fetchall() GROUP BY {CASHFLOW_KIND_SQL}""", (start, next_month)).fetchall()
category_rows = conn.execute(f"""SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind,
t.category, t.subcategory, t.currency,
SUM(t.amount) AS amount, COUNT(*) AS count
FROM transactions t
LEFT JOIN ledger_categories lc
ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<?
GROUP BY {CASHFLOW_KIND_SQL}, t.category, t.subcategory, t.currency
ORDER BY cashflow_kind, amount DESC""", (start, next_month)).fetchall()
type_totals = {r["type"]: int(r["total"] or 0) for r in type_rows} type_totals = {r["type"]: int(r["total"] or 0) for r in type_rows}
cashflow_totals = {r["cashflow_kind"]: int(r["total"] or 0) for r in cashflow_rows} cashflow_totals = {r["cashflow_kind"]: int(r["total"] or 0) for r in cashflow_rows}
income = cashflow_totals.get("income", 0) income = cashflow_totals.get("income", 0)
expenses = cashflow_totals.get("expense", 0) expenses = cashflow_totals.get("expense", 0)
net_cashflow = income - expenses net_cashflow = income - expenses
savings_rate = round(net_cashflow / income, 4) if income else None savings_rate = round(net_cashflow / income, 4) if income else None
return ok(month=month_norm, income=income, expenses=expenses, net_cashflow=net_cashflow, savings_rate=savings_rate, by_type=type_totals, by_cashflow_kind=cashflow_totals) categories = [row_to_dict(r) for r in category_rows]
unknown_total = {}
unknown_category_items = []
for row in categories:
if row.get("cashflow_kind") == "unknown":
currency = row.get("currency") or "미지정"
unknown_total[currency] = unknown_total.get(currency, 0) + int(row.get("amount") or 0)
unknown_category_items.append(row)
return ok(month=month_norm, income=income, expenses=expenses, net_cashflow=net_cashflow, savings_rate=savings_rate, by_type=type_totals, by_cashflow_kind=cashflow_totals, categories=categories, unknown_total=unknown_total, unknown_category_items=unknown_category_items)
@read_operation
def get_settlement_cashflow_snapshot(monthly_settlement_id: str, db_path: str | None = None) -> dict[str, Any]:
"""Read only the persisted cashflow rows for a closed settlement."""
with read_db(db_path) as conn:
rows = conn.execute("""SELECT cashflow_kind, category, subcategory, amount,
transaction_count AS count, currency
FROM settlement_cashflow_snapshots
WHERE monthly_settlement_id=?
ORDER BY cashflow_kind, category, subcategory, currency""", (monthly_settlement_id,)).fetchall()
settlement = conn.execute("SELECT status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,)).fetchone()
categories = [row_to_dict(row) for row in rows]
if not categories:
saved = bool(settlement and settlement["status"] == "closed")
return ok(saved=saved, categories=[], income=0 if saved else None, expenses=0 if saved else None,
net_cashflow=0 if saved else None, month="", message="저장된 현금흐름이 비어 있습니다." if saved else "저장된 현금흐름 스냅샷이 없습니다.")
by_currency = {}
for row in categories:
cur = row.get("currency") or "미지정"
bucket = by_currency.setdefault(cur, {"income": 0, "expenses": 0, "net_cashflow": 0})
if row["cashflow_kind"] == "income": bucket["income"] += int(row["amount"] or 0)
elif row["cashflow_kind"] == "expense": bucket["expenses"] += int(row["amount"] or 0)
for bucket in by_currency.values(): bucket["net_cashflow"] = bucket["income"] - bucket["expenses"]
only = next(iter(by_currency.values())) if len(by_currency) == 1 else None
unknown_total = {}
unknown_category_items = []
for row in categories:
if row.get("cashflow_kind") == "unknown":
currency = row.get("currency") or "미지정"
unknown_total[currency] = unknown_total.get(currency, 0) + int(row.get("amount") or 0)
unknown_category_items.append(row)
return ok(saved=True, categories=categories, unknown_total=unknown_total, unknown_category_items=unknown_category_items, by_currency=by_currency,
income=only["income"] if only else None,
expenses=only["expenses"] if only else None,
net_cashflow=only["net_cashflow"] if only else None, month="")
@read_operation @read_operation
def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "", db_path: str | None = None) -> dict[str, Any]: def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "", db_path: str | None = None) -> dict[str, Any]:
@@ -224,9 +224,106 @@ def migrate_db(conn: sqlite3.Connection) -> None:
conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,)) conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,))
def _execute_script_transactional(conn: sqlite3.Connection, script: str) -> None:
"""Execute a schema script without executescript's implicit COMMIT."""
statement = ""
for line in script.splitlines():
statement += line + "\n"
if sqlite3.complete_statement(statement):
_execute_one_schema_statement(conn, statement)
statement = ""
if statement.strip():
_execute_one_schema_statement(conn, statement)
def _execute_one_schema_statement(conn: sqlite3.Connection, statement: str) -> None:
if statement.lstrip().upper().startswith("PRAGMA JOURNAL_MODE=WAL"):
return
if statement.lstrip().upper().startswith("CREATE INDEX") or statement.lstrip().upper().startswith("CREATE UNIQUE INDEX"):
return
conn.execute(statement)
def _stored_version(conn: sqlite3.Connection) -> str | None:
try:
row = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()
except sqlite3.OperationalError as exc:
if "no such table" in str(exc):
return None
raise
return row["value"] if row else None
def _compatible_known_v16(conn: sqlite3.Connection) -> bool:
"""Allow only the already-approved v16 development shape to normalize to v15."""
required = {
"accounts": {"id", "name", "type", "liquidity_bucket"},
"transactions": {"id", "transaction_date", "amount", "raw_payload"},
"account_snapshot": {"id", "monthly_settlement_id", "amount", "verification_status"},
"monthly_settlement": {"id", "month", "status"},
"settlement_cashflow_snapshots": {"id", "monthly_settlement_id", "amount"},
}
for table, columns in required.items():
if not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone():
return False
actual = _existing_columns(conn, table)
if not columns <= actual:
return False
return not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name IN ('asset_snapshot_runs','manual_assets','asset_snapshots','account_valuations')").fetchone()
def _upgrade_cashflow_storage(conn):
"""Preserve run identity: corrections cannot be merged into a single month.
Each frozen group belongs to exactly one legacy run or canonical month.
No inferred dates or fabricated approvals are needed to retain legacy rows.
"""
columns = _existing_columns(conn, "settlement_cashflow_snapshots")
if {"settlement_run_id", "monthly_settlement_id"} <= columns:
return
objects = conn.execute(
"SELECT sql FROM sqlite_master WHERE tbl_name='settlement_cashflow_snapshots' AND type IN ('index','trigger') AND sql IS NOT NULL"
).fetchall()
if columns:
conn.execute("ALTER TABLE settlement_cashflow_snapshots RENAME TO cashflow_upgrade_source")
start = SCHEMA_SQL.index("CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots")
end = SCHEMA_SQL.index(";", start) + 1
conn.execute(SCHEMA_SQL[start:end])
if columns:
names = ",".join(sorted(columns))
conn.execute(f"INSERT INTO settlement_cashflow_snapshots ({names}) SELECT {names} FROM cashflow_upgrade_source")
conn.execute("DROP TABLE cashflow_upgrade_source")
for row in objects:
conn.execute(row["sql"])
def _normalize_monthly_settlement_schema_15(conn: sqlite3.Connection) -> None:
"""Remove the development-only schema-16 run pointer from schema 15."""
if "latest_run_id" in _existing_columns(conn, "monthly_settlement"):
conn.execute("ALTER TABLE monthly_settlement DROP COLUMN latest_run_id")
def init_db(db_path: str | None = None) -> str: def init_db(db_path: str | None = None) -> str:
path = resolve_db_path(db_path) path = resolve_db_path(db_path)
with connect(str(path)) as conn: with connect(str(path)) as conn:
conn.executescript(SCHEMA_SQL) stored = _stored_version(conn)
if stored not in (None, "11", "12", "13", "14", "15"):
if stored != "16" or not _compatible_known_v16(conn):
raise sqlite3.DatabaseError(f"unsupported schema version {stored}; refusing implicit downgrade")
conn.execute("BEGIN IMMEDIATE")
# SQLite DDL and all row copies participate in this transaction.
_execute_script_transactional(conn, SCHEMA_SQL)
_upgrade_cashflow_storage(conn)
migrate_db(conn) migrate_db(conn)
_add_missing_columns(conn, "monthly_settlement", {"approved_cashflow_fingerprint": "TEXT"})
_normalize_monthly_settlement_schema_15(conn)
# Re-run indexes only after all older-version columns have been added.
for statement in SCHEMA_SQL.split(";"):
if statement.strip().upper().startswith(("CREATE INDEX", "CREATE UNIQUE INDEX")):
conn.execute(statement)
if conn.execute("PRAGMA foreign_key_check").fetchone():
raise sqlite3.IntegrityError("migration foreign key check failed")
conn.commit()
return str(path) return str(path)
+123 -64
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
SCHEMA_VERSION = "14" SCHEMA_VERSION = "15"
SCHEMA_SQL = """ SCHEMA_SQL = """
PRAGMA journal_mode=WAL; PRAGMA journal_mode=WAL;
@@ -131,23 +131,7 @@ CREATE TABLE IF NOT EXISTS ledger_categories (
CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory); CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory); CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory);
CREATE TABLE IF NOT EXISTS manual_assets ( CREATE TABLE IF NOT EXISTS account_snapshot (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_amount INTEGER,
valuation_date TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT 'KRW',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name);
CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date);
CREATE TABLE IF NOT EXISTS asset_snapshots (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL, basis_date TEXT NOT NULL,
source_type TEXT NOT NULL, source_type TEXT NOT NULL,
@@ -159,7 +143,7 @@ CREATE TABLE IF NOT EXISTS asset_snapshots (
valuation_source TEXT NOT NULL DEFAULT '', valuation_source TEXT NOT NULL DEFAULT '',
source_note TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '', memo TEXT NOT NULL DEFAULT '',
snapshot_run_id TEXT NOT NULL DEFAULT '', monthly_settlement_id TEXT REFERENCES monthly_settlement(id) ON DELETE RESTRICT,
correction_of_run_id TEXT NOT NULL DEFAULT '', correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '',
is_correction INTEGER NOT NULL DEFAULT 0, is_correction INTEGER NOT NULL DEFAULT 0,
@@ -171,55 +155,23 @@ CREATE TABLE IF NOT EXISTS asset_snapshots (
exclusion_reason TEXT NOT NULL DEFAULT '', exclusion_reason TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date); CREATE INDEX IF NOT EXISTS idx_account_snapshot_basis_date ON account_snapshot(basis_date);
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id); CREATE INDEX IF NOT EXISTS idx_account_snapshot_source ON account_snapshot(source_type, source_id);
CREATE TABLE IF NOT EXISTS asset_snapshot_runs (
id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL,
close_status TEXT NOT NULL,
selection_policy TEXT NOT NULL DEFAULT 'exact',
idempotency_key TEXT NOT NULL DEFAULT '',
approved_by TEXT NOT NULL DEFAULT '',
approved_at TEXT NOT NULL DEFAULT '',
approval_reason TEXT NOT NULL DEFAULT '',
approval_reference TEXT NOT NULL DEFAULT '',
correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '',
applied_evidence TEXT NOT NULL DEFAULT '[]',
excluded_evidence TEXT NOT NULL DEFAULT '[]',
carried_evidence TEXT NOT NULL DEFAULT '[]',
request_fingerprint TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
settlement_month TEXT,
currency TEXT,
income_total INTEGER,
expense_total INTEGER,
net_cashflow INTEGER,
total_assets INTEGER,
total_liabilities INTEGER,
net_worth INTEGER,
cashflow_policy_json TEXT,
aggregation_version TEXT,
snapshot_schema_version INTEGER,
source_snapshot_json TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> '';
CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots ( CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
settlement_run_id TEXT NOT NULL REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT, monthly_settlement_id TEXT REFERENCES monthly_settlement(id) ON DELETE RESTRICT,
settlement_run_id TEXT REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT,
cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')), cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')),
category TEXT NOT NULL DEFAULT '', category TEXT NOT NULL DEFAULT '',
subcategory TEXT NOT NULL DEFAULT '', subcategory TEXT NOT NULL DEFAULT '',
amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'), amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'),
transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0), transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0),
currency TEXT NOT NULL, currency TEXT NOT NULL,
UNIQUE (settlement_run_id, cashflow_kind, category, subcategory, currency) CHECK ((monthly_settlement_id IS NOT NULL) <> (settlement_run_id IS NOT NULL)),
UNIQUE (settlement_run_id, cashflow_kind, category, subcategory, currency),
UNIQUE (monthly_settlement_id, cashflow_kind, category, subcategory, currency)
); );
CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_run ON settlement_cashflow_snapshots(settlement_run_id); CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_month ON settlement_cashflow_snapshots(monthly_settlement_id);
CREATE TABLE IF NOT EXISTS review_decisions ( CREATE TABLE IF NOT EXISTS review_decisions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -257,6 +209,117 @@ CREATE TABLE IF NOT EXISTS reconciliation_sessions (
CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date); CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date);
CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at); CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at);
CREATE TABLE IF NOT EXISTS monthly_settlement (
id TEXT PRIMARY KEY,
month TEXT NOT NULL UNIQUE CHECK (
length(month) = 7 AND substr(month, 5, 1) = '-' AND
CAST(substr(month, 1, 4) AS INTEGER) BETWEEN 1 AND 9999 AND
CAST(substr(month, 6, 2) AS INTEGER) BETWEEN 1 AND 12
),
basis_date TEXT NOT NULL CHECK (date(basis_date) = basis_date),
status TEXT NOT NULL CHECK (status IN ('draft', 'in_review', 'approved', 'closed', 'cancelled')),
version INTEGER NOT NULL CHECK (typeof(version) = 'integer' AND version > 0),
memo TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_by TEXT NOT NULL,
updated_at TEXT NOT NULL,
reviewed_by TEXT,
reviewed_at TEXT,
review_fingerprint TEXT,
approved_by TEXT,
approved_at TEXT,
approved_version INTEGER CHECK (approved_version IS NULL OR (typeof(approved_version) = 'integer' AND approved_version > 0)),
approved_fingerprint TEXT,
approved_cashflow_fingerprint TEXT,
approval_reason TEXT,
approval_flags_json TEXT NOT NULL DEFAULT '{}',
closed_by TEXT,
closed_at TEXT,
cancelled_by TEXT,
cancelled_at TEXT,
cancellation_reason TEXT,
CHECK (status NOT IN ('approved', 'closed') OR (
approved_by IS NOT NULL AND approved_at IS NOT NULL AND
approved_version IS NOT NULL AND approved_fingerprint IS NOT NULL
)),
CHECK (status <> 'closed' OR (closed_by IS NOT NULL AND closed_at IS NOT NULL)),
CHECK (status <> 'cancelled' OR (
cancelled_by IS NOT NULL AND cancelled_at IS NOT NULL AND cancellation_reason IS NOT NULL
))
);
CREATE INDEX IF NOT EXISTS idx_month_settlement_status_month ON monthly_settlement(status, month);
CREATE TABLE IF NOT EXISTS manual_assets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_amount INTEGER,
valuation_date TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT 'KRW',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS asset_snapshots (
id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL,
source_type TEXT NOT NULL,
source_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
amount INTEGER,
currency TEXT NOT NULL DEFAULT 'KRW',
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_source TEXT NOT NULL DEFAULT '',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
snapshot_run_id TEXT NOT NULL DEFAULT '',
correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '',
is_correction INTEGER NOT NULL DEFAULT 0,
closed_at TEXT NOT NULL DEFAULT '',
valuation_id TEXT NOT NULL DEFAULT '',
selection_policy TEXT NOT NULL DEFAULT 'exact',
verification_status TEXT NOT NULL DEFAULT 'unverified',
evidence_kind TEXT NOT NULL DEFAULT 'applied',
exclusion_reason TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS asset_snapshot_runs (
id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL,
close_status TEXT NOT NULL,
selection_policy TEXT NOT NULL DEFAULT 'exact',
idempotency_key TEXT NOT NULL DEFAULT '',
approved_by TEXT NOT NULL DEFAULT '',
approved_at TEXT NOT NULL DEFAULT '',
approval_reason TEXT NOT NULL DEFAULT '',
approval_reference TEXT NOT NULL DEFAULT '',
correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '',
applied_evidence TEXT NOT NULL DEFAULT '[]',
excluded_evidence TEXT NOT NULL DEFAULT '[]',
carried_evidence TEXT NOT NULL DEFAULT '[]',
request_fingerprint TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
settlement_month TEXT,
currency TEXT,
income_total INTEGER,
expense_total INTEGER,
net_cashflow INTEGER,
total_assets INTEGER,
total_liabilities INTEGER,
net_worth INTEGER,
cashflow_policy_json TEXT,
aggregation_version TEXT,
snapshot_schema_version INTEGER,
source_snapshot_json TEXT
);
CREATE TABLE IF NOT EXISTS account_valuations ( CREATE TABLE IF NOT EXISTS account_valuations (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id), account_id TEXT NOT NULL REFERENCES accounts(id),
@@ -286,12 +349,8 @@ CREATE TABLE IF NOT EXISTS account_valuations (
fee INTEGER, fee INTEGER,
representative_liability_definition TEXT representative_liability_definition TEXT
); );
CREATE INDEX IF NOT EXISTS idx_account_valuations_account_date ON account_valuations(account_id, basis_date, recorded_at);
CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuations(verification_status, basis_date);
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL;
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '14'); INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '15');
""" """
ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = { ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = {
+2 -3
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jarca-ledger-mcp" name = "jarca-ledger-mcp"
version = "0.1.7" version = "0.1.8"
description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis" description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -44,5 +44,4 @@ line-length = 240
select = ["E9", "F"] select = ["E9", "F"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["jarca_ledger_mcp*"] include = ["jarca_ledger_mcp*", "dashboard*"]
exclude = ["dashboard*"]
+289
View File
@@ -0,0 +1,289 @@
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'bank',
institution TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT 'KRW',
active INTEGER NOT NULL DEFAULT 1,
memo TEXT NOT NULL DEFAULT '',
account_number_masked TEXT NOT NULL DEFAULT '',
holder_name TEXT NOT NULL DEFAULT '',
last_balance INTEGER,
last_balance_basis_date TEXT NOT NULL DEFAULT '',
last_synced_at TEXT NOT NULL DEFAULT '',
statement_status TEXT NOT NULL DEFAULT 'unknown',
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
transaction_date TEXT NOT NULL,
posted_date TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL,
amount INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'KRW',
category TEXT NOT NULL DEFAULT '',
subcategory TEXT NOT NULL DEFAULT '',
merchant TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
account_id TEXT NOT NULL DEFAULT '',
payment_method TEXT NOT NULL DEFAULT '',
person TEXT NOT NULL DEFAULT '',
project TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
is_fixed INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'manual',
import_batch_id TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
dedupe_key TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
transaction_datetime TEXT NOT NULL DEFAULT '',
counterparty TEXT NOT NULL DEFAULT '',
balance_after INTEGER,
branch TEXT NOT NULL DEFAULT '',
raw_summary TEXT NOT NULL DEFAULT '',
raw_payload TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transaction_date);
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category, subcategory);
CREATE INDEX IF NOT EXISTS idx_transactions_person ON transactions(person);
CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_dedupe ON transactions(dedupe_key) WHERE dedupe_key <> '';
CREATE TABLE IF NOT EXISTS budgets (
id TEXT PRIMARY KEY,
month TEXT NOT NULL,
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
person TEXT NOT NULL DEFAULT '',
amount INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'KRW',
memo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_budgets_month ON budgets(month);
CREATE UNIQUE INDEX IF NOT EXISTS idx_budgets_unique ON budgets(month, category, subcategory, person);
CREATE TABLE IF NOT EXISTS import_batches (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
filename TEXT NOT NULL DEFAULT '',
imported_at TEXT NOT NULL,
row_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
memo TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS category_rules (
id TEXT PRIMARY KEY,
priority INTEGER NOT NULL DEFAULT 100,
match_field TEXT NOT NULL,
match_type TEXT NOT NULL DEFAULT 'contains',
pattern TEXT NOT NULL,
amount_min INTEGER,
amount_max INTEGER,
day_of_month INTEGER,
transaction_type TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
person TEXT NOT NULL DEFAULT '',
is_fixed INTEGER,
confidence REAL NOT NULL DEFAULT 1.0,
active INTEGER NOT NULL DEFAULT 1,
memo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority);
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person);
CREATE TABLE IF NOT EXISTS ledger_categories (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
cashflow_kind TEXT NOT NULL DEFAULT 'expense',
active INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 100,
memo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory);
CREATE TABLE IF NOT EXISTS manual_assets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_amount INTEGER,
valuation_date TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT 'KRW',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name);
CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date);
CREATE TABLE IF NOT EXISTS asset_snapshots (
id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL,
source_type TEXT NOT NULL,
source_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
amount INTEGER,
currency TEXT NOT NULL DEFAULT 'KRW',
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_source TEXT NOT NULL DEFAULT '',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
snapshot_run_id TEXT NOT NULL DEFAULT '',
correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '',
is_correction INTEGER NOT NULL DEFAULT 0,
closed_at TEXT NOT NULL DEFAULT '',
valuation_id TEXT NOT NULL DEFAULT '',
selection_policy TEXT NOT NULL DEFAULT 'exact',
verification_status TEXT NOT NULL DEFAULT 'unverified',
evidence_kind TEXT NOT NULL DEFAULT 'applied',
exclusion_reason TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date);
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id);
CREATE TABLE IF NOT EXISTS asset_snapshot_runs (
id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL,
close_status TEXT NOT NULL,
selection_policy TEXT NOT NULL DEFAULT 'exact',
idempotency_key TEXT NOT NULL DEFAULT '',
approved_by TEXT NOT NULL DEFAULT '',
approved_at TEXT NOT NULL DEFAULT '',
approval_reason TEXT NOT NULL DEFAULT '',
approval_reference TEXT NOT NULL DEFAULT '',
correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '',
applied_evidence TEXT NOT NULL DEFAULT '[]',
excluded_evidence TEXT NOT NULL DEFAULT '[]',
carried_evidence TEXT NOT NULL DEFAULT '[]',
request_fingerprint TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
settlement_month TEXT,
currency TEXT,
income_total INTEGER,
expense_total INTEGER,
net_cashflow INTEGER,
total_assets INTEGER,
total_liabilities INTEGER,
net_worth INTEGER,
cashflow_policy_json TEXT,
aggregation_version TEXT,
snapshot_schema_version INTEGER,
source_snapshot_json TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> '';
CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots (
id TEXT PRIMARY KEY,
settlement_run_id TEXT NOT NULL REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT,
cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')),
category TEXT NOT NULL DEFAULT '',
subcategory TEXT NOT NULL DEFAULT '',
amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'),
transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0),
currency TEXT NOT NULL,
UNIQUE (settlement_run_id, cashflow_kind, category, subcategory, currency)
);
CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_run ON settlement_cashflow_snapshots(settlement_run_id);
CREATE TABLE IF NOT EXISTS review_decisions (
id TEXT PRIMARY KEY,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
decision_type TEXT NOT NULL DEFAULT 'unknown',
reason_code TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
operator TEXT NOT NULL DEFAULT 'operator',
decided_at TEXT NOT NULL DEFAULT '',
review_after TEXT NOT NULL DEFAULT '',
target_payload TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_review_decisions_target ON review_decisions(target_type, target_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_review_decisions_status ON review_decisions(status, review_after, updated_at);
CREATE TABLE IF NOT EXISTS reconciliation_sessions (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
basis_date TEXT NOT NULL,
expected_balance INTEGER,
actual_balance INTEGER,
difference INTEGER,
status TEXT NOT NULL DEFAULT 'open',
closed_at TEXT NOT NULL DEFAULT '',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
operator TEXT NOT NULL DEFAULT 'operator',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date);
CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at);
CREATE TABLE IF NOT EXISTS account_valuations (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id),
basis_date TEXT NOT NULL,
amount INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'KRW',
valuation_kind TEXT NOT NULL DEFAULT 'balance',
source_type TEXT NOT NULL DEFAULT 'manual',
source_ref TEXT NOT NULL DEFAULT '',
verification_status TEXT NOT NULL DEFAULT 'unverified',
recorded_at TEXT NOT NULL,
memo TEXT NOT NULL DEFAULT '',
idempotency_key TEXT NOT NULL DEFAULT '',
supersedes_id TEXT REFERENCES account_valuations(id),
correction_reason TEXT NOT NULL DEFAULT '',
as_of_at TEXT,
timezone TEXT,
cost_basis INTEGER,
net_contributed INTEGER,
reported_principal_kind TEXT,
reported_principal_label TEXT,
profit INTEGER,
return_rate REAL,
return_basis TEXT,
principal INTEGER,
interest INTEGER,
fee INTEGER,
representative_liability_definition TEXT
);
CREATE INDEX IF NOT EXISTS idx_account_valuations_account_date ON account_valuations(account_id, basis_date, recorded_at);
CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuations(verification_status, basis_date);
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL;
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '14');
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import sqlite3
import pytest
from streamlit.testing.v1 import AppTest
from jarca_ledger_mcp.application.account_valuation_service import (
register_account_valuation,
update_account_snapshot_category,
)
from jarca_ledger_mcp.application.monthly_settlement_service import create_monthly_settlement
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
def _ledger(tmp_path, *, account_type="bank", bucket="cash"):
path = tmp_path / "category.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as conn:
conn.execute(
"INSERT INTO accounts(id,name,type,currency,liquidity_bucket,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
("acct-1", "계좌", account_type, "KRW", bucket, "2026-10-01", "2026-10-01"),
)
conn.commit()
settlement = create_monthly_settlement("2026-10", db_path=str(path))["settlement_id"]
return path, settlement
def test_registration_uses_account_default_and_rejects_invalid_explicit_override(tmp_path):
path, settlement = _ledger(tmp_path, bucket="deposit")
result = register_account_valuation("acct-1", "2026-10-31", 10, "KRW", monthly_settlement_id=settlement, db_path=str(path))
assert result["success"]
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT liquidity_bucket FROM account_snapshot").fetchone() == ("deposit",)
invalid_dir = tmp_path / "invalid"
invalid_dir.mkdir()
path2, settlement2 = _ledger(invalid_dir, bucket="cash")
result = register_account_valuation("acct-1", "2026-10-31", 10, "KRW", monthly_settlement_id=settlement2, liquidity_bucket="bogus", db_path=str(path2))
assert result["success"] is False and result["error_code"] == "validation_error"
def test_category_edit_only_changes_bucket_and_preserves_everything_else(tmp_path):
path, settlement = _ledger(tmp_path)
result = register_account_valuation("acct-1", "2026-10-31", 123, "KRW", memo="keep", monthly_settlement_id=settlement, db_path=str(path))
snapshot = result["snapshot_id"]
with sqlite3.connect(path) as conn:
before = conn.execute("SELECT * FROM account_snapshot WHERE id=?", (snapshot,)).fetchone()
changed = update_account_snapshot_category(snapshot, settlement, "loan", db_path=str(path))
assert changed["success"] and changed["liquidity_bucket"] == "loan"
with sqlite3.connect(path) as conn:
after = conn.execute("SELECT * FROM account_snapshot WHERE id=?", (snapshot,)).fetchone()
cols = [row[1] for row in conn.execute("PRAGMA table_info(account_snapshot)")]
assert dict(zip(cols, before)) | {"liquidity_bucket": "loan"} == dict(zip(cols, after))
@pytest.mark.parametrize("status", ["draft", "in_review"])
def test_category_edit_allows_draft_and_in_review(tmp_path, status):
path, settlement = _ledger(tmp_path)
snapshot = register_account_valuation("acct-1", "2026-10-31", 1, "KRW", monthly_settlement_id=settlement, db_path=str(path))["snapshot_id"]
with sqlite3.connect(path) as conn:
conn.execute("UPDATE monthly_settlement SET status=? WHERE id=?", (status, settlement))
conn.commit()
assert update_account_snapshot_category(snapshot, settlement, "investment", db_path=str(path))["success"]
@pytest.mark.parametrize("status", ["approved", "closed"])
def test_category_edit_blocks_approved_and_closed(tmp_path, status):
path, settlement = _ledger(tmp_path)
snapshot = register_account_valuation("acct-1", "2026-10-31", 1, "KRW", monthly_settlement_id=settlement, db_path=str(path))["snapshot_id"]
with sqlite3.connect(path) as conn:
conn.execute("UPDATE monthly_settlement SET status=?, approved_by='test', approved_at='2026-10-31', approved_version=1, approved_fingerprint='fp', closed_by=CASE WHEN ?='closed' THEN 'test' ELSE closed_by END, closed_at=CASE WHEN ?='closed' THEN '2026-10-31' ELSE closed_at END WHERE id=?", (status, status, status, settlement))
conn.commit()
result = update_account_snapshot_category(snapshot, settlement, "investment", db_path=str(path))
assert result["success"] is False and result["error_code"] == "invalid_settlement_status"
def test_category_edit_rejects_wrong_monthly(tmp_path):
path, settlement = _ledger(tmp_path)
other = create_monthly_settlement("2026-11", db_path=str(path))["settlement_id"]
snapshot = register_account_valuation("acct-1", "2026-10-31", 1, "KRW", monthly_settlement_id=settlement, db_path=str(path))["snapshot_id"]
result = update_account_snapshot_category(snapshot, other, "investment", db_path=str(path))
assert result["success"] is False and result["error_code"] == "validation_error"
def test_app_category_select_saves_clicked_snapshot_and_monthly(monkeypatch):
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
calls = []
monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *a, **k: {"success": True, "month": "2026-10", "selected_month": "2026-10", "basis_date": "2026-10-31", "reports": {"cashflow": {}, "budget": {}, "assets": {}}})
monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_record", lambda *a, **k: {"success": True, "settlement": {"id": "ms-1", "month": "2026-10", "status": "draft"}})
monkeypatch.setattr(settlement_gateway, "list_account_snapshots_for_settlement", lambda *a, **k: {"success": True, "closed": False, "status": "draft", "count": 1, "registered_count": 1, "unregistered_count": 0, "snapshots": [{"account_id": "a1", "account_name": "대출", "amount": 100, "currency": "KRW", "basis_date": "2026-10-31", "snapshot_id": "snap-1", "display_status": "확인", "account_type": "loan", "account_liquidity_bucket": "loan", "snapshot_account_category": "loan"}]})
monkeypatch.setattr(settlement_gateway, "update_account_snapshot_category", lambda snapshot_id, monthly_id, value, **k: calls.append((snapshot_id, monthly_id, value)) or {"success": True})
app = AppTest.from_string("import streamlit as st\nst.query_params.update(page='monthly_settlement', id='ms-1')\nfrom dashboard.page_views.monthly_settlement import page_monthly_settlement\npage_monthly_settlement(db_path='unused.sqlite3')\n").run()
dialog = [b for b in app.button if b.label == "✎"][0].click().run()
assert dialog.selectbox[0].value == "loan"
save = [button for button in dialog.button if button.label == "저장"][0]
dialog.selectbox[0].set_value("deposit")
save.click().run()
assert save.label == "저장"
+250
View File
@@ -0,0 +1,250 @@
from __future__ import annotations
import sqlite3
from streamlit.testing.v1 import AppTest
from jarca_ledger_mcp.application.monthly_settlement_service import (
create_monthly_settlement,
list_account_snapshots_for_settlement,
)
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
from dashboard.page_views.monthly_settlement import _account_evaluation_totals, _format_evaluation_total
def test_account_evaluation_totals_native_currency_null_and_zero():
result = _account_evaluation_totals([
{"account_id": "a1", "snapshot_id": "s1", "amount": 0, "currency": "KRW"},
{"account_id": "a2", "snapshot_id": "s2", "amount": -2500, "currency": "USD"},
{"account_id": "a3", "snapshot_id": "s3", "amount": None, "currency": "KRW"},
{"account_id": "a4", "snapshot_id": None, "amount": 999999, "currency": "KRW"},
])
assert result["totals"] == {"KRW": 0, "USD": -2500}
assert result["unknown_count"] == 1
assert result["registered_count"] == 3
assert result["unregistered_count"] == 1
assert _format_evaluation_total("KRW", 0) == "0원"
assert _format_evaluation_total("USD", -2500) == "-2,500 USD"
def test_account_evaluation_totals_missing_currency_is_separate():
result = _account_evaluation_totals([
{"account_id": "a1", "snapshot_id": "s1", "amount": 100, "currency": None},
{"account_id": "a2", "snapshot_id": None, "amount": None, "currency": "KRW"},
])
assert result["totals"] == {"미지정": 100}
def _account(conn, ident, name, currency="KRW"):
conn.execute(
"INSERT INTO accounts(id,name,currency,created_at,updated_at) VALUES(?,?,?,?,?)",
(ident, name, currency, "2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z"),
)
def _snapshot(conn, ident, settlement, account, name, amount, currency="KRW"):
conn.execute(
"""INSERT INTO account_snapshot
(id,basis_date,source_type,source_id,name,amount,currency,monthly_settlement_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?)""",
(ident, "2026-10-31", "account", account, name, amount, currency, settlement, "2026-10-31T00:00:00Z"),
)
def test_draft_uses_all_accounts_and_exact_monthly_snapshot(tmp_path):
path = tmp_path / "ledger.sqlite3"
init_db(str(path))
settlement = create_monthly_settlement("2026-10", db_path=str(path))["settlement_id"]
with sqlite3.connect(path) as conn:
_account(conn, "a1", "계좌1")
_account(conn, "a2", "계좌2")
_snapshot(conn, "s1", settlement, "a1", "계좌1", 0)
_snapshot(conn, "other", "other-month", "a2", "계좌2", 99)
conn.commit()
result = list_account_snapshots_for_settlement(settlement, db_path=str(path))
assert [(x["source_id"], x["amount"], x["display_status"]) for x in result["snapshots"]] == [("a1", 0, "확인"), ("a2", None, "미등록")]
assert result["count"] == 2 and result["registered_count"] == 1 and result["unregistered_count"] == 1
def test_closed_uses_historical_rows_only_and_preserves_null_zero_and_duplicate(tmp_path):
path = tmp_path / "ledger.sqlite3"
init_db(str(path))
settlement = create_monthly_settlement("2026-10", db_path=str(path))["settlement_id"]
with sqlite3.connect(path) as conn:
_account(conn, "old", "과거계좌", "USD")
_snapshot(conn, "s-null", settlement, "old", "보존이름", None, "USD")
_snapshot(conn, "s-zero", settlement, "old", "보존이름", 0, "USD")
conn.execute("UPDATE monthly_settlement SET status='closed', approved_by='test', approved_at='2026-10-31T00:00:00Z', approved_version=1, approved_fingerprint='fp', closed_by='test', closed_at='2026-10-31T00:00:00Z' WHERE id=?", (settlement,))
_account(conn, "new", "신규계좌")
conn.commit()
result = list_account_snapshots_for_settlement(settlement, db_path=str(path))
assert result["closed"] is True
assert [x["amount"] for x in result["snapshots"]] == [None, 0]
assert [x["display_status"] for x in result["snapshots"]] == ["미확인", "확인"]
assert all(x["name"] == "보존이름" for x in result["snapshots"])
assert all(x["source_id"] != "new" for x in result["snapshots"])
def test_monthly_renderer_opens_real_registration_dialog_from_actual_button(monkeypatch):
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
monkeypatch.setattr(
overview_gateway,
"get_monthly_settlement_summary",
lambda *args, **kwargs: {
"success": True,
"month": "2026-10",
"selected_month": "2026-10",
"basis_date": "2026-10-31",
"actual_income": 0,
"actual_expense": 0,
"net_cashflow": 0,
"reports": {"cashflow": {}, "budget": {}, "assets": {}},
},
)
monkeypatch.setattr(
settlement_gateway,
"get_monthly_settlement_record",
lambda *args, **kwargs: {"success": True, "settlement": {"id": "ms-1", "month": "2026-10", "status": "draft"}},
)
monkeypatch.setattr(
settlement_gateway,
"list_account_snapshots_for_settlement",
lambda *args, **kwargs: {
"success": True,
"closed": False,
"count": 3,
"registered_count": 2,
"unregistered_count": 1,
"snapshots": [
{"account_id": "acct-1", "account_name": "주계좌", "amount": 1630800, "currency": "KRW", "basis_date": "2026-10-31", "display_status": "확인", "snapshot_id": "snap-1", "account_type": "bank", "account_liquidity_bucket": "bank"},
{"account_id": "acct-2", "account_name": "임시 주택담보대출", "amount": 1562300, "currency": "KRW", "basis_date": "2026-10-31", "display_status": "확인", "snapshot_id": "snap-2", "account_type": "loan", "account_liquidity_bucket": "loan"},
{"account_id": "acct-3", "account_name": "보조계좌", "amount": None, "currency": "USD", "basis_date": None, "display_status": "미등록", "snapshot_id": None},
],
},
)
app = AppTest.from_string(
"import streamlit as st\n"
"st.query_params.update(page='monthly_settlement', id='ms-1')\n"
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
"page_monthly_settlement(db_path='synthetic-unused.sqlite3')\n",
default_timeout=30,
).run()
assert not app.exception
assert any(metric.label == "자산 합계 (KRW)" and metric.value == "1,630,800원" for metric in app.metric)
assert any(metric.label == "부채 합계 (KRW)" and metric.value == "1,562,300원" for metric in app.metric)
assert any(metric.label == "순자산 (KRW)" and metric.value == "68,500원" for metric in app.metric)
metric_columns = [column for column in app.columns if column.metric]
assert len(metric_columns) == 3
assert [column.metric[0].label for column in metric_columns] == [
"자산 합계 (KRW)", "부채 합계 (KRW)", "순자산 (KRW)",
]
assert any("평가등록 2 / 전체 3" in caption.value and "미등록 1" in caption.value for caption in app.caption)
assert not [expander for expander in app.expander if expander.label == "필요 메모/근거"]
account_buttons = [button for button in app.button if button.label == "보조계좌"]
assert len(account_buttons) == 1
assert not [button for button in app.button if button.label == "평가 등록"]
dialog = account_buttons[0].click().run()
assert not dialog.exception
inputs = {widget.label: widget for widget in dialog.text_input}
assert inputs["계정"].value == "보조계좌"
assert inputs["계정"].disabled is True
assert inputs["통화"].value == "USD"
assert dialog.date_input[0].value.isoformat() == "2026-10-31"
assert "평가금액 (정수)" in inputs
assert dialog.text_area[0].label == "메모 (선택)"
def test_monthly_renderer_preserves_ledger_projection_and_source_choices(monkeypatch):
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
monkeypatch.setattr(
overview_gateway,
"get_monthly_settlement_summary",
lambda *args, **kwargs: {
"success": True, "month": "2026-10", "selected_month": "2026-10",
"basis_date": "2026-10-31", "reports": {"cashflow": {}, "budget": {}, "assets": {}},
},
)
monkeypatch.setattr(
settlement_gateway,
"get_monthly_settlement_record",
lambda *args, **kwargs: {"success": True, "settlement": {"id": "ms-10", "month": "2026-10", "status": "draft"}},
)
monkeypatch.setattr(
settlement_gateway,
"list_account_snapshots_for_settlement",
lambda *args, **kwargs: {
"success": True, "closed": False, "count": 1, "registered_count": 0,
"unregistered_count": 1,
"snapshots": [{
"account_id": "acct-10", "account_name": "실제계정10", "amount": None,
"currency": "KRW", "basis_date": None, "display_status": "미등록",
"snapshot_id": None,
"previous_ledger": {"id": "prev-10", "transaction_date": "2026-10-30", "balance_after": 1234, "currency": "KRW"},
"next_ledger": {"id": "next-10", "transaction_date": "2026-11-01", "balance_after": 2345, "currency": "KRW"},
}],
},
)
app = AppTest.from_string(
"import streamlit as st\n"
"st.query_params.update(page='monthly_settlement', id='ms-10')\n"
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
"page_monthly_settlement(db_path='synthetic-unused.sqlite3')\n",
default_timeout=30,
).run()
assert not app.exception
dialog = [button for button in app.button if button.label == "실제계정10"][0].click().run()
assert not dialog.exception
source = dialog.selectbox[0]
assert len(source.options) == 3
assert source.value.startswith("이전 기록")
inputs = {widget.label: widget for widget in dialog.text_input}
assert inputs["평가금액 (정수)"].value == "1234"
assert inputs["평가금액 (정수)"].disabled is True
assert any(option.startswith("이후 기록") for option in source.options)
assert inputs["평가금액 (정수)"].disabled is True
def test_monthly_renderer_mixed_rows_keeps_snapshot_id_for_unlink(monkeypatch):
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *args, **kwargs: {
"success": True, "month": "2026-10", "selected_month": "2026-10",
"basis_date": "2026-10-31", "reports": {"cashflow": {}, "budget": {}, "assets": {}},
})
monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_record", lambda *args, **kwargs: {
"success": True, "settlement": {"id": "ms-mixed", "month": "2026-10", "status": "draft"},
})
monkeypatch.setattr(settlement_gateway, "list_account_snapshots_for_settlement", lambda *args, **kwargs: {
"success": True, "closed": False, "status": "draft", "count": 2,
"registered_count": 1, "unregistered_count": 1,
"snapshots": [
{"account_id": "acct-1", "account_name": "등록계정", "amount": 100,
"currency": "KRW", "basis_date": "2026-10-31", "display_status": "확인",
"snapshot_id": "snap-1"},
{"account_id": "acct-2", "account_name": "미등록계정", "amount": None,
"currency": "USD", "basis_date": None, "display_status": "미등록",
"snapshot_id": None},
],
})
app = AppTest.from_string(
"import streamlit as st\n"
"st.query_params.update(page='monthly_settlement', id='ms-mixed')\n"
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
"page_monthly_settlement(db_path='synthetic-unused.sqlite3')\n",
default_timeout=30,
).run()
assert not app.exception
unlink = [button for button in app.button if button.label == "×"]
assert len(unlink) == 1
assert unlink[0].help == "연결 해제"
dialog = unlink[0].click().run()
assert not dialog.exception
assert any("등록계정" in markdown.value for markdown in dialog.markdown)
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
import sqlite3
import pytest
from jarca_ledger_mcp.application.monthly_settlement_service import (
create_monthly_settlement,
link_account_snapshot,
list_account_snapshots_for_settlement,
)
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
def _account(conn, ident="acct-1", name="실제계정", currency="KRW"):
conn.execute(
"INSERT INTO accounts(id,name,currency,created_at,updated_at) VALUES(?,?,?,?,?)",
(ident, name, currency, "2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z"),
)
def _snapshot(conn, ident, *, account="acct-1", basis_date="2026-10-31", currency="KRW", settlement=None, amount=1234):
conn.execute(
"""INSERT INTO account_snapshot
(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,
monthly_settlement_id,valuation_id,verification_status,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
(ident, basis_date, "account", account, "원본 스냅샷명", amount, currency, "cash", settlement,
"valuation-1", "unverified", "2026-10-31T00:00:00Z"),
)
def _ledger(tmp_path):
path = tmp_path / "link.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as conn:
draft = create_monthly_settlement("2026-10", db_path=str(path))["settlement_id"]
conn.execute("UPDATE monthly_settlement SET basis_date='2026-10-31' WHERE id=?", (draft,))
_account(conn)
conn.commit()
return path, draft
def test_link_updates_only_monthly_fk_and_preserves_snapshot_fields(tmp_path):
path, settlement = _ledger(tmp_path)
with sqlite3.connect(path) as conn:
_snapshot(conn, "candidate")
before = conn.execute("SELECT * FROM account_snapshot WHERE id='candidate'").fetchone()
columns = [row[1] for row in conn.execute("PRAGMA table_info(account_snapshot)")]
result = link_account_snapshot("candidate", settlement, db_path=str(path))
assert result["success"] is True
with sqlite3.connect(path) as conn:
after = conn.execute("SELECT * FROM account_snapshot WHERE id='candidate'").fetchone()
assert len(before) == len(after) == len(columns)
changed = {column: (old, new) for column, old, new in zip(columns, before, after) if old != new}
assert changed == {"monthly_settlement_id": (None, settlement)}
def test_actual_read_function_returns_unlinked_candidates_in_exact_order(tmp_path):
path, settlement = _ledger(tmp_path)
with sqlite3.connect(path) as conn:
_snapshot(conn, "old", basis_date="2026-09-30", amount=1)
_snapshot(conn, "exact-old-created", basis_date="2026-10-31", amount=2)
_snapshot(conn, "exact-new-created", basis_date="2026-10-31", amount=3)
conn.execute("UPDATE account_snapshot SET created_at='2026-10-31T02:00:00Z' WHERE id='exact-old-created'")
conn.execute("UPDATE account_snapshot SET created_at='2026-10-31T03:00:00Z' WHERE id='exact-new-created'")
conn.commit()
result = list_account_snapshots_for_settlement(settlement, db_path=str(path))
row = result["snapshots"][0]
assert [item["snapshot_id"] for item in row["unlinked_candidates"]] == ["exact-new-created", "exact-old-created", "old"]
@pytest.mark.parametrize("status", ["approved", "closed"])
def test_link_rejects_approved_and_closed(tmp_path, status):
path, settlement = _ledger(tmp_path)
with sqlite3.connect(path) as conn:
_snapshot(conn, "candidate")
conn.execute("""UPDATE monthly_settlement SET status=?, approved_by='test',
approved_at='2026-10-31T00:00:00Z', approved_version=1,
approved_fingerprint='test', closed_by='test', closed_at='2026-10-31T00:00:00Z' WHERE id=?""", (status, settlement))
conn.commit()
result = link_account_snapshot("candidate", settlement, db_path=str(path))
assert result["success"] is False
assert result["error_code"] == "invalid_settlement_status"
def test_link_rejects_unrelated_month_and_already_linked_snapshot(tmp_path):
path, settlement = _ledger(tmp_path)
with sqlite3.connect(path) as conn:
other = create_monthly_settlement("2026-11", db_path=str(path))["settlement_id"]
_snapshot(conn, "linked-other", settlement=other)
_snapshot(conn, "linked-this", settlement=settlement)
conn.commit()
assert link_account_snapshot("linked-other", settlement, db_path=str(path))["error_code"] == "already_linked"
assert link_account_snapshot("linked-this", settlement, db_path=str(path))["error_code"] == "already_linked"
def test_basis_date_mismatch_requires_explicit_true(tmp_path):
path, settlement = _ledger(tmp_path)
with sqlite3.connect(path) as conn:
_snapshot(conn, "mismatch", basis_date="2026-09-30")
conn.commit()
rejected = link_account_snapshot("mismatch", settlement, db_path=str(path))
assert rejected["error_code"] == "basis_date_mismatch"
accepted = link_account_snapshot("mismatch", settlement, allow_basis_date_mismatch=True, db_path=str(path))
assert accepted["success"] is True
def test_link_rejects_account_currency_mismatch(tmp_path):
path, settlement = _ledger(tmp_path)
with sqlite3.connect(path) as conn:
_snapshot(conn, "bad-currency", currency="USD")
conn.commit()
result = link_account_snapshot("bad-currency", settlement, db_path=str(path))
assert result["error_code"] == "validation_error"
@@ -0,0 +1,47 @@
from __future__ import annotations
from streamlit.testing.v1 import AppTest
def test_registration_dialog_exposes_real_account_snapshot_link_mode_and_fallback(monkeypatch):
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *args, **kwargs: {
"success": True, "month": "2026-10", "selected_month": "2026-10", "basis_date": "2026-10-31",
"reports": {"cashflow": {}, "budget": {}, "assets": {}},
})
monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_record", lambda *args, **kwargs: {
"success": True, "settlement": {"id": "ms-link-ui", "month": "2026-10", "basis_date": "2026-10-31", "status": "draft"},
})
monkeypatch.setattr(settlement_gateway, "list_account_snapshots_for_settlement", lambda *args, **kwargs: {
"success": True, "closed": False, "status": "draft", "count": 1, "registered_count": 0,
"unregistered_count": 1,
"snapshots": [{
"account_id": "acct-ui", "account_name": "실제 원본계정", "amount": None, "currency": "KRW",
"basis_date": None, "display_status": "미등록", "snapshot_id": None,
"unlinked_candidates": [
{"snapshot_id": "snap-exact", "basis_date": "2026-10-31", "amount": 777, "currency": "KRW", "verification_status": "unverified"},
],
"previous_ledger": {"id": "prev", "transaction_date": "2026-10-30", "balance_after": 1234, "currency": "KRW"},
"next_ledger": {"id": "next", "transaction_date": "2026-11-01", "balance_after": 2345, "currency": "KRW"},
}],
})
app = AppTest.from_string(
"import streamlit as st\n"
"st.query_params.update(page='monthly_settlement', id='ms-link-ui')\n"
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
"page_monthly_settlement(db_path='synthetic-unused.sqlite3')\n",
default_timeout=30,
).run()
assert not app.exception
dialog = [button for button in app.button if button.label == "실제 원본계정"][0].click().run()
assert not dialog.exception
assert {widget.label: widget for widget in dialog.text_input}["계정"].value == "실제 원본계정"
assert dialog.radio[0].value == "기존 스냅샷 연결"
assert dialog.selectbox[0].label == "기존 스냅샷"
assert "777" in dialog.selectbox[0].options[0]
assert {widget.label: widget for widget in dialog.text_input}["선택 금액"].disabled is True
fallback = dialog.radio[0].set_value("새 평가 등록").run()
assert not fallback.exception
@@ -0,0 +1,145 @@
from __future__ import annotations
import sqlite3
import pytest
from jarca_ledger_mcp.application.account_valuation_service import register_account_valuation
from jarca_ledger_mcp.application.monthly_settlement_service import create_monthly_settlement
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
@pytest.fixture
def ledger(tmp_path):
path = tmp_path / "actual-service.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as conn:
conn.execute(
"INSERT INTO accounts(id,name,currency,created_at,updated_at) VALUES(?,?,?,?,?)",
("acct-1", "주계좌", "KRW", "2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z"),
)
conn.commit()
settlement = create_monthly_settlement("2026-10", db_path=str(path))
return path, settlement["settlement_id"]
@pytest.mark.parametrize(
("amount", "expected_amount"),
[(0, 0), (123456, 123456)],
)
def test_actual_service_saves_zero_and_amount_with_fk_and_unverified(ledger, amount, expected_amount):
path, settlement_id = ledger
result = register_account_valuation(
"acct-1", "2026-10-31", amount, "KRW", monthly_settlement_id=settlement_id, db_path=str(path)
)
assert result["success"] is True
with sqlite3.connect(path) as conn:
row = conn.execute(
"SELECT amount,currency,basis_date,monthly_settlement_id,verification_status,source_type,source_id FROM account_snapshot"
).fetchone()
assert row == (expected_amount, "KRW", "2026-10-31", settlement_id, "unverified", "account", "acct-1")
@pytest.mark.parametrize("existing_amount", [None, 0])
def test_actual_service_rejects_duplicate_even_when_existing_amount_is_null_or_zero(ledger, existing_amount):
path, settlement_id = ledger
with sqlite3.connect(path) as conn:
conn.execute(
"""INSERT INTO account_snapshot
(id,basis_date,source_type,source_id,name,amount,currency,monthly_settlement_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?)""",
("existing", "2026-10-31", "account", "acct-1", "주계좌", existing_amount, "KRW", settlement_id, "2026-10-31T00:00:00Z"),
)
conn.commit()
result = register_account_valuation(
account_id="acct-1", basis_date="2026-10-31", amount=99, currency="KRW",
monthly_settlement_id=settlement_id, db_path=str(path),
)
assert result == {
"success": False,
"error_code": "duplicate",
"error": "account evaluation already registered",
"snapshot_id": "existing",
}
@pytest.mark.parametrize("status", ["closed", "approved"])
def test_actual_service_blocks_closed_and_approved_settlements(ledger, status):
path, settlement_id = ledger
with sqlite3.connect(path) as conn:
conn.execute(
"""UPDATE monthly_settlement
SET status=?, approved_by='test', approved_at='2026-10-31T00:00:00Z',
approved_version=1, approved_fingerprint='fp',
closed_by=CASE WHEN ?='closed' THEN 'test' ELSE closed_by END,
closed_at=CASE WHEN ?='closed' THEN '2026-10-31T00:00:00Z' ELSE closed_at END
WHERE id=?""",
(status, status, status, settlement_id),
)
conn.commit()
result = register_account_valuation(
"acct-1", "2026-10-31", 10, "KRW", monthly_settlement_id=settlement_id, db_path=str(path)
)
assert result["success"] is False
assert result["error_code"] in {"closed_settlement", "invalid_settlement_status"}
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
@pytest.mark.parametrize(
("kwargs", "error_code"),
[
({"currency": "USD"}, "validation_error"),
({"basis_date": "2026-09-30"}, "validation_error"),
({"monthly_settlement_id": "missing"}, "not_found"),
],
)
def test_actual_service_validates_currency_date_and_settlement_and_rolls_back(ledger, kwargs, error_code):
path, settlement_id = ledger
args = {
"account_id": "acct-1", "basis_date": "2026-10-31", "amount": 10, "currency": "KRW",
"monthly_settlement_id": settlement_id, "db_path": str(path),
}
args.update(kwargs)
result = register_account_valuation(**args)
assert result["success"] is False
assert result["error_code"] == error_code
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM monthly_settlement").fetchone()[0] == 1
def test_mcp_preserves_legacy_full_argument_contract_and_monthly_dispatch(ledger, monkeypatch):
from jarca_ledger_mcp.adapters.mcp import server
path, settlement_id = ledger
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(path))
legacy = server.register_account_valuation(
"acct-1", "2026-10-31", 150, "KRW", "balance", "manual", "statement-1",
"verified", "legacy memo", "legacy-key", "2026-10-31T10:00:00+09:00",
"Asia/Seoul", 100, 100, "cost_basis", "Initial cost", 50, 0.5,
"cost_basis", None, None, None, None,
)
assert legacy["success"], legacy
assert legacy["valuation"]["cost_basis"] == 100
assert legacy["valuation"]["source_ref"] == "statement-1"
monthly = server.register_account_valuation(
"acct-1", "2026-10-31", 150, monthly_settlement_id=settlement_id,
liquidity_bucket="cash",
)
assert monthly["success"], monthly
assert monthly["verification_status"] == "unverified"
valuations = server.list_account_valuations("acct-1")["valuations"]
assert {item["id"] for item in valuations} == {
legacy["valuation"]["id"], monthly["snapshot_id"],
}
assert valuations == sorted(
valuations,
key=lambda item: (item["basis_date"], item["recorded_at"], item["id"]),
)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM account_valuations").fetchone() == (1,)
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone() == (1,)
advertised = server.mcp._tool_manager._tools["register_account_valuation"].parameters
assert {"source_ref", "cost_basis", "monthly_settlement_id"} <= advertised["properties"].keys()
assert "db_path" not in advertised["properties"]
assert {"create_manual_asset", "list_manual_assets", "update_manual_asset", "deactivate_manual_asset"} <= server.mcp._tool_manager._tools.keys()
+57 -1
View File
@@ -17,7 +17,7 @@ def test_account_valuation_schema_is_v12_and_idempotent(tmp_path):
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
cols = {row[1] for row in conn.execute("PRAGMA table_info(account_valuations)")} cols = {row[1] for row in conn.execute("PRAGMA table_info(account_valuations)")}
version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0]
assert version == "14" assert version == "15"
assert {"id", "account_id", "basis_date", "amount", "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "recorded_at", "memo", "idempotency_key", "supersedes_id", "correction_reason", "as_of_at", "timezone", "cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition"} <= cols assert {"id", "account_id", "basis_date", "amount", "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "recorded_at", "memo", "idempotency_key", "supersedes_id", "correction_reason", "as_of_at", "timezone", "cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition"} <= cols
@@ -100,3 +100,59 @@ def test_account_name_only_update_does_not_create_candidate(tmp_path):
updated = service.update_ledger_account(account_id, name="새 이름", db_path=path) updated = service.update_ledger_account(account_id, name="새 이름", db_path=path)
values = service.list_account_valuations(account_id, db_path=path) values = service.list_account_valuations(account_id, db_path=path)
assert updated["success"] and values["count"] == 1 assert updated["success"] and values["count"] == 1
def test_list_account_valuations_includes_legacy_and_monthly_snapshot_rows(tmp_path):
path = str(tmp_path / "coexisting-valuations.sqlite3")
account_id = _account(path)
legacy = service.register_account_valuation(
account_id, "2026-08-31", 900, verification_status="verified",
idempotency_key="legacy-value", db_path=path,
)
settlement = service.create_monthly_settlement("2026-09", db_path=path)
canonical = service.register_account_valuation(
account_id, "2026-09-30", 1000, monthly_settlement_id=settlement["settlement_id"],
verification_status="unverified", db_path=path,
)
listed = service.list_account_valuations(account_id, db_path=path)
assert legacy["success"] and canonical["success"] and listed["success"]
assert listed["count"] == 2
assert [item["id"] for item in listed["valuations"]] == [
legacy["valuation"]["id"], canonical["snapshot_id"],
]
snapshot = listed["valuations"][1]
assert set(listed["valuations"][0]) <= set(snapshot)
assert snapshot["account_id"] == account_id
assert snapshot["monthly_settlement_id"] == settlement["settlement_id"]
assert snapshot["recorded_at"] == snapshot["created_at"]
def test_list_account_valuations_globally_orders_canonical_before_newer_legacy(tmp_path):
path = str(tmp_path / "chronological-valuations.sqlite3")
account_id = _account(path)
settlement = service.create_monthly_settlement("2026-09", db_path=path)
canonical = service.register_account_valuation(
account_id, "2026-09-30", 900,
monthly_settlement_id=settlement["settlement_id"],
source_note="monthly-close", db_path=path,
)
legacy = service.register_account_valuation(
account_id, "2026-10-31", 1000,
verification_status="verified", source_ref="legacy-statement",
idempotency_key="legacy-october", db_path=path,
)
listed = service.list_account_valuations(account_id, db_path=path)
assert listed["success"] and listed["count"] == 2
assert [item["id"] for item in listed["valuations"]] == [
canonical["snapshot_id"], legacy["valuation"]["id"],
]
canonical_row, legacy_row = listed["valuations"]
assert canonical_row["monthly_settlement_id"] == settlement["settlement_id"]
assert canonical_row["source_note"] == "monthly-close"
assert canonical_row["recorded_at"] == canonical_row["created_at"]
assert legacy_row["source_ref"] == "legacy-statement"
assert legacy_row["idempotency_key"] == "legacy-october"
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
import sqlite3
from jarca_ledger_mcp.application.account_valuation_service import (
list_account_valuations,
register_account_valuation,
)
from jarca_ledger_mcp.application.assets_service import (
create_manual_asset,
list_manual_assets,
preview_asset_snapshot_close,
)
from jarca_ledger_mcp.application.settlement_draft_service import create_settlement_draft
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
def _tables(path):
with sqlite3.connect(path) as conn:
return {r[0] for r in conn.execute("select name from sqlite_master where type='table'")}
EXPECTED_TABLES = {
# Sourced from schema.py plus migration-created settlement draft tables.
"schema_meta", "manual_assets", "asset_snapshots", "asset_snapshot_runs", "account_valuations",
"accounts",
"transactions",
"budgets",
"import_batches",
"category_rules",
"ledger_categories",
"account_snapshot",
"settlement_cashflow_snapshots",
"review_decisions",
"reconciliation_sessions",
"monthly_settlement",
"settlement_drafts",
"settlement_draft_items",
}
def test_fresh_init_preserves_account_valuations_and_exact_schema_tables(tmp_path):
path = tmp_path / "fresh.sqlite3"
init_db(str(path))
tables = _tables(path)
assert "account_valuations" in tables
assert tables == EXPECTED_TABLES
def test_init_is_idempotent(tmp_path):
path = tmp_path / "repeat.sqlite3"
assert init_db(str(path)) == str(path)
assert init_db(str(path)) == str(path)
assert _tables(path) == EXPECTED_TABLES
def test_legacy_nonempty_account_valuations_row_is_preserved(tmp_path):
path = tmp_path / "legacy.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as conn:
conn.execute("insert into account_valuations (id,account_id,amount,basis_date,recorded_at) values ('legacy-1','acct-1',123,'2026-09-30','t')")
conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('acct-1','Keep me','bank',1,'t','t')")
conn.commit()
init_db(str(path))
with sqlite3.connect(path) as conn:
assert conn.execute("select count(*) from accounts where id='acct-1'").fetchone()[0] == 1
assert conn.execute("select amount from account_valuations where id='legacy-1'").fetchone() == (123,)
def test_invalid_valuation_operations_do_not_create_db(tmp_path):
path = tmp_path / "must-not-exist.sqlite3"
result = register_account_valuation("acct", "invalid-date", 1, db_path=str(path))
listed = list_account_valuations("acct", db_path=str(path))
assert result["success"] is False and result["error_code"] == "validation_error"
assert listed["success"] is False and listed["error_code"] == "db_not_initialized"
assert not path.exists()
def test_active_account_preview_reports_missing_without_sql_error(tmp_path):
path = tmp_path / "preview.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as conn:
conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('acct-1','Active','bank',1,'t','t')")
conn.commit()
result = preview_asset_snapshot_close("2026-09-30", db_path=str(path))
assert result["success"] is True
assert result["close_status"] in {"incomplete_draft", "complete_exact"}
assert not any(w.get("code") == "db_error" for w in result["warnings"])
def test_active_account_draft_returns_review_blocker_without_sql_error(tmp_path):
path = tmp_path / "draft.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as conn:
conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('acct-1','Active','bank',1,'t','t')")
conn.commit()
result = create_settlement_draft("2026-09", "tester", "draft-key", db_path=str(path))
assert result["success"] is True
assert result.get("review", {}).get("approval_required") is True
assert not any("sql" in str(v).lower() for v in result.get("review", {}).values())
def test_manual_asset_flow_remains_supported(tmp_path):
path = tmp_path / "manual.sqlite3"
init_db(str(path))
created = create_manual_asset("Emergency fund", 100000, "2026-09-30", "cash", db_path=str(path))
listed = list_manual_assets(db_path=str(path))
assert created["success"] and listed["success"]
assert listed["manual_assets"][0]["id"] == created["manual_asset"]["id"]
assert listed["manual_assets"][0]["valuation_amount"] == 100000
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import sqlite3
from jarca_ledger_mcp.application.account_valuation_service import list_account_valuations, register_account_valuation
from jarca_ledger_mcp.application.assets_service import close_asset_snapshot, get_asset_snapshot_report
from jarca_ledger_mcp.application.monthly_settlement_service import list_monthly_settlement_overview
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
def tables(path):
with sqlite3.connect(path) as c:
return {r[0] for r in c.execute("select name from sqlite_master where type='table'")}
def test_fresh_init_retains_asset_snapshot_runs(tmp_path):
path = tmp_path / "fresh.sqlite3"
init_db(str(path))
assert "asset_snapshot_runs" in tables(path)
def test_init_twice_is_idempotent_and_schema15(tmp_path):
path = tmp_path / "repeat.sqlite3"
init_db(str(path)); init_db(str(path))
with sqlite3.connect(path) as c:
assert c.execute("select value from schema_meta where key='schema_version'").fetchone()[0] == "15"
assert "asset_snapshot_runs" in tables(path)
def test_legacy_child_rows_require_deterministic_parent_mapping(tmp_path):
path = tmp_path / "legacy.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as c:
c.execute("insert into asset_snapshot_runs (id,basis_date,close_status,created_at) values ('run-1','2026-09-30','complete_exact','t')")
c.execute("insert into monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) values ('month-1','2026-09','2026-09-30','draft',1,'test','now','test','now')")
c.execute("insert into settlement_cashflow_snapshots (id,monthly_settlement_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) values ('cf-1','month-1','income','','',10,1,'KRW')")
c.commit()
init_db(str(path))
with sqlite3.connect(path) as c:
assert c.execute("select monthly_settlement_id, amount from settlement_cashflow_snapshots").fetchone() == ('month-1', 10)
assert "asset_snapshot_runs" in tables(path)
assert "monthly_settlement_id" in (c.execute("select sql from sqlite_master where name='settlement_cashflow_snapshots'").fetchone()[0] or "")
def test_unsupported_legacy_operations_do_not_touch_missing_db(tmp_path):
path = tmp_path / "missing.sqlite3"
assert register_account_valuation("a", "invalid-date", 1, db_path=str(path))["error_code"] == "validation_error"
assert list_account_valuations("a", db_path=str(path))["error_code"] == "db_not_initialized"
assert not path.exists()
def test_invalid_close_and_read_do_not_create_db(tmp_path):
path = tmp_path / "missing.sqlite3"
assert close_asset_snapshot("invalid-date", db_path=str(path))["error_code"] == "validation_error"
assert get_asset_snapshot_report("2026-09-30", db_path=str(path))["error_code"] == "db_not_initialized"
assert not path.exists()
def test_monthly_overview_keeps_real_parent_row(tmp_path):
path = tmp_path / "monthly.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as c:
c.execute("insert into monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) values (?,?,?,?,?,?,?,?,?)", ('m-1','2026-09','2026-09-30','draft',1,'test','t','test','t'))
c.commit()
result = list_monthly_settlement_overview(db_path=str(path))
assert result["success"] and result["months"][0]["settlement_id"] == "m-1"
assert result["months"][0]["income_total"] is None
+19
View File
@@ -324,6 +324,25 @@ def test_legacy_reader_preserves_unrecorded_currency_and_marks_legacy(tmp_path):
assert all(value is None for value in result["totals"].values()) assert all(value is None for value in result["totals"].values())
def test_month_lookup_ignores_later_direct_asset_correction(tmp_path):
path = str(tmp_path / "reader-interleaved-runs.sqlite3")
_seed_reader_run(path, True, {"transactions": [], "assets": []})
with sqlite3.connect(path) as conn:
conn.execute(
"""INSERT INTO asset_snapshot_runs
(id,basis_date,close_status,correction_of_run_id,correction_reason,created_at)
VALUES ('direct-correction','2026-09-30','conditional','synthetic-run','asset correction','2026-10-01')"""
)
monthly = ok(service.get_monthly_settlement_snapshot(month="2026-09", db_path=path))
exact = ok(service.get_monthly_settlement_snapshot(run_id="direct-correction", db_path=path))
assert monthly["run_id"] == "synthetic-run"
assert monthly["is_comprehensive"] is True
assert exact["run_id"] == "direct-correction"
assert exact["legacy"] is True
def test_legacy_reader_accepts_original_header_without_migration(tmp_path): def test_legacy_reader_accepts_original_header_without_migration(tmp_path):
from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot
path = str(tmp_path / "reader-old-columns.sqlite3") path = str(tmp_path / "reader-old-columns.sqlite3")
@@ -82,6 +82,9 @@ def test_dashboard_exposes_additive_policy_and_saved_history_without_implicit_mu
source = drafts.read_text(encoding="utf-8") source = drafts.read_text(encoding="utf-8")
monthly_source = monthly.read_text(encoding="utf-8") monthly_source = monthly.read_text(encoding="utf-8")
combined = source + "\n" + monthly_source combined = source + "\n" + monthly_source
legacy_source = (Path(__file__).parents[1] / "dashboard/page_views/legacy_monthly_settlement.py").read_text()
combined += legacy_source
monthly_source += legacy_source
assert "종합 월정산 정책" in combined assert "종합 월정산 정책" in combined
assert "저장된 월정산 스냅샷" in combined assert "저장된 월정산 스냅샷" in combined
assert POLICY in source or POLICY in monthly_source assert POLICY in source or POLICY in monthly_source
@@ -283,6 +286,8 @@ def test_saved_monthly_panel_renders_real_conditional_exclusion_carry_and_approv
monkeypatch.setattr(reconciliation_gateway, "get_balance_reconciliation_report", lambda *a, **k: {"success": True, "items": []}) monkeypatch.setattr(reconciliation_gateway, "get_balance_reconciliation_report", lambda *a, **k: {"success": True, "items": []})
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": []}) monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": []})
app = AppTest.from_string( app = AppTest.from_string(
f"import streamlit as st\n"
f"st.query_params.update(page='월정산', month='2026-09')\n"
f"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n" f"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
f"page_monthly_settlement({path!r})", default_timeout=30).run() f"page_monthly_settlement({path!r})", default_timeout=30).run()
assert not app.exception, f"saved panel AppTest failed before field assertions: {app.exception}" assert not app.exception, f"saved panel AppTest failed before field assertions: {app.exception}"
@@ -368,6 +373,8 @@ def test_saved_monthly_snapshot_renders_close_legacy_and_asset_evidence(monkeypa
monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_snapshot", lambda *a, **k: {"success": True, **snapshot}) monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_snapshot", lambda *a, **k: {"success": True, **snapshot})
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": []}) monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": []})
app = AppTest.from_string( app = AppTest.from_string(
"import streamlit as st\n"
"st.query_params.update(page='월정산', month='2026-09')\n"
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n" "from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
"page_monthly_settlement('synthetic-unused.sqlite3')", default_timeout=30) "page_monthly_settlement('synthetic-unused.sqlite3')", default_timeout=30)
app.run() app.run()
+2 -4
View File
@@ -90,8 +90,8 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
assert "상태: 정상" in body_text assert "상태: 정상" in body_text
assert "자산 상세: 경고 3건 / 제외 1건" in body_text assert "자산 상세: 경고 3건 / 제외 1건" in body_text
assert "월정산 결과 / Evidence" in body_text assert "월정산 결과 / Evidence" in body_text
assert "읽기 전용 근거 요약" in body_text assert "근거 요약" in body_text
assert "이번 범위에는 월정산 close 저장/실행이 포함되지 않습니다" in body_text assert "정산 전 운영 블로커 점검" in body_text
assert "다음 액션" in body_text assert "다음 액션" in body_text
assert "예산 조정 후보" in body_text assert "예산 조정 후보" in body_text
assert "분류/검토 규칙 후보" in body_text assert "분류/검토 규칙 후보" in body_text
@@ -120,7 +120,6 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
assert "선택 월: 2026-06" in body_text assert "선택 월: 2026-06" in body_text
assert "기준일: 2026-06-30" in body_text assert "기준일: 2026-06-30" in body_text
assert "기준 시각" in body_text assert "기준 시각" in body_text
assert "모드: 읽기 전용" in body_text
assert "확인필요 큐로 이동" in body_text assert "확인필요 큐로 이동" in body_text
assert "계좌 화면에서 잔액대조 확인" in body_text assert "계좌 화면에서 잔액대조 확인" in body_text
assert "Import 화면에서 이력 확인" in body_text assert "Import 화면에서 이력 확인" in body_text
@@ -129,7 +128,6 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
assert ".local/share/jarca/uploads" not in body_text assert ".local/share/jarca/uploads" not in body_text
assert "/home/" not in body_text assert "/home/" not in body_text
assert "raw_payload" not in body_text assert "raw_payload" not in body_text
assert "🔒 읽기 전용" in body_text
def _dashboard_body(at): def _dashboard_body(at):
+12
View File
@@ -43,6 +43,18 @@ def test_sidebar_selection_updates_only_page_query_parameter(monkeypatch):
assert query == {"page": "거래내역", "month": "2026-06", "account_id": "checking"} assert query == {"page": "거래내역", "month": "2026-06", "account_id": "checking"}
def test_sidebar_monthly_settlement_serializes_canonical_page(monkeypatch):
query = {"page": "요약", "month": "2026-06"}
state = {"dashboard_page": "월정산"}
callback = {}
monkeypatch.setattr(common.st, "query_params", query)
monkeypatch.setattr(common.st, "session_state", state)
monkeypatch.setattr(common.st, "radio", lambda *args, **kwargs: callback.setdefault("fn", kwargs["on_change"]) or state["dashboard_page"])
common.render_sidebar_nav(PAGES, "월정산")
callback["fn"]()
assert query == {"page": "monthly_settlement", "month": "2026-06"}
def test_missing_or_unknown_page_falls_back_to_overview(): def test_missing_or_unknown_page_falls_back_to_overview():
assert common.validate_dashboard_page(None, PAGES) == "요약" assert common.validate_dashboard_page(None, PAGES) == "요약"
assert common.validate_dashboard_page("not-a-page", PAGES) == "요약" assert common.validate_dashboard_page("not-a-page", PAGES) == "요약"
+1 -1
View File
@@ -122,7 +122,7 @@ def test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent(tmp_
assert conn.execute("SELECT name FROM asset_snapshots WHERE id='s'").fetchone() assert conn.execute("SELECT name FROM asset_snapshots WHERE id='s'").fetchone()
indexes = {r[1] for r in conn.execute("PRAGMA index_list(transactions)")} indexes = {r[1] for r in conn.execute("PRAGMA index_list(transactions)")}
assert {"legacy_transactions_date", "idx_transactions_datetime"} <= indexes assert {"legacy_transactions_date", "idx_transactions_datetime"} <= indexes
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "14" assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_path): def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_path):
+1 -1
View File
@@ -6,7 +6,7 @@ from jarca_ledger_mcp import service
def test_ledger_category_create_list_delete_reactivate(tmp_path): def test_ledger_category_create_list_delete_reactivate(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3") db_path = str(tmp_path / "ledger.sqlite3")
health = service.health_check(db_path) health = service.health_check(db_path)
assert health["schema_version"] == "14" assert health["schema_version"] == "15"
created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path) created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path)
assert created["success"] is True assert created["success"] is True
+67
View File
@@ -0,0 +1,67 @@
import sqlite3
from jarca_ledger_mcp.application.accounts_write_services import create_ledger_account
from jarca_ledger_mcp.application.assets_service import (
create_manual_asset,
list_manual_assets,
preview_asset_snapshot_close,
)
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
def tables(path):
with sqlite3.connect(path) as conn:
return {r[0] for r in conn.execute("select name from sqlite_master where type='table'")}
def test_fresh_and_repeated_init_retain_manual_assets(tmp_path):
path = str(tmp_path / "fresh.sqlite3")
init_db(path)
init_db(path)
assert "manual_assets" in tables(path)
def test_existing_legacy_manual_assets_is_preserved_without_other_row_loss(tmp_path):
path = str(tmp_path / "legacy.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute("insert into manual_assets (id,name,created_at,updated_at) values ('old', 'legacy','t','t')")
conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('a','Keep','bank',1,'t','t')")
conn.commit()
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("select count(*) from accounts where id='a'").fetchone()[0] == 1
assert conn.execute("select name from manual_assets where id='old'").fetchone() == ("legacy",)
assert "manual_assets" in tables(path)
def test_manual_asset_public_operations_are_supported(tmp_path):
path = str(tmp_path / "supported.sqlite3")
init_db(path)
created = create_manual_asset("Emergency", 100, "2026-09-30", "cash", db_path=path)
listed = list_manual_assets(db_path=path)
assert created["success"] and listed["success"]
assert listed["manual_assets"][0]["id"] == created["manual_asset"]["id"]
def test_account_real_estate_creation_and_snapshot_preview_use_normal_path(tmp_path):
path = str(tmp_path / "real-estate.sqlite3")
init_db(path)
created = create_ledger_account(
"House", "other", liquidity_bucket="real_estate", last_balance=500000,
last_balance_basis_date="2026-09-30", db_path=path,
)
assert created["success"] is True
assert created["account"]["liquidity_bucket"] == "real_estate"
assert preview_asset_snapshot_close("2026-09-30", db_path=path)["success"] is True
def test_manual_assets_present_after_initialization_with_existing_accounts(tmp_path):
path = str(tmp_path / "accounts.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
before = conn.execute("select count(*) from accounts").fetchone()[0]
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("select count(*) from accounts").fetchone()[0] == before
assert "manual_assets" in tables(path)
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
import sqlite3
import pytest
from jarca_ledger_mcp.db import init_db
def _insert_sql():
return """INSERT INTO monthly_settlement
(id, month, basis_date, status, version, created_by, created_at, updated_by, updated_at)
VALUES (?, ?, ?, 'draft', 1, 'tester', '2026-10-15T00:00:00+09:00', 'tester', '2026-10-15T00:00:00+09:00')"""
def test_fresh_db_has_month_settlement_schema_15_without_latest_run_id(tmp_path):
path = str(tmp_path / "fresh.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
assert conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='monthly_settlement'").fetchone()[0] == 1
columns = {row[1]: row for row in conn.execute("PRAGMA table_info(monthly_settlement)")}
assert columns["approval_flags_json"][4] == "'{}'"
assert columns["memo"][4] == "''"
columns = {row[1] for row in conn.execute("PRAGMA table_info(monthly_settlement)")}
assert "latest_run_id" not in columns
assert conn.execute("PRAGMA foreign_key_list(monthly_settlement)").fetchall() == []
conn.execute(_insert_sql(), ("m1", "2026-09", "2026-09-30"))
row = conn.execute("SELECT memo, approval_flags_json FROM monthly_settlement").fetchone()
assert row == ("", "{}")
def test_migration_is_idempotent_and_preserves_legacy_drafts(tmp_path):
path = str(tmp_path / "existing.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute("INSERT INTO settlement_drafts (id, month, basis_date, status, version, created_by, created_at, updated_at, create_key) VALUES ('draft-1','2026-09','2026-09-30','active',1,'u','t','t','k')")
before = conn.execute("SELECT id, month, basis_date, status FROM settlement_drafts").fetchall()
init_db(path)
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT id, month, basis_date, status FROM settlement_drafts").fetchall() == before
assert conn.execute("SELECT COUNT(*) FROM monthly_settlement").fetchone()[0] == 0
def test_schema15_upgrade_removes_schema16_run_link_and_preserves_rows(tmp_path):
path = str(tmp_path / "schema15.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('run-1','2026-09-30','complete_exact','t')")
conn.execute("ALTER TABLE monthly_settlement ADD COLUMN latest_run_id TEXT REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT")
conn.execute("UPDATE schema_meta SET value='15' WHERE key='schema_version'")
conn.execute("""INSERT INTO monthly_settlement
(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at,
approved_by,approved_at,approved_version,approved_fingerprint,closed_by,closed_at,latest_run_id)
VALUES ('m1','2026-09','2026-09-30','closed',2,'u','t','u','t','r','t',1,'fp','c','t','run-1')""")
conn.execute(
"""INSERT INTO account_snapshot
(id,basis_date,source_type,source_id,name,amount,monthly_settlement_id,created_at)
VALUES ('snapshot-1','2026-09-30','account','account-1','Main',100,'m1','t')"""
)
before = conn.execute("SELECT id,month,basis_date,status,version,memo,approved_by,closed_by FROM monthly_settlement").fetchall()
init_db(path)
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
assert conn.execute("SELECT id,month,basis_date,status,version,memo,approved_by,closed_by FROM monthly_settlement").fetchall() == before
assert "latest_run_id" not in {row[1] for row in conn.execute("PRAGMA table_info(monthly_settlement)")}
assert conn.execute("SELECT id FROM asset_snapshot_runs").fetchall() == [("run-1",)]
assert conn.execute("SELECT id,monthly_settlement_id FROM account_snapshot").fetchall() == [("snapshot-1", "m1")]
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
migrated_shape = [row[1:] for row in conn.execute("PRAGMA table_info(monthly_settlement)")]
canonical_path = str(tmp_path / "canonical.sqlite3")
init_db(canonical_path)
with sqlite3.connect(canonical_path) as conn:
canonical_shape = [row[1:] for row in conn.execute("PRAGMA table_info(monthly_settlement)")]
assert migrated_shape == canonical_shape
@pytest.mark.parametrize("column,value", [("month", "2026-13"), ("month", "202609"), ("basis_date", "2026-02-30"), ("version", 0)])
def test_month_settlement_rejects_invalid_values(tmp_path, column, value):
path = str(tmp_path / "invalid.sqlite3")
init_db(path)
values = {"month": "2026-09", "basis_date": "2026-09-30", "version": 1}
values[column] = value
with sqlite3.connect(path) as conn, pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO monthly_settlement (id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
("bad", values["month"], values["basis_date"], "draft", values["version"], "u", "t", "u", "t"),
)
def test_status_condition_checks_and_month_uniqueness(tmp_path):
path = str(tmp_path / "conditions.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute(_insert_sql(), ("m1", "2026-09", "2026-09-30"))
with pytest.raises(sqlite3.IntegrityError):
conn.execute(_insert_sql(), ("m2", "2026-09", "2026-09-30"))
with pytest.raises(sqlite3.IntegrityError):
conn.execute("UPDATE monthly_settlement SET status='approved' WHERE id='m1'")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("UPDATE monthly_settlement SET status='cancelled' WHERE id='m1'")
conn.execute("UPDATE monthly_settlement SET status='approved', approved_by='reviewer', approved_at='t', approved_version=1, approved_fingerprint='fp' WHERE id='m1'")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("UPDATE monthly_settlement SET status='closed' WHERE id='m1'")
@@ -0,0 +1,141 @@
from __future__ import annotations
import sqlite3
import pytest
from jarca_ledger_mcp.application.monthly_settlement_service import (
approve_monthly_settlement,
close_monthly_settlement,
create_monthly_settlement,
review_monthly_settlement,
)
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
from jarca_ledger_mcp.application.transaction_report_services import get_settlement_cashflow_snapshot
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
def _db(tmp_path, rows=()):
path = tmp_path / "monthly-cashflow-actual.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as c:
for row in rows:
c.execute("INSERT INTO transactions(id,transaction_date,type,amount,currency,category,subcategory,account_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", (*row, "2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z"))
c.commit()
return path
def _row(i, typ="expense", amount=100, currency="KRW", category="Food"):
return (i, "2026-10-10", typ, amount, currency, category, "Main", "acct")
def _review_approve(path):
s = create_monthly_settlement("2026-10", db_path=str(path)); sid = s["settlement_id"]
reviewed = review_monthly_settlement(sid, db_path=str(path))
assert reviewed["success"] and reviewed["version"] == 1 and reviewed["review_fingerprint"]
approved = approve_monthly_settlement(sid, 1, reviewed["review_fingerprint"], db_path=str(path))
assert approved["success"] and approved["status"] == "approved" and approved["version"] == 1
return sid, reviewed, approved
def test_gateway_cancels_approval_against_actual_temp_db(tmp_path):
path = _db(tmp_path, [_row("t1")])
sid, _, _ = _review_approve(path)
result = settlement_gateway.cancel_monthly_settlement_approval(sid, 1, db_path=str(path))
assert result == {"success": True, "settlement_id": sid, "status": "in_review", "version": 2}
with sqlite3.connect(path) as c:
row = c.execute("SELECT status, version, approved_by, approved_at, approved_cashflow_fingerprint FROM monthly_settlement WHERE id=?", (sid,)).fetchone()
assert row == ("in_review", 2, None, None, None)
def test_review_approve_close_persists_and_freezes_original_ledger(tmp_path):
path = _db(tmp_path, [_row("t1")]); sid, reviewed, _ = _review_approve(path)
closed = close_monthly_settlement(sid, 1, db_path=str(path))
assert closed["success"] and closed["status"] == "closed" and closed["snapshot_count"] == 1
with sqlite3.connect(path) as c:
c.execute("UPDATE transactions SET amount=999 WHERE id='t1'"); c.commit()
saved = get_settlement_cashflow_snapshot(sid, db_path=str(path))
assert saved["saved"] and saved["categories"][0]["amount"] == 100
def test_review_then_ledger_edit_rejects_approval(tmp_path):
path = _db(tmp_path, [_row("t1")]); s = create_monthly_settlement("2026-10", db_path=str(path)); r = review_monthly_settlement(s["settlement_id"], db_path=str(path))
with sqlite3.connect(path) as c: c.execute("UPDATE transactions SET amount=101 WHERE id='t1'"); c.commit()
out = approve_monthly_settlement(s["settlement_id"], 1, r["review_fingerprint"], db_path=str(path))
assert not out["success"] and out["error_code"] == "stale_review"
def test_approve_then_offset_transaction_rejects_close_even_same_total(tmp_path):
path = _db(tmp_path, [_row("t1", amount=100)]); sid, _, _ = _review_approve(path)
with sqlite3.connect(path) as c:
c.execute("UPDATE transactions SET amount=50 WHERE id='t1'")
c.execute("INSERT INTO transactions(id,transaction_date,type,amount,currency,category,subcategory,account_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", _row("t2", amount=50) + ("2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z")); c.commit()
out = close_monthly_settlement(sid, 1, db_path=str(path))
assert not out["success"] and out["error_code"] == "stale_cashflow"
def test_null_fingerprint_close_rejects(tmp_path):
path = _db(tmp_path); sid, _, _ = _review_approve(path)
with sqlite3.connect(path) as c: c.execute("UPDATE monthly_settlement SET approved_cashflow_fingerprint=NULL WHERE id=?", (sid,)); c.commit()
out = close_monthly_settlement(sid, 1, db_path=str(path)); assert not out["success"] and out["error_code"] == "missing_fingerprint"
def test_close_snapshot_insert_failure_rolls_back_status(tmp_path, monkeypatch):
path = _db(tmp_path, [_row("t1")]); sid, _, _ = _review_approve(path)
with sqlite3.connect(path) as c: c.execute("CREATE TRIGGER fail_snapshot BEFORE INSERT ON settlement_cashflow_snapshots BEGIN SELECT RAISE(ABORT,'boom'); END;") ; c.commit()
out = close_monthly_settlement(sid, 1, db_path=str(path)); assert not out["success"]
with sqlite3.connect(path) as c: assert c.execute("SELECT status FROM monthly_settlement WHERE id=?", (sid,)).fetchone()[0] == "approved"
@pytest.mark.parametrize("currency", ["KRW", "USD"])
def test_multifx_rows_remain_separate(tmp_path, currency):
path = _db(tmp_path, [_row("a", amount=100, currency="KRW"), _row("b", amount=7, currency=currency)])
sid, _, _ = _review_approve(path); assert close_monthly_settlement(sid, 1, db_path=str(path))["success"]
saved = get_settlement_cashflow_snapshot(sid, db_path=str(path)); assert {x["currency"] for x in saved["categories"]} >= {"KRW", currency}
def test_empty_close_is_saved_without_fake_marker(tmp_path):
path = _db(tmp_path); sid, _, _ = _review_approve(path); closed = close_monthly_settlement(sid, 1, db_path=str(path)); assert closed["snapshot_count"] == 0
saved = get_settlement_cashflow_snapshot(sid, db_path=str(path)); assert saved["saved"] and saved["categories"] == []
def test_double_close_is_idempotent(tmp_path):
path = _db(tmp_path, [_row("t1")]); sid, _, _ = _review_approve(path); first = close_monthly_settlement(sid, 1, db_path=str(path)); second = close_monthly_settlement(sid, 999, db_path=str(path))
assert first["success"] and second["success"] and second["idempotent"] is True
def test_legacy_finalization_and_correction_preserve_closed_canonical_month(tmp_path):
from jarca_ledger_mcp import service
path = _db(tmp_path, [_row("t1")])
sid, _, _ = _review_approve(path)
assert close_monthly_settlement(sid, 1, db_path=str(path))["success"]
canonical = get_settlement_cashflow_snapshot(sid, db_path=str(path))
with sqlite3.connect(path) as conn:
parent = conn.execute("SELECT * FROM monthly_settlement WHERE id=?", (sid,)).fetchone()
asset = service.create_manual_asset("Legacy cash", 500, "2026-10-31", "cash", db_path=str(path))
assert asset["success"]
previous = ""
for number in range(2):
draft = service.create_settlement_draft(
"2026-10", "tester", f"legacy-draft-{number}", db_path=str(path),
correction_of_run_id=previous, correction_reason="correct" if previous else "",
)
assert draft["success"], draft
did = draft["draft"]["id"]
review = service.review_settlement_draft(did, db_path=str(path))
assert review["success"]
approved = service.approve_settlement_draft(
did, review["version"], review["review_fingerprint"], "tester", "reviewed", "fixture", db_path=str(path),
)
assert approved["success"], approved
closed = service.finalize_settlement_draft(
did, review["version"], review["review_fingerprint"], f"legacy-close-{number}", db_path=str(path),
)
assert closed["success"], closed
previous = closed["snapshot_run_id"]
assert get_settlement_cashflow_snapshot(sid, db_path=str(path)) == canonical
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT * FROM monthly_settlement WHERE id=?", (sid,)).fetchone() == parent
assert conn.execute("SELECT COUNT(DISTINCT settlement_run_id) FROM settlement_cashflow_snapshots").fetchone() == (2,)
assert conn.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE monthly_settlement_id=?", (sid,)).fetchone() == (1,)
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
@@ -0,0 +1,81 @@
from __future__ import annotations
import sqlite3
from jarca_ledger_mcp.db import init_db
FIELD = "approved_cashflow_fingerprint"
def _column(path: str) -> tuple:
with sqlite3.connect(path) as conn:
return next(row for row in conn.execute("PRAGMA table_info(monthly_settlement)") if row[1] == FIELD)
def test_fresh_schema_has_nullable_text_field_without_default(tmp_path):
path = str(tmp_path / "fresh.sqlite3")
init_db(path)
column = _column(path)
assert column[2].upper() == "TEXT"
assert column[3] == 0
assert column[4] is None
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
conn.execute(
"INSERT INTO monthly_settlement "
"(id, month, basis_date, status, version, created_by, created_at, updated_by, updated_at) "
"VALUES (?, ?, ?, 'draft', 1, 'tester', 'now', 'tester', 'now')",
("m1", "2026-09", "2026-09-30"),
)
assert conn.execute(f"SELECT {FIELD} FROM monthly_settlement WHERE id='m1'").fetchone() == (None,)
def test_same_version_existing_db_upgrade_is_idempotent_and_preserves_rows(tmp_path):
path = str(tmp_path / "existing.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute(
"INSERT INTO monthly_settlement "
"(id, month, basis_date, status, version, created_by, created_at, updated_by, updated_at) "
"VALUES ('m1', '2026-09', '2026-09-30', 'draft', 1, 'tester', 'now', 'tester', 'now')"
)
conn.execute(f"UPDATE monthly_settlement SET {FIELD}='cashflow-fp' WHERE id='m1'")
before = conn.execute("SELECT id, month, status, version, memo, approved_fingerprint, approved_cashflow_fingerprint FROM monthly_settlement").fetchall()
before_tables = conn.execute("SELECT name, sql FROM sqlite_master WHERE type IN ('table', 'index') AND name NOT LIKE 'monthly_settlement%' AND name <> 'sqlite_sequence' ORDER BY name").fetchall()
init_db(path)
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
assert conn.execute("SELECT id, month, status, version, memo, approved_fingerprint, approved_cashflow_fingerprint FROM monthly_settlement").fetchall() == before
assert conn.execute("SELECT name, sql FROM sqlite_master WHERE type IN ('table', 'index') AND name NOT LIKE 'monthly_settlement%' AND name <> 'sqlite_sequence' ORDER BY name").fetchall() == before_tables
def test_schema15_db_missing_field_gets_only_targeted_additive_column(tmp_path):
path = str(tmp_path / "legacy16.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute("ALTER TABLE monthly_settlement RENAME TO monthly_settlement_without_fingerprint")
conn.execute(
"""CREATE TABLE monthly_settlement (
id TEXT PRIMARY KEY, month TEXT NOT NULL UNIQUE, basis_date TEXT NOT NULL,
status TEXT NOT NULL, version INTEGER NOT NULL, memo TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL, created_at TEXT NOT NULL, updated_by TEXT NOT NULL,
updated_at TEXT NOT NULL, reviewed_by TEXT, reviewed_at TEXT, review_fingerprint TEXT,
approved_by TEXT, approved_at TEXT, approved_version INTEGER, approved_fingerprint TEXT,
approval_reason TEXT, approval_flags_json TEXT NOT NULL DEFAULT '{}', closed_by TEXT,
closed_at TEXT, cancelled_by TEXT, cancelled_at TEXT, cancellation_reason TEXT
)"""
)
conn.execute("INSERT INTO monthly_settlement SELECT id,month,basis_date,status,version,memo,created_by,created_at,updated_by,updated_at,reviewed_by,reviewed_at,review_fingerprint,approved_by,approved_at,approved_version,approved_fingerprint,approval_reason,approval_flags_json,closed_by,closed_at,cancelled_by,cancelled_at,cancellation_reason FROM monthly_settlement_without_fingerprint")
conn.execute("DROP TABLE monthly_settlement_without_fingerprint")
conn.execute("INSERT INTO schema_meta(key,value) VALUES('schema_version','15') ON CONFLICT(key) DO UPDATE SET value='15'")
conn.execute(
"INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) "
"VALUES ('m1','2026-10','2026-10-31','draft',1,'u','t','u','t')"
)
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
assert _column(path)[2].upper() == "TEXT"
assert conn.execute("SELECT approved_cashflow_fingerprint FROM monthly_settlement WHERE id='m1'").fetchone() == (None,)
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
import sqlite3
from jarca_ledger_mcp.application.monthly_settlement_service import create_monthly_settlement
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
def test_create_monthly_settlement_persists_canonical_draft_and_rejects_duplicate(tmp_path):
path = tmp_path / "ledger.sqlite3"
init_db(str(path))
created = create_monthly_settlement("2026-10", db_path=str(path))
assert created["success"]
assert created["month"] == "2026-10"
assert created["status"] == "draft"
assert created["settlement_id"]
with sqlite3.connect(path) as conn:
row = conn.execute(
"SELECT id, month, basis_date, status FROM monthly_settlement WHERE id=?",
(created["settlement_id"],),
).fetchone()
assert row == (created["settlement_id"], "2026-10", "2026-10-31", "draft")
duplicate = create_monthly_settlement("2026-10", db_path=str(path))
assert duplicate["success"] is False
assert duplicate["error_code"] == "duplicate_month"
assert duplicate["settlement_id"] == created["settlement_id"]
+1 -1
View File
@@ -256,7 +256,7 @@ def test_schema13_migration_is_additive_and_idempotent(tmp_path):
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
assert conn.execute("SELECT id FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone()[0] == 'legacy-run' assert conn.execute("SELECT id FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone()[0] == 'legacy-run'
assert {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} >= {'settlement_drafts','settlement_draft_items','asset_snapshot_runs','asset_snapshots'} assert {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} >= {'settlement_drafts','settlement_draft_items','asset_snapshot_runs','asset_snapshots'}
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '14' assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '15'
def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path): def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path):
+63
View File
@@ -0,0 +1,63 @@
from pathlib import Path
import sqlite3
from jarca_ledger_mcp.db import init_db
from jarca_ledger_mcp.application.monthly_settlement_service import (
list_monthly_settlement_history,
get_monthly_settlement_history_record,
)
def _seed_history(path: str) -> None:
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute(
"""INSERT INTO asset_snapshot_runs
(id,basis_date,close_status,draft_version,approved_at,correction_of_run_id,
correction_reason,created_at,settlement_month,income_total,expense_total,
net_cashflow,net_worth)
VALUES ('run-1','2026-09-30','complete_exact',3,'2026-10-01','','',
'2026-10-01','2026-09',100,40,60,1000)"""
)
conn.execute(
"""INSERT INTO asset_snapshot_runs
(id,basis_date,close_status,draft_version,approved_at,correction_of_run_id,
correction_reason,created_at,settlement_month,income_total,expense_total,
net_cashflow,net_worth)
VALUES ('run-2','2026-09-30','conditional',4,'2026-10-02','run-1','fix',
'2026-10-02','2026-09',100,45,55,995)"""
)
def test_monthly_history_lists_close_and_correction_records(tmp_path: Path):
path = str(tmp_path / "history.sqlite3")
_seed_history(path)
result = list_monthly_settlement_history("2026-09", db_path=path)
assert result["success"] is True
assert [row["run_id"] for row in result["history"]] == ["run-2", "run-1"]
assert result["history"][0]["correction_target"] == "run-1"
assert result["read_only"] is True
def test_monthly_history_record_reads_exact_run(tmp_path: Path):
path = str(tmp_path / "history-read.sqlite3")
_seed_history(path)
result = get_monthly_settlement_history_record("run-1", db_path=path)
assert result["success"] is True
assert result["history"]["run_id"] == "run-1"
assert result["history"]["net_cashflow"] == 60
assert result["history"]["net_worth"] == 1000
def test_monthly_history_record_reports_missing_run(tmp_path: Path):
path = str(tmp_path / "history-missing.sqlite3")
init_db(path)
result = get_monthly_settlement_history_record("missing", db_path=path)
assert result["success"] is False
assert result["error_code"] == "not_found"
+241
View File
@@ -0,0 +1,241 @@
from __future__ import annotations
import sqlite3
from jarca_ledger_mcp.db import init_db
from dashboard.page_views import monthly_settlement as view
class _FakeStreamlit:
def __init__(self, query=None):
self.query_params = dict(query or {})
self.session_state = {}
self.dataframes = []
self.errors = []
self.headers = []
self.titles = []
self.buttons = []
self.markdowns = []
def header(self, value):
self.headers.append(value)
def title(self, value):
self.titles.append(value)
def caption(self, value):
pass
def markdown(self, value, **kwargs):
self.markdowns.append(value)
def write(self, value):
self.markdowns.append(str(value))
def selectbox(self, label, options, **kwargs):
return options[0]
def info(self, value):
pass
def error(self, value):
self.errors.append(value)
def dataframe(self, value, **kwargs):
self.dataframes.append((value, kwargs))
class _Column:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def button(self, label, **kwargs):
return False
def popover(self, *args, **kwargs):
return self
def columns(self, spec):
return [self._Column() for _ in spec]
def container(self, **kwargs):
return self._Column()
def button(self, label, **kwargs):
self.buttons.append((label, kwargs))
return False
def popover(self, *args, **kwargs):
return self._Column()
def link_button(self, label, url, **kwargs):
self.buttons.append((label, {"url": url, **kwargs}))
return False
def rerun(self):
return None
def _fixture_db(tmp_path):
path = tmp_path / "monthly-navigation.sqlite3"
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute(
"""INSERT INTO monthly_settlement
(id, month, basis_date, status, version, created_by, created_at, updated_by, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
("settlement-2026-09", "2026-09", "2026-09-30", "draft", 1, "test", "2026-09-30T00:00:00Z", "test", "2026-09-30T00:00:00Z"),
)
conn.commit()
return path
def test_month_text_link_uses_exact_canonical_id_and_direct_detail_reads_month(tmp_path, monkeypatch):
db_path = _fixture_db(tmp_path)
fake = _FakeStreamlit()
monkeypatch.setattr(view, "st", fake)
monkeypatch.setattr(view.overview_gateway, "list_monthly_settlement_overview", lambda **kwargs: {
"success": True,
"months": [{"settlement_id": "settlement-2026-09", "month": "2026-09", "status_label": "초안", "income": 100}],
})
view._render_monthly_list(str(db_path))
month_buttons = [item for item in fake.buttons if item[0] == "2026-09"]
assert len(month_buttons) == 1
assert month_buttons[0][1]["type"] == "tertiary"
assert month_buttons[0][1]["on_click"] is view._set_monthly_detail
assert month_buttons[0][1]["args"] == ("settlement-2026-09", "전체", "전체")
assert "?page=monthly_settlement" not in "\n".join(fake.markdowns)
month_buttons[0][1]["on_click"](*month_buttons[0][1]["args"])
assert fake.query_params == {"page": "monthly_settlement", "id": "settlement-2026-09"}
assert not fake.dataframes
detail_fake = _FakeStreamlit({"page": "monthly_settlement", "id": "settlement-2026-09"})
monkeypatch.setattr(view, "st", detail_fake)
calls = []
monkeypatch.setattr(view, "_render_monthly_detail", lambda db, month: calls.append((db, month)))
view.page_monthly_settlement(str(db_path))
assert calls == [(str(db_path), "2026-09")]
assert detail_fake.titles == ["월정산 상세 · 2026-09"]
assert [label for label, _ in detail_fake.buttons] == ["← 월정산 목록", "검토승인"]
def test_unregistered_month_selection_does_not_create_none_link(monkeypatch):
fake = _FakeStreamlit()
monkeypatch.setattr(view, "st", fake)
monkeypatch.setattr(view.overview_gateway, "list_monthly_settlement_overview", lambda **kwargs: {
"success": True,
"months": [{"settlement_id": None, "month": "2026-08", "status_label": "정산 미생성·상세 선택 불가·현재집계(미확정)"}],
})
view._render_monthly_list("fixture.sqlite3")
rendered = "\n".join(fake.markdowns)
assert "2026-08" in rendered
assert "[2026-08]" not in rendered
assert "?page=monthly_settlement&id=None" not in rendered
def test_direct_reload_unknown_id_does_not_fallback_to_month_or_list(tmp_path, monkeypatch):
db_path = _fixture_db(tmp_path)
fake = _FakeStreamlit({"page": "monthly_settlement", "id": "unknown-id", "month": "2026-09"})
monkeypatch.setattr(view, "st", fake)
calls = []
monkeypatch.setattr(view, "_render_monthly_list", lambda db: calls.append(("list", db)))
view.page_monthly_settlement(str(db_path))
assert calls == []
assert fake.errors == ["월정산을 찾을 수 없습니다."]
def test_legacy_month_query_rewrites_to_canonical_id(tmp_path, monkeypatch):
db_path = _fixture_db(tmp_path)
fake = _FakeStreamlit({"page": "monthly_settlement", "month": "2026-09"})
monkeypatch.setattr(view, "st", fake)
monkeypatch.setattr(view.overview_gateway, "list_monthly_settlement_overview", lambda **kwargs: {
"success": True,
"months": [{"settlement_id": "settlement-2026-09", "month": "2026-09", "status_label": "초안"}],
})
monkeypatch.setattr(view, "_render_monthly_detail", lambda db, month: None)
view.page_monthly_settlement(str(db_path))
assert fake.query_params == {"page": "monthly_settlement", "id": "settlement-2026-09"}
def test_back_to_list_uses_canonical_page_and_preserves_filters(monkeypatch):
fake = _FakeStreamlit({"page": "monthly_settlement", "id": "settlement-2026-09", "year": "2026", "status": "미마감"})
monkeypatch.setattr(view, "st", fake)
view._clear_monthly_detail("2026", "미마감")
assert fake.query_params == {"page": "monthly_settlement", "year": "2026", "status": "미마감"}
assert fake.session_state["dashboard_page"] == "월정산"
def test_add_button_opens_dialog_and_navigates_to_created_canonical_id(monkeypatch):
class DialogFake(_FakeStreamlit):
def __init__(self):
super().__init__()
self.reruns = 0
def dialog(self, title):
assert title == "월정산 추가"
return lambda fn: fn
def text_input(self, label, **kwargs):
assert label == "대상 월"
return "2026-10"
def button(self, label, **kwargs):
self.buttons.append((label, kwargs))
return label in {"+", "추가"}
def rerun(self):
self.reruns += 1
fake = DialogFake()
monkeypatch.setattr(view, "st", fake)
monkeypatch.setattr(view.overview_gateway, "list_monthly_settlement_overview", lambda **kwargs: {"success": True, "months": []})
monkeypatch.setattr(view.settlement_gateway, "create_monthly_settlement", lambda month, **kwargs: {"success": True, "settlement_id": "settlement-2026-10"})
view._render_monthly_list("fixture.sqlite3")
assert fake.query_params == {"page": "monthly_settlement", "id": "settlement-2026-10"}
assert fake.reruns == 1
def test_month_link_target_contains_no_write_operation():
source = view._render_monthly_list.__code__.co_names
assert "create_settlement_draft" not in source
assert "finalize_settlement_draft" not in source
def test_back_button_single_click_clears_detail_query_and_reruns(monkeypatch):
class ClickFake(_FakeStreamlit):
def __init__(self):
super().__init__({"page": "monthly_settlement", "id": "settlement-2026-09", "month": "2026-09", "year": "2026", "status": "미마감"})
self.session_state.update({"dashboard_page": "월정산", "monthly_settlement_detail_id": "settlement-2026-09", "monthly_settlement_detail_month": "2026-09"})
self.reruns = 0
def rerun(self):
self.reruns += 1
fake = ClickFake()
monkeypatch.setattr(view, "st", fake)
view._clear_monthly_detail("2026", "미마감")
assert fake.reruns == 1
assert fake.query_params == {"page": "monthly_settlement", "year": "2026", "status": "미마감"}
assert "monthly_settlement_detail_id" not in fake.session_state
assert "monthly_settlement_detail_month" not in fake.session_state
def test_next_rerun_without_detail_query_does_not_restore_detail(monkeypatch):
fake = _FakeStreamlit({"page": "monthly_settlement", "year": "2026", "status": "미마감"})
fake.session_state.update({"dashboard_page": "월정산"})
monkeypatch.setattr(view, "st", fake)
rendered = []
monkeypatch.setattr(view, "_render_monthly_list", lambda db: rendered.append(db))
monkeypatch.setattr(view, "_render_monthly_detail", lambda db, month: (_ for _ in ()).throw(AssertionError("detail restored")))
view.page_monthly_settlement("fixture.sqlite3")
assert rendered == ["fixture.sqlite3"]
+58
View File
@@ -0,0 +1,58 @@
from pathlib import Path
import sqlite3
from jarca_ledger_mcp.db import init_db
from jarca_ledger_mcp.application.monthly_settlement_service import list_monthly_settlement_overview
def test_monthly_overview_excludes_transaction_only_months(tmp_path: Path):
db = tmp_path / "fixture.sqlite3"
init_db(str(db))
with sqlite3.connect(db) as conn:
conn.execute(
"INSERT INTO transactions(id,transaction_date,type,amount,created_at,updated_at) VALUES (?,?,?,?,?,?)",
("tx-1", "2026-09-03", "income", 100, "2026-09-03T00:00:00Z", "2026-09-03T00:00:00Z"),
)
conn.commit()
result = list_monthly_settlement_overview(db_path=str(db))
assert result["success"]
assert result["months"] == []
def test_monthly_overview_keeps_existing_parent_and_joins_live_transaction_totals(tmp_path: Path):
db = tmp_path / "fixture.sqlite3"
init_db(str(db))
with sqlite3.connect(db) as conn:
conn.execute(
"INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
("settlement-1", "2026-09", "2026-09-30", "draft", 1, "test", "now", "test", "now"),
)
conn.execute(
"INSERT INTO transactions(id,transaction_date,type,amount,created_at,updated_at) VALUES (?,?,?,?,?,?)",
("tx-1", "2026-09-03", "income", 100, "2026-09-03T00:00:00Z", "2026-09-03T00:00:00Z"),
)
conn.commit()
result = list_monthly_settlement_overview(db_path=str(db))
assert result["success"]
assert len(result["months"]) == 1
row = result["months"][0]
assert row["month"] == "2026-09"
assert row["settlement_id"] == "settlement-1"
assert row["income"] == 100
assert row["net_cashflow_display"] is None
assert row["net_worth_display"] is None
def test_monthly_overview_keeps_parent_without_transactions(tmp_path: Path):
db = tmp_path / "fixture.sqlite3"
init_db(str(db))
with sqlite3.connect(db) as conn:
conn.execute(
"INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
("settlement-1", "2026-09", "2026-09-30", "draft", 1, "test", "now", "test", "now"),
)
conn.commit()
row = list_monthly_settlement_overview(db_path=str(db))["months"][0]
assert row["settlement_id"] == "settlement-1"
assert row["income"] == 0
assert row["expense"] == 0
@@ -0,0 +1,107 @@
from __future__ import annotations
import inspect
from pathlib import Path
from dashboard.page_views import settlement_drafts as view
from jarca_ledger_mcp import service
from jarca_ledger_mcp.adapters.mcp import server, tool_contracts
HISTORY_TOOLS = (
"list_monthly_settlement_history",
"get_monthly_settlement_history_record",
)
class _FakeStreamlit:
def __init__(self) -> None:
self.session_state: dict[str, object] = {}
self.selectbox_calls: list[tuple[str, list[str], dict]] = []
self.captions: list[str] = []
self.json_values: list[object] = []
self.dataframes: list[object] = []
self.info_values: list[str] = []
self.error_values: list[str] = []
def selectbox(self, label, options, **kwargs):
values = list(options)
self.selectbox_calls.append((label, values, kwargs))
return values[-1]
def caption(self, value):
self.captions.append(str(value))
def json(self, value):
self.json_values.append(value)
def dataframe(self, value, **kwargs):
self.dataframes.append(value)
def info(self, value):
self.info_values.append(str(value))
def error(self, value):
self.error_values.append(str(value))
def test_history_tools_are_registered_with_public_no_db_path_schemas():
tools = server.mcp._tool_manager._tools
for name in HISTORY_TOOLS:
assert name in tool_contracts.TOOL_CONTRACTS
assert name in tool_contracts.__all__
assert name in tools
schema = tools[name].parameters if hasattr(tools[name], "parameters") else tools[name].inputSchema
properties = schema.get("properties", {})
assert "db_path" not in properties
assert set(properties) == set(inspect.signature(getattr(service, name)).parameters) - {"db_path"}
def test_history_control_opens_selected_run_and_render_fetches_exact_record(monkeypatch):
fake = _FakeStreamlit()
monkeypatch.setattr(view, "st", fake)
calls: list[tuple] = []
def list_history(month="", *, db_path=None):
calls.append(("list", month, db_path))
return {
"success": True,
"history": [
{"run_id": "run-old", "settlement_month": month, "version": 2, "closed_at": "2026-10-01", "close_status": "complete_exact"},
{"run_id": "run-new", "settlement_month": month, "version": 3, "closed_at": "2026-10-02", "close_status": "conditional", "correction_target": "run-old", "reason": "fixed"},
],
}
def get_record(run_id, *, db_path=None):
calls.append(("get", run_id, db_path))
return {"success": True, "history": {"run_id": run_id, "net_cashflow": 55, "net_worth": 995}}
monkeypatch.setattr(view.gateway, "list_monthly_settlement_history", list_history)
monkeypatch.setattr(view.gateway, "get_monthly_settlement_history_record", get_record)
view._open_settlement_history("draft-1", "run-old")
assert fake.session_state["settlement_history_open_draft"] == "draft-1"
assert fake.session_state["settlement_history_run_draft-1"] == "run-old"
view._render_settlement_history("fixture.sqlite3", "2026-09", "draft-1")
assert calls == [("list", "2026-09", "fixture.sqlite3"), ("get", "run-new", "fixture.sqlite3")]
assert fake.selectbox_calls[0][0] == "불변 마감 run 선택"
assert fake.selectbox_calls[0][1] == ["run-old", "run-new"]
assert fake.json_values == [{"run_id": "run-new", "net_cashflow": 55, "net_worth": 995}]
def test_public_history_contract_and_immutable_run_model_are_documented():
root = Path(__file__).parents[1]
interface = (root / "docs/interface-contract.md").read_text(encoding="utf-8")
data_model = (root / "docs/data-model.md").read_text(encoding="utf-8")
for operation in HISTORY_TOOLS:
assert operation in interface
assert "MCP signatures omit internal `db_path`" in interface
assert "마감내역보기" in interface
assert "append-only immutable settlement history" in data_model
assert "correction_of_run_id" in data_model
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import inspect
from pathlib import Path
from jarca_ledger_mcp import service
from jarca_ledger_mcp.adapters.mcp import server
def test_create_real_estate_defaults_to_real_estate_bucket(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
result = service.create_ledger_account("아파트", type="real_estate", db_path=db_path)
assert result["success"] is True
assert result["account"]["type"] == "real_estate"
assert result["account"]["liquidity_bucket"] == "real_estate"
def test_update_other_to_real_estate_preserves_explicit_bucket(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account("자산", type="other", db_path=db_path)
updated = service.update_ledger_account(
created["account"]["id"], type="real_estate", db_path=db_path
)
assert updated["success"] is True
assert updated["account"]["type"] == "real_estate"
assert updated["account"]["liquidity_bucket"] == "other"
def test_real_estate_appears_in_list_and_asset_preview(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account(
"주택", type="real_estate", last_balance=500_000_000, db_path=db_path
)
listed = service.list_ledger_accounts(db_path=db_path)
assert listed["success"] is True
assert listed["accounts"][0]["liquidity_bucket"] == "real_estate"
assert service.register_account_valuation(
created["account"]["id"], "2026-10-16", 500_000_000,
verification_status="verified", db_path=db_path,
)["success"]
preview = service.preview_asset_snapshot_close("2026-10-16", db_path=db_path)
assert preview["success"] is True
assert any(
item["source_id"] == created["account"]["id"]
and item["liquidity_bucket"] == "real_estate"
for item in preview["included_items"]
)
def test_mcp_exposes_real_estate_type_in_create_and_update_schema():
create_annotation = inspect.signature(server.create_ledger_account).parameters["type"].annotation
update_annotation = inspect.signature(server.update_ledger_account).parameters["type"].annotation
assert "real_estate" in str(create_annotation)
assert "real_estate" in str(update_annotation)
def test_unknown_account_type_remains_rejected(tmp_path):
result = service.create_ledger_account(
"알 수 없음", type="property", db_path=str(tmp_path / "ledger.sqlite3")
)
assert result["success"] is False
assert result["error_code"] == "validation_error"
def test_data_model_documents_real_estate_account_type():
data_model = (Path(__file__).parents[1] / "docs" / "data-model.md").read_text()
type_row = next(line for line in data_model.splitlines() if line.startswith("| `type`"))
assert "real_estate" in type_row
+1 -1
View File
@@ -16,7 +16,7 @@ def test_review_decision_and_reconciliation_schema_are_additive_idempotent(tmp_p
assert status["integrity_check"] == "ok" assert status["integrity_check"] == "ok"
assert status["table_counts"]["review_decisions"] == 0 assert status["table_counts"]["review_decisions"] == 0
assert status["table_counts"]["reconciliation_sessions"] == 0 assert status["table_counts"]["reconciliation_sessions"] == 0
assert service.health_check(db_path)["schema_version"] == "14" assert service.health_check(db_path)["schema_version"] == "15"
def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None: def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None:
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
import pytest
from jarca_ledger_mcp.infrastructure.sqlite import migrations
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
FIXTURE = Path(__file__).parent / "fixtures" / "schema_v14.sql"
def _v14_db(path: Path) -> None:
with sqlite3.connect(path) as db:
db.executescript(FIXTURE.read_text())
db.execute("INSERT INTO accounts VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ("a1", "Main", "bank", "Bank", "KRW", 1, "memo", "1234", "User", 100000, "2026-01-31", "", "ok", "cash", "2026-01-01", "2026-01-31"))
db.execute("INSERT INTO transactions (id,transaction_date,type,amount,account_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?)", ("t1", "2026-01-15", "expense", -1200, "a1", "2026-01-15", "2026-01-15"))
db.execute("INSERT INTO asset_snapshots (id,basis_date,source_type,source_id,name,amount,closed_at,created_at) VALUES (?,?,?,?,?,?,?,?)", ("s1", "2026-01-31", "account", "a1", "Main", 100000, "2026-01-31T10:00:00", "2026-01-31T10:00:00"))
db.execute("INSERT INTO asset_snapshot_runs (id,basis_date,close_status,created_at) VALUES (?,?,?,?)", ("run1", "2026-01-31", "closed", "2026-01-31T10:00:00"))
db.execute("INSERT OR REPLACE INTO schema_meta VALUES ('schema_version','14')")
db.commit()
def _rows(path: Path, table: str):
with sqlite3.connect(path) as db:
return db.execute(f"SELECT * FROM {table} ORDER BY 1").fetchall()
def test_actual_v14_fixture_is_tag_ddl_and_migrates_preserving_rows(tmp_path):
path = tmp_path / "ledger.sqlite3"
_v14_db(path)
before_accounts, before_tx = _rows(path, "accounts"), _rows(path, "transactions")
init_db(str(path))
with sqlite3.connect(path) as db:
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
assert db.execute("SELECT amount, closed_at FROM asset_snapshots WHERE id='s1'").fetchone() == (100000, "2026-01-31T10:00:00")
assert db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='asset_snapshot_runs'").fetchone() is not None
assert db.execute("PRAGMA foreign_key_check").fetchall() == []
assert db.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
assert _rows(path, "accounts") == before_accounts
assert _rows(path, "transactions") == before_tx
def test_actual_v14_migration_is_idempotent_and_snapshot_unchanged(tmp_path):
path = tmp_path / "ledger.sqlite3"
_v14_db(path)
init_db(str(path))
first = _rows(path, "asset_snapshots")
init_db(str(path))
assert _rows(path, "asset_snapshots") == first
def test_migration_failure_rolls_back_legacy_renames_and_metadata(tmp_path, monkeypatch):
path = tmp_path / "ledger.sqlite3"
_v14_db(path)
original = migrations.migrate_db
def fail(conn):
raise RuntimeError("injected")
monkeypatch.setattr(migrations, "migrate_db", fail)
with pytest.raises(RuntimeError, match="injected"):
init_db(str(path))
with sqlite3.connect(path) as db:
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "14"
assert db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='asset_snapshots'").fetchone()
assert not db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='account_snapshot'").fetchone()
monkeypatch.setattr(migrations, "migrate_db", original)
def test_new_init_has_same_contract(tmp_path):
path = tmp_path / "new.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as db:
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
cols = {r[1] for r in db.execute("PRAGMA table_info(account_snapshot)")}
assert {"monthly_settlement_id", "verification_status", "evidence_kind"} <= cols
def test_populated_v14_upgrade_preserves_evidence_corrections_and_cashflow(tmp_path):
path = tmp_path / "populated.sqlite3"
_v14_db(path)
with sqlite3.connect(path) as db:
db.execute("INSERT INTO manual_assets(id,name,valuation_amount,created_at,updated_at) VALUES('manual','House',500,'t','t')")
db.execute("INSERT INTO account_valuations(id,account_id,basis_date,amount,recorded_at) VALUES('v1','a1','2026-01-31',100,'t')")
db.execute("INSERT INTO account_valuations(id,account_id,basis_date,amount,recorded_at,supersedes_id,correction_reason) VALUES('v2','a1','2026-01-31',110,'t','v1','correct')")
db.execute("UPDATE asset_snapshots SET snapshot_run_id='run1' WHERE id='s1'")
# A direct asset close can have no settlement_month. A correction shares
# its basis date, but its frozen cashflow must never merge with run1.
db.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at,correction_of_run_id,correction_reason) VALUES('run2','2026-01-31','conditional','t','run1','correct')")
for run, amount in [("run1", 10), ("run2", 20)]:
db.execute("INSERT INTO settlement_cashflow_snapshots VALUES(?,?,'income','','',?,1,'KRW')", ("cf-" + run, run, amount))
db.execute("CREATE INDEX custom_valuation_amount ON account_valuations(amount)")
tables = ("accounts", "transactions", "manual_assets", "account_valuations", "asset_snapshots", "asset_snapshot_runs")
with sqlite3.connect(path) as db:
columns = {table: ",".join(row[1] for row in db.execute(f"PRAGMA table_info({table})")) for table in tables}
before = {table: db.execute(f"SELECT {columns[table]} FROM {table} ORDER BY id").fetchall() for table in tables}
init_db(str(path))
init_db(str(path))
with sqlite3.connect(path) as db:
assert {table: db.execute(f"SELECT {columns[table]} FROM {table} ORDER BY id").fetchall() for table in tables} == before
with sqlite3.connect(path) as db:
assert db.execute("SELECT settlement_run_id,monthly_settlement_id,amount FROM settlement_cashflow_snapshots ORDER BY id").fetchall() == [("run1", None, 10), ("run2", None, 20)]
assert db.execute("SELECT COUNT(*) FROM monthly_settlement").fetchone() == (0,)
assert db.execute("SELECT COUNT(*) FROM account_snapshot").fetchone() == (0,)
assert db.execute("SELECT name FROM sqlite_master WHERE name='custom_valuation_amount'").fetchone()
assert db.execute("PRAGMA integrity_check").fetchone() == ("ok",)
assert db.execute("PRAGMA foreign_key_check").fetchall() == []
def test_orphan_cashflow_migration_rolls_back_without_deleting_legacy_data(tmp_path):
path = tmp_path / "orphan.sqlite3"
_v14_db(path)
with sqlite3.connect(path) as db:
db.execute("INSERT INTO settlement_cashflow_snapshots VALUES('orphan','missing','income','','',1,1,'KRW')")
before = _rows(path, "settlement_cashflow_snapshots")
with pytest.raises(sqlite3.IntegrityError):
init_db(str(path))
assert _rows(path, "settlement_cashflow_snapshots") == before
with sqlite3.connect(path) as db:
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone() == ("14",)
assert not db.execute("SELECT name FROM sqlite_master WHERE name='account_snapshot'").fetchone()
+1 -1
View File
@@ -10,7 +10,7 @@ def test_health_check_initializes_db(tmp_path):
db_path = tmp_path / "ledger.sqlite3" db_path = tmp_path / "ledger.sqlite3"
result = service.health_check(str(db_path)) result = service.health_check(str(db_path))
assert result["success"] is True assert result["success"] is True
assert result["schema_version"] == "14" assert result["schema_version"] == "15"
assert db_path.exists() assert db_path.exists()
+24 -13
View File
@@ -8,11 +8,9 @@ from jarca_ledger_mcp.db import init_db
def _schema(path): def _schema(path):
with sqlite3.connect(path) as db: with sqlite3.connect(path) as db:
tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")} tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
assert {"settlement_cashflow_snapshots", "asset_snapshot_runs"} <= tables assert {"settlement_cashflow_snapshots", "monthly_settlement"} <= tables
header = {r[1] for r in db.execute("PRAGMA table_info(asset_snapshot_runs)")}
detail = {r[1] for r in db.execute("PRAGMA table_info(settlement_cashflow_snapshots)")} detail = {r[1] for r in db.execute("PRAGMA table_info(settlement_cashflow_snapshots)")}
assert {"settlement_month", "currency", "income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"} <= header assert {"id", "monthly_settlement_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"} <= detail
assert {"id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"} <= detail
def test_cashflow_detail_constraints_preserve_blank_categories(tmp_path): def test_cashflow_detail_constraints_preserve_blank_categories(tmp_path):
@@ -21,23 +19,36 @@ def test_cashflow_detail_constraints_preserve_blank_categories(tmp_path):
_schema(path) _schema(path)
with sqlite3.connect(path) as db: with sqlite3.connect(path) as db:
db.execute("PRAGMA foreign_keys=ON") db.execute("PRAGMA foreign_keys=ON")
db.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('run-a','2026-09-30','complete_exact','now')") db.execute("INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES('month-a','2026-09','2026-09-30','draft',1,'test','now','test','now')")
insert = "INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)" insert = "INSERT INTO settlement_cashflow_snapshots(id,monthly_settlement_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)"
good = ("d1", "run-a", "expense", "", "", -7, 1, "KRW") good = ("d1", "month-a", "expense", "", "", -7, 1, "KRW")
db.execute(insert, good) db.execute(insert, good)
for values in [("d2", "missing", "expense", "x", "y", 1, 1, "KRW"), ("d3", "run-a", "other", "x", "y", 1, 1, "KRW"), ("d4", "run-a", "expense", "", "", 7, 1, "KRW"), ("d5", "run-a", "expense", "", "", None, 1, "KRW"), ("d6", "run-a", "expense", "", "", 1, 0, "KRW"), ("d7", "run-a", "expense", None, "", 1, 1, "KRW"), ("d8", "run-a", "expense", "", None, 1, 1, "KRW")]: for values in [("d2", "missing", "expense", "x", "y", 1, 1, "KRW"), ("d3", "month-a", "other", "x", "y", 1, 1, "KRW"), ("d4", "month-a", "expense", "", "", 7, 1, "KRW"), ("d5", "month-a", "expense", "", "", None, 1, "KRW"), ("d6", "month-a", "expense", "", "", 1, 0, "KRW"), ("d7", "month-a", "expense", None, "", 1, 1, "KRW"), ("d8", "month-a", "expense", "", None, 1, 1, "KRW")]:
with pytest.raises(sqlite3.IntegrityError): with pytest.raises(sqlite3.IntegrityError):
db.execute(insert, values) db.execute(insert, values)
with pytest.raises(sqlite3.IntegrityError): with pytest.raises(sqlite3.IntegrityError):
db.execute(insert, ("d9", "run-a", "expense", "", "", -7, 1, "KRW")) db.execute(insert, ("d9", "month-a", "expense", "", "", -7, 1, "KRW"))
db.execute(insert, ("d10", "run-a", "income", "", "", 2, 1, "KRW")) db.execute(insert, ("d10", "month-a", "income", "", "", 2, 1, "KRW"))
indexes = {r[1]: r[2] for r in db.execute("PRAGMA index_list(settlement_cashflow_snapshots)")} indexes = {r[1]: r[2] for r in db.execute("PRAGMA index_list(settlement_cashflow_snapshots)")}
unique_fields = [tuple(r[2] for r in db.execute(f"PRAGMA index_info('{name}')")) for name, unique in indexes.items() if unique] unique_fields = [tuple(r[2] for r in db.execute(f"PRAGMA index_info('{name}')")) for name, unique in indexes.items() if unique]
assert any(x == ("settlement_run_id", "cashflow_kind", "category", "subcategory", "currency") for x in unique_fields) assert any(x == ("monthly_settlement_id", "cashflow_kind", "category", "subcategory", "currency") for x in unique_fields)
assert db.execute("SELECT amount,category,subcategory FROM settlement_cashflow_snapshots WHERE id='d1'").fetchone() == (-7, "", "") assert db.execute("SELECT amount,category,subcategory FROM settlement_cashflow_snapshots WHERE id='d1'").fetchone() == (-7, "", "")
with pytest.raises(sqlite3.IntegrityError): with pytest.raises(sqlite3.IntegrityError):
db.execute("DELETE FROM asset_snapshot_runs WHERE id='run-a'") db.execute("DELETE FROM monthly_settlement WHERE id='month-a'")
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE settlement_run_id='run-a'").fetchone()[0] == 2 assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE monthly_settlement_id='month-a'").fetchone()[0] == 2
def test_init_is_idempotent_and_retains_schema_15_contract(tmp_path):
path = str(tmp_path / "fresh.sqlite3")
init_db(path)
_schema(path)
with sqlite3.connect(path) as db:
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '15'
assert db.execute("SELECT 1 FROM sqlite_master WHERE name='asset_snapshot_runs'").fetchone()
first = db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0]
init_db(path)
with sqlite3.connect(path) as db:
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == first
def test_legacy_migration_is_idempotent_and_keeps_unstored_totals_null(tmp_path): def test_legacy_migration_is_idempotent_and_keeps_unstored_totals_null(tmp_path):
+18 -7
View File
@@ -26,7 +26,9 @@ def _app_wrapper(tmp_path: Path) -> Path:
def test_reusable_settlement_draft_panel_exists_and_is_importable(tmp_path): def test_reusable_settlement_draft_panel_exists_and_is_importable(tmp_path):
assert VIEW.is_file(), "shared Dashboard settlement draft panel is missing" assert VIEW.is_file(), "shared Dashboard settlement draft panel is missing"
from streamlit.testing.v1 import AppTest from streamlit.testing.v1 import AppTest
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run() app = AppTest.from_file(str(_app_wrapper(tmp_path)))
app.session_state["authenticated_actor"] = "fixture-operator"
app = app.run()
assert not app.exception assert not app.exception
@@ -35,7 +37,11 @@ def test_monthly_and_assets_pages_invoke_shared_panel():
pages = [p for p in root.rglob("*.py") if p.name in ("monthly_settlement.py", "assets.py")] pages = [p for p in root.rglob("*.py") if p.name in ("monthly_settlement.py", "assets.py")]
assert len(pages) == 2 assert len(pages) == 2
for page in pages: for page in pages:
assert "render_settlement_drafts" in page.read_text(encoding="utf-8") source = page.read_text(encoding="utf-8")
if page.name == "monthly_settlement.py":
assert "page_legacy_monthly_settlement" in source
source += (page.parent / "legacy_monthly_settlement.py").read_text(encoding="utf-8")
assert "render_settlement_drafts" in source
def test_streamlit_panel_renders_lifecycle_without_writing_or_creating_db(monkeypatch, tmp_path): def test_streamlit_panel_renders_lifecycle_without_writing_or_creating_db(monkeypatch, tmp_path):
@@ -44,7 +50,9 @@ def test_streamlit_panel_renders_lifecycle_without_writing_or_creating_db(monkey
db_path = tmp_path / "dashboard-missing.sqlite3" db_path = tmp_path / "dashboard-missing.sqlite3"
# Explicit synthetic DB path and no authenticated actor: render only. # Explicit synthetic DB path and no authenticated actor: render only.
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path)) monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run() app = AppTest.from_file(str(_app_wrapper(tmp_path)))
app.session_state["authenticated_actor"] = "fixture-operator"
app = app.run()
assert not app.exception assert not app.exception
page = "\n".join([e.value for e in app.markdown] + [e.value for e in app.caption] + [e.value for e in app.info] + [e.label for e in app.button]) page = "\n".join([e.value for e in app.markdown] + [e.value for e in app.caption] + [e.value for e in app.info] + [e.label for e in app.button])
for expected in ("초안", "검토", "승인", "최종", "취소", "정정", "평가자료", "제외 사유"): for expected in ("초안", "검토", "승인", "최종", "취소", "정정", "평가자료", "제외 사유"):
@@ -91,13 +99,16 @@ def test_app_test_create_review_approve_finalize_roundtrip(tmp_path, monkeypatch
seeded = create_manual_asset("Synthetic asset", 125, "2026-09-30", "other", source_note="fixture", db_path=str(db_path)) seeded = create_manual_asset("Synthetic asset", 125, "2026-09-30", "other", source_note="fixture", db_path=str(db_path))
assert seeded.get("success"), seeded assert seeded.get("success"), seeded
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path)) monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run() monkeypatch.setattr("dashboard.authentication.get_authenticated_actor", lambda: "fixture-operator")
app = AppTest.from_file(str(_app_wrapper(tmp_path)))
app.session_state["authenticated_actor"] = "fixture-operator"
app = app.run()
assert not app.exception assert not app.exception
app.button[0].click().run() app.button[0].click().run()
assert not app.exception assert not app.exception
assert app.button[0].label == "초안 생성 / 정정 초안" next(button for button in app.button if button.label == "초안 만들기").click().run()
assert len(app.selectbox[0].options) == 1 assert not app.exception
assert "active" in " ".join(item.value for item in app.caption) assert any(button.label == "검토 / 새로고침 대상" for button in app.button)
# Exercise read-only review through the rendered explicit button. # Exercise read-only review through the rendered explicit button.
review = next(button for button in app.button if button.label == "검토 / 새로고침 대상") review = next(button for button in app.button if button.label == "검토 / 새로고침 대상")
review.click().run(timeout=10) review.click().run(timeout=10)
+7 -2
View File
@@ -207,7 +207,7 @@ def test_settlement_cashflow_model_documents_schema_and_legacy_reproduction(tmp_
names = { names = {
"asset_snapshot_runs": ("settlement_month", "currency", "income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"), "asset_snapshot_runs": ("settlement_month", "currency", "income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"),
"settlement_drafts": ("cashflow_policy_json", "aggregation_version"), "settlement_drafts": ("cashflow_policy_json", "aggregation_version"),
"settlement_cashflow_snapshots": ("id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"), "settlement_cashflow_snapshots": ("id", "monthly_settlement_id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"),
} }
database = tmp_path / "synthetic-doc-schema.sqlite3" database = tmp_path / "synthetic-doc-schema.sqlite3"
init_db(str(database)) init_db(str(database))
@@ -220,7 +220,12 @@ def test_settlement_cashflow_model_documents_schema_and_legacy_reproduction(tmp_
reproduction = _comprehensive_section(doc, "### Comprehensive snapshot evidence inputs") reproduction = _comprehensive_section(doc, "### Comprehensive snapshot evidence inputs")
for field in ("source_snapshot_json", "transactions", "assets", "transaction_date", "originalcashflow_kind", "applied_amount", "valuation_id", "valuation_date", "authority_source", "cashflow_policy_json", "legacy", "NULL"): for field in ("source_snapshot_json", "transactions", "assets", "transaction_date", "originalcashflow_kind", "applied_amount", "valuation_id", "valuation_date", "authority_source", "cashflow_policy_json", "legacy", "NULL"):
assert field in reproduction, field assert field in reproduction, field
for constraint in ("ON DELETE RESTRICT", "UNIQUE", "transaction_count > 0", "idx_settlement_cashflow_snapshots_run"): for constraint in (
"ON DELETE RESTRICT", "UNIQUE", "transaction_count > 0",
"idx_settlement_cashflow_snapshots_run", "exactly one parent",
"(settlement_run_id,cashflow_kind,category,subcategory,currency)",
"(monthly_settlement_id,cashflow_kind,category,subcategory,currency)",
):
assert constraint in doc, constraint assert constraint in doc, constraint
+12 -1
View File
@@ -17,7 +17,8 @@ from mcp.client.stdio import stdio_client
OPS = ('create_settlement_draft', 'list_settlement_drafts', 'get_settlement_draft', OPS = ('create_settlement_draft', 'list_settlement_drafts', 'get_settlement_draft',
'update_settlement_draft', 'refresh_settlement_draft_targets', 'update_settlement_draft', 'refresh_settlement_draft_targets',
'set_settlement_draft_item', 'review_settlement_draft', 'set_settlement_draft_item', 'review_settlement_draft',
'approve_settlement_draft', 'finalize_settlement_draft', 'cancel_settlement_draft') 'approve_settlement_draft', 'finalize_settlement_draft', 'cancel_settlement_draft',
'list_monthly_settlement_history', 'get_monthly_settlement_history_record')
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -147,6 +148,16 @@ async def _exercise_stdio_lifecycle(tmp_path):
assert archived == before assert archived == before
assert {table: rows(db, table) for table in tables} == before_replay assert {table: rows(db, table) for table in tables} == before_replay
history = await call('list_monthly_settlement_history', {'month': '2026-09'})
require_fields(history, {'success', 'history', 'month', 'read_only'}, 'history list stdio result')
assert history['success'] and history['read_only'] is True
assert [item['run_id'] for item in history['history']] == [closed['run_id']]
record = await call('get_monthly_settlement_history_record', {'run_id': closed['run_id']})
require_fields(record, {'success', 'history', 'read_only'}, 'history record stdio result')
assert record['success'] and record['read_only'] is True
assert record['history']['run_id'] == closed['run_id']
assert record['history']['net_worth'] == 100
second = await call('create_settlement_draft', {'month':'2026-10','created_by':'stdio-test','idempotency_key':'mcp-cancel'}) second = await call('create_settlement_draft', {'month':'2026-10','created_by':'stdio-test','idempotency_key':'mcp-cancel'})
assert second['success'] assert second['success']
cancelled = await call('cancel_settlement_draft', {'draft_id':second['draft']['id'],'expected_version':second['draft']['version'],'cancelled_by':'stdio-test','reason':'synthetic cancellation'}) cancelled = await call('cancel_settlement_draft', {'draft_id':second['draft']['id'],'expected_version':second['draft']['version'],'cancelled_by':'stdio-test','reason':'synthetic cancellation'})
@@ -9,7 +9,13 @@ from jarca_ledger_mcp.db import init_db
def panel(monkeypatch, path): def panel(monkeypatch, path):
monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path)) monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path))
app = AppTest.from_string('from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts()\n').run(timeout=20) monkeypatch.setattr('dashboard.authentication.get_authenticated_actor', lambda: 'fixture-operator')
app = AppTest.from_string(f'from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts(db_path={str(path)!r})\n')
app.session_state["authenticated_actor"] = "fixture-operator"
app = app.run(timeout=20)
assert not app.exception
control(app.button, '정산 시작').click().run(timeout=20)
control(app.button, '초안 만들기').click().run(timeout=20)
assert not app.exception assert not app.exception
return app return app
@@ -38,7 +44,7 @@ def test_panel_reports_partial_save_when_item_update_fails(tmp_path, monkeypatch
assert s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', assert s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified',
idempotency_key='synthetic-value', db_path=path)['success'] idempotency_key='synthetic-value', db_path=path)['success']
app = panel(monkeypatch, path) app = panel(monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20) control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20) control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0] draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
review_key = f"review_result_{draft['id']}" review_key = f"review_result_{draft['id']}"
+13 -7
View File
@@ -16,9 +16,15 @@ def control(elements, label):
def app_for(tmp_path, monkeypatch, path): def app_for(tmp_path, monkeypatch, path):
monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path)) monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path))
monkeypatch.setattr('dashboard.authentication.get_authenticated_actor', lambda: 'fixture-operator')
appfile = tmp_path / 'panel.py' appfile = tmp_path / 'panel.py'
appfile.write_text('from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts()\n') appfile.write_text(f'from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts(db_path={str(path)!r})\n')
app = AppTest.from_file(str(appfile)).run(timeout=20) app = AppTest.from_file(str(appfile))
app.session_state["authenticated_actor"] = "fixture-operator"
app = app.run(timeout=20)
assert not app.exception
control(app.button, '정산 시작').click().run(timeout=20)
control(app.button, '초안 만들기').click().run(timeout=20)
assert not app.exception assert not app.exception
return app return app
@@ -29,7 +35,7 @@ def test_panel_selects_after_date_evidence_approves_and_finalizes_actual_databas
account = s.create_ledger_account('Synthetic retirement', type='investment', liquidity_bucket='investment', db_path=path)['account']['id'] account = s.create_ledger_account('Synthetic retirement', type='investment', liquidity_bucket='investment', db_path=path)['account']['id']
value = s.register_account_valuation(account, '2026-10-05', 100, valuation_kind='total_value', verification_status='verified', idempotency_key='future', db_path=path)['valuation']['id'] value = s.register_account_valuation(account, '2026-10-05', 100, valuation_kind='total_value', verification_status='verified', idempotency_key='future', db_path=path)['valuation']['id']
app = app_for(tmp_path, monkeypatch, path) app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20) control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.text_input, '대상 ID').set_value(account) control(app.text_input, '대상 ID').set_value(account)
control(app.text_input, '평가자료 ID (비우면 해제)').set_value(value) control(app.text_input, '평가자료 ID (비우면 해제)').set_value(value)
control(app.selectbox, '선택 방식').select('manual_substitute') control(app.selectbox, '선택 방식').select('manual_substitute')
@@ -69,7 +75,7 @@ def test_panel_valuation_listing_shows_real_selected_evidence_ids(tmp_path, monk
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id'] account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id'] vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id']
app = app_for(tmp_path, monkeypatch, path) app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20) control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.text_input, '대상 ID').set_value(account) control(app.text_input, '대상 ID').set_value(account)
control(app.button, '평가자료 조회').click().run(timeout=20) control(app.button, '평가자료 조회').click().run(timeout=20)
assert not app.exception assert not app.exception
@@ -81,7 +87,7 @@ def test_panel_supports_investment_total_and_explicit_loan_definition(tmp_path,
init_db(path) init_db(path)
loan = s.create_ledger_account('Synthetic loan', type='loan', liquidity_bucket='loan', db_path=path)['account']['id'] loan = s.create_ledger_account('Synthetic loan', type='loan', liquidity_bucket='loan', db_path=path)['account']['id']
app = app_for(tmp_path, monkeypatch, path) app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20) control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
assert {'total_value', 'cash_balance'} <= set(control(app.selectbox, '평가 종류').options) assert {'total_value', 'cash_balance'} <= set(control(app.selectbox, '평가 종류').options)
control(app.text_input, '평가 등록 계좌 ID').set_value(loan) control(app.text_input, '평가 등록 계좌 ID').set_value(loan)
control(app.text_input, '평가일 YYYY-MM-DD').set_value('2026-09-30') control(app.text_input, '평가일 YYYY-MM-DD').set_value('2026-09-30')
@@ -103,7 +109,7 @@ def test_panel_edit_invalidates_cached_review_before_approval(tmp_path, monkeypa
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id'] account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path) s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)
app = app_for(tmp_path, monkeypatch, path) app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20) control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20) control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0] draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
key = f"review_result_{draft['id']}" key = f"review_result_{draft['id']}"
@@ -135,7 +141,7 @@ def test_panel_correction_invalidates_review_without_registration_account_input(
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id'] account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id'] vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id']
app = app_for(tmp_path, monkeypatch, path) app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20) control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20) control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0] draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
old = app.session_state[f"review_result_{draft['id']}"]['review_fingerprint'] old = app.session_state[f"review_result_{draft['id']}"]['review_fingerprint']
+24
View File
@@ -0,0 +1,24 @@
from pathlib import Path
from jarca_ledger_mcp.db import init_db
from jarca_ledger_mcp.application import settlement_draft_service as service
def test_cancelled_draft_does_not_reserve_create_key_for_new_draft(tmp_path: Path):
db = str(tmp_path / "ledger.sqlite3")
init_db(db)
first = service.create_settlement_draft("2026-09", "actor", "same-key", db_path=db)
assert first["success"]
cancelled = service.cancel_settlement_draft(first["draft"]["id"], 1, "actor", "restart", db_path=db)
assert cancelled["success"]
second = service.create_settlement_draft("2026-09", "actor", "new-key-after-cancel", db_path=db)
assert second["success"]
assert second["draft"]["id"] != first["draft"]["id"]
def test_dashboard_exposes_the_five_state_entry_labels():
source = Path(__file__).parents[1].joinpath("dashboard/page_views/settlement_drafts.py").read_text()
for label in ("정산시작", "이어서작업", "마감내역보기", "정정시작", "정정이어서작업"):
assert label in source
assert "검토·승인·마감·취소·평가" not in source
Generated
+1 -1
View File
@@ -469,7 +469,7 @@ wheels = [
[[package]] [[package]]
name = "jarca-ledger-mcp" name = "jarca-ledger-mcp"
version = "0.1.7" version = "0.1.8"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "mcp" }, { name = "mcp" },