Files
jarca-ledger-mcp/jarca_ledger_mcp/application/settlement_draft_service.py
T

397 lines
33 KiB
Python

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)