feat: add persistent monthly settlement drafts and approved valuation selection

This commit is contained in:
2026-10-10 02:49:34 +00:00
parent 68212e773b
commit 92f44661c3
37 changed files with 2902 additions and 29 deletions
+41
View File
@@ -443,3 +443,44 @@ def main() -> None:
if __name__ == "__main__":
main()
@mcp.tool()
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 = "") -> dict:
return contracts.create_settlement_draft(month, created_by, idempotency_key, basis_date, memo, correction_of_run_id, correction_reason)
@mcp.tool()
def list_settlement_drafts(month: str = "") -> dict:
return contracts.list_settlement_drafts(month)
@mcp.tool()
def get_settlement_draft(draft_id: str) -> dict:
return contracts.get_settlement_draft(draft_id)
@mcp.tool()
def update_settlement_draft(draft_id: str, expected_version: int, updated_by: str, memo: str) -> dict:
return contracts.update_settlement_draft(draft_id, expected_version, updated_by, memo)
@mcp.tool()
def refresh_settlement_draft_targets(draft_id: str, expected_version: int, updated_by: str) -> dict:
return contracts.refresh_settlement_draft_targets(draft_id, expected_version, updated_by)
@mcp.tool()
def set_settlement_draft_item(draft_id: str, source_type: str, source_id: str, expected_version: int, selected_by: str, valuation_id: str = "", selection_mode: str = "exact", selection_reason: str = "", included: bool = True, exclusion_reason: str = "") -> dict:
return contracts.set_settlement_draft_item(draft_id, source_type, source_id, expected_version, selected_by, valuation_id, selection_mode, selection_reason, included, exclusion_reason)
@mcp.tool()
def review_settlement_draft(draft_id: str) -> dict:
return contracts.review_settlement_draft(draft_id)
@mcp.tool()
def approve_settlement_draft(draft_id: str, expected_version: int, review_fingerprint: str, approved_by: str, approval_reason: str, approval_reference: str, allow_missing_exclusions: bool = False, allow_carried_before: bool = False, allow_manual_substitute: bool = False) -> dict:
return contracts.approve_settlement_draft(draft_id, expected_version, review_fingerprint, approved_by, approval_reason, approval_reference, allow_missing_exclusions, allow_carried_before, allow_manual_substitute)
@mcp.tool()
def finalize_settlement_draft(draft_id: str, expected_version: int, review_fingerprint: str, idempotency_key: str) -> dict:
return contracts.finalize_settlement_draft(draft_id, expected_version, review_fingerprint, idempotency_key)
@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)
@@ -1,6 +1,16 @@
from __future__ import annotations
from ...service import (
approve_settlement_draft as approve_settlement_draft,
cancel_settlement_draft as cancel_settlement_draft,
create_settlement_draft as create_settlement_draft,
finalize_settlement_draft as finalize_settlement_draft,
get_settlement_draft as get_settlement_draft,
list_settlement_drafts as list_settlement_drafts,
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,
update_settlement_draft as update_settlement_draft,
annotations as annotations,
apply_categorization_rules as apply_categorization_rules,
close_asset_snapshot as close_asset_snapshot,
@@ -74,7 +84,21 @@ from ...service import (
update_manual_asset as update_manual_asset,
)
TOOL_CONTRACTS = {
name: globals()[name]
for 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",
)
}
__all__ = [
"approve_settlement_draft", "cancel_settlement_draft", "create_settlement_draft",
"finalize_settlement_draft", "get_settlement_draft", "list_settlement_drafts",
"refresh_settlement_draft_targets", "review_settlement_draft",
"set_settlement_draft_item", "update_settlement_draft",
"annotations",
"apply_categorization_rules",
"close_asset_snapshot",
@@ -0,0 +1,59 @@
from __future__ import annotations
import inspect
from ...service import (
approve_settlement_draft as approve_settlement_draft,
cancel_settlement_draft as cancel_settlement_draft,
correct_account_valuation as correct_account_valuation,
create_settlement_draft as create_settlement_draft,
finalize_settlement_draft as finalize_settlement_draft,
get_settlement_draft as get_settlement_draft,
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,
update_settlement_draft as update_settlement_draft,
)
from ...application import settlement_draft_service as _draft_service
from ... import service as _service
def _resolve_db_path() -> str:
from ...db import resolve_db_path
return str(resolve_db_path())
def _guard(db_path: str | None) -> str:
return db_path or _resolve_db_path()
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
return getattr(target, fn.__name__)(*args, db_path=_guard(db_path), **kwargs)
call.__name__ = fn.__name__
call.__doc__ = fn.__doc__
call.__signature__ = inspect.signature(fn)
return call
# Keep the service signatures unchanged; path resolution/auth is adapter policy.
for _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",
"register_account_valuation", "list_account_valuations", "correct_account_valuation",
):
globals()[_name] = _wrap(getattr(_service, _name))
__all__ = [
"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",
"register_account_valuation", "list_account_valuations", "correct_account_valuation",
]
+41 -4
View File
@@ -67,6 +67,18 @@ def _public_evidence(item: dict[str, Any], strict: bool = False) -> dict[str, An
"valuation_id", "valuation_kind", "active", "last_synced_at", "last_balance", "valuation_amount", "reason",
"unverified_candidates", "candidate", "candidates", "state_hash", "blocking", "snapshot_run_id", "is_correction",
"correction_of_run_id", "correction_reason",
# Draft-close provenance is intentionally an exact allowlist. Nested
# objects are recursively filtered by these same keys.
"date_difference_days", "direction", "original_recorded_at", "selection_mode",
"date_difference_direction", "recorded_at", "representative_liability_definition",
"allow_missing_exclusions", "allow_carried_before", "allow_manual_substitute",
"reason", "selection_reason", "selected_by", "selected_at", "item_version", "source_ref",
"source_attributes", "approval", "approval_reference", "approved_by",
"approved_at", "approval_reason", "draft_id", "draft_version",
"review_fingerprint", "contains_substitutions", "contains_carried",
"has_approved_missing", "carried_forward", "source_type", "source_id",
"amount", "currency", "valuation_id", "valuation_date", "age_days",
"verification_status", "evidence_kind", "carried_forward",
}
if isinstance(item, dict):
return {key: _public_evidence(value, strict) for key, value in item.items() if key in allowed or (not strict and isinstance(value, (dict, list)))}
@@ -304,6 +316,9 @@ def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[
integrity_error = any(value is None for value in evidence.values())
metadata = {
"snapshot_run_id": run["id"],
"draft_id": run["draft_id"] if "draft_id" in run.keys() else "",
"draft_version": run["draft_version"] if "draft_version" in run.keys() else 0,
"review_fingerprint": run["review_fingerprint"] if "review_fingerprint" in run.keys() else "",
"is_correction": bool(run["correction_of_run_id"]),
"correction_of_run_id": run["correction_of_run_id"] or "",
"correction_reason": run["correction_reason"] or "",
@@ -408,10 +423,18 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
"""Expose immutable run evidence without source notes or memos."""
applied = [_public_evidence(item, strict=True) for item in metadata.get("applied_evidence", [])]
excluded = [_public_evidence(item, strict=True) for item in metadata.get("excluded_evidence", [])]
carried = [_public_evidence(item, strict=True) for item in metadata.get("carried_evidence", [])]
contains_substitutions = any(item.get("selection_mode") == "manual_substitute" for item in applied)
contains_carried = bool(carried) or any(item.get("selection_mode") == "carried_before" or item.get("carried_forward") for item in applied)
has_approved_missing = any(item.get("reason") == "approved_missing" for item in excluded)
# Evidence is immutable: do not inject absent draft keys into legacy
# exclusions/carried rows. Draft finalization persists its own lineage.
warnings = []
exclusions = [_public_evidence(item, strict=True) for item in metadata.get("excluded_evidence", [])]
candidate_warnings: dict[tuple[str, str], dict[str, Any]] = {}
for item in exclusions:
for item in excluded:
if item.get("candidate"):
key = (item.get("source_type", ""), item.get("source_id", ""))
warning_shape = {"code": "unverified_candidate", "source_type": key[0], "source_id": key[1], "candidates": []}
@@ -424,9 +447,16 @@ def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
warnings.extend(candidate_warnings.values())
return {
"warnings": warnings,
"excluded_items": exclusions,
"carried_items": [_public_evidence(item, strict=True) for item in metadata.get("carried_evidence", [])],
"excluded_items": excluded,
"applied_evidence": applied,
"carried_items": carried,
"contains_substitutions": contains_substitutions,
"contains_carried": contains_carried,
"has_approved_missing": has_approved_missing,
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
"draft_id": metadata.get("draft_id", ""),
"draft_version": metadata.get("draft_version", 0),
"review_fingerprint": metadata.get("review_fingerprint", ""),
"close_status": metadata.get("close_status", ""),
"selection_policy": metadata.get("selection_policy", ""),
"approval": {} if metadata.get("legacy_closed") else {k: metadata.get(k, "") for k in ("approved_by", "approved_at", "approval_reason", "approval_reference")},
@@ -1104,6 +1134,13 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
"source": "asset_snapshots",
"item_count": len(items),
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
"draft_id": run_public.get("draft_id", ""),
"draft_version": run_public.get("draft_version", 0),
"review_fingerprint": run_public.get("review_fingerprint", ""),
"contains_substitutions": run_public.get("contains_substitutions", False),
"contains_carried": run_public.get("contains_carried", False),
"has_approved_missing": run_public.get("has_approved_missing", False),
"applied_evidence": run_public.get("applied_evidence", []),
"close_status": metadata.get("close_status", ""),
"selection_policy": metadata.get("selection_policy", ""),
"warnings": run_public["warnings"],
@@ -75,6 +75,13 @@ def _source_coverage(
"excluded_items": asset_report.get("excluded_items", []),
"carried_items": asset_report.get("carried_items", asset_report.get("included_items", [])),
"monthly_source_snapshot_id": asset_report.get("snapshot_run_id", ""),
"draft_id": asset_report.get("close_metadata", {}).get("draft_id", ""),
"draft_version": asset_report.get("close_metadata", {}).get("draft_version", 0),
"review_fingerprint": asset_report.get("close_metadata", {}).get("review_fingerprint", ""),
"contains_substitutions": asset_report.get("close_metadata", {}).get("contains_substitutions", False),
"contains_carried": asset_report.get("close_metadata", {}).get("contains_carried", False),
"has_approved_missing": asset_report.get("close_metadata", {}).get("has_approved_missing", False),
"applied_evidence": asset_report.get("close_metadata", {}).get("applied_evidence", []),
},
}
@@ -0,0 +1,396 @@
from __future__ import annotations
import calendar
import hashlib
import json
import sqlite3
from datetime import date
from uuid import uuid4
from .shared import db, read_db, now_iso, ok, fail
def _valid_version(v):
return isinstance(v, int) and not isinstance(v, bool) and v > 0
def _month_end(month):
if not isinstance(month, str) or len(month) != 7:
raise ValueError('month must be YYYY-MM')
y, m = map(int, month.split('-'))
if date(y, m, 1).strftime('%Y-%m') != month:
raise ValueError('month must be YYYY-MM')
return f'{month}-{calendar.monthrange(y,m)[1]:02d}'
def _err(code, message): return fail(code, message)
def _items(conn, draft_id):
rows = conn.execute('SELECT * FROM settlement_draft_items WHERE draft_id=? ORDER BY source_type,source_id', (draft_id,)).fetchall()
return [dict(r) for r in rows]
def _draft(conn, did):
r=conn.execute('SELECT * FROM settlement_drafts WHERE id=?',(did,)).fetchone()
return dict(r) if r else None
def _current_semantics(conn, item):
if item['source_type']=='account':
src=conn.execute('SELECT * FROM accounts WHERE id=?',(item['source_id'],)).fetchone()
val=conn.execute('SELECT * FROM account_valuations WHERE id=?',(item.get('valuation_id',''),)).fetchone() if item.get('valuation_id') else None
child=conn.execute('SELECT id FROM account_valuations WHERE supersedes_id=?',(item.get('valuation_id',''),)).fetchone() if item.get('valuation_id') else None
return {'source':dict(src) if src else None,'valuation':dict(val) if val else None,'child':dict(child) if child else None}
src=conn.execute('SELECT * FROM manual_assets WHERE id=?',(item['source_id'],)).fetchone()
return {'source':dict(src) if src else None}
def _nonempty(value):
return isinstance(value, str) and bool(value.strip())
def _latest_close(conn, basis):
from .assets_service import _snapshot_rows_for_latest_run
_, metadata = _snapshot_rows_for_latest_run(conn, basis)
return metadata.get('snapshot_run_id', '')
def _freeze(conn, source_type, source_id, valuation_id=''):
return _current_semantics(conn, {'source_type': source_type, 'source_id': source_id, 'valuation_id': valuation_id})
def _review(conn, draft):
stored = _items(conn, draft['id'])
items, included, excluded, missing, warnings, states = [], [], [], [], [], []
for item in stored:
current = _current_semantics(conn, item)
frozen = json.loads(item.get('evidence_json') or '{}')
# No permissive fallback from malformed/absent frozen evidence to live authority.
source = frozen.get('source')
if source is None and item['source_type'] != 'account' and 'valuation_amount' in frozen:
source = frozen # development draft compatibility, not legacy run backfill
source = source or {}
row = {k: v for k, v in item.items() if k != 'evidence_json'}
row['item_version'] = item['version']
row.update(source_type='account' if item['source_type'] == 'account' else 'manual_asset',
included=bool(item['included']), basis_date=draft['basis_date'],
name=source.get('name', ''), currency=source.get('currency', 'KRW'),
liquidity_bucket='loan' if source.get('type') == 'loan' else source.get('liquidity_bucket', 'other'),
authority_source='account_valuations' if item['source_type'] == 'account' else 'manual_assets')
row['is_liability'] = row['liquidity_bucket'] == 'loan'
row['state_hash'] = hashlib.sha256(json.dumps(frozen, sort_keys=True).encode()).hexdigest()
source_drift = source != (current.get('source') or {})
value = frozen.get('valuation') if item['source_type'] == 'account' else source
drift = source_drift or (item['source_type'] == 'account' and value != current.get('valuation'))
child = current.get('child')
row.update(drift=drift, review_needed=bool(drift or child), amount=None, candidate_amount=None)
if child:
row['correction_child_id'] = child['id']
warnings.append({'code': 'correction_child', 'source_id': item['source_id']})
if drift:
warnings.append({'code': 'source_drift', 'source_id': item['source_id']})
mode = item['selection_mode']
if value:
actual = value.get('basis_date', '') if item['source_type'] == 'account' else value.get('valuation_date', '')
delta = (date.fromisoformat(actual) - date.fromisoformat(draft['basis_date'])).days if actual else None
candidate = value.get('amount') if item['source_type'] == 'account' else value.get('valuation_amount')
status = value.get('verification_status', '') if item['source_type'] == 'account' else 'verified'
row.update(valuation_date=actual, date_difference_days=delta,
date_difference_direction='after' if delta is not None and delta > 0 else 'before' if delta is not None and delta < 0 else 'same' if delta == 0 else 'unknown',
age_days=-delta if delta is not None else None, candidate_amount=candidate,
verification_status=status, recorded_at=value.get('recorded_at', ''),
source_ref=value.get('source_ref', ''), valuation_kind=value.get('valuation_kind', ''),
representative_liability_definition=value.get('representative_liability_definition'),
carried_forward=mode == 'carried_before', evidence_kind='manual_substitute' if mode == 'manual_substitute' else 'carried_before' if mode == 'carried_before' else 'exact')
eligible_date = delta == 0 if mode == 'exact' else delta is not None and delta < 0 if mode == 'carried_before' else item['source_type'] == 'account' and delta is not None
if status == 'verified' and eligible_date and source.get('active') and row['currency'] == 'KRW' and not row['review_needed']:
row['amount'] = candidate if row['included'] else None
elif item['source_type'] == 'account':
# Candidate visibility does not make it selected or authoritative.
candidate = conn.execute('SELECT amount FROM account_valuations WHERE account_id=? AND basis_date<=? ORDER BY recorded_at DESC,rowid DESC LIMIT 1', (item['source_id'], draft['basis_date'])).fetchone()
if candidate:
row['candidate_amount'] = candidate['amount']
if row['currency'] != 'KRW':
warnings.append({'code': 'non_krw', 'source_id': item['source_id']})
row['status'] = 'excluded' if not row['included'] else 'ready' if row['amount'] is not None else 'missing'
if row['status'] == 'missing':
row['reason'] = 'review_needed' if row['review_needed'] else 'missing_evidence'
missing.append(row)
elif row['status'] == 'excluded':
row['reason'] = row['exclusion_reason']
excluded.append(row)
else:
included.append(row)
items.append(row)
# All candidate changes and source/selected evidence are bound privately; never exposed as raw JSON.
candidates = [dict(v) for v in conn.execute('SELECT * FROM account_valuations WHERE account_id=? ORDER BY id', (item['source_id'],))] if item['source_type'] == 'account' else []
states.append({'stored': item, 'current': current, 'candidates': candidates})
assets = sum(x['amount'] for x in included if not x['is_liability'])
liabilities = sum(x['amount'] for x in included if x['is_liability'])
latest = _latest_close(conn, draft['basis_date'])
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']}, sort_keys=True, ensure_ascii=False)
totals = {'total_assets': assets, 'total_liabilities': liabilities, 'net_worth': assets - liabilities}
return {'draft_id': draft['id'], 'version': draft['version'], 'review_fingerprint': hashlib.sha256(serial.encode()).hexdigest(),
'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,
'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),
'has_approved_missing': bool(missing or excluded)}
def _result(conn,did):
d=_draft(conn,did)
if not d:return _err('not_found','settlement draft not found')
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)
review = _review(conn, d)
return ok(draft={k: v for k, v in d.items() if k != 'final_response'}, 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):
try:
basis=_month_end(month)
if basis_date and basis_date != basis: raise ValueError('basis_date must be month end')
if not _nonempty(created_by) or not _nonempty(idempotency_key): raise ValueError('creator and idempotency key required')
if not all(isinstance(v, str) for v in (basis_date, memo, correction_of_run_id, correction_reason)): raise ValueError('draft metadata must be strings')
if correction_of_run_id and not _nonempty(correction_reason): raise ValueError('correction reason required')
with db(db_path) as c:
c.execute('BEGIN IMMEDIATE')
payload_hash=hashlib.sha256(json.dumps([month,created_by,basis,memo,correction_of_run_id,correction_reason],separators=(',',':')).encode()).hexdigest()
old=c.execute('SELECT * FROM settlement_drafts WHERE create_key=?',(idempotency_key,)).fetchone()
if old:
if old['original_request_hash']!=payload_hash: return _err('conflict','idempotency key payload conflict')
return _result(c,old['id'])
if c.execute("SELECT 1 FROM settlement_drafts WHERE month=? AND status='active'",(month,)).fetchone(): return _err('conflict','active draft already exists for month')
latest = _latest_close(c, basis)
if correction_of_run_id != latest:
return _err('validation_error', 'latest closed run must be linked explicitly with correction reason')
did=uuid4().hex; ts=now_iso()
c.execute('INSERT INTO settlement_drafts(id,month,basis_date,status,version,created_by,created_at,updated_at,memo,create_key,correction_of_run_id,correction_reason,original_request_hash) VALUES(?,?,? ,\'active\',1,?,?,?,?,?,?,?,?)',(did,month,basis,created_by,ts,ts,memo,idempotency_key,correction_of_run_id,correction_reason,payload_hash))
# Freeze initial membership; account selections may use only verified, non-future evidence.
from .assets_service import _account_valuation_items
eligible, _, _ = _account_valuation_items(c, basis, 'exact')
selected = {x['source_id']: x.get('valuation_id', '') for x in eligible}
for a in c.execute("SELECT * FROM accounts WHERE active=1 ORDER BY id").fetchall():
vid = selected.get(a['id'], '')
ev = _freeze(c, 'account', a['id'], vid)
c.execute('INSERT INTO settlement_draft_items(id,draft_id,source_type,source_id,valuation_id,selection_mode,included,version,selected_by,selected_at,evidence_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)',(uuid4().hex,did,'account',a['id'],vid,'exact',1,1,created_by,ts,json.dumps(ev,sort_keys=True)))
for a in c.execute("SELECT * FROM manual_assets WHERE active=1 ORDER BY id").fetchall():
ev = _freeze(c, 'manual_asset', a['id'])
c.execute('INSERT INTO settlement_draft_items(id,draft_id,source_type,source_id,valuation_id,selection_mode,included,version,selected_by,selected_at,evidence_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)',(uuid4().hex,did,'manual_asset',a['id'],'','exact',1,1,created_by,ts,json.dumps(ev,sort_keys=True)))
return _result(c,did)
except (ValueError,TypeError,sqlite3.Error) as e: return _err('validation_error' if isinstance(e,(ValueError,TypeError)) else 'db_error','invalid draft request' if isinstance(e,(ValueError,TypeError)) else 'database error')
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])
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 get_settlement_draft(draft_id: str,db_path: str|None=None):
try:
with read_db(db_path) as c:return _result(c,draft_id)
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 _mutate(did, version, actor, sql, params, db_path):
if not _valid_version(version) or not _nonempty(actor):return _err('validation_error','positive integer version and actor required')
try:
with db(db_path) as c:
c.execute('BEGIN IMMEDIATE'); d=_draft(c,did)
if not d:return _err('not_found','draft not found')
if d['status']!='active' or d['version']!=version:return _err('conflict','draft state/version conflict')
c.execute(sql,(*params,did)); c.execute("UPDATE settlement_drafts SET version=version+1,updated_at=?,approval_fingerprint='',approval_version=0,approved_by='',approved_at='',approval_reason='',approval_reference='',approval_flags='{}' WHERE id=?",(now_iso(),did))
return _result(c,did)
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 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)
def refresh_settlement_draft_targets(draft_id: str,expected_version: int,updated_by: str,db_path: str|None=None):
if not _valid_version(expected_version) or not _nonempty(updated_by):
return _err('validation_error', 'version and actor required')
try:
with db(db_path) as c:
c.execute('BEGIN IMMEDIATE')
d = _draft(c, draft_id)
if not d: return _err('not_found', 'draft not found')
if d['status'] != 'active' or d['version'] != expected_version:
return _err('conflict', 'draft state/version conflict')
ts = now_iso()
from .assets_service import _account_valuation_items
eligible, _, _ = _account_valuation_items(c, d['basis_date'], 'exact')
selected = {x['source_id']: x.get('valuation_id', '') for x in eligible}
existing = {(x['source_type'], x['source_id']): x for x in _items(c, draft_id)}
active = set()
for typ, table in [('account', 'accounts'), ('manual_asset', 'manual_assets')]:
for source in c.execute(f'SELECT id FROM {table} WHERE active=1 ORDER BY id').fetchall():
key = (typ, source['id'])
active.add(key)
if key not in existing:
vid = selected.get(source['id'], '') if typ == 'account' else ''
ev = _freeze(c, typ, source['id'], vid)
c.execute('INSERT INTO settlement_draft_items(id,draft_id,source_type,source_id,valuation_id,selection_mode,included,version,selected_by,selected_at,evidence_json) VALUES(?,?,?,?,?,?,1,1,?,?,?)',
(uuid4().hex, draft_id, typ, source['id'], vid, 'exact', updated_by, ts, json.dumps(ev, sort_keys=True)))
elif typ == 'manual_asset':
# This explicit action can refresh manual values; it must never
# silently replace an account's selected valuation with a child.
ev = _freeze(c, typ, source['id'])
c.execute('UPDATE settlement_draft_items SET evidence_json=?,version=version+1,selected_by=?,selected_at=? WHERE id=?',
(json.dumps(ev, sort_keys=True), updated_by, ts, existing[key]['id']))
for key, item in existing.items():
if key not in active:
# Preserve the retired row and frozen evidence as an audited exclusion.
c.execute("UPDATE settlement_draft_items SET included=0,exclusion_reason='target retired by explicit refresh',version=version+1,selected_by=?,selected_at=? WHERE id=?",
(updated_by, ts, item['id']))
c.execute("UPDATE settlement_drafts SET version=version+1,updated_at=?,approval_fingerprint='',approval_version=0,approved_by='',approved_at='',approval_reason='',approval_reference='',approval_flags='{}' WHERE id=?", (ts, draft_id))
return _result(c, draft_id)
except sqlite3.Error: return _err('db_error', 'database error')
except (TypeError, ValueError): return _err('validation_error', 'invalid persisted draft evidence')
def set_settlement_draft_item(draft_id: str,source_type: str,source_id: str,expected_version: int,selected_by: str,valuation_id: str='',selection_mode: str='exact',selection_reason: str='',included: bool=True,exclusion_reason: str='',db_path: str|None=None):
source_type = 'manual_asset' if source_type == 'manual' else source_type
if type(included) is not bool or source_type not in ('account', 'manual_asset') or selection_mode not in ('exact', 'carried_before', 'manual_substitute'):
return _err('validation_error', 'invalid item fields')
if not _valid_version(expected_version) or not _nonempty(selected_by) or not _nonempty(source_id) or not isinstance(valuation_id, str) or not isinstance(selection_reason, str) or not isinstance(exclusion_reason, str):
return _err('validation_error', 'valid version, source and actor required')
if not included and not _nonempty(exclusion_reason): return _err('validation_error', 'exclusion reason required')
if selection_mode in ('carried_before', 'manual_substitute') and not _nonempty(selection_reason): return _err('validation_error', 'selection reason required')
try:
with db(db_path) as c:
c.execute('BEGIN IMMEDIATE')
d = _draft(c, draft_id)
if not d: return _err('not_found', 'draft not found')
if d['status'] != 'active' or d['version'] != expected_version: return _err('conflict', 'draft state/version conflict')
item = c.execute('SELECT * FROM settlement_draft_items WHERE draft_id=? AND source_type=? AND source_id=?', (draft_id, source_type, source_id)).fetchone()
if not item: return _err('validation_error', 'source is not a draft target; refresh targets first')
ev = _freeze(c, source_type, source_id, valuation_id)
source = ev.get('source')
if included and (not source or not source.get('active')): return _err('validation_error', 'included target must be active')
if source_type == 'account' and valuation_id:
v = ev.get('valuation')
if not v or v['account_id'] != source_id: return _err('validation_error', 'valuation does not belong to account')
if v['currency'] != source['currency']: return _err('validation_error', 'valuation currency mismatch')
kind, loan = v['valuation_kind'], source['type'] == 'loan'
valid_kind = kind in ('loan', 'loan_balance', 'balance') if loan else kind in ('total_value', 'cash_balance', 'balance') if source['type'] == 'investment' else kind == 'balance'
if not valid_kind or (loan and v['representative_liability_definition'] not in ('principal_only', 'principal_interest_fees')) or (not loan and v['representative_liability_definition']):
return _err('validation_error', 'valuation purpose or representative liability is invalid')
status = v['verification_status']
if status not in ('verified', 'unverified') or (status != 'verified' and selection_mode != 'manual_substitute'):
return _err('validation_error', 'selection requires confirmed evidence except review-only manual substitute')
delta = (date.fromisoformat(v['basis_date']) - date.fromisoformat(d['basis_date'])).days
if selection_mode == 'exact' and delta != 0: return _err('validation_error', 'exact requires basis-date valuation')
if selection_mode == 'carried_before' and delta >= 0: return _err('validation_error', 'carried_before requires earlier valuation')
if ev.get('child') and included: return _err('conflict', 'select corrected child explicitly or exclude target')
elif source_type == 'account':
if selection_mode != 'exact': return _err('validation_error', 'an explicit valuation ID is required')
else:
if valuation_id or selection_mode == 'manual_substitute': return _err('validation_error', 'manual asset supports exact or carried_before only')
if included and source.get('valuation_date'):
delta = (date.fromisoformat(source['valuation_date']) - date.fromisoformat(d['basis_date'])).days
if (selection_mode == 'exact' and delta != 0) or (selection_mode == 'carried_before' and delta >= 0): return _err('validation_error', 'manual evidence date does not match selection mode')
ts = now_iso()
c.execute('UPDATE settlement_draft_items SET valuation_id=?,selection_mode=?,selection_reason=?,included=?,exclusion_reason=?,selected_by=?,selected_at=?,version=version+1,evidence_json=? WHERE id=?', (valuation_id, selection_mode, selection_reason, int(included), exclusion_reason, selected_by, ts, json.dumps(ev, sort_keys=True), item['id']))
c.execute("UPDATE settlement_drafts SET version=version+1,updated_at=?,approval_fingerprint='',approval_version=0,approved_by='',approved_at='',approval_reason='',approval_reference='',approval_flags='{}' WHERE id=?", (ts, draft_id))
return _result(c, draft_id)
except (ValueError, TypeError): return _err('validation_error', 'invalid selection evidence')
except sqlite3.Error: return _err('db_error', 'database error')
def review_settlement_draft(draft_id: str,db_path: str|None=None):
try:
with read_db(db_path) as c:
d=_draft(c,draft_id)
if not d:return _err('not_found','draft not found')
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')
def approve_settlement_draft(draft_id: str,expected_version: int,review_fingerprint: str,approved_by: str,approval_reason: str,approval_reference: str,allow_missing_exclusions: bool=False,allow_carried_before: bool=False,allow_manual_substitute: bool=False,db_path: str|None=None):
if not _valid_version(expected_version) or not all(_nonempty(x) for x in (approved_by, approval_reason, approval_reference, review_fingerprint)):return _err('validation_error','approval metadata required')
if any(type(x) is not bool for x in (allow_missing_exclusions, allow_carried_before, allow_manual_substitute)): return _err('validation_error', 'approval flags must be booleans')
try:
with db(db_path) as c:
c.execute('BEGIN IMMEDIATE'); d=_draft(c,draft_id)
if not d or d['version']!=expected_version or d['status']!='active':return _err('conflict','draft version/state conflict')
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['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')
flags=json.dumps({'allow_missing_exclusions':allow_missing_exclusions,'allow_carried_before':allow_carried_before,'allow_manual_substitute':allow_manual_substitute})
c.execute('UPDATE settlement_drafts SET approval_fingerprint=?,approval_version=?,approved_by=?,approved_at=?,approval_reason=?,approval_reference=?,approval_flags=? WHERE id=?',(review_fingerprint,expected_version,approved_by,now_iso(),approval_reason,approval_reference,flags,draft_id))
return _result(c,draft_id)
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 finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerprint: str,idempotency_key: str,db_path: str|None=None):
if not _valid_version(expected_version) or not _nonempty(idempotency_key) or not _nonempty(review_fingerprint):return _err('validation_error','version and close key required')
try:
with db(db_path) as c:
c.execute('BEGIN IMMEDIATE')
previous=c.execute('SELECT id,request_fingerprint FROM asset_snapshot_runs WHERE idempotency_key=?',(idempotency_key,)).fetchone()
d=_draft(c,draft_id)
req=hashlib.sha256(f'{draft_id}:{expected_version}:{review_fingerprint}'.encode()).hexdigest()
if previous:
if previous['request_fingerprint']!=req:return _err('conflict','idempotency key payload conflict')
saved=c.execute('SELECT final_response FROM settlement_drafts WHERE id=?',(draft_id,)).fetchone()
if saved and saved[0]:return json.loads(saved[0])
return _err('conflict','persisted replay unavailable')
if not d or d['status']!='active' or d['version']!=expected_version:return _err('conflict','draft state/version conflict')
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 _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 '{}')
if r['missing_items'] and not flags.get('allow_missing_exclusions'):return _err('validation_error','missing items not approved')
if r['contains_substitutions'] and not flags.get('allow_manual_substitute'):return _err('validation_error','substitutes not approved')
if r['contains_carried'] and not flags.get('allow_carried_before'):return _err('validation_error','carried items not approved')
from .assets_service import _canonical_provenance, _evidence_provenance, _ok_with_provenance_aliases, _summarize_valuation_items
approval = {k: d[k] for k in ('approved_by', 'approved_at', 'approval_reason', 'approval_reference')}
approval.update(flags)
correction = {'is_correction': bool(d['correction_of_run_id']), 'correction_of_run_id': d['correction_of_run_id'], 'correction_reason': d['correction_reason']}
applied = [{**x, 'draft_id': draft_id, 'draft_version': expected_version, 'review_fingerprint': review_fingerprint, 'approval': approval} for x in r['included_items']]
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))
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','')))
evidence = _evidence_provenance(applied, basis)
response = _ok_with_provenance_aliases(
draft_id=draft_id, draft_version=expected_version, review_fingerprint=review_fingerprint,
snapshot_run_id=run, run_id=run, basis_date=basis, close_status=status, closed_at=ts,
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'],
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'},
)
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
except sqlite3.Error:return _err('db_error','database error')
except (TypeError, ValueError):return _err('validation_error','invalid persisted draft evidence')
def cancel_settlement_draft(draft_id: str,expected_version: int,cancelled_by: str,reason: str,db_path: str|None=None):
if not _nonempty(reason):return _err('validation_error','cancellation reason required')
return _mutate(draft_id,expected_version,cancelled_by,"UPDATE settlement_drafts SET status='cancelled',cancelled_by=?,cancellation_reason=?,cancelled_at=? WHERE id=?",(cancelled_by,reason,now_iso()),db_path)
@@ -18,6 +18,31 @@ def _add_missing_columns(conn: sqlite3.Connection, table: str, columns: dict[str
def migrate_db(conn: sqlite3.Connection) -> None:
"""Apply additive, idempotent migrations only."""
conn.execute("""CREATE TABLE IF NOT EXISTS settlement_drafts (
id TEXT PRIMARY KEY, month TEXT NOT NULL, basis_date TEXT NOT NULL,
status TEXT NOT NULL, version INTEGER NOT NULL, created_by TEXT NOT NULL,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, memo TEXT NOT NULL DEFAULT '',
create_key TEXT NOT NULL UNIQUE, correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '', final_run_id TEXT NOT NULL DEFAULT '',
final_response TEXT NOT NULL DEFAULT '', cancelled_by TEXT NOT NULL DEFAULT '',
cancellation_reason TEXT NOT NULL DEFAULT '', cancelled_at TEXT NOT NULL DEFAULT '',
approval_fingerprint TEXT NOT NULL DEFAULT '', approval_version INTEGER NOT NULL DEFAULT 0,
approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '',
approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '',
approval_flags TEXT NOT NULL DEFAULT '{}'
)""")
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 ''"})
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 '',
selection_mode TEXT NOT NULL DEFAULT 'exact', selection_reason TEXT NOT NULL DEFAULT '',
included INTEGER NOT NULL DEFAULT 1, exclusion_reason TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1, selected_by TEXT NOT NULL DEFAULT '',
selected_at TEXT NOT NULL DEFAULT '', evidence_json TEXT NOT NULL DEFAULT '{}',
UNIQUE(draft_id,source_type,source_id)
)""")
_add_missing_columns(conn, "accounts", ACCOUNT_ADDITIVE_COLUMNS)
_add_missing_columns(conn, "transactions", TRANSACTION_ADDITIVE_COLUMNS)
conn.execute("CREATE INDEX IF NOT EXISTS idx_transactions_datetime ON transactions(transaction_datetime)")
@@ -116,7 +141,7 @@ def migrate_db(conn: sqlite3.Connection) -> None:
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
)""")
_add_missing_columns(conn, "asset_snapshot_runs", {"request_fingerprint": "TEXT NOT NULL DEFAULT ''"})
_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 ''"})
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 = "12"
SCHEMA_VERSION = "13"
SCHEMA_SQL = """
PRAGMA journal_mode=WAL;
+2
View File
@@ -14,6 +14,7 @@ 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 settlement_draft_service as _settlement_draft_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
@@ -38,6 +39,7 @@ _PROXY_MODULES = (
_reconciliation_service,
_review_decision_service,
_monthly_settlement_service,
_settlement_draft_service,
_imports_service,
_import_transfer_services,
_cross_transfer_service,