← Voltar ao blog

Exportar pedidos do Magento 2 para o ERP sem perder nenhum

Rodrigo Biazzi

Exportar pedidos do Magento para um ERP parece simples: quando o pedido é feito, envie. Os problemas aparecem depois. O ERP ficou fora do ar por dez minutos e três pedidos nunca chegaram. Um retry criou um duplicado. Um pedido foi enviado antes de o pagamento ser confirmado e o galpão despachou mesmo assim. Ninguém percebeu até o cliente ligar.

Este artigo descreve um design de exportação de pedidos para o Magento Open Source e o Adobe Commerce 2.4.8 que não perde pedidos e não duplica. Fala menos de um ERP específico e mais da estrutura em volta da chamada de API, que é onde a maioria das integrações falha. Para o panorama geral, veja padrões de integração com ERP.

Princípios

  1. O banco de dados é a fonte da verdade do status de exportação. Cada pedido registra se foi exportado, quando e qual a referência no ERP. Eventos e filas são jeitos de acelerar, não o registro.
  2. A exportação é assíncrona. O checkout do cliente nunca espera o ERP.
  3. Toda exportação pode ser repetida com segurança. Enviar o mesmo pedido duas vezes não pode criar dois pedidos no ERP.
  4. As falhas são visíveis. Alguém é avisado quando um pedido trava, antes de o cliente reclamar.

Status de exportação no pedido

Adicione colunas à sales_order com o db_schema.xml:

<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="sales_order">
        <column xsi:type="varchar" name="erp_export_status" nullable="false" length="20" default="pending" comment="ERP Export Status"/>
        <column xsi:type="smallint" name="erp_export_attempts" unsigned="true" nullable="false" default="0" comment="ERP Export Attempts"/>
        <column xsi:type="timestamp" name="erp_exported_at" nullable="true" comment="ERP Exported At"/>
        <column xsi:type="varchar" name="erp_reference" nullable="true" length="64" comment="ERP Order Reference"/>
        <column xsi:type="text" name="erp_last_error" nullable="true" comment="ERP Last Error"/>
        <index referenceId="SALES_ORDER_ERP_EXPORT_STATUS" indexType="btree">
            <column name="erp_export_status"/>
        </index>
    </table>
</schema>

Quando essa coluna é adicionada a uma loja existente, todo pedido histórico vira pending. Antes de habilitar a exportação, rode uma atualização única que marque os pedidos antigos como skipped, ou a primeira execução vai tentar enviar anos de pedidos ao ERP.

Algumas equipes preferem uma tabela separada, indexada pelo id do pedido, em vez de colunas novas na sales_order. Isso mantém a tabela de vendas limpa e é mais fácil de remover depois. As duas abordagens funcionam. O importante é que o status seja armazenado e indexado.

Quando um pedido está pronto para exportar?

Combine isso com o negócio antes de escrever código. Respostas comuns:

  • Quando é feito, para pagamentos offline em que o ERP cuida do acompanhamento do pagamento.
  • Quando é pago, o que no Magento normalmente significa que existe uma invoice. Em pagamentos online com capture no checkout, é imediato. Com authorize apenas, acontece depois.
  • Quando chega a um status específico, por exemplo depois de uma análise antifraude.

Escreva essa regra como um método em uma classe, por exemplo isReadyForExport(OrderInterface $order): bool. Tanto o handler do evento quanto o cron sweep abaixo chamam esse método.

Escolhendo o evento de gatilho

Uma escolha tentadora é o sales_order_place_after. Ele dispara dentro do Order::place(), antes de o pedido ser salvo. Se você publicar uma mensagem ali, um consumer rápido pode tentar carregar um pedido que ainda não existe, ou que nunca vai existir se a transação sofrer rollback.

Uma escolha mais segura é o sales_order_save_commit_after. Ele é disparado depois do commit da transação do banco que salvou o pedido, então o pedido realmente está lá. Ele dispara em todo save de pedido, então o observer precisa ser barato: confira o status de exportação e a regra de prontidão, e publique só se os dois disserem sim.

<?php
declare(strict_types=1);

namespace Vendor\ErpExport\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\MessageQueue\PublisherInterface;
use Vendor\ErpExport\Model\ExportRules;

class PublishOrderForExport implements ObserverInterface
{
    public const TOPIC = 'vendor.erp.order.export';

    public function __construct(
        private readonly PublisherInterface $publisher,
        private readonly ExportRules $exportRules
    ) {
    }

    public function execute(Observer $observer): void
    {
        $order = $observer->getEvent()->getOrder();

        $status = $order->getData('erp_export_status') ?? 'pending';
        if ($status !== 'pending') {
            return;
        }

        if (!$this->exportRules->isReadyForExport($order)) {
            return;
        }

        $this->publisher->publish(self::TOPIC, (string) $order->getEntityId());
    }
}

Um objeto de pedido recém-criado não carrega o default da coluna que vem do banco, então um valor ausente é tratado como pending. Registre o observer no etc/events.xml global, para cobrir também pedidos criados no admin e pela API. A mensagem leva só o id do pedido. O consumer sempre carrega dados novos, o que evita exportar uma cópia desatualizada.

O consumer

O tópico da fila, o consumer e o handler são declarados em communication.xml, queue_publisher.xml, queue_topology.xml e queue_consumer.xml. O artigo consumers do RabbitMQ cobre esses arquivos e como manter os consumers rodando. O handler é onde a exportação acontece:

<?php
declare(strict_types=1);

namespace Vendor\ErpExport\Model\Queue;

