Exportar pedidos do Magento 2 para o ERP sem perder nenhum
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
- 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.
- A exportação é assíncrona. O checkout do cliente nunca espera o ERP.
- Toda exportação pode ser repetida com segurança. Enviar o mesmo pedido duas vezes não pode criar dois pedidos no ERP.
- 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 nodi.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
Order status customizado no Magento 2: states, statuses e workflow
Crie order statuses customizados no Magento 2, ligue-os a states com um data patch, altere-os pelo código com segurança e evite que o Magento os redefina ao salvar.
Consumers do RabbitMQ no Magento 2 que não morrem em silêncio
Configure filas do RabbitMQ no Magento 2.4 do jeito certo: arquivos XML de fila, consumers via cron ou supervisor, max-messages e como perceber um consumer morto.
Shipping method customizado no Magento 2: crie um módulo de carrier
Como criar um shipping method customizado no Magento 2.4: config.xml, system.xml, um carrier model com collectRates, regras de frete grátis e APIs externas de tarifa.
Checklist de limpeza de malware no Magento 2 para lojistas
Checklist defensivo para uma loja Magento 2 hackeada: sinais de comprometimento, achar código injetado em arquivos e banco, rebuild limpo, novas credenciais e hardening.