Contrato do laboratório
Uma reconciliação precisa de definir o que compara antes de calcular. O laboratório usa Batch e Item no namespace inventado urn:dr:training:reconciliation:1. Não são mensagens ISO nem uma simplificação normativa de pain.001. O perfil DR-REC-1 aceita uma a cem linhas, identidade T seguida de dois algarismos, uma moeda por lote e montantes estritamente positivos. O total deve coincidir com a soma e a contagem com os itens. Estas regras permitem observar falhas concretas sem introduzir dados bancários. Antes de transferir o raciocínio para trabalho real, obter o MDR, schema, regras da comunidade e definição dos campos efetivamente usados.
Representar antes de somar
Criar Decimal diretamente a partir do texto validado conserva o valor decimal de 0.10 e 0.20. A soma do exercício é exatamente 0.30. Converter primeiro para float pode introduzir uma aproximação que uma conversão posterior não elimina. O modelo limita cada valor a nove algarismos inteiros e duas casas; precisão 32 é suficiente para as somas delimitadas. Não se promete exatidão ilimitada. Separar também valor e representação: 1.2 e 1.20 têm o mesmo valor, mas bytes diferentes. Uma comparação de hashes de ficheiros responde a outra pergunta e não substitui a comparação dos dados de negócio.
Rejeitar em vez de inventar
O perfil fictício permite apenas algarismos ASCII e ponto decimal, sem sinal ou expoente. A regra é deliberadamente mais restrita que xs:decimal. A biblioteca aceitar 1e2 não significa que a interface o aceite. Texto vazio, NaN, vírgula, número negativo ou 1.005 devem produzir a rejeição prevista. Não há arredondamento autorizado neste exercício. Transformar ausência em zero esconderia informação e violaria a regra de positividade. Numa interface real, a escala e o arredondamento dependem do contrato; não concluir que todas as moedas ou mensagens usam duas casas. Guardar a entrada e a regra falhada no diagnóstico sintético.
Controlos que se complementam
Duas linhas de dez euros podem somar vinte mesmo quando uma referência foi repetida e outra desapareceu. Por isso, soma, contagem e identidade são verificações diferentes. A fixture T01,T01 recusa duplicação mesmo com total correto. A comparação com o conjunto esperado identifica a referência em falta. Misturar EUR e USD é recusado porque o perfil não define câmbio nem um total monetário comum. Se uma transformação excluir itens por regra autorizada, recalcular agregados e manter a ligação às exclusões. Corrigir apenas o cabeçalho ou renomear uma referência repetida não demonstra fidelidade da transformação.
Ler o XML no âmbito certo
Executar o programa numa pasta temporária com Python 3.13 e guardar evidence.json. O programa analisa XML sintético de confiança usando ElementTree e verifica regras próprias. Não carrega XSD e não é um gateway seguro para entrada não confiável. O nome expandido inclui URI e nome local; alterar só o prefixo mantém o resultado. Já Ccy sem prefixo e a:Ccy com namespace não são o mesmo atributo. O namespace por defeito não se aplica ao primeiro. O teste de filho Extra demonstra ainda que ler o texto do elemento não verifica sozinho toda a estrutura permitida.
Fecho e aplicação no trabalho
Reservar quinze minutos para prever os resultados, vinte para executar e alterar uma fixture, quinze para analisar rejeições e dez para explicar as conclusões. Usar um total errado, uma contagem divergente e uma repetição, um defeito de cada vez. Comparar o resultado esperado com o observado e localizar a primeira regra que falhou. Num incidente APS, esta disciplina separa erro de representação, transformação e contrato. O relatório de 34 verificações inclui também os estados da próxima aula. Demonstra o modelo didático executado; conformidade externa, dados reais e aceitação da equipa de produção exigem evidência própria.
"""Original DR synthetic reconciliation lab. Python 3.13 stdlib, no network.
Run: python3 run.py --output evidence.json
Not ISO messages, XSD validation, payment execution, or bank acceptance evidence.
Only trusted in-script XML fixtures are parsed. Not a production XML gateway.
"""
import argparse
import hashlib
import json
import re
import sys
from decimal import Decimal, localcontext
from pathlib import Path
from xml.etree import ElementTree as ET
NS = 'urn:dr:training:reconciliation:1'
PROFILE = 'DR-REC-1'
checks = []
def check(name, actual, expected):
assert actual == expected, (name, actual, expected)
checks.append(dict(name=name, actual=actual, expected=expected, passed=True))
def error(fn):
try:
fn()
except ValueError as exc:
return str(exc)
raise AssertionError('Expected rejection')
def amount(text):
# Deliberately narrower than xs:decimal: ASCII, unsigned, <=9 whole
# digits and <=2 fraction digits. A DR fixture rule, not a currency law.
if not re.fullmatch(r'[0-9]{1,9}(?:\.[0-9]{1,2})?', text):
raise ValueError('amount lexical profile')
value = Decimal(text)
if value <= 0:
raise ValueError('amount positive profile')
return value
def batch(rows, count=None, total='0.30', prefix=''):
tag = lambda local: prefix + ':' + local if prefix else local
decl = 'xmlns:' + prefix if prefix else 'xmlns'
body = ''.join(f'<{tag("Item")} id="{i}" Ccy="{c}">{a}</{tag("Item")}>' for i,c,a in rows)
return f'<{tag("Batch")} {decl}="{NS}" profile="{PROFILE}" count="{len(rows) if count is None else count}" total="{total}">{body}</{tag("Batch")}>'
def reconcile(xml):
try:
root = ET.fromstring(xml)
except ET.ParseError:
raise ValueError('XML syntax') from None
if root.tag != '{'+NS+'}Batch':
raise ValueError('root expanded name')
if root.get('profile') != PROFILE:
raise ValueError('profile binding')
if set(root.attrib) != {'profile','count','total'}:
raise ValueError('root attributes')
if not re.fullmatch(r'[0-9]{1,3}', root.get('count','')):
raise ValueError('count lexical profile')
items = list(root)
if len(items) != int(root.get('count')) or not 1 <= len(items) <= 100:
raise ValueError('item count')
ids = set()
totals = {}
with localcontext() as ctx:
ctx.prec = 32 # Bound: <=100 values, each <=9 whole +2 fraction digits.
for item in items:
if item.tag != '{'+NS+'}Item' or list(item):
raise ValueError('item structure')
if set(item.attrib) != {'id','Ccy'}:
raise ValueError('item attributes')
ident = item.get('id')
if not re.fullmatch(r'T[0-9]{2}', ident):
raise ValueError('item identity')
if ident in ids:
raise ValueError('duplicate item identity')
ids.add(ident)
currency = item.get('Ccy')
if currency not in {'EUR','USD'}:
raise ValueError('fixture currency')
totals[currency] = totals.get(currency, Decimal('0')) + amount(item.text or '')
# This particular exercise declares one currency and one total.
if len(totals) != 1:
raise ValueError('mixed currency total')
declared = amount(root.get('total'))
if next(iter(totals.values())) != declared:
raise ValueError('control total')
return dict(count=len(items), ids=sorted(ids), totals={k:format(v,'f') for k,v in totals.items()})
def observe(events, expected):
"""Fictional event log; not pacs.002 codes or a universal state machine."""
seen, latest = {}, {}
for e in events:
eid, target, seq, state = e
if target not in expected:
raise ValueError('unknown target')
if state not in {'RECEIVED','PENDING','ACCEPTED','REJECTED'}:
raise ValueError('unknown fixture state')
if type(seq) is not int or seq < 1:
raise ValueError('invalid sequence')
if eid in seen:
if seen[eid] != e:
raise ValueError('conflicting event identity')
continue
seen[eid] = e
old = latest.get(target)
if old and seq == old[0] and state != old[1]:
raise ValueError('conflicting target sequence')
if old is None or seq > old[0]:
latest[target] = (seq,state)
return {t:latest[t][1] if t in latest else 'UNKNOWN' for t in sorted(expected)}
def main():
rows = [('T01','EUR','0.10'),('T02','EUR','0.20')]
good = batch(rows)
check('decimal total', reconcile(good)['totals'], {'EUR':'0.30'})
check('two distinct items', reconcile(good)['count'], 2)
check('prefix independent result', reconcile(batch(rows,prefix='x')), reconcile(good))
check('decimal value equivalent', amount('1.20') == amount('1.2'), True)
check('binary float contrast', 0.1 + 0.2 == 0.3, False)
for name, text in [('exponent','1e2'),('comma','1,20'),('nonfinite','NaN'),('negative','-1.00'),('excess scale','1.005'),('blank',''),('too many digits','1000000000'),('nonascii','١.٢')]:
check(name+' rejected', error(lambda:amount(text)), 'amount lexical profile')
check('zero rejected', error(lambda:amount('0.00')), 'amount positive profile')
check('wrong namespace rejected', error(lambda:reconcile(good.replace(NS,NS+'x'))), 'root expanded name')
check('wrong profile rejected', error(lambda:reconcile(good.replace(PROFILE,'DR-REC-2'))), 'profile binding')
check('broken XML rejected', error(lambda:reconcile(good[:-1])), 'XML syntax')
check('declared count mismatch', error(lambda:reconcile(batch(rows,count=3))), 'item count')
check('duplicate item rejected', error(lambda:reconcile(batch([rows[0],rows[0]],total='0.20'))), 'duplicate item identity')
check('mixed currency rejected', error(lambda:reconcile(batch([rows[0],('T02','USD','0.20')]))), 'mixed currency total')
check('incorrect total rejected', error(lambda:reconcile(batch(rows,total='0.31'))), 'control total')
check('namespaced attribute differs', error(lambda:reconcile(good.replace(' Ccy=', ' xmlns:a="'+NS+'" a:Ccy='))), 'item attributes')
check('nested item rejected', error(lambda:reconcile(good.replace('0.10</Item>','0.10<Extra/></Item>'))), 'item structure')
expected = {'T01','T02','T03'}
events = [('e1','T01',1,'RECEIVED'),('e2','T01',2,'ACCEPTED'),('e3','T02',1,'REJECTED')]
states = observe(events,expected)
check('partial outcome retains unknown', states, {'T01':'ACCEPTED','T02':'REJECTED','T03':'UNKNOWN'})
check('duplicate event is idempotent', observe(events+[events[1]],expected), states)
check('late lower sequence does not regress', observe(events+[('e4','T01',1,'RECEIVED')],expected), states)
check('same event conflicting body', error(lambda:observe(events+[('e2','T01',2,'REJECTED')],expected)), 'conflicting event identity')
check('same sequence conflicting state', error(lambda:observe(events+[('e5','T01',2,'REJECTED')],expected)), 'conflicting target sequence')
check('unknown target isolated', error(lambda:observe([('e6','T99',1,'ACCEPTED')],expected)), 'unknown target')
check('unknown state isolated', error(lambda:observe([('e7','T01',1,'SETTLED')],expected)), 'unknown fixture state')
check('received is not accepted', observe(events[:1],expected)['T01'], 'RECEIVED')
check('observed state coverage', sum(s!='UNKNOWN' for s in states.values()), 2)
check('accepted item count', sum(s=='ACCEPTED' for s in states.values()), 1)
check('all missing remains unknown', observe([],expected), {t:'UNKNOWN' for t in sorted(expected)})
output = dict(scope='Original trusted synthetic fixtures only; not XSD validation, ISO conformance, settlement evidence or production parser security.',python=sys.version.split()[0],runnerSha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),passed=len(checks),checks=checks)
parser=argparse.ArgumentParser();parser.add_argument('--output',required=True)
Path(parser.parse_args().output).write_text(json.dumps(output,indent=2)+'\n')
print(json.dumps({'passed':len(checks),'scope':output['scope']}))
if __name__ == '__main__':
main()
Caso: contagem dois e total vinte escondem T01 duplicado e T02 ausente. Acrescentar reconciliação de identidades muda a decisão de aceitação.
Armadilhas comuns
Float antes de Decimal; zero para valores ausentes; soma entre moedas sem acordo; totais corretos como prova de identidades preservadas.
Tópicos relacionados: Mensagens, cabeçalhos e estados · Validação por camadas · Mapeamento e integridade de dados
Definir unidade e limites, validar representação, reconciliar soma, contagem e identidades, e declarar o âmbito efetivamente demonstrado.
Referência: Decimal arithmetic · DR ISO20022 fundamentals professional assessment2026.10