Início › Desafios › Desafio 05
Avisar o ERP quando a venda fecha, sem sequestrar a transação
A operação pediu algo simples: quando uma oportunidade é ganha, criar o pedido no ERP automaticamente.
Você faz o callout no trigger, testa com uma venda e funciona. Aí sobe para produção e, no primeiro dia
de movimento, metade das vendas trava com uma exceção que ninguém tinha visto antes:
You have uncommitted work pending. O usuário só vê que não conseguiu salvar a oportunidade.
Integração é onde o Apex bem escrito e o Apex de demonstração se separam de vez.
O cenário
Quando a oportunidade entra em Closed Won, o time quer um pedido criado no ERP externo. A
primeira versão faz o callout direto no trigger, e ela tem um problema que só aparece com movimento de
verdade:
trigger OpportunityTrigger on Opportunity (after update) {
for (Opportunity opp : Trigger.new) {
if (opp.StageName == 'Closed Won') {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:ERP/orders');
req.setMethod('POST');
req.setBody('{"opp":"' + opp.Id + '"}');
// CalloutException: You have uncommitted work pending
new Http().send(req);
}
}
}
O trigger roda depois de um DML (o próprio update que ganhou a oportunidade), então a transação já tem trabalho não confirmado. A plataforma proíbe abrir um callout com DML pendente, porque a chamada externa pode demorar e ela não vai segurar um lock de banco esperando um servidor de terceiro responder. Some a isso o resto: callout dentro do laço estoura o limite de chamadas, um timeout do ERP derruba a oportunidade inteira, e se você reprocessar depois do timeout corre o risco de criar o pedido duas vezes.
Pré-requisitos na org. Para o cenário fechar, assuma que existem: um
Named Credential chamado ERP apontando para o serviço externo; um campo
ERP_Pedido_Id__c (Texto, External Id) na Opportunity, vazio enquanto o pedido não foi
criado; e um objeto Log_Integracao__c com os campos Referencia__c (Texto) e
Detalhe__c (Área de texto longo) para registrar falhas.
O que você deve construir
Uma classe ErpPedidoQueueable que enfileira o envio para fora da transação
do trigger e faz o callout de forma assíncrona. O contrato mínimo, que o teste vai usar, é este:
public with sharing class ErpPedidoQueueable implements Queueable, Database.AllowsCallouts {
// Ponto de entrada do trigger: NAO faz callout, so enfileira o trabalho.
public static void enfileirar(Set<Id> oppIds)
// O trabalho assincrono: aqui o callout e permitido.
public void execute(QueueableContext ctx)
}
O trigger vira uma linha (ErpPedidoQueueable.enfileirar(idsGanhas);) e toda a integração
mora na classe. O ERP expõe um endpoint de lote que recebe vários pedidos de uma vez e responde por item.
Regras de aceite
- O callout nunca roda na transação do trigger. Chamar
enfileirarde umafter updatenão pode lançarYou have uncommitted work pending. O trabalho sai da transação síncrona. - Um callout por lote, não um por registro. Um conjunto com 150 oportunidades resolve com uma consulta e uma chamada HTTP, não 150. Nada de SOQL, DML ou callout dentro do laço.
- Falha não perde o dado. Se o ERP responde erro ou o callout estoura por timeout, a oportunidade não pode ser marcada como integrada, e o registro precisa entrar para reprocessamento.
- Toda falha vira log. Cada erro, do lote inteiro ou de um item específico, é gravado
em
Log_Integracao__ccom o motivo, para o time saber o que reenviar. - Idempotência (o caso de borda que reprova a maioria): executar o mesmo envio duas
vezes não pode criar dois pedidos no ERP. Uma oportunidade que já tem
ERP_Pedido_Id__cé ignorada, e cada item carrega uma chave de idempotência para o caso do reenvio após um timeout em que o ERP na verdade recebeu.
O que este desafio avalia
- Se você entende por que a plataforma proíbe callout com DML pendente, e não só como contornar o erro.
- Se você escolhe o mecanismo assíncrono certo e sabe por que
Queueablecabe melhor que@futureaqui. - Se o seu tratamento de erro preserva o dado em vez de engolir a exceção.
- Se você percebe que timeout não é a ausência de resposta: pode ser uma resposta que chegou tarde, e que a idempotência existe exatamente para esse caso.
Travei. Me dá uma direção (sem entregar a resposta)
Quatro perguntas que destravam:
- Por que a plataforma deixa um
Queueablefazer callout, mas o trigger síncrono não? O que muda entre as duas transações? - O que a interface
Database.AllowsCalloutsautoriza, e o que acontece se você a esquecer? - Se o ERP der timeout, como você distingue "não recebeu" de "recebeu e a resposta se perdeu"? Qual das duas duplica o pedido no reenvio?
- Onde você guarda "esta oportunidade já foi integrada" de um jeito que sobreviva ao reprocessamento e não dependa de rodar de novo na mesma transação?
Termos de busca que ajudam: apex you have uncommitted work pending callout, queueable database allowscallouts apex, idempotency key http retry, httpcalloutmock test queueable apex.
Como testar
Callout não se testa em Execute Anonymous: precisa de HttpCalloutMock, que só vive em
contexto de teste. Cole esta classe de teste na org, rode-a e leia os asserts. Ela cobre o caminho feliz em
lote, a idempotência do reenvio e a falha por timeout que não pode perder o dado.
@isTest
private class ErpPedidoQueueableTest {
// Mock que responde sucesso para cada pedido recebido no corpo do lote.
private class ErpSucessoMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
Map<String, Object> corpo =
(Map<String, Object>) JSON.deserializeUntyped(req.getBody());
List<Object> pedidos = (List<Object>) corpo.get('pedidos');
List<Map<String, Object>> resultados = new List<Map<String, Object>>();
Integer n = 1;
for (Object p : pedidos) {
Map<String, Object> item = (Map<String, Object>) p;
resultados.add(new Map<String, Object>{
'oppId' => item.get('idempotencia'),
'pedidoId' => 'PED-' + n++,
'ok' => true
});
}
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setBody(JSON.serialize(new Map<String, Object>{ 'resultados' => resultados }));
return res;
}
}
// Mock que simula o ERP fora do ar: o send() estoura.
private class ErpTimeoutMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
CalloutException e = new CalloutException();
e.setMessage('Read timed out');
throw e;
}
}
private static List<Opportunity> ganhas(Integer quantas) {
List<Opportunity> opps = new List<Opportunity>();
for (Integer i = 0; i < quantas; i++) {
opps.add(new Opportunity(Name = 'Deal ' + i, StageName = 'Closed Won',
CloseDate = Date.today()));
}
insert opps;
return opps;
}
@isTest
static void enviaLoteEMarcaTodas() {
List<Opportunity> opps = ganhas(150);
Set<Id> ids = new Map<Id, Opportunity>(opps).keySet();
Test.setMock(HttpCalloutMock.class, new ErpSucessoMock());
Test.startTest();
ErpPedidoQueueable.enfileirar(ids);
Test.stopTest(); // o Queueable roda aqui, com o mock ativo
Integer marcadas = [SELECT count() FROM Opportunity
WHERE Id IN :ids AND ERP_Pedido_Id__c != null];
System.assertEquals(150, marcadas, 'todas as 150 deveriam ter o id do ERP');
// Idempotencia: reprocessar nao acha pendente, nao faz callout, nao muda nada.
new ErpPedidoQueueable(ids).execute(null);
Integer aindaMarcadas = [SELECT count() FROM Opportunity
WHERE Id IN :ids AND ERP_Pedido_Id__c != null];
System.assertEquals(150, aindaMarcadas, 'o reenvio nao pode duplicar nem alterar');
}
@isTest
static void falhaNaoMarcaELoga() {
Opportunity opp = ganhas(1)[0];
Test.setMock(HttpCalloutMock.class, new ErpTimeoutMock());
Test.startTest();
ErpPedidoQueueable.enfileirar(new Set<Id>{ opp.Id });
Test.stopTest();
opp = [SELECT ERP_Pedido_Id__c FROM Opportunity WHERE Id = :opp.Id];
System.assertEquals(null, opp.ERP_Pedido_Id__c, 'falha nao pode marcar como integrada');
System.assert([SELECT count() FROM Log_Integracao__c] > 0, 'a falha tem que virar log');
}
}
Resolveu? O que fica não é o Queueable: é a pergunta que toda integração
cobra e que nenhuma tela mostra. "O que acontece se a outra ponta demorar, cair no meio, ou receber a
mesma chamada duas vezes?". Quem só testa o caminho feliz descobre a resposta em produção.