feat: extend comprehensive monthly settlement snapshots (#18)
This commit is contained in:
@@ -130,10 +130,27 @@ def _review(conn, draft):
|
||||
latest = _latest_close(conn, draft['basis_date'])
|
||||
summary = _summarize_valuation_items(included)
|
||||
totals = {key: summary[key] for key in ('total_assets', 'total_liabilities', 'net_worth')}
|
||||
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': totals}, sort_keys=True, ensure_ascii=False)
|
||||
from .settlement_cashflow_service import _normalize_cashflow_policy, _review_cashflow, _AGGREGATION_VERSION
|
||||
if draft.get('aggregation_version') != _AGGREGATION_VERSION:
|
||||
raise ValueError('unsupported stored aggregation version')
|
||||
policy = _normalize_cashflow_policy(json.loads(draft.get('cashflow_policy_json') or '{}'))
|
||||
cf = _review_cashflow(conn, draft['month'], policy)
|
||||
loan_sum = sum((x.get('candidate_amount') or 0) for x in included if x.get('is_liability'))
|
||||
loan_sign = -1 if loan_sum < 0 else 1 if loan_sum > 0 else 0
|
||||
asset_snapshot = []
|
||||
for x in items:
|
||||
raw = x.get('candidate_amount')
|
||||
applied_amount = raw * loan_sign if x.get('amount') is not None and x.get('is_liability') else x.get('amount')
|
||||
asset_snapshot.append({k: x.get(k) for k in ('source_id','source_type','valuation_id','valuation_date','basis_date','currency','authority_source','liquidity_bucket','is_liability','selection_mode','exclusion_reason','status','verification_status')} | {'amount': raw, 'applied_amount': applied_amount})
|
||||
cf['source_snapshot']['assets'] = asset_snapshot
|
||||
totals.update({k: cf[k] for k in ('income_total','expense_total','net_cashflow')})
|
||||
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, '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,
|
||||
'approval_required': True, 'totals': totals, **totals,
|
||||
'complete': bool(items) and not missing and not cf['cashflow_blockers'],
|
||||
'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),
|
||||
@@ -143,11 +160,15 @@ def _review(conn, draft):
|
||||
def _result(conn,did):
|
||||
d=_draft(conn,did)
|
||||
if not d:return _err('not_found','settlement draft not found')
|
||||
draft_out = {k: v for k, v in d.items() if k != 'final_response'}
|
||||
if draft_out.get('cashflow_policy_json'):
|
||||
from .settlement_cashflow_service import _normalize_cashflow_policy
|
||||
draft_out['cashflow_policy'] = _normalize_cashflow_policy(json.loads(draft_out['cashflow_policy_json']))
|
||||
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)
|
||||
return ok(draft=draft_out, 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)
|
||||
return ok(draft=draft_out, 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):
|
||||
@@ -189,7 +210,12 @@ 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])
|
||||
drafts = [dict(x) for x in rows]
|
||||
for item in drafts:
|
||||
if item.get('cashflow_policy_json'):
|
||||
from .settlement_cashflow_service import _normalize_cashflow_policy
|
||||
item['cashflow_policy'] = _normalize_cashflow_policy(json.loads(item['cashflow_policy_json']))
|
||||
return ok(drafts=drafts)
|
||||
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')
|
||||
|
||||
|
||||
@@ -211,6 +237,19 @@ def _mutate(did, version, actor, sql, params, db_path):
|
||||
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 set_settlement_cashflow_policy(draft_id: str, expected_version: int, updated_by: str, cashflow_policy: dict, db_path: str|None=None):
|
||||
from .settlement_cashflow_service import _normalize_cashflow_policy
|
||||
if not _valid_version(expected_version) or not _nonempty(updated_by) or not isinstance(draft_id, str) or not draft_id:
|
||||
return _err('validation_error', 'valid version, draft and actor required')
|
||||
try:
|
||||
policy = _normalize_cashflow_policy(cashflow_policy)
|
||||
encoded = json.dumps(policy, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
|
||||
except (ValueError, TypeError):
|
||||
return _err('validation_error', 'invalid cashflow policy')
|
||||
return _mutate(draft_id, expected_version, updated_by,
|
||||
"UPDATE settlement_drafts SET cashflow_policy_json=?,aggregation_version='comprehensive-v1' WHERE id=?", (encoded,), db_path)
|
||||
|
||||
|
||||
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)
|
||||
@@ -312,6 +351,9 @@ def review_settlement_draft(draft_id: str,db_path: str|None=None):
|
||||
with read_db(db_path) as c:
|
||||
d=_draft(c,draft_id)
|
||||
if not d:return _err('not_found','draft not found')
|
||||
if d['status'] == 'completed' and d['final_response']:
|
||||
saved = json.loads(d['final_response'])
|
||||
return {**saved, 'version': d['version']}
|
||||
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')
|
||||
|
||||
@@ -326,6 +368,7 @@ def approve_settlement_draft(draft_id: str,expected_version: int,review_fingerpr
|
||||
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['cashflow_blockers']: return _err('validation_error', 'cashflow review blockers must be resolved')
|
||||
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')
|
||||
@@ -352,6 +395,7 @@ def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerp
|
||||
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 r['cashflow_blockers']: return _err('validation_error', 'cashflow review blockers must be resolved')
|
||||
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 '{}')
|
||||
@@ -366,12 +410,39 @@ def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerp
|
||||
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))
|
||||
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 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,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()
|
||||
expected_header = {k:r[k] for k in ('income_total','expense_total','net_cashflow','total_assets','total_liabilities','net_worth','aggregation_version','snapshot_schema_version')}
|
||||
expected_header.update(settlement_month=d['month'],currency='KRW',basis_date=basis,draft_id=draft_id,draft_version=expected_version,review_fingerprint=review_fingerprint,request_fingerprint=req,close_status=status,idempotency_key=idempotency_key,correction_of_run_id=d['correction_of_run_id'],correction_reason=d['correction_reason'])
|
||||
if not header or any(header[k] != v for k,v in expected_header.items()):
|
||||
raise ValueError('persisted header mismatch')
|
||||
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')
|
||||
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):
|
||||
raise ValueError('persisted asset count mismatch')
|
||||
for row,(sid,item) in zip(persisted_assets,expected_assets):
|
||||
if row['id'] != sid or any(row[k] != item[k] for k in fields) or row['basis_date'] != basis or row['valuation_id'] != (item.get('valuation_id') or '') or row['selection_policy'] != item['selection_mode'] or row['verification_status'] != item.get('verification_status','unverified') or row['evidence_kind'] != ('excluded' if item in excluded else 'applied') or row['exclusion_reason'] != item.get('exclusion_reason',''):
|
||||
raise ValueError('persisted asset detail mismatch')
|
||||
if sum(x['amount'] for x in saved_groups if x['cashflow_kind']=='income') != r['income_total'] or sum(x['amount'] for x in saved_groups if x['cashflow_kind']=='expense') != r['expense_total'] or r['net_cashflow'] != r['income_total']-r['expense_total']:
|
||||
raise ValueError('cashflow totals mismatch')
|
||||
asset_check = _summarize_valuation_items([x for x in persisted_assets if x['evidence_kind']=='applied'])
|
||||
if (asset_check['total_assets'] != r['total_assets'] or asset_check['total_liabilities'] != r['total_liabilities']
|
||||
or r['net_worth'] != r['total_assets']-r['total_liabilities']):
|
||||
raise ValueError('asset totals mismatch')
|
||||
evidence = _evidence_provenance(applied, basis)
|
||||
response = _ok_with_provenance_aliases(
|
||||
draft_id=draft_id, draft_version=expected_version, review_fingerprint=review_fingerprint,
|
||||
@@ -379,11 +450,14 @@ def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerp
|
||||
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'],
|
||||
income_total=r['income_total'], expense_total=r['expense_total'], net_cashflow=r['net_cashflow'],
|
||||
total_assets=r['total_assets'], total_liabilities=r['total_liabilities'], net_worth=r['net_worth'],
|
||||
cashflow_policy=r['cashflow_policy'], aggregation_version=r['aggregation_version'], snapshot_schema_version=r['snapshot_schema_version'], cashflow_items=r['cashflow_items'], cashflow_blockers=r['cashflow_blockers'], source_snapshot=r['source_snapshot'],
|
||||
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'},
|
||||
**{k: v for k, v in _summarize_valuation_items(applied, excluded).items() if k not in ('excluded_items','total_assets','total_liabilities','net_worth')},
|
||||
)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user