"""Synthetic JSON reconciliation fixture; no Odoo database or accounting sign-off.

Run: python3 reconcile-model.py
Decimal values are compared under an explicit zero-tolerance example policy.
Source and target scope must match before record reconciliation is meaningful.
"""
from collections import Counter, defaultdict
from copy import deepcopy
from decimal import Decimal
import json

FIELDS=('currency','amount','state','customer_ref','customer_company',
        'ordered_qty','delivered_qty','invoiced_qty','attachment_digest')
DECIMAL_FIELDS={'amount','ordered_qty','delivered_qty','invoiced_qty'}

def fixture():
    return {'scope':{'snapshot':'2026-10-31T23:59:59Z','companies':['A','B'],
                     'records':'all five specified source order revisions',
                     'amount_basis':'source-document amount, no currency conversion',
                     'attachment_policy':'original bytes retained'},
      'orders':[
        {'company':'A','source_id':'O100','currency':'EUR','amount':'100.00','state':'sale','customer_ref':'C-A','customer_company':'A','ordered_qty':'10','delivered_qty':'6','invoiced_qty':'4','attachment_digest':'original-A100'},
        {'company':'A','source_id':'O101','currency':'EUR','amount':'200.00','state':'sale','customer_ref':'C-A','customer_company':'A','ordered_qty':'5','delivered_qty':'0','invoiced_qty':'0','attachment_digest':'original-A101'},
        {'company':'B','source_id':'O100','currency':'EUR','amount':'100.00','state':'sale','customer_ref':'C-B','customer_company':'B','ordered_qty':'10','delivered_qty':'6','invoiced_qty':'4','attachment_digest':'original-B100'},
        {'company':'A','source_id':'O102','currency':'USD','amount':'50.00','state':'sale','customer_ref':'C-A','customer_company':'A','ordered_qty':'2','delivered_qty':'1','invoiced_qty':'0','attachment_digest':'original-A102'},
        {'company':'B','source_id':'O103','currency':'EUR','amount':'50.00','state':'draft','customer_ref':'C-B','customer_company':'B','ordered_qty':'1','delivered_qty':'0','invoiced_qty':'0','attachment_digest':'original-B103'},
      ]}

def key(row):return row['company'],row['source_id']

def totals(rows):
    out=defaultdict(Decimal)
    for r in rows:out[(r['company'],r['currency'])]+=Decimal(r['amount'])
    return {'/'.join(k):format(v,'.2f') for k,v in sorted(out.items())}

def reconcile(source,target):
    findings=[]
    if source['scope']!=target['scope']:
        return {'comparable':False,'findings':[{'kind':'scope_mismatch'}]}
    a,b=source['orders'],target['orders']
    for label,rows in [('source',a),('target',b)]:
        for k,n in Counter(map(key,rows)).items():
            if n>1:findings.append({'kind':'duplicate_key','side':label,'key':list(k),'count':n})
    sa,sb={key(r):r for r in a},{key(r):r for r in b}
    for k in sorted(sa.keys()-sb.keys()):findings.append({'kind':'missing','key':list(k)})
    for k in sorted(sb.keys()-sa.keys()):findings.append({'kind':'unexpected','key':list(k)})
    for k in sorted(sa.keys()&sb.keys()):
        for f in FIELDS:
            old,new=sa[k][f],sb[k][f]
            old_cmp,new_cmp=(Decimal(old),Decimal(new)) if f in DECIMAL_FIELDS else (old,new)
            if old_cmp!=new_cmp:
                item={'kind':'field_difference','key':list(k),'field':f,'expected':old,'actual':new}
                if f in DECIMAL_FIELDS:item['variance']=str(new_cmp-old_cmp)
                findings.append(item)
    return {'comparable':True,'row_counts':{'source':len(a),'target':len(b)},
            'counts_match':len(a)==len(b),'source_totals':totals(a),'target_totals':totals(b),
            'totals_match':totals(a)==totals(b),'findings':findings}

def main():
    source=fixture();cases=[]
    def run(name,mutate,expected_kinds,explanation):
        target=deepcopy(source);mutate(target);result=reconcile(source,target)
        actual=Counter(x['kind'] for x in result['findings'])
        assert actual==Counter(expected_kinds),(name,actual,expected_kinds)
        cases.append({'case':name,'expected_finding_kinds':dict(Counter(expected_kinds)),
                      'observed':result,'interpretation':explanation})
    run('unchanged_baseline',lambda t:None,[], 'A control demonstrates that unchanged input is accepted by the comparison, not that a real ERP migration is approved.')
    def offset(t):t['orders'][0]['amount']='110.00';t['orders'][1]['amount']='190.00'
    run('offsetting_amount_errors',offset,['field_difference']*2,'Five rows and every company/currency total match, while two document amounts are wrong.')
    run('partial_delivery_changed',lambda t:t['orders'][0].update(delivered_qty='10'),['field_difference'],'Header counts and amounts cannot prove remaining fulfillment quantities.')
    run('customer_link_changed',lambda t:t['orders'][0].update(customer_ref='C-B',customer_company='B'),['field_difference']*2,'An apparently valid related customer can belong to the wrong company.')
    run('attachment_replaced',lambda t:t['orders'][0].update(attachment_digest='different-bytes'),['field_difference'],'The attachment count may remain unchanged while content identity is wrong. Fixture digest labels stand in for a verified content hash.')
    run('draft_state_changed',lambda t:t['orders'][4].update(state='sale'),['field_difference'],'State transitions require separate approval even when the monetary total is unchanged.')
    run('source_identifier_replaced',lambda t:t['orders'][1].update(source_id='O999'),['missing','unexpected'],'Matching record counts can conceal one missing and one unplanned document.')
    run('duplicate_composite_key',lambda t:t['orders'][4].update(source_id='O100'),['duplicate_key','missing']+['field_difference']*6,'Duplicate keys are reported before a dictionary lookup could conceal them.')
    run('different_snapshot',lambda t:t['scope'].update(snapshot='2026-11-01T12:00:00Z'),['scope_mismatch'],'A later target snapshot is not silently compared with an earlier source baseline.')
    output={'kind':'executed synthetic JSON reconciliation model','run_on':'2026-11-05',
      'runtime':'Python standard library; no Odoo or database connection',
      'case_count':len(cases),'all_expected_observations_matched':True,
      'fixture_policy':source['scope'],'cases':cases,
      'limits':['No Odoo migration ran.','No ledger, valuation, tax, legal retention or accounting sign-off is established.',
                'The zero-tolerance and immutable-attachment policies are fixture choices; actual exceptions require approved rules and evidence.',
                'The fixture compares named fields only and does not model line-table structure, posting logic or integration delivery.']}
    print(json.dumps(output,indent=2))

if __name__=='__main__':main()