use Magento\Framework\App\ResourceConnection;
use Magento\Sales\Api\OrderRepositoryInterface;
use Psr\Log\LoggerInterface;
use Throwable;
use Vendor\ErpExport\Model\ErpClient;
use Vendor\ErpExport\Model\OrderMapper;

class ExportHandler
{
    private const MAX_ATTEMPTS = 5;

    public function __construct(
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly OrderMapper $mapper,
        private readonly ErpClient $client,
        private readonly ResourceConnection $resource,
        private readonly LoggerInterface $logger
    ) {
    }

    public function process(string $orderId): void
    {
        $order = $this->orderRepository->get((int) $orderId);

        if ($order->getData('erp_export_status') === 'exported') {
            return;
        }

        try {
            $reference = $this->client->sendOrder($this->mapper->map($order));
            $this->update((int) $orderId, [
                'erp_export_status' => 'exported',
                'erp_reference' => $reference,
                'erp_exported_at' => gmdate('Y-m-d H:i:s'),
                'erp_last_error' => null,
            ]);
        } catch (Throwable $e) {
            $attempts = (int) $order->getData('erp_export_attempts') + 1;
            $this->update((int) $orderId, [
                'erp_export_status' => $attempts >= self::MAX_ATTEMPTS ? 'failed' : 'pending',
                'erp_export_attempts' => $attempts,
                'erp_last_error' => mb_substr($e->getMessage(), 0, 1000),
            ]);
            $this->logger->error('ERP export failed', ['order_id' => $orderId, 'exception' => $e]);
        }
    }

    private function update(int $orderId, array $data): void
    {
        $connection = $this->resource->getConnection('sales');
        $connection->update(
            $this->resource->getTableName('sales_order', 'sales'),
            $data,
            ['entity_id = ?' => $orderId]
        );
    }
}

A atualização de status grava direto na tabela em vez de salvar o pedido pelo repository. Salvar o pedido inteiro dispararia o sales_order_save_commit_after de novo, rodaria todos os outros observers e plugins ligados ao save de pedidos e correria o risco de sobrescrever alterações feitas por um admin nesse meio tempo. Um update direcionado às nossas próprias colunas evita tudo isso.

Idempotência

O handler confere o status antes de enviar, mas isso só protege contra duplicatas dentro do Magento. A proteção de verdade precisa estar na chamada ao ERP. Envie uma chave única com cada pedido, normalmente o increment id, e faça o lado do ERP rejeitar ou devolver o registro existente quando a chave já existe. Muitos ERPs têm um campo de referência externa justamente para isso.

Sem isso, um timeout é perigoso. O ERP pode ter criado o pedido e só falhado ao responder. O Magento vê um erro, tenta de novo e cria um segundo pedido. Com uma chave idempotente, o retry devolve a referência existente e o Magento marca o pedido como exportado.

O cron sweep

Eventos e filas falham de jeitos silenciosos: um consumer que não está rodando, uma mensagem perdida durante o restart do broker, um pedido criado por importação direta no banco. Um cron a cada poucos minutos fecha essa lacuna. Ele seleciona pedidos com erp_export_status = 'pending', prontos pela regra de negócio e mais antigos que alguns minutos para não competir com a fila, e os publica de novo.

Mantenha limitado. Selecione um batch limitado por execução, ordenado por entity_id, e deixe a próxima execução continuar. Use um lock para que duas execuções nunca se sobreponham. O guia de cron jobs cobre os dois pontos.

Visibilidade e operação

  • Coluna no grid do admin. Adicione o status de exportação ao grid de pedidos para o suporte ver sem acesso ao banco. O grid de pedidos tem tabela própria, a sales_order_grid, então a coluna precisa ser adicionada lá e na configuração do grid no di.xml.
  • Alertas. Avise alguém quando um pedido chegar a failed, ou quando o pedido pending mais antigo passar de um limite combinado.
  • Um comando de reenvio. Um comando de CLI que zera o status e as tentativas de um pedido e o publica. Poupa muita edição manual no banco.
  • Logs com contexto. Registre o id do pedido, o request e a resposta, mascarando dados pessoais quando necessário.

Mapeando o pedido

Mantenha o mapeamento em uma classe própria que recebe um OrderInterface e devolve um array. Ele deve ser uma função pura, sem chamadas de API, para ser fácil de testar com pedidos de fixture. Seja explícito nas coisas que o ERP considera: imposto por linha, descontos por linha ou por pedido, frete como linha ou como campo do cabeçalho, produtos configuráveis em que o filho carrega o SKU, produtos bundle e a moeda. A maioria dos erros de dados que vi em exportações vem desses casos, não do transporte.

FAQ

Devo exportar pedidos em um observer durante o checkout?

Não. Chamar o ERP durante o checkout faz o cliente esperar e perde a exportação do pedido quando o ERP está fora do ar. Registre a necessidade de exportar e faça de forma assíncrona.

Qual evento deve disparar a exportação?

sales_order_save_commit_after, filtrado pelo seu status de exportação e pela regra de prontidão. O sales_order_place_after dispara antes de o pedido ser salvo, o que é cedo demais para uma fila.

Preciso de RabbitMQ para isso?

Não. A coluna de status e o cron sweep sozinhos já formam uma exportação funcional. Uma fila acrescenta velocidade e distribui o trabalho, e o Magento também pode usar o backend de fila em MySQL.

Como evito pedidos duplicados no ERP?

Envie uma chave única, como o increment id, e faça o ERP rejeitar ou devolver registros existentes com essa chave. Assim os retries são seguros.

Atendo como freelancer em Magento e Adobe Commerce, incluindo integrações de ERP e de pedidos. Se precisar de ajuda com isso, fale comigo.

Artigos relacionados