Openi DeveloperDeveloper
Guia de integração

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 composite

No 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:

ValorResultado persistidoInterpretação
left_to_many_rightdirection: "one_to_many"Um record do dataset left é explicado por N records do dataset right.
many_left_to_rightdirection: "many_to_one"N records do dataset left explicam um record do dataset right.
bothroda as duas direçõesPrimeiro 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:

CampoDefaultDescrição
enabledfalseLiga a fase composicional.
directionbothDireção da composição.
maxGroupSize10Máximo de membros no lado N. Limite absoluto: 100.
amountTolerancePercent1Tolerância percentual entre valor alvo e soma dos membros.
amountFieldblockingAmountField em record.data usado como valor. Se omitido, usa blockingAmount.
groupByFieldnenhumField obrigatório para agrupar candidatos por contraparte/documento.
dateStrategynot_before_targetEstratégia de data para restringir candidatos: none, not_before_target, window ou period.
dateFieldLeftblockingDateField de data/período do lado left. Se omitido, usa blockingDate.
dateFieldRightblockingDateField de data/período do lado right. Se omitido, usa blockingDate.
dateToleranceDays0Tolerância em dias quando dateStrategy = "window".
allowNegativeAmountsfalsePermite valores negativos no alvo e nos membros; valores zero continuam ignorados.
preferCompleteGroupfalsePrioriza grupos completos quando todos os candidatos elegíveis fecham na tolerância.
skipAlreadyMatchedtrueIgnora 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égiaRegra
not_before_targetO candidato precisa ter data igual ou posterior à data do alvo.
windowO candidato precisa cair entre targetDate - dateToleranceDays e targetDate + dateToleranceDays.
periodO candidato precisa ter a mesma data/período normalizado do alvo. Aceita valores YYYY-MM ou datas YYYY-MM-DD.
noneNã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 >= Tmatch cria composite com decision: "MATCH";
  • Tpossible <= score < Tmatch cria composite com decision: "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:

CampoDefaultDescrição
partial.enabledfalsePermite criar composites PARTIAL.
partial.minMembers2Mínimo de membros já presentes para abrir um parcial.
partial.minCompletionRatio0.2Fração mínima composedAmount / targetAmount.
partial.uniformityRatio0.05Variação máxima permitida entre os valores dos candidatos.

Um composite parcial:

  • é persistido com status: "PARTIAL";
  • sempre começa como decision: "POSSIBLE";
  • pode receber expectedMemberCount quando 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_parcela

Configuração:

{
  "composition": {
    "enabled": true,
    "groupByField": "doc",
    "installmentDiscovery": {
      "enabled": true,
      "maxDivisor": 60,
      "divisorTolerance": 0.01,
      "totalCountField": "parcelas_total",
      "unitValueField": "valor_parcela"
    }
  }
}

Campos:

CampoDefaultDescrição
installmentDiscovery.enabledfalseLiga a inferência por divisor.
installmentDiscovery.maxDivisor60Maior N aceito. Limite absoluto: 100.
installmentDiscovery.divisorTolerance0.01Tolerância usada para validar o valor de parcela esperado.
installmentDiscovery.totalCountFieldnenhumField no target com total de parcelas. Quando válido, tem prioridade.
installmentDiscovery.unitValueFieldnenhumField no candidato com valor unitário autoritativo.

Regras de segurança:

  • só roda se existe exatamente um candidato;
  • exige groupByField ou pelo menos um params.filters não vazio;
  • cria status: "PARTIAL" e decision: "POSSIBLE";
  • nunca gera um MATCH automático sozinho.

Persistência e consulta

Composites ficam em duas estruturas:

  • composite_matches: cabeçalho do composite;
  • composite_match_members: records membros, com side: "one" para o alvo e side: "many" para os membros agregados.

Listar composites:

GET /v1/composite-matches?ruleSetId=&jobId=&decision=&direction=&page=&limit=

Filtros:

ParamValores
ruleSetIdUUID do rule set
jobIdUUID do matching job
decisionMATCH ou POSSIBLE
directionone_to_many ou many_to_one
page, limitpaginaçã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ão MATCH;
  • COMPOSITE_POSSIBLE: composites com decisão POSSIBLE;
  • 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:

EventoQuando dispara
composite_match.createdNovo composite criado pela composition phase.
composite_match.extendedComposite PARTIAL recebeu novos membros, mas ainda não completou.
composite_match.completedComposite 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 amountField explícito quando o dataset não tiver blockingAmount confiável.
  • Configure groupByField com documento, conta, contraparte ou outro identificador estável.
  • Configure dateStrategy explicitamente quando a ordem temporal ou o período contábil for parte da regra de negócio.
  • Use preferCompleteGroup quando todos os candidatos elegíveis de uma contraparte/período devem compor o total, mesmo que um subconjunto menor também feche.
  • Use filters para validar relações que groupByField não cobre.
  • Mantenha maxGroupSize baixo no início; aumente só quando houver caso real.
  • Prefira skipAlreadyMatched: true para evitar que um record já resolvido em 1:1 entre em um composite.
  • Habilite partial apenas para fluxos com chegada incremental previsível.
  • Habilite installmentDiscovery apenas com guarda de contraparte.
  • Revise POSSIBLE antes de automatizar baixa financeira.

On this page