fix: preserve schema 15 compatibility for v0.1.8
This commit is contained in:
@@ -39,10 +39,11 @@ def _draft(conn, did):
|
||||
def _current_semantics(conn, item):
|
||||
if item['source_type']=='account':
|
||||
src=conn.execute('SELECT * FROM accounts WHERE id=?',(item['source_id'],)).fetchone()
|
||||
val=None
|
||||
child=None
|
||||
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}
|
||||
return {'source': 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):
|
||||
@@ -107,8 +108,10 @@ def _review(conn, draft):
|
||||
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':
|
||||
row['candidate_status'] = 'missing'
|
||||
row['source_missing_warning'] = 'account valuation source unavailable'
|
||||
# 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'
|
||||
@@ -122,7 +125,7 @@ def _review(conn, draft):
|
||||
included.append(row)
|
||||
items.append(row)
|
||||
# All candidate changes and source/selected evidence are bound privately; never exposed as raw JSON.
|
||||
candidates = []
|
||||
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})
|
||||
latest = _latest_close(conn, draft['basis_date'])
|
||||
summary = _summarize_valuation_items(included)
|
||||
@@ -144,7 +147,7 @@ def _review(conn, draft):
|
||||
full_totals = {**totals}
|
||||
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'], 'totals': full_totals, 'cashflow': cf}, sort_keys=True, ensure_ascii=False)
|
||||
return {'draft_id': draft['id'], 'version': draft['version'], 'review_fingerprint': hashlib.sha256(serial.encode()).hexdigest(),
|
||||
'cashflow_policy': policy, 'cashflow_fingerprint': cf['cashflow_fingerprint'], 'aggregation_version': cf['aggregation_version'], 'snapshot_schema_version': cf['snapshot_schema_version'], 'cashflow_items': cf['cashflow_items'], 'cashflow_blockers': cf['cashflow_blockers'], 'cashflow_warnings': cf['cashflow_warnings'], 'source_snapshot': cf['source_snapshot'],
|
||||
'cashflow_policy': policy, 'aggregation_version': cf['aggregation_version'], 'snapshot_schema_version': cf['snapshot_schema_version'], 'cashflow_items': cf['cashflow_items'], 'cashflow_blockers': cf['cashflow_blockers'], 'cashflow_warnings': cf['cashflow_warnings'], 'source_snapshot': cf['source_snapshot'],
|
||||
'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 and not cf['cashflow_blockers'],
|
||||
@@ -174,19 +177,17 @@ def create_settlement_draft(month: str, created_by: str, idempotency_key: str, b
|
||||
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 bool(correction_of_run_id) != bool(correction_reason.strip()): raise ValueError('correction run and reason must be provided together')
|
||||
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=? AND status <> 'cancelled'",(idempotency_key,)).fetchone()
|
||||
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 not correction_of_run_id and latest:
|
||||
return _err('validation_error', 'closed month requires correction linkage and reason')
|
||||
if correction_of_run_id and correction_of_run_id != latest:
|
||||
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))
|
||||
@@ -198,6 +199,9 @@ def create_settlement_draft(month: str, created_by: str, idempotency_key: str, b
|
||||
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')
|
||||
|
||||
@@ -262,10 +266,12 @@ def refresh_settlement_draft_targets(draft_id: str,expected_version: int,updated
|
||||
if d['status'] != 'active' or d['version'] != expected_version:
|
||||
return _err('conflict', 'draft state/version conflict')
|
||||
ts = now_iso()
|
||||
selected = {}
|
||||
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')]:
|
||||
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)
|
||||
@@ -292,8 +298,8 @@ def refresh_settlement_draft_targets(draft_id: str,expected_version: int,updated
|
||||
|
||||
|
||||
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):
|
||||
if source_type == 'manual': source_type = 'manual_asset'
|
||||
if source_type != 'account' or type(included) is not bool or selection_mode not in ('exact', 'carried_before', 'manual_substitute'):
|
||||
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')
|
||||
@@ -328,7 +334,7 @@ def set_settlement_draft_item(draft_id: str,source_type: str,source_id: str,expe
|
||||
elif source_type == 'account':
|
||||
if selection_mode != 'exact': return _err('validation_error', 'an explicit valuation ID is required')
|
||||
else:
|
||||
return _err('unsupported', 'manual assets are no longer supported')
|
||||
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')
|
||||
@@ -373,7 +379,6 @@ def approve_settlement_draft(draft_id: str,expected_version: int,review_fingerpr
|
||||
|
||||
|
||||
def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerprint: str,idempotency_key: str,db_path: str|None=None):
|
||||
return fail('unsupported', 'run history unavailable')
|
||||
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:
|
||||
@@ -404,21 +409,16 @@ def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerp
|
||||
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; monthly_settlement_id=uuid4().hex; ts=now_iso(); basis=d['basis_date']; status='conditional' if excluded or r['contains_substitutions'] or r['contains_carried'] else 'complete_exact'
|
||||
month_row=c.execute('SELECT id FROM monthly_settlement WHERE month=?',(d['month'],)).fetchone()
|
||||
if month_row: monthly_settlement_id=month_row['id']
|
||||
else:
|
||||
c.execute("INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES(?,?,?,?,?,?,?,?,?)",(monthly_settlement_id,d['month'],basis,'draft',1,d['approved_by'],ts,d['approved_by'],ts))
|
||||
c.execute('UPDATE monthly_settlement SET status=\'closed\',version=version+1,approved_by=?,approved_at=?,approved_version=?,approved_fingerprint=?,approval_reason=?,closed_by=?,closed_at=?,updated_by=?,updated_at=? WHERE id=?',(d['approved_by'] or 'legacy-approval',d['approved_at'] or ts,expected_version,review_fingerprint,d['approval_reason'] or 'legacy approval',d['approved_by'] or 'legacy-approval',ts,d['approved_by'] or 'legacy-approval',ts,monthly_settlement_id))
|
||||
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,settlement_month,currency,income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth,cashflow_policy_json,aggregation_version,snapshot_schema_version,source_snapshot_json) 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,d['month'],'KRW',r['income_total'],r['expense_total'],r['net_cashflow'],r['total_assets'],r['total_liabilities'],r['net_worth'],json.dumps(r['cashflow_policy'],sort_keys=True),r['aggregation_version'],r['snapshot_schema_version'],json.dumps(r['source_snapshot'],sort_keys=True)))
|
||||
snapshot_ids = []
|
||||
for x in applied + excluded:
|
||||
sid = uuid4().hex
|
||||
snapshot_ids.append(sid)
|
||||
c.execute('INSERT INTO account_snapshot(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,monthly_settlement_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'],'','','',monthly_settlement_id,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','')))
|
||||
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','')))
|
||||
for item in r['cashflow_items']:
|
||||
c.execute('INSERT INTO settlement_cashflow_snapshots(id,monthly_settlement_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)', (uuid4().hex,monthly_settlement_id,item['cashflow_kind'],item['category'],item['subcategory'],item['amount'],item['transaction_count'],item['currency']))
|
||||
saved_groups = [dict(x) for x in c.execute('SELECT cashflow_kind,category,subcategory,amount,transaction_count,currency FROM settlement_cashflow_snapshots WHERE monthly_settlement_id=? ORDER BY cashflow_kind,category,subcategory,currency', (monthly_settlement_id,))]
|
||||
c.execute('INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)', (uuid4().hex,run,item['cashflow_kind'],item['category'],item['subcategory'],item['amount'],item['transaction_count'],item['currency']))
|
||||
saved_groups = [dict(x) for x in c.execute('SELECT cashflow_kind,category,subcategory,amount,transaction_count,currency FROM settlement_cashflow_snapshots WHERE settlement_run_id=? ORDER BY cashflow_kind,category,subcategory,currency', (run,))]
|
||||
expected_groups = sorted([{'cashflow_kind':x['cashflow_kind'],'category':x['category'],'subcategory':x['subcategory'],'amount':x['amount'],'transaction_count':x['transaction_count'],'currency':x['currency']} for x in r['cashflow_items']], key=lambda x:(x['cashflow_kind'],x['category'],x['subcategory'],x['currency']))
|
||||
if saved_groups != expected_groups: raise ValueError('persisted cashflow detail mismatch')
|
||||
header = c.execute('SELECT * FROM asset_snapshot_runs WHERE id=?', (run,)).fetchone()
|
||||
@@ -429,12 +429,7 @@ def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerp
|
||||
for key,value in (('source_snapshot_json',r['source_snapshot']),('cashflow_policy_json',r['cashflow_policy']),('applied_evidence',applied),('excluded_evidence',excluded),('carried_evidence',carried)):
|
||||
if json.loads(header[key]) != value:
|
||||
raise ValueError('persisted evidence mismatch')
|
||||
# A correction shares the canonical month with its parent. Read
|
||||
# back only this run's lineage, never the parent's rows.
|
||||
if d['correction_of_run_id']:
|
||||
persisted_assets = [dict(x) for x in c.execute('SELECT * FROM account_snapshot WHERE monthly_settlement_id=? AND correction_of_run_id=? ORDER BY id', (monthly_settlement_id, d['correction_of_run_id']))]
|
||||
else:
|
||||
persisted_assets = [dict(x) for x in c.execute("SELECT * FROM account_snapshot WHERE monthly_settlement_id=? AND COALESCE(correction_of_run_id,'')='' ORDER BY id", (monthly_settlement_id,))]
|
||||
persisted_assets = [dict(x) for x in c.execute('SELECT * FROM asset_snapshots WHERE snapshot_run_id=? ORDER BY id', (run,))]
|
||||
fields = ('source_type','source_id','amount','currency','liquidity_bucket')
|
||||
expected_assets = sorted(zip(snapshot_ids, applied+excluded), key=lambda pair:pair[0])
|
||||
if len(persisted_assets) != len(expected_assets):
|
||||
|
||||
Reference in New Issue
Block a user