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)