feat: add monthly settlement read model

Implements REQ-022 monthly settlement read-model foundation.
This commit is contained in:
2026-07-12 00:43:50 +09:00
committed by GitHub
parent ef07fba765
commit c0580f7173
4 changed files with 149 additions and 0 deletions
+2
View File
@@ -9,6 +9,7 @@ from . import categorization_services as _categorization_services
from . import cross_transfer_service as _cross_transfer_service
from . import imports_service as _imports_service
from . import import_transfer_services as _import_transfer_services
from . import monthly_settlement_service as _monthly_settlement_service
from . import reconciliation_service as _reconciliation_service
from . import shared as shared
from . import system_services as _system_services
@@ -27,6 +28,7 @@ _EXPORT_MODULES = (
_cross_transfer_service,
_imports_service,
_import_transfer_services,
_monthly_settlement_service,
_reconciliation_service,
_system_services,
_transactions_write_services,
@@ -0,0 +1,113 @@
from __future__ import annotations
from .assets_service import get_asset_snapshot_report
from .imports_service import list_ledger_import_batches
from .reconciliation_service import get_balance_reconciliation_report
from .review_decision_service import get_review_operating_indicators
from .shared import Any, fail, normalize_month, normalize_snapshot_at, ok
from .transaction_report_services import get_budget_vs_actual_report, get_monthly_cashflow_report
def _month_end_date(month: str) -> str:
return normalize_snapshot_at(month, default_now=False)[:10]
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")
def _unresolved_blocker_summary(review_report: dict[str, Any], reconciliation_report: dict[str, Any]) -> dict[str, Any]:
indicators = review_report.get("indicators", {}) if review_report.get("success") else {}
reconciliation_items = reconciliation_report.get("items", []) if reconciliation_report.get("success") else []
reconciliation_attention_count = sum(1 for item in reconciliation_items if item.get("status") != "OK")
counts = {
"uncategorized_recommendation_backlog": int(indicators.get("uncategorized_recommendation_backlog") or 0),
"pending_review_count": int(indicators.get("pending_review_count") or 0),
"held_recheck_count": int(indicators.get("held_recheck_count") or 0),
"unreconciled_account_count": int(indicators.get("unreconciled_account_count") or 0),
"reconciliation_attention_count": reconciliation_attention_count,
}
return {"count": sum(counts.values()), "counts": counts}
def _source_coverage(
cashflow_report: dict[str, Any],
budget_report: dict[str, Any],
review_report: dict[str, Any],
reconciliation_report: dict[str, Any],
import_report: dict[str, Any],
asset_report: dict[str, Any],
) -> dict[str, Any]:
latest_import = (import_report.get("import_batches") or [None])[0]
return {
"cashflow": {"success": bool(cashflow_report.get("success")), "source": "transactions"},
"budget": {
"success": bool(budget_report.get("success")),
"budget_count": len(budget_report.get("items", [])),
},
"review": {
"success": bool(review_report.get("success")),
"indicators": review_report.get("indicators", {}) if review_report.get("success") else {},
},
"reconciliation": {
"success": bool(reconciliation_report.get("success")),
"item_count": int(reconciliation_report.get("count") or 0),
},
"imports": {
"success": bool(import_report.get("success")),
"recent_batch_count": int(import_report.get("count") or 0),
"latest_imported_at": latest_import.get("imported_at") if isinstance(latest_import, dict) else "",
},
"assets": {
"success": bool(asset_report.get("success")),
"basis_date": asset_report.get("basis_date", ""),
"source": asset_report.get("source", ""),
"is_closed_snapshot": bool(asset_report.get("is_closed_snapshot", False)),
"net_worth": asset_report.get("net_worth"),
},
}
def get_monthly_settlement_summary(month: str = "", db_path: str | None = None) -> dict[str, Any]:
"""Return the read-only monthly settlement summary foundation for dashboard/MCP consumers."""
try:
selected_month = normalize_month(month)
basis_date = _month_end_date(selected_month)
except Exception as exc:
return fail("validation_error", str(exc))
cashflow_report = get_monthly_cashflow_report(selected_month, db_path=db_path)
if not cashflow_report.get("success"):
return cashflow_report
budget_report = get_budget_vs_actual_report(selected_month, db_path=db_path)
review_report = get_review_operating_indicators(db_path=db_path)
reconciliation_report = get_balance_reconciliation_report(db_path=db_path)
import_report = list_ledger_import_batches(limit=5, db_path=db_path)
asset_report = get_asset_snapshot_report(basis_date=basis_date, db_path=db_path)
unresolved_blockers = _unresolved_blocker_summary(review_report, reconciliation_report)
return ok(
selected_month=selected_month,
month=selected_month,
basis_date=basis_date,
actual_income=int(cashflow_report.get("income") or 0),
actual_expense=int(cashflow_report.get("expenses") or 0),
net_cashflow=int(cashflow_report.get("net_cashflow") or 0),
savings_rate=cashflow_report.get("savings_rate"),
budget_overrun_count=_budget_overrun_count(budget_report),
unresolved_blocker_count=int(unresolved_blockers["count"]),
unresolved_blockers=unresolved_blockers,
source_coverage=_source_coverage(
cashflow_report,
budget_report,
review_report,
reconciliation_report,
import_report,
asset_report,
),
reports={
"cashflow": cashflow_report,
"budget": budget_report,
},
read_only=True,
)
+2
View File
@@ -12,6 +12,7 @@ from .application import categorization_services as _categorization_services
from .application import cross_transfer_service as _cross_transfer_service
from .application import imports_service as _imports_service
from .application import import_transfer_services as _import_transfer_services
from .application import monthly_settlement_service as _monthly_settlement_service
from .application import reconciliation_service as _reconciliation_service
from .application import review_decision_service as _review_decision_service
from .application import shared as _shared
@@ -34,6 +35,7 @@ _PROXY_MODULES = (
_transaction_report_services,
_reconciliation_service,
_review_decision_service,
_monthly_settlement_service,
_imports_service,
_import_transfer_services,
_cross_transfer_service,