feat: add persistent monthly settlement drafts and approved valuation selection
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user