Matching composicional
Como configurar e operar conciliações 1:N, N:1, parciais e por parcelas.
O matching composicional cobre casos em que uma conciliação não é explicada por um único par de records. Em vez de comparar apenas 1:1, o Nexus procura um grupo de records em um lado cujo valor somado explique um record do outro lado.
Exemplos comuns:
- uma cobrança única no ERP conciliada contra várias transações bancárias;
- vários lançamentos contábeis conciliados contra uma nota ou repasse consolidado;
- parcelas recorrentes que ainda não chegaram por completo;
- arquivos Francesinha/OpenFinance em que uma transação agregada precisa ser expandida em itens menores.
Como entra no fluxo
A composição é uma fase do rule set. Ela roda depois da fase normal de matching 1:1:
[1] Matching 1:1 do rule set
│
▼
[2] Completa composites PARTIAL existentes
│
▼
[3] Composition phase: procura novos grupos 1:N / N:1
│
▼
[4] Persiste composite_matches e composite_match_members
│
▼
[5] Atualiza contadores do job e dispara webhooks de compositeNo Admin, a configuração fica na seção Composition matching do rule set e é usada no fluxo de jsonata-batch. Pela API, a configuração é enviada em params.composition.
Direções
O campo composition.direction define qual lado é o record único e qual lado fornece o grupo:
| Valor | Resultado persistido | Interpretação |
|---|---|---|
left_to_many_right | direction: "one_to_many" | Um record do dataset left é explicado por N records do dataset right. |
many_left_to_right | direction: "many_to_one" | N records do dataset left explicam um record do dataset right. |
both | roda as duas direções | Primeiro tenta left_to_many_right; depois many_left_to_right. |
Quando direction = "both", records usados na primeira direção ficam bloqueados para a segunda. Isso evita composites contraditórios usando o mesmo record.
Configuração básica
Exemplo de rule set com composição:
{
"name": "Conciliação com composição",
"leftDatasetId": "dataset-left",
"rightDatasetId": "dataset-right",
"engine": "jsonata-batch",
"params": {
"Tmatch": 0.85,
"Tpossible": 0.6,
"filters": [
"left.doc = right.doc"
],
"blocking": {
"enabled": true,
"amountKey": "amount",
"dateKey": "date",
"amountTolerancePercent": 1,
"dateToleranceDays": 5
},
"composition": {
"enabled": true,
"direction": "both",
"maxGroupSize": 10,
"amountTolerancePercent": 1,
"amountField": "amount",
"groupByField": "doc",
"dateStrategy": "window",
"dateFieldLeft": "issue_date",
"dateFieldRight": "payment_date",
"dateToleranceDays": 5,
"preferCompleteGroup": true,
"skipAlreadyMatched": true
}
}
}Campos principais:
| Campo | Default | Descrição |
|---|---|---|
enabled | false | Liga a fase composicional. |
direction | both | Direção da composição. |
maxGroupSize | 10 | Máximo de membros no lado N. Limite absoluto: 100. |
amountTolerancePercent | 1 | Tolerância percentual entre valor alvo e soma dos membros. |
amountField | blockingAmount | Field em record.data usado como valor. Se omitido, usa blockingAmount. |
groupByField | nenhum | Field obrigatório para agrupar candidatos por contraparte/documento. |
dateStrategy | not_before_target | Estratégia de data para restringir candidatos: none, not_before_target, window ou period. |
dateFieldLeft | blockingDate | Field de data/período do lado left. Se omitido, usa blockingDate. |
dateFieldRight | blockingDate | Field de data/período do lado right. Se omitido, usa blockingDate. |
dateToleranceDays | 0 | Tolerância em dias quando dateStrategy = "window". |
allowNegativeAmounts | false | Permite valores negativos no alvo e nos membros; valores zero continuam ignorados. |
preferCompleteGroup | false | Prioriza grupos completos quando todos os candidatos elegíveis fecham na tolerância. |
skipAlreadyMatched | true | Ignora records que já tiveram MATCH 1:1 no mesmo rule set. |
Configure groupByField ou filters sempre que a composição puder cruzar contrapartes. Sem uma guarda de contraparte, o resultado pode fechar numericamente, mas continuar semanticamente errado.
Estratégia de data
Quando dateStrategy não é none, a composição compara a data do alvo com a data de cada candidato. Os campos vêm de dateFieldLeft/dateFieldRight; se eles não forem configurados, o Nexus usa blockingDate.
| Estratégia | Regra |
|---|---|
not_before_target | O candidato precisa ter data igual ou posterior à data do alvo. |
window | O candidato precisa cair entre targetDate - dateToleranceDays e targetDate + dateToleranceDays. |
period | O candidato precisa ter a mesma data/período normalizado do alvo. Aceita valores YYYY-MM ou datas YYYY-MM-DD. |
none | Não aplica restrição de data na composição. |
Se o alvo não tiver data, o filtro de data é ignorado para aquele alvo. Se o candidato não tiver data quando a estratégia exige data, ele não entra no grupo.
Filtros na composição
Os filters recebem o par no mesmo formato do matching 1:1:
{
"left": { "doc": "123", "amount": 100 },
"right": { "doc": "123", "amount": 40 }
}Em one_to_many, left é o alvo e right é o candidato. Em many_to_one, right é o alvo e left é o candidato.
Score e decisão
O score do composite representa quão próxima a soma dos membros ficou do valor alvo:
score = 1 - abs(targetAmount - composedAmount) / abs(targetAmount)Depois disso, os thresholds do rule set classificam o resultado:
score >= Tmatchcria composite comdecision: "MATCH";Tpossible <= score < Tmatchcria composite comdecision: "POSSIBLE";- abaixo de
Tpossible, o resultado não é persistido.
Parciais
Parciais servem para cenários incrementais, principalmente parcelas, em que nem todos os membros chegaram ainda.
Configuração:
{
"composition": {
"enabled": true,
"groupByField": "doc",
"partial": {
"enabled": true,
"minMembers": 2,
"minCompletionRatio": 0.2,
"uniformityRatio": 0.05
}
}
}Campos:
| Campo | Default | Descrição |
|---|---|---|
partial.enabled | false | Permite criar composites PARTIAL. |
partial.minMembers | 2 | Mínimo de membros já presentes para abrir um parcial. |
partial.minCompletionRatio | 0.2 | Fração mínima composedAmount / targetAmount. |
partial.uniformityRatio | 0.05 | Variação máxima permitida entre os valores dos candidatos. |
Um composite parcial:
- é persistido com
status: "PARTIAL"; - sempre começa como
decision: "POSSIBLE"; - pode receber
expectedMemberCountquando o total esperado de membros é conhecido ou inferido; - é reavaliado quando novos records chegam ao dataset.
Completar parciais
Quando partial.enabled = true, novas ingestões podem completar composites parciais do rule set. O Nexus reavalia os parciais com os mesmos filtros de composição e atualiza o status quando a tolerância configurada é atendida.
Descoberta de parcelas
installmentDiscovery cria um PARTIAL mesmo quando há apenas um candidato no lado many. Use essa opção quando um lançamento agregado representa parcelas recorrentes que ainda serão recebidas:
targetAmount / N ~= valor_da_parcelaConfiguração:
{
"composition": {
"enabled": true,
"groupByField": "doc",
"installmentDiscovery": {
"enabled": true,
"maxDivisor": 60,
"divisorTolerance": 0.01,
"totalCountField": "parcelas_total",
"unitValueField": "valor_parcela"
}
}
}Campos:
| Campo | Default | Descrição |
|---|---|---|
installmentDiscovery.enabled | false | Liga a inferência por divisor. |
installmentDiscovery.maxDivisor | 60 | Maior N aceito. Limite absoluto: 100. |
installmentDiscovery.divisorTolerance | 0.01 | Tolerância usada para validar o valor de parcela esperado. |
installmentDiscovery.totalCountField | nenhum | Field no target com total de parcelas. Quando válido, tem prioridade. |
installmentDiscovery.unitValueField | nenhum | Field no candidato com valor unitário autoritativo. |
Regras de segurança:
- só roda se existe exatamente um candidato;
- exige
groupByFieldou pelo menos umparams.filtersnão vazio; - cria
status: "PARTIAL"edecision: "POSSIBLE"; - nunca gera um
MATCHautomático sozinho.
Persistência e consulta
Composites ficam em duas estruturas:
composite_matches: cabeçalho do composite;composite_match_members: records membros, comside: "one"para o alvo eside: "many"para os membros agregados.
Listar composites:
GET /v1/composite-matches?ruleSetId=&jobId=&decision=&direction=&page=&limit=Filtros:
| Param | Valores |
|---|---|
ruleSetId | UUID do rule set |
jobId | UUID do matching job |
decision | MATCH ou POSSIBLE |
direction | one_to_many ou many_to_one |
page, limit | paginação, limit até 100 |
Detalhe com membros:
GET /v1/composite-matches/{id}Exemplo de resposta:
{
"id": "composite-id",
"ruleSetId": "rule-set-id",
"matchingJobId": "job-id",
"direction": "one_to_many",
"status": "COMPLETE",
"expectedMemberCount": null,
"score": 0.998,
"decision": "MATCH",
"targetAmount": 1000,
"composedAmount": 998,
"amountDelta": 2,
"explanation": {
"type": "composite",
"direction": "one_to_many",
"status": "COMPLETE",
"memberCount": 3,
"score": 0.998
},
"members": [
{
"recordId": "record-alvo",
"side": "one",
"amount": 1000,
"dataset": "erp"
},
{
"recordId": "record-1",
"side": "many",
"amount": 400,
"dataset": "banco"
}
]
}O endpoint de detalhe retorna 404 com corpo { "error": "not_found", "message": "Composite match not found" }, diferente do envelope padrão de erros.
Contadores de job
Os contadores de matching mantêm composição separada de 1:1:
MATCH,POSSIBLE,NO_MATCH: resultados par-a-par;COMPOSITE_MATCH: composites com decisãoMATCH;COMPOSITE_POSSIBLE: composites com decisãoPOSSIBLE;COMPLETION_DETAILS: detalhes de composites estendidos/completados durante o completion pass.
Isso permite acompanhar composição sem distorcer métricas 1:1.
Webhooks
Eventos suportados:
| Evento | Quando dispara |
|---|---|
composite_match.created | Novo composite criado pela composition phase. |
composite_match.extended | Composite PARTIAL recebeu novos membros, mas ainda não completou. |
composite_match.completed | Composite PARTIAL mudou para COMPLETE. |
Payload resumido de composite_match.created:
{
"tenantId": "tenant-id",
"compositeMatchId": "composite-id",
"score": 0.998,
"decision": "MATCH",
"direction": "one_to_many",
"ruleSet": { "id": "rule-set-id", "name": "Conciliação" },
"one": {
"recordId": "record-alvo",
"externalId": "ERP-1",
"dataset": "erp",
"amount": 1000,
"body": {}
},
"many": [
{
"recordId": "record-1",
"externalId": "BANK-1",
"dataset": "banco",
"amount": 400,
"body": {}
}
],
"explanation": {
"type": "composite",
"status": "COMPLETE",
"targetAmount": 1000,
"composedAmount": 998,
"delta": 2,
"memberCount": 3,
"expectedMemberCount": null,
"score": 0.998
}
}composite_match.extended e composite_match.completed usam payload de completion com addedMembers, previousStatus, currentStatus e explanation.type = "composite_completion".
Boas práticas
- Use
amountFieldexplícito quando o dataset não tiverblockingAmountconfiável. - Configure
groupByFieldcom documento, conta, contraparte ou outro identificador estável. - Configure
dateStrategyexplicitamente quando a ordem temporal ou o período contábil for parte da regra de negócio. - Use
preferCompleteGroupquando todos os candidatos elegíveis de uma contraparte/período devem compor o total, mesmo que um subconjunto menor também feche. - Use
filterspara validar relações quegroupByFieldnão cobre. - Mantenha
maxGroupSizebaixo no início; aumente só quando houver caso real. - Prefira
skipAlreadyMatched: truepara evitar que um record já resolvido em 1:1 entre em um composite. - Habilite
partialapenas para fluxos com chegada incremental previsível. - Habilite
installmentDiscoveryapenas com guarda de contraparte. - Revise
POSSIBLEantes de automatizar baixa financeira.

