Início › Desafios › Desafio 05 › Gabarito
Gabarito: avisar o ERP quando a venda fecha, sem sequestrar a transação
Se você ainda não tentou resolver, volte e tente: o valor deste desafio está em bater de frente com o
You have uncommitted work pending e descobrir sozinho por que ele existe. Este gabarito
explica as quatro decisões que separam uma integração que aguenta produção de uma que funciona só na
demonstração: sair da transação, chamar em lote, preservar o dado na falha e não duplicar no reenvio.
Spoiler à frente. A solução completa está logo abaixo. Voltar para o desafio
O problema em uma frase
O trigger dispara depois de um DML, então a transação já tem trabalho não confirmado, e a plataforma
recusa qualquer callout nesse estado com System.CalloutException: You have uncommitted work
pending. Please commit or rollback before calling out. A regra existe porque um callout pode
demorar segundos, e o Salesforce não vai manter um lock de banco aberto esperando um servidor de terceiro.
A saída não é forçar a chamada: é tirar o callout da transação síncrona e fazê-lo depois,
em um contexto assíncrono que nasce sem DML pendente. E, feito isso, tratar o que a rede sempre traz:
lentidão, queda e resposta duplicada.
A solução completa
trigger OpportunityTrigger on Opportunity (after update) {
// Coleta quem ENTROU em Closed Won e ainda nao foi integrada.
Set<Id> ganhas = new Set<Id>();
for (Opportunity opp : Trigger.new) {
Opportunity antiga = Trigger.oldMap.get(opp.Id);
if (opp.StageName == 'Closed Won'
&& antiga.StageName != 'Closed Won'
&& opp.ERP_Pedido_Id__c == null) {
ganhas.add(opp.Id);
}
}
// NAO faz callout aqui: so enfileira o trabalho para fora da transacao.
ErpPedidoQueueable.enfileirar(ganhas);
}
public with sharing class ErpPedidoQueueable implements Queueable, Database.AllowsCallouts {
private Set<Id> oppIds;
private Integer tentativa;
private static final Integer MAX_TENTATIVAS = 3;
public ErpPedidoQueueable(Set<Id> oppIds) {
this(oppIds, 1);
}
private ErpPedidoQueueable(Set<Id> oppIds, Integer tentativa) {
this.oppIds = oppIds;
this.tentativa = tentativa;
}
// Chamado do trigger: apenas enfileira. Zero callout na transacao sincrona.
public static void enfileirar(Set<Id> oppIds) {
if (oppIds == null || oppIds.isEmpty()) {
return;
}
System.enqueueJob(new ErpPedidoQueueable(oppIds));
}
public void execute(QueueableContext ctx) {
// Idempotencia camada 1: so envia quem ainda nao tem id do ERP.
List<Opportunity> pendentes = [
SELECT Id, Name, Amount
FROM Opportunity
WHERE Id IN :oppIds AND ERP_Pedido_Id__c = null
];
if (pendentes.isEmpty()) {
return;
}
HttpResponse resp;
try {
resp = enviarLote(pendentes);
} catch (Exception e) {
// Callout inteiro caiu (timeout, DNS). Ninguem foi marcado: reprocessa tudo.
registrar(null, 'Lote falhou: ' + e.getTypeName() + ' - ' + e.getMessage());
reprocessar(new Map<Id, Opportunity>(pendentes).keySet());
return;
}
if (resp.getStatusCode() != 200) {
registrar(null, 'ERP HTTP ' + resp.getStatusCode() + ': ' + resp.getBody());
reprocessar(new Map<Id, Opportunity>(pendentes).keySet());
return;
}
Map<String, Object> corpo =
(Map<String, Object>) JSON.deserializeUntyped(resp.getBody());
List<Object> resultados = (List<Object>) corpo.get('resultados');
Map<Id, Opportunity> porId = new Map<Id, Opportunity>(pendentes);
List<Opportunity> integradas = new List<Opportunity>();
List<Log_Integracao__c> logs = new List<Log_Integracao__c>();
Set<Id> falharam = new Set<Id>();
for (Object o : resultados) {
Map<String, Object> r = (Map<String, Object>) o;
Id oppId = (Id) r.get('oppId');
Opportunity opp = porId.get(oppId);
if (opp == null) {
continue;
}
if (r.get('ok') == true) {
opp.ERP_Pedido_Id__c = String.valueOf(r.get('pedidoId'));
integradas.add(opp);
} else {
falharam.add(oppId);
logs.add(novoLog(oppId, 'ERP recusou: ' + String.valueOf(r.get('erro'))));
}
}
if (!integradas.isEmpty()) {
update integradas; // marca so DEPOIS da confirmacao do ERP
}
if (!logs.isEmpty()) {
insert logs;
}
reprocessar(falharam);
}
private HttpResponse enviarLote(List<Opportunity> opps) {
List<Map<String, Object>> itens = new List<Map<String, Object>>();
for (Opportunity opp : opps) {
itens.add(new Map<String, Object>{
'idempotencia' => opp.Id, // chave: reenvio nao duplica no ERP
'nome' => opp.Name,
'valor' => opp.Amount
});
}
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:ERP/orders/batch');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setTimeout(20000);
req.setBody(JSON.serialize(new Map<String, Object>{ 'pedidos' => itens }));
return new Http().send(req);
}
private void reprocessar(Set<Id> ids) {
// Chaining nao roda em teste; guardar e o padrao documentado.
if (ids.isEmpty() || tentativa >= MAX_TENTATIVAS || Test.isRunningTest()) {
return;
}
System.enqueueJob(new ErpPedidoQueueable(ids, tentativa + 1));
}
private void registrar(Id refId, String detalhe) {
insert novoLog(refId, detalhe);
}
private Log_Integracao__c novoLog(Id refId, String detalhe) {
return new Log_Integracao__c(
Referencia__c = refId == null ? null : String.valueOf(refId),
Detalhe__c = detalhe
);
}
}
Destrinchando parte por parte
1. O trigger só enfileira
// NAO faz callout aqui: so enfileira o trabalho para fora da transacao. ErpPedidoQueueable.enfileirar(ganhas);
Esta é a linha que mata o You have uncommitted work pending. System.enqueueJob
não abre conexão nenhuma: apenas agenda um job para rodar depois que a transação atual confirmar. Quando
o Queueable executa, ele nasce em uma transação nova, sem DML pendente, e aí o callout é
permitido. O trigger continua fino, coletando só os Ids de quem entrou em
Closed Won e ainda não foi integrado, e delega o resto.
2. Por que Queueable, e a interface que autoriza o callout
public with sharing class ErpPedidoQueueable
implements Queueable, Database.AllowsCallouts {
@future(callout=true) também tira o callout da transação, mas Queueable ganha em
três pontos que este desafio cobra: aceita tipos complexos como argumento (um Set<Id>
direto, sem serializar para uma lista de strings), devolve um Id de job para monitorar, e permite
encadear outro job, que é como o retry funciona aqui. O detalhe que reprova em silêncio é
Database.AllowsCallouts: sem essa interface, o execute compila, roda, e estoura
na hora do send com Callout from scheduled or batch is not supported. Ela é o
que declara "este job vai chamar para fora".
3. Um callout para o lote inteiro
for (Opportunity opp : opps) {
itens.add(new Map<String, Object>{
'idempotencia' => opp.Id,
'nome' => opp.Name,
'valor' => opp.Amount
});
}
// ... um unico send para todos
return new Http().send(req);
A tentação é um callout por oportunidade dentro do laço. Isso não só multiplica a latência como bate no teto de 100 callouts por transação: um lote de 150 vendas já estoura antes da metade. A solução é o endpoint de lote: monta-se um corpo com todos os itens e faz-se uma chamada. Uma consulta, uma chamada HTTP, um update no fim. É a mesma disciplina de bulkificação do SOQL e do DML, aplicada ao recurso mais caro de todos, que é a rede.
4. Marcar só depois da confirmação, e as duas camadas de idempotência
// camada 1: filtro que ignora quem ja foi
WHERE Id IN :oppIds AND ERP_Pedido_Id__c = null
// camada 2: chave que o ERP usa para nao criar de novo
'idempotencia' => opp.Id
// e a marca so acontece DEPOIS do 200:
opp.ERP_Pedido_Id__c = String.valueOf(r.get('pedidoId'));
Idempotência é o coração deste desafio, e ela tem duas camadas porque um timeout é traiçoeiro: quando a
chamada estoura, você não sabe se o ERP não recebeu ou se recebeu, criou o
pedido e a resposta se perdeu no caminho de volta. Se você reenviar cegamente nesse segundo
caso, cria o pedido duas vezes. A camada 1, o filtro ERP_Pedido_Id__c = null, evita reenviar
quem já teve confirmação registrada. Mas ela sozinha não cobre o timeout, porque nesse caso a marca nunca
foi gravada. É a camada 2 que fecha o buraco: cada item carrega uma chave de idempotência (o próprio Id da
oportunidade), e o ERP, ao ver a mesma chave de novo, devolve o pedido que já existe em vez de criar
outro. E a marca só é gravada depois do 200: enquanto não há confirmação, a
oportunidade continua pendente e elegível para reprocessar, o que garante que a falha nunca perde o dado.
5. Retry com teto, e o guarda de teste
private void reprocessar(Set<Id> ids) {
if (ids.isEmpty() || tentativa >= MAX_TENTATIVAS || Test.isRunningTest()) {
return;
}
System.enqueueJob(new ErpPedidoQueueable(ids, tentativa + 1));
}
Quem falhou volta para a fila em um job encadeado, carregando o número da tentativa. O teto de tentativas
impede o laço infinito quando o ERP fica fora do ar por horas: depois de três voltas, para de tentar e o
log já registrou cada falha, então o time tem a lista do que reenviar manualmente. O
Test.isRunningTest() não é gambiarra: encadear Queueable dentro de um teste
lança Maximum stack depth has been reached, e o guarda é o padrão documentado pela própria
Salesforce para esse caso. O caminho de sucesso e o de falha continuam 100% testáveis; só o
encadeamento fica de fora, e ele não é a regra que o teste precisa provar.
Erros comuns
- Callout direto no trigger. O clássico
You have uncommitted work pending. Não se contorna comtry/catch: a chamada nem chega a sair. - Esquecer
Database.AllowsCallouts. Compila e roda, mas estoura nosendcom "callout não suportado". O erro aparece só quando a linha do callout executa. - Marcar como integrada antes da resposta. Se o callout falha depois da marca, a oportunidade fica com um pedido que não existe no ERP, e ninguém reprocessa porque parece pronta.
- Reenviar sem chave de idempotência. Funciona até o primeiro timeout que na verdade chegou ao ERP. Aí o reenvio cria o segundo pedido, e a duplicação é do lado que você nem controla.
- Engolir a exceção com
catch (Exception e) {}vazio. A oportunidade não é marcada, mas também não é reprocessada nem logada. O dado some sem deixar rastro, que é o pior dos mundos. - Callout dentro do laço. Passa no teste com uma venda e bate no teto de 100 callouts no primeiro lote de verdade.
Como saber se a sua solução passou
Rode a classe de teste do desafio. O primeiro método enfileira 150 vendas com o mock de sucesso e o
assert final tem que contar exatamente 150 oportunidades com ERP_Pedido_Id__c
preenchido, provando o callout em lote; a segunda chamada, sem pendentes, não pode alterar nada, provando
a idempotência. O segundo método usa o mock que estoura no send e verifica duas coisas: a
oportunidade continua sem id do ERP (a falha não marcou) e existe pelo menos um
Log_Integracao__c (a falha virou registro). Passou se os quatro asserts fecham.
Um passo além
Em produção, três evoluções valem o estudo. Primeira: substituir o retry manual por backoff exponencial, espaçando as tentativas em vez de reenfileirar na hora, porque martelar um ERP que está caindo só piora a queda. Segunda: quando o volume é grande e contínuo, trocar o job por um evento de plataforma ou pela fila de integração, desacoplando de vez o horário da venda do horário do envio; o artigo alertas em tempo real com Platform Events mostra esse padrão de desacoplamento. Terceira: fechar a conta dos limites assíncronos antes de escalar, porque o teto de callouts, o de jobs na fila e o de profundidade de encadeamento aparecem juntos quando o movimento cresce; os 6 governor limits que mais estouram cobre exatamente onde a integração encosta neles. E se a dúvida for onde essa lógica deveria morar, Flow ou Apex: quando usar cada um trata da fronteira entre automação declarativa e código.
O aprendizado que fica: integração não é a chamada que dá certo, é o plano para quando ela dá errado. A rede vai estar lenta, o outro lado vai cair no meio, e a mesma mensagem vai chegar duas vezes. Código que assume isso desde o começo é o que separa quem já integrou de verdade de quem só testou o caminho feliz.