Compare commits
5
Commits
dda76b3bbd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29eacb4aed | ||
|
|
172f36a933 | ||
|
|
1134f058cc | ||
|
|
a97548c36b | ||
|
|
38a854d525 |
@@ -19,3 +19,4 @@ tmp_*
|
|||||||
.hermes/
|
.hermes/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
/data/
|
||||||
|
|||||||
@@ -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/서버로 운영합니다.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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("페이지를 불러오지 못했습니다. 다시 시도해 주세요.")
|
||||||
|
|||||||
@@ -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=요약)")
|
||||||
@@ -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=요약)")
|
|
||||||
|
|||||||
@@ -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", []))
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -136,8 +136,11 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: jarca-ledger-dashboard-auth
|
name: jarca-ledger-dashboard-auth
|
||||||
key: signing-secret
|
key: signing-secret
|
||||||
|
# Traefik reaches this pod from the K3s pod CIDR. Uvicorn must
|
||||||
|
# trust that proxy range so HTTPS Origin checks see the public
|
||||||
|
# scheme instead of the internal HTTP hop.
|
||||||
- name: FORWARDED_ALLOW_IPS
|
- name: FORWARDED_ALLOW_IPS
|
||||||
value: 127.0.0.1
|
value: 10.42.0.0/16
|
||||||
ports:
|
ports:
|
||||||
- name: dashboard
|
- name: dashboard
|
||||||
containerPort: 8501
|
containerPort: 8501
|
||||||
|
|||||||
+32
-5
@@ -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.
|
||||||
|
|||||||
@@ -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,3 +1,3 @@
|
|||||||
"""Jarca Ledger MCP server."""
|
"""Jarca Ledger MCP server."""
|
||||||
|
|
||||||
__version__ = "0.1.7"
|
__version__ = "0.1.8"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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*"]
|
|
||||||
|
|||||||
Vendored
+289
@@ -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');
|
||||||
@@ -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 == "저장"
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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) == "요약"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,)
|
||||||
@@ -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"]
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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']}"
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user