feat: extend comprehensive monthly settlement snapshots (#18)

This commit is contained in:
gitea-impl-agent
2026-10-13 04:36:38 +00:00
parent f67c07adf0
commit dd35e0785f
30 changed files with 1817 additions and 48 deletions
+11
View File
@@ -484,3 +484,14 @@ def finalize_settlement_draft(draft_id: str, expected_version: int, review_finge
@mcp.tool()
def cancel_settlement_draft(draft_id: str, expected_version: int, cancelled_by: str, reason: str) -> dict:
return contracts.cancel_settlement_draft(draft_id, expected_version, cancelled_by, reason)
@mcp.tool()
def set_settlement_cashflow_policy(draft_id: str, expected_version: int, updated_by: str, cashflow_policy: dict) -> dict:
"""Set explicit monthly cashflow classification policy for a settlement draft."""
return contracts.set_settlement_cashflow_policy(draft_id, expected_version, updated_by, cashflow_policy)
@mcp.tool()
def get_monthly_settlement_snapshot(month: str = "", run_id: str = "") -> dict:
"""Read a saved monthly settlement snapshot, optionally selecting an exact run."""
return contracts.get_monthly_settlement_snapshot(month, run_id)
@@ -10,6 +10,8 @@ from ...service import (
refresh_settlement_draft_targets as refresh_settlement_draft_targets,
review_settlement_draft as review_settlement_draft,
set_settlement_draft_item as set_settlement_draft_item,
set_settlement_cashflow_policy as set_settlement_cashflow_policy,
get_monthly_settlement_snapshot as get_monthly_settlement_snapshot,
update_settlement_draft as update_settlement_draft,
annotations as annotations,
apply_categorization_rules as apply_categorization_rules,
@@ -91,6 +93,7 @@ TOOL_CONTRACTS = {
"update_settlement_draft", "refresh_settlement_draft_targets",
"set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
)
}
@@ -99,6 +102,7 @@ __all__ = [
"finalize_settlement_draft", "get_settlement_draft", "list_settlement_drafts",
"refresh_settlement_draft_targets", "review_settlement_draft",
"set_settlement_draft_item", "update_settlement_draft",
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
"annotations",
"apply_categorization_rules",
"close_asset_snapshot",
@@ -9,15 +9,18 @@ from ...service import (
create_settlement_draft as create_settlement_draft,
finalize_settlement_draft as finalize_settlement_draft,
get_settlement_draft as get_settlement_draft,
get_monthly_settlement_snapshot as get_monthly_settlement_snapshot,
list_account_valuations as list_account_valuations,
list_settlement_drafts as list_settlement_drafts,
refresh_settlement_draft_targets as refresh_settlement_draft_targets,
register_account_valuation as register_account_valuation,
review_settlement_draft as review_settlement_draft,
set_settlement_draft_item as set_settlement_draft_item,
set_settlement_cashflow_policy as set_settlement_cashflow_policy,
update_settlement_draft as update_settlement_draft,
)
from ...application import settlement_draft_service as _draft_service
from ...application import monthly_settlement_service as _monthly_service
from ... import service as _service
@@ -32,7 +35,12 @@ def _guard(db_path: str | None) -> str:
def _wrap(fn):
def call(*args, db_path=None, **kwargs):
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
if fn.__name__ == "set_settlement_cashflow_policy":
target = _draft_service
elif fn.__name__ == "get_monthly_settlement_snapshot":
target = _monthly_service
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
return getattr(target, fn.__name__)(*args, db_path=_guard(db_path), **kwargs)
call.__name__ = fn.__name__
call.__doc__ = fn.__doc__
@@ -47,6 +55,7 @@ for _name in (
"set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
"register_account_valuation", "list_account_valuations", "correct_account_valuation",
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
):
globals()[_name] = _wrap(getattr(_service, _name))
@@ -56,4 +65,5 @@ __all__ = [
"set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
"register_account_valuation", "list_account_valuations", "correct_account_valuation",
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
]
@@ -1,5 +1,9 @@
from __future__ import annotations
import json
from .shared import read_db, DatabaseNotInitializedError
from .assets_service import get_asset_snapshot_report
from .imports_service import list_ledger_import_batches
from .reconciliation_service import get_balance_reconciliation_report
@@ -130,3 +134,122 @@ def get_monthly_settlement_summary(month: str = "", db_path: str | None = None)
},
read_only=True,
)
def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path: str | None = None) -> dict[str, Any]:
"""Read a persisted monthly settlement close without consulting live ledger state."""
if not isinstance(month, str) or not isinstance(run_id, str):
return fail("validation_error", "month and run_id must be strings")
try:
selected_month = normalize_month(month) if month else ""
if selected_month and (len(selected_month) != 7 or selected_month[4] != "-"):
raise ValueError("invalid month")
except (TypeError, ValueError):
return fail("validation_error", "invalid settlement month")
def parsed(value: Any, default: Any):
if value in (None, ""):
return default
return json.loads(value)
try:
with read_db(db_path) as conn:
tables = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
if "asset_snapshot_runs" not in tables or "asset_snapshots" not in tables:
return fail("not_found", "settlement snapshot not found")
run = None
rows = []
legacy = False
metadata: dict[str, Any] = {}
if run_id:
run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE id=?", (run_id,)).fetchone()
if run:
from .assets_service import _snapshot_rows_for_run
rows, metadata = _snapshot_rows_for_run(conn, run_id)
else:
cols = {r["name"] for r in conn.execute("PRAGMA table_info(asset_snapshots)")}
if {"snapshot_run_id", "closed_at"} <= cols:
rows = conn.execute("SELECT rowid AS _rowid,* FROM asset_snapshots WHERE snapshot_run_id=? AND closed_at<>'' ORDER BY rowid", (run_id,)).fetchall()
if rows:
legacy = True
metadata = {"snapshot_run_id": run_id, "legacy_closed": True, "close_status": "legacy_read_only"}
else:
if selected_month:
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:
basis = _month_end_date(selected_month) if selected_month else ""
if basis:
from .assets_service import _snapshot_rows_for_latest_run
rows, metadata = _snapshot_rows_for_latest_run(conn, basis)
legacy = bool(metadata.get("legacy_closed"))
if legacy:
rows = [r for r in rows if r["snapshot_run_id"] == metadata.get("snapshot_run_id") and r["closed_at"]]
elif not metadata.get("snapshot_run_id"):
rows = []
if metadata.get("snapshot_run_id"):
run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE id=?", (metadata["snapshot_run_id"],)).fetchone()
if not run and not rows:
return fail("not_found", "settlement snapshot not found")
if run:
rid = run["id"]
basis_date = run["basis_date"]
run_month = run["settlement_month"] if "settlement_month" in run.keys() else None
if selected_month and ((run_month and run_month != selected_month) or (not run_month and basis_date[:7] != selected_month)):
return fail("validation_error", "run does not match settlement month")
if not metadata:
from .assets_service import _snapshot_rows_for_run
rows, metadata = _snapshot_rows_for_run(conn, rid)
else:
rid = metadata.get("snapshot_run_id", run_id)
basis_date = rows[0]["basis_date"]
if selected_month and basis_date[:7] != selected_month:
return fail("validation_error", "run does not match settlement month")
legacy = True
keys = set(run.keys()) if run else set()
def field(name): return run[name] if run and name in keys else None
from .settlement_cashflow_service import _AGGREGATION_VERSION
if (field("snapshot_schema_version") is not None or field("aggregation_version") is not None) and (
field("snapshot_schema_version") != 1 or field("aggregation_version") != _AGGREGATION_VERSION
):
raise ValueError("unsupported comprehensive snapshot version")
comprehensive = bool(field("snapshot_schema_version") == 1 and not legacy)
legacy = not comprehensive
cashflows = []
if comprehensive and "settlement_cashflow_snapshots" in tables:
cashflows = [dict(r) for r in conn.execute("SELECT cashflow_kind,category,subcategory,amount,transaction_count,currency FROM settlement_cashflow_snapshots WHERE settlement_run_id=? ORDER BY cashflow_kind,category,subcategory,currency", (rid,))]
totals = {"income_total": field("income_total") if comprehensive else None, "expense_total": field("expense_total") if comprehensive else None, "net_cashflow": field("net_cashflow") if comprehensive else None, "total_assets": field("total_assets") if comprehensive else None, "total_liabilities": field("total_liabilities") if comprehensive else None, "net_worth": field("net_worth") if comprehensive else None}
safe_items = []
for row in rows:
d = dict(row)
safe_items.append({k: d.get(k) for k in ("id","source_type","source_id","name","amount","currency","liquidity_bucket","valuation_source","snapshot_run_id","correction_of_run_id","correction_reason","is_correction","closed_at","valuation_id","selection_policy","verification_status","evidence_kind","exclusion_reason","created_at") if k in d})
source = parsed(field("source_snapshot_json"), {}) if comprehensive else {}
if not isinstance(source, dict): raise ValueError()
if comprehensive:
tx_fields = ("id", "transaction_date", "type", "amount", "currency", "category", "subcategory", "original_cashflow_kind", "originalcashflow_kind", "cashflow_kind", "applied_amount", "sourceprovenance", "category_cashflow_kind", "category_active")
asset_fields = ("id", "source_id", "source_type", "valuation_id", "valuation_date", "basis_date", "amount", "currency", "liquidity_bucket", "selection_mode", "applied_amount", "authority_source", "is_liability", "status", "verified", "verification_status", "evidence_kind", "exclusion_reason", "state_hash")
def project(records, fields):
if not isinstance(records, list) or any(not isinstance(x, dict) for x in records):
raise ValueError()
result = []
for record in records:
item = {k: record[k] for k in fields if k in record}
if any(value is not None and not isinstance(value, (str, int, bool)) for value in item.values()):
raise ValueError()
for key, allowed in (("decision", ("category", "subcategory", "cashflow_kind", "reason")), ("source_classification", ("id", "category", "subcategory", "cashflow_kind", "active"))):
value = record.get(key)
if isinstance(value, dict):
item[key] = {k: value[k] for k in allowed if k in value and (value[k] is None or isinstance(value[k], (str, int, bool)))}
result.append(item)
return result
source = {"transactions": project(source.get("transactions", []), tx_fields), "assets": project(source.get("assets", []), asset_fields)}
policy = parsed(field("cashflow_policy_json"), {}) if comprehensive else None
if comprehensive:
from .settlement_cashflow_service import _normalize_cashflow_policy
if not isinstance(policy, dict):
raise ValueError()
policy = _normalize_cashflow_policy(policy)
return ok(source="settlement_snapshot", snapshot_run_id=rid, run_id=rid, settlement_month=field("settlement_month") or basis_date[:7], basis_date=basis_date, currency=field("currency"), is_closed_snapshot=True, is_comprehensive=comprehensive, legacy=legacy, aggregation_version=field("aggregation_version") if comprehensive else None, snapshot_schema_version=field("snapshot_schema_version") if comprehensive else None, cashflow_policy=policy, cashflow_items=cashflows, items=safe_items, source_snapshot=source, totals=totals, **totals, close_status=(field("close_status") or metadata.get("close_status", "legacy_read_only")), correction={"is_correction": bool(field("correction_of_run_id") or metadata.get("is_correction")), "correction_of_run_id": field("correction_of_run_id") or metadata.get("correction_of_run_id", ""), "correction_reason": field("correction_reason") or metadata.get("correction_reason", "")})
except DatabaseNotInitializedError:
return fail("db_not_initialized", "ledger database is not initialized")
except Exception:
return fail("db_error", "settlement snapshot read failed")
@@ -0,0 +1,126 @@
"""Private, deterministic aggregation for comprehensive settlement previews."""
from __future__ import annotations
from datetime import date
from .shared import CASHFLOW_KIND_SQL
_AGGREGATION_VERSION = "comprehensive-v1"
_SNAPSHOT_SCHEMA_VERSION = 1
_ALLOWED_KINDS = {"income", "expense", "excluded"}
def _normalize_cashflow_policy(payload: dict) -> dict:
if not isinstance(payload, dict) or set(payload) - {"savings_categories", "category_decisions"}:
raise ValueError("cashflow policy must contain only supported keys")
savings = payload.get("savings_categories", [])
decisions = payload.get("category_decisions", [])
if not isinstance(savings, list) or not isinstance(decisions, list):
raise ValueError("policy categories and decisions must be lists")
def pair(row, required):
if not isinstance(row, dict) or set(row) != required:
raise ValueError("invalid category policy entry")
if any(not isinstance(row[k], str) for k in ("category", "subcategory")):
raise ValueError("category fields must be strings")
return row["category"], row["subcategory"]
normalized_savings = [pair(x, {"category", "subcategory"}) for x in savings]
if len(set(normalized_savings)) != len(normalized_savings):
raise ValueError("duplicate savings category")
normalized_decisions = []
for row in decisions:
cat, sub = pair(row, {"category", "subcategory", "cashflow_kind", "reason"})
kind, reason = row["cashflow_kind"], row["reason"]
if not isinstance(kind, str) or kind not in _ALLOWED_KINDS:
raise ValueError("invalid cashflow decision kind")
if not isinstance(reason, str) or not reason.strip():
raise ValueError("decision reason must be nonempty")
normalized_decisions.append((cat, sub, kind, reason))
keys = [(x[0], x[1]) for x in normalized_decisions]
if len(set(keys)) != len(keys):
raise ValueError("duplicate category decision")
return {"savings_categories": [{"category": c, "subcategory": s} for c, s in sorted(normalized_savings)],
"category_decisions": [{"category": c, "subcategory": s, "cashflow_kind": k, "reason": r}
for c, s, k, r in sorted(normalized_decisions)]}
def _review_cashflow(conn, month: str, policy: dict) -> dict:
policy = _normalize_cashflow_policy(policy)
start = date.fromisoformat(month + "-01")
end = date(start.year + (start.month == 12), 1 if start.month == 12 else start.month + 1, 1)
rows = conn.execute(f"""SELECT t.id,t.transaction_date,t.type,t.amount,t.currency,t.category,t.subcategory,
{CASHFLOW_KIND_SQL} AS originalcashflow_kind, lc.cashflow_kind AS category_cashflow_kind,
lc.active AS category_active
FROM transactions t LEFT JOIN ledger_categories lc
ON lc.category=t.category AND lc.subcategory=t.subcategory
WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<?
ORDER BY t.transaction_date,t.id""", (start.isoformat(), end.isoformat())).fetchall()
savings = {(x["category"], x["subcategory"]) for x in policy["savings_categories"]}
decisions = {(x["category"], x["subcategory"]): x for x in policy["category_decisions"]}
grouped, source, blockers = {}, [], []
income = expense = 0
for row in rows:
t = dict(row)
original = t["originalcashflow_kind"]
pair = (t["category"], t["subcategory"])
decision = decisions.get(pair)
typ, catkind = t["type"], original
amount = t["amount"]
raw_valid = isinstance(amount, int) and not isinstance(amount, bool)
reason = None
# Explicit movement classes are immutable exclusions; investment is only selected by pair.
general_transfer = catkind == "transfer" or (typ == "transfer" and catkind != "investment")
initial_adjustment = typ == "adjustment"
explicit_exclusion = decision and decision["cashflow_kind"] == "excluded"
kind = "excluded"
applied = amount if raw_valid else None
if general_transfer or initial_adjustment or catkind in {"exclude", "transfer"} or explicit_exclusion:
kind = "excluded"
elif typ == "refund":
if catkind in {"expense", "debt_payment"} or pair in savings or (decision and decision["cashflow_kind"] == "expense"):
kind, applied = "expense", -amount if raw_valid else None
else:
reason = "refund requires an expense classification or explicit expense decision"
elif catkind == "investment":
if pair in savings:
kind = "expense"
else:
kind = "excluded"
elif catkind == "debt_payment":
kind = "expense"
elif catkind in {"income", "expense"}:
kind = catkind
elif decision and decision["cashflow_kind"] in {"income", "expense"}:
kind = decision["cashflow_kind"]
else:
reason = "unknown cashflow classification requires an explicit decision"
if not raw_valid and kind != "excluded":
reason = "transaction amount is not an integer"
if reason:
blockers.append({"transaction_id": t["id"], "reason": reason})
kind, applied = "unknown", None
if kind != "excluded" and t["currency"].upper() != "KRW":
blockers.append({"transaction_id": t["id"], "reason": "non-KRW currency requires FX review"})
applied = None
provenance = "category" if t["category_active"] else "fallback"
evidence = {k: t[k] for k in ("id", "transaction_date", "type", "amount", "currency", "category", "subcategory")}
evidence.update(originalcashflow_kind=original, cashflow_kind=kind, applied_amount=applied,
decision=decision, sourceprovenance=provenance,
category_cashflow_kind=t["category_cashflow_kind"], category_active=t["category_active"])
source.append(evidence)
if applied is None or kind not in {"income", "expense"}:
if kind == "excluded" and applied is not None:
key = (kind, pair[0], pair[1], t["currency"].upper())
grp = grouped.setdefault(key, [0, 0]); grp[0] += applied; grp[1] += 1
continue
if t["currency"].upper() != "KRW":
continue
key = (kind, pair[0], pair[1], t["currency"].upper())
grp = grouped.setdefault(key, [0, 0]); grp[0] += applied; grp[1] += 1
if kind == "income": income += applied
else: expense += applied
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())]
return {"cashflow_policy": policy, "aggregation_version": _AGGREGATION_VERSION,
"snapshot_schema_version": _SNAPSHOT_SCHEMA_VERSION, "income_total": income,
"expense_total": expense, "net_cashflow": income-expense, "cashflow_items": items,
"cashflow_blockers": blockers, "cashflow_warnings": [], "source_snapshot": {"transactions": source}}
@@ -130,10 +130,27 @@ def _review(conn, draft):
latest = _latest_close(conn, draft['basis_date'])
summary = _summarize_valuation_items(included)
totals = {key: summary[key] for key in ('total_assets', 'total_liabilities', 'net_worth')}
serial = json.dumps({'draft_id': draft['id'], 'version': draft['version'], 'basis_date': draft['basis_date'], 'states': states, 'warnings': warnings, 'latest_run': latest, 'correction_parent': draft['correction_of_run_id'], 'totals': totals}, sort_keys=True, ensure_ascii=False)
from .settlement_cashflow_service import _normalize_cashflow_policy, _review_cashflow, _AGGREGATION_VERSION
if draft.get('aggregation_version') != _AGGREGATION_VERSION:
raise ValueError('unsupported stored aggregation version')
policy = _normalize_cashflow_policy(json.loads(draft.get('cashflow_policy_json') or '{}'))
cf = _review_cashflow(conn, draft['month'], policy)
loan_sum = sum((x.get('candidate_amount') or 0) for x in included if x.get('is_liability'))
loan_sign = -1 if loan_sum < 0 else 1 if loan_sum > 0 else 0
asset_snapshot = []
for x in items:
raw = x.get('candidate_amount')
applied_amount = raw * loan_sign if x.get('amount') is not None and x.get('is_liability') else x.get('amount')
asset_snapshot.append({k: x.get(k) for k in ('source_id','source_type','valuation_id','valuation_date','basis_date','currency','authority_source','liquidity_bucket','is_liability','selection_mode','exclusion_reason','status','verification_status')} | {'amount': raw, 'applied_amount': applied_amount})
cf['source_snapshot']['assets'] = asset_snapshot
totals.update({k: cf[k] for k in ('income_total','expense_total','net_cashflow')})
full_totals = {**totals}
serial = json.dumps({'draft_id': draft['id'], 'version': draft['version'], 'basis_date': draft['basis_date'], 'states': states, 'warnings': warnings, 'latest_run': latest, 'correction_parent': draft['correction_of_run_id'], 'totals': full_totals, 'cashflow': cf}, sort_keys=True, ensure_ascii=False)
return {'draft_id': draft['id'], 'version': draft['version'], 'review_fingerprint': hashlib.sha256(serial.encode()).hexdigest(),
'cashflow_policy': policy, 'aggregation_version': cf['aggregation_version'], 'snapshot_schema_version': cf['snapshot_schema_version'], 'cashflow_items': cf['cashflow_items'], 'cashflow_blockers': cf['cashflow_blockers'], 'cashflow_warnings': cf['cashflow_warnings'], 'source_snapshot': cf['source_snapshot'],
'items': items, 'included_items': included, 'excluded_items': excluded, 'missing_items': missing, 'warnings': warnings,
'approval_required': True, 'totals': totals, **totals, 'complete': bool(items) and not missing,
'approval_required': True, 'totals': totals, **totals,
'complete': bool(items) and not missing and not cf['cashflow_blockers'],
'unregistered_liabilities': not any(x['is_liability'] for x in items),
'contains_substitutions': any(x['selection_mode'] == 'manual_substitute' for x in included),
'contains_carried': any(x['selection_mode'] == 'carried_before' for x in included),
@@ -143,11 +160,15 @@ def _review(conn, draft):
def _result(conn,did):
d=_draft(conn,did)
if not d:return _err('not_found','settlement draft not found')
draft_out = {k: v for k, v in d.items() if k != 'final_response'}
if draft_out.get('cashflow_policy_json'):
from .settlement_cashflow_service import _normalize_cashflow_policy
draft_out['cashflow_policy'] = _normalize_cashflow_policy(json.loads(draft_out['cashflow_policy_json']))
if d['status'] == 'completed' and d['final_response']:
final = json.loads(d['final_response'])
return ok(draft={k: v for k, v in d.items() if k != 'final_response'}, items=final.get('items', []) + final.get('excluded_items', []), final_response=final)
return ok(draft=draft_out, items=final.get('items', []) + final.get('excluded_items', []), final_response=final)
review = _review(conn, d)
return ok(draft={k: v for k, v in d.items() if k != 'final_response'}, items=review['items'], review=review)
return ok(draft=draft_out, items=review['items'], review=review)
def create_settlement_draft(month: str, created_by: str, idempotency_key: str, basis_date: str = '', memo: str = '', correction_of_run_id: str = '', correction_reason: str = '', db_path: str|None=None):
@@ -189,7 +210,12 @@ def list_settlement_drafts(month: str='',db_path: str|None=None):
try:
with read_db(db_path) as c:
rows=c.execute('SELECT * FROM settlement_drafts'+(' WHERE month=?' if month else '')+' ORDER BY created_at DESC',(month,) if month else ()).fetchall()
return ok(drafts=[dict(x) for x in rows])
drafts = [dict(x) for x in rows]
for item in drafts:
if item.get('cashflow_policy_json'):
from .settlement_cashflow_service import _normalize_cashflow_policy
item['cashflow_policy'] = _normalize_cashflow_policy(json.loads(item['cashflow_policy_json']))
return ok(drafts=drafts)
except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error')
@@ -211,6 +237,19 @@ def _mutate(did, version, actor, sql, params, db_path):
except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error')
def set_settlement_cashflow_policy(draft_id: str, expected_version: int, updated_by: str, cashflow_policy: dict, db_path: str|None=None):
from .settlement_cashflow_service import _normalize_cashflow_policy
if not _valid_version(expected_version) or not _nonempty(updated_by) or not isinstance(draft_id, str) or not draft_id:
return _err('validation_error', 'valid version, draft and actor required')
try:
policy = _normalize_cashflow_policy(cashflow_policy)
encoded = json.dumps(policy, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
except (ValueError, TypeError):
return _err('validation_error', 'invalid cashflow policy')
return _mutate(draft_id, expected_version, updated_by,
"UPDATE settlement_drafts SET cashflow_policy_json=?,aggregation_version='comprehensive-v1' WHERE id=?", (encoded,), db_path)
def update_settlement_draft(draft_id: str,expected_version: int,updated_by: str,memo: str,db_path: str|None=None):
if not isinstance(memo, str): return _err('validation_error', 'memo must be a string')
return _mutate(draft_id,expected_version,updated_by,'UPDATE settlement_drafts SET memo=? WHERE id=?',(memo,),db_path)
@@ -312,6 +351,9 @@ def review_settlement_draft(draft_id: str,db_path: str|None=None):
with read_db(db_path) as c:
d=_draft(c,draft_id)
if not d:return _err('not_found','draft not found')
if d['status'] == 'completed' and d['final_response']:
saved = json.loads(d['final_response'])
return {**saved, 'version': d['version']}
return ok(**_review(c,d))
except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error')
@@ -326,6 +368,7 @@ def approve_settlement_draft(draft_id: str,expected_version: int,review_fingerpr
r=_review(c,d)
if r['review_fingerprint']!=review_fingerprint:return _err('conflict','review fingerprint changed')
if any(x['included'] and x.get('review_needed') for x in r['items']): return _err('conflict', 'changed evidence requires explicit selection or exclusion')
if r['cashflow_blockers']: return _err('validation_error', 'cashflow review blockers must be resolved')
if r['has_approved_missing'] and not allow_missing_exclusions:return _err('validation_error','missing/excluded items require approval')
if r['contains_carried'] and not allow_carried_before:return _err('validation_error','carried values require approval')
if r['contains_substitutions'] and not allow_manual_substitute:return _err('validation_error','substitutes require approval')
@@ -352,6 +395,7 @@ def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerp
r=_review(c,d)
if not r['items']: return _err('validation_error', 'an empty draft cannot create a close run')
if any(x['included'] and x.get('review_needed') for x in r['items']): return _err('conflict', 'changed evidence requires explicit selection or exclusion')
if r['cashflow_blockers']: return _err('validation_error', 'cashflow review blockers must be resolved')
if _latest_close(c, d['basis_date']) != d['correction_of_run_id']: return _err('conflict', 'correction parent changed')
if r['review_fingerprint']!=review_fingerprint or d['approval_fingerprint']!=review_fingerprint or d['approval_version']!=expected_version:return _err('conflict','review/approval stale')
flags=json.loads(d['approval_flags'] or '{}')
@@ -366,12 +410,39 @@ def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerp
excluded = [{**x, 'draft_id': draft_id, 'draft_version': expected_version, 'review_fingerprint': review_fingerprint, 'approval': approval, 'reason': 'approved_missing', 'exclusion_reason': x.get('exclusion_reason') or x.get('reason', 'missing_evidence')} for x in r['excluded_items'] + r['missing_items']]
carried = [x for x in applied if x['selection_mode'] == 'carried_before']
run=uuid4().hex; ts=now_iso(); basis=d['basis_date']; status='conditional' if excluded or r['contains_substitutions'] or r['contains_carried'] else 'complete_exact'
c.execute('INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,idempotency_key,approved_by,approved_at,approval_reason,approval_reference,correction_of_run_id,correction_reason,applied_evidence,excluded_evidence,carried_evidence,request_fingerprint,created_at,draft_id,draft_version,review_fingerprint) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(run,basis,status,'exact',idempotency_key,d['approved_by'],d['approved_at'],d['approval_reason'],d['approval_reference'],d['correction_of_run_id'],d['correction_reason'],json.dumps(applied,sort_keys=True),json.dumps(excluded,sort_keys=True),json.dumps(carried,sort_keys=True),req,ts,draft_id,expected_version,review_fingerprint))
c.execute('INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,idempotency_key,approved_by,approved_at,approval_reason,approval_reference,correction_of_run_id,correction_reason,applied_evidence,excluded_evidence,carried_evidence,request_fingerprint,created_at,draft_id,draft_version,review_fingerprint,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) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(run,basis,status,'exact',idempotency_key,d['approved_by'],d['approved_at'],d['approval_reason'],d['approval_reference'],d['correction_of_run_id'],d['correction_reason'],json.dumps(applied,sort_keys=True),json.dumps(excluded,sort_keys=True),json.dumps(carried,sort_keys=True),req,ts,draft_id,expected_version,review_fingerprint,d['month'],'KRW',r['income_total'],r['expense_total'],r['net_cashflow'],r['total_assets'],r['total_liabilities'],r['net_worth'],json.dumps(r['cashflow_policy'],sort_keys=True),r['aggregation_version'],r['snapshot_schema_version'],json.dumps(r['source_snapshot'],sort_keys=True)))
snapshot_ids = []
for x in applied + excluded:
sid = uuid4().hex
snapshot_ids.append(sid)
c.execute('INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at,valuation_id,selection_policy,verification_status,evidence_kind,exclusion_reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(sid,basis,x['source_type'],x['source_id'],x.get('name',''),x['amount'],x['currency'],x['liquidity_bucket'],x['selection_mode'],'','',run,d['correction_of_run_id'],d['correction_reason'],int(bool(d['correction_of_run_id'])),ts,ts,x.get('valuation_id') or '',x['selection_mode'],x.get('verification_status','unverified'),'excluded' if x in excluded else 'applied',x.get('exclusion_reason','')))
for item in r['cashflow_items']:
c.execute('INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)', (uuid4().hex,run,item['cashflow_kind'],item['category'],item['subcategory'],item['amount'],item['transaction_count'],item['currency']))
saved_groups = [dict(x) for x in c.execute('SELECT cashflow_kind,category,subcategory,amount,transaction_count,currency FROM settlement_cashflow_snapshots WHERE settlement_run_id=? ORDER BY cashflow_kind,category,subcategory,currency', (run,))]
expected_groups = sorted([{'cashflow_kind':x['cashflow_kind'],'category':x['category'],'subcategory':x['subcategory'],'amount':x['amount'],'transaction_count':x['transaction_count'],'currency':x['currency']} for x in r['cashflow_items']], key=lambda x:(x['cashflow_kind'],x['category'],x['subcategory'],x['currency']))
if saved_groups != expected_groups: raise ValueError('persisted cashflow detail mismatch')
header = c.execute('SELECT * FROM asset_snapshot_runs WHERE id=?', (run,)).fetchone()
expected_header = {k:r[k] for k in ('income_total','expense_total','net_cashflow','total_assets','total_liabilities','net_worth','aggregation_version','snapshot_schema_version')}
expected_header.update(settlement_month=d['month'],currency='KRW',basis_date=basis,draft_id=draft_id,draft_version=expected_version,review_fingerprint=review_fingerprint,request_fingerprint=req,close_status=status,idempotency_key=idempotency_key,correction_of_run_id=d['correction_of_run_id'],correction_reason=d['correction_reason'])
if not header or any(header[k] != v for k,v in expected_header.items()):
raise ValueError('persisted header mismatch')
for key,value in (('source_snapshot_json',r['source_snapshot']),('cashflow_policy_json',r['cashflow_policy']),('applied_evidence',applied),('excluded_evidence',excluded),('carried_evidence',carried)):
if json.loads(header[key]) != value:
raise ValueError('persisted evidence mismatch')
persisted_assets = [dict(x) for x in c.execute('SELECT * FROM asset_snapshots WHERE snapshot_run_id=? ORDER BY id', (run,))]
fields = ('source_type','source_id','amount','currency','liquidity_bucket')
expected_assets = sorted(zip(snapshot_ids, applied+excluded), key=lambda pair:pair[0])
if len(persisted_assets) != len(expected_assets):
raise ValueError('persisted asset count mismatch')
for row,(sid,item) in zip(persisted_assets,expected_assets):
if row['id'] != sid or any(row[k] != item[k] for k in fields) or row['basis_date'] != basis or row['valuation_id'] != (item.get('valuation_id') or '') or row['selection_policy'] != item['selection_mode'] or row['verification_status'] != item.get('verification_status','unverified') or row['evidence_kind'] != ('excluded' if item in excluded else 'applied') or row['exclusion_reason'] != item.get('exclusion_reason',''):
raise ValueError('persisted asset detail mismatch')
if sum(x['amount'] for x in saved_groups if x['cashflow_kind']=='income') != r['income_total'] or sum(x['amount'] for x in saved_groups if x['cashflow_kind']=='expense') != r['expense_total'] or r['net_cashflow'] != r['income_total']-r['expense_total']:
raise ValueError('cashflow totals mismatch')
asset_check = _summarize_valuation_items([x for x in persisted_assets if x['evidence_kind']=='applied'])
if (asset_check['total_assets'] != r['total_assets'] or asset_check['total_liabilities'] != r['total_liabilities']
or r['net_worth'] != r['total_assets']-r['total_liabilities']):
raise ValueError('asset totals mismatch')
evidence = _evidence_provenance(applied, basis)
response = _ok_with_provenance_aliases(
draft_id=draft_id, draft_version=expected_version, review_fingerprint=review_fingerprint,
@@ -379,11 +450,14 @@ def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerp
source='snapshot', is_closed_snapshot=True, selection_policy='exact',
inserted_count=len(snapshot_ids), inserted_snapshot_ids=snapshot_ids,
items=applied, excluded_items=excluded, totals=r['totals'],
income_total=r['income_total'], expense_total=r['expense_total'], net_cashflow=r['net_cashflow'],
total_assets=r['total_assets'], total_liabilities=r['total_liabilities'], net_worth=r['net_worth'],
cashflow_policy=r['cashflow_policy'], aggregation_version=r['aggregation_version'], snapshot_schema_version=r['snapshot_schema_version'], cashflow_items=r['cashflow_items'], cashflow_blockers=r['cashflow_blockers'], source_snapshot=r['source_snapshot'],
contains_substitutions=r['contains_substitutions'], contains_carried=r['contains_carried'], has_approved_missing=bool(excluded),
settlement_label='대체 평가자료를 포함한 월정산' if r['contains_substitutions'] else '조건부 월정산' if status == 'conditional' else '정확한 월말 평가 월정산',
warnings=r['warnings'], carried_items=carried, approval=approval, correction=correction, **correction,
provenance=_canonical_provenance(valuation_date=evidence['valuation_date'], age_days=evidence['age_days'], carried=bool(carried), verification_status=evidence['verification_status'], evidence_kind=evidence['evidence_kind'], basis_date=basis, snapshot_run_id=run, close_status=status, selection_policy='exact', warnings=r['warnings'], exclusions=excluded, approval=approval, correction=correction),
**{k: v for k, v in _summarize_valuation_items(applied, excluded).items() if k != 'excluded_items'},
**{k: v for k, v in _summarize_valuation_items(applied, excluded).items() if k not in ('excluded_items','total_assets','total_liabilities','net_worth')},
)
c.execute("UPDATE settlement_drafts SET status='completed',version=version+1,final_run_id=?,final_response=?,updated_at=? WHERE id=?",(run,json.dumps(response),ts,draft_id))
return response
@@ -33,7 +33,7 @@ def migrate_db(conn: sqlite3.Connection) -> None:
)""")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_settlement_drafts_active_month ON settlement_drafts(month) WHERE status='active'")
conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_drafts_month ON settlement_drafts(month,created_at)")
_add_missing_columns(conn, "settlement_drafts", {"original_request_hash": "TEXT NOT NULL DEFAULT ''"})
_add_missing_columns(conn, "settlement_drafts", {"original_request_hash": "TEXT NOT NULL DEFAULT ''", "cashflow_policy_json": "TEXT NOT NULL DEFAULT '{}'", "aggregation_version": "TEXT NOT NULL DEFAULT 'comprehensive-v1'"})
conn.execute("""CREATE TABLE IF NOT EXISTS settlement_draft_items (
id TEXT PRIMARY KEY, draft_id TEXT NOT NULL REFERENCES settlement_drafts(id),
source_type TEXT NOT NULL, source_id TEXT NOT NULL, valuation_id TEXT NOT NULL DEFAULT '',
@@ -142,6 +142,32 @@ def migrate_db(conn: sqlite3.Connection) -> None:
carried_evidence TEXT NOT NULL DEFAULT '[]', request_fingerprint TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
)""")
_add_missing_columns(conn, "asset_snapshot_runs", {"request_fingerprint": "TEXT NOT NULL DEFAULT ''", "draft_id": "TEXT NOT NULL DEFAULT ''", "draft_version": "INTEGER NOT NULL DEFAULT 0", "review_fingerprint": "TEXT NOT NULL DEFAULT ''"})
_add_missing_columns(conn, "asset_snapshot_runs", {
"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",
})
conn.execute("""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)
)""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_run ON settlement_cashflow_snapshots(settlement_run_id)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> ''")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = ''")
conn.execute("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 <> ''")
@@ -1,6 +1,6 @@
from __future__ import annotations
SCHEMA_VERSION = "13"
SCHEMA_VERSION = "14"
SCHEMA_SQL = """
PRAGMA journal_mode=WAL;
@@ -190,12 +190,37 @@ CREATE TABLE IF NOT EXISTS asset_snapshot_runs (
excluded_evidence TEXT NOT NULL DEFAULT '[]',
carried_evidence TEXT NOT NULL DEFAULT '[]',
request_fingerprint TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
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,
@@ -266,7 +291,7 @@ CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuati
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', '12');
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '14');
"""
ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = {