|
|
|
@@ -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)
|