InícioDesafiosDesafio 05 › Gabarito

Gabarito · Desafio 05 Nível: Avançado

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 com try/catch: a chamada nem chega a sair.
  • Esquecer Database.AllowsCallouts. Compila e roda, mas estoura no send com "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.