在 Magento 2 开发中,我们经常遇到需要在订单创建时同步自定义数据到 sales_order_grid 表的场景。本文记录了一次从失败方案到成功方案的完整演进过程,分享如何使用 Magento 的 Related Object 机制来解决数据同步时序问题。

问题背景

业务需求

我们的项目需要在订单中存储 Event 相关信息(活动日期、类型、名称、城市、县等),并将 county(县)字段同步到 sales_order_grid 表以便在订单列表中展示。

数据结构

  • quote_event:购物车阶段的 Event 数据
  • sales_order_event:订单阶段的 Event 数据(包含 order_idcounty 字段)
  • sales_order_grid:订单网格表(需要显示 county

最初方案:使用 events.xml Observer(失败)

实现方式

首先创建 Observer 在订单保存成功后保存 event 数据:

<!-- app/code/Florida/Sales/etc/events.xml -->
<event name="sales_model_service_quote_submit_success">
    <observer name="saveOrderEventFromQuote"
              instance="Florida\Sales\Observer\SaveOrderEventFromQuote"/>
</event>
<?php
namespace Florida\Sales\Observer;

class SaveOrderEventFromQuote implements ObserverInterface
{
    public function execute(\Magento\Framework\Event\Observer $observer)
    {
        $order = $observer->getEvent()->getOrder();
        $quote = $observer->getEvent()->getQuote();

        $orderId = $order->getId();

        // 从 quote_event 读取数据
        $quoteEvent = $this->quoteEventRepository->getByQuoteId($quote->getId());

        // 保存到 sales_order_event
        $orderEvent = $this->orderEventFactory->create();
        $orderEvent->setOrderId((int)$orderId);
        $orderEvent->setCounty($quoteEvent->getCounty());
        // ... 复制其他字段

        $this->orderEventRepository->save($orderEvent);
    }
}

同时配置 Grid 的 JOIN 逻辑:

<!-- app/code/Florida/Sales/etc/di.xml -->
<virtualType name="Magento\Sales\Model\ResourceModel\Order\Grid">
    <arguments>
        <argument name="joins" xsi:type="array">
            <item name="sales_order_event" xsi:type="array">
                <item name="table" xsi:type="string">sales_order_event</item>
                <item name="origin_column" xsi:type="string">entity_id</item>
                <item name="target_column" xsi:type="string">order_id</item>
            </item>
        </argument>
        <argument name="columns" xsi:type="array">
            <item name="county" xsi:type="string">sales_order_event.county</item>
        </argument>
    </arguments>
</virtualType>

遇到的问题

订单创建后,发现 sales_order_grid.county 字段始终为空。

通过查看日志和数据库,我们发现:

时间轴:
T+0s: sales_order_grid 更新
      SQL: INSERT INTO sales_order_grid ... SELECT ... LEFT JOIN sales_order_event ...
      结果:county = NULL(sales_order_event 表中还没有数据!)

T+1s: sales_order_event 写入
      结果:数据已保存,但 Grid 已经更新过了

问题根因分析

sales_order_grid 的更新时机

通过分析 Magento 源码发现,Grid 的更新在 sales_order_process_relation 事件中触发:

// vendor/magento/module-sales/etc/events.xml
<event name="sales_order_process_relation">
    <observer name="sales_grid_order_sync_insert"
              instance="SalesOrderIndexGridSyncInsert"/>
</event>

Grid::refresh() 方法会生成如下 SQL:

INSERT INTO sales_order_grid (..., county, ...)
SELECT ..., sales_order_event.county, ...
FROM sales_order
LEFT JOIN sales_order_event ON sales_order.entity_id = sales_order_event.order_id
WHERE sales_order.entity_id = '497'

关键点:这个 LEFT JOIN 发生在订单保存完成后立即执行。

sales_order_event 的写入时机

我们的 Observer 在 sales_model_service_quote_submit_success 事件中触发:

<event name="sales_model_service_quote_submit_success">
    <observer name="saveOrderEventFromQuote" .../>
</event>

这个事件在订单保存之后触发。

时序对比

订单保存流程:
1. OrderRepository::save($order)
   ↓
2. processRelation() 处理关联对象(items, payment, addresses)
   ↓
3. 触发 sales_order_process_relation 事件
   ↓
4. GridSyncInsertObserver::execute()
   ↓
5. Grid::refresh(order_id)
   ├─ 生成 LEFT JOIN sales_order_event 的 SQL
   └─ 执行 INSERT ... SELECT
   ❌ 此时 sales_order_event 表中没有数据!county = NULL
   ↓
6. 触发 sales_model_service_quote_submit_success 事件
   ↓
7. SaveOrderEventFromQuote::execute()
   └─ 保存到 sales_order_event(太晚了!)

结论:由于 sales_order_grid 的更新时机比 sales_order_event 的保存时机早,导致 LEFT JOIN 时获取不到 county 数据。

解决方案研究:借鉴 Magento 核心实现

分析 sales_order_address 的处理方式

既然 sales_order_address 能够正确地将数据同步到 Grid,它是如何实现的呢?

通过分析 Magento 核心代码,我们发现 sales_order_address 使用了 Related Object 机制。

关键代码

  1. Order Model 的 relatedObjects 属性
// vendor/magento/module-sales/Model/Order.php
protected $_relatedObjects = [];

public function addRelatedObject(\Magento\Framework\Model\AbstractModel $object)
{
    $this->_relatedObjects[] = $object;
    return $this;
}

public function getRelatedObjects()
{
    return $this->_relatedObjects;
}
  1. Relation Processor 处理 relatedObjects
// vendor/magento/module-sales/Model/ResourceModel/Order/Relation.php
public function processRelation(\Magento\Framework\Model\AbstractModel $object)
{
    // ... 处理 items, payment, status histories ...

    // 处理 related objects
    if (null !== $object->getRelatedObjects()) {
        foreach ($object->getRelatedObjects() as $relatedObject) {
            $relatedObject->setOrder($object);
            $relatedObject->save();
        }
    }

    // ... 处理 addresses ...
}
  1. Address Model 的 setOrder/getOrder 方法
// vendor/magento/module-sales/Model/Order/Address.php
protected $order = null;

public function setOrder(\Magento\Sales\Model\Order $order)
{
    $this->order = $order;
    return $this;
}

public function getOrder()
{
    if (!$this->order) {
        $this->order = $this->orderFactory->create()->load($this->getParentId());
    }
    return $this->order;
}
  1. Address ResourceModel 自动设置 parent_id
// vendor/magento/module-sales/Model/ResourceModel/Order/Address.php
protected function _beforeSave(\Magento\Framework\Model\AbstractModel $object)
{
    parent::_beforeSave($object);

    // 自动设置 parent_id
    if (!$object->getParentId() && $object->getOrder()) {
        $object->setParentId($object->getOrder()->getId());
    }

    return $this;
}

关键发现

通过分析 Address 的实现,我们发现:

  1. Address 在 Order 保存前就被添加为 relatedObject
  2. processRelation() 在订单保存时自动保存所有 relatedObjects
  3. parent_id 由 ResourceModel 自动设置
  4. 整个过程在 Order 保存的同一个事务中完成
  5. Grid 更新在 processRelation() 之后触发,此时 Address 已经保存

这正是我们需要的!

核心思路

sales_order_event 像处理 sales_order_address 一样,作为 Order 的 Related Object

  1. 在订单保存之前创建 event 对象
  2. 调用 $order->addRelatedObject($event)
  3. Magento ORM 会自动保存 event
  4. order_id 自动设置
  5. Grid 更新时 event 已存在,可以正常 JOIN

实现步骤

步骤 1:为 Event Model 添加关联方法

<?php
namespace Florida\Sales\Model\Order;

use Magento\Sales\Model\Order as SalesOrder;
use Magento\Sales\Model\OrderFactory;

class Event extends AbstractModel implements OrderEventInterface
{
    protected OrderFactory $orderFactory;
    protected ?SalesOrder $order = null;

    public function __construct(
        \Magento\Framework\Model\Context $context,
        \Magento\Framework\Registry $registry,
        OrderFactory $orderFactory,
        // ... 其他参数
    ) {
        $this->orderFactory = $orderFactory;
        parent::__construct($context, $registry, ...);
    }

    /**
     * Set order object
     */
    public function setOrder(SalesOrder $order)
    {
        $this->order = $order;
        return $this;
    }

    /**
     * Get order object (lazy load)
     */
    public function getOrder(): ?SalesOrder
    {
        if (!$this->order) {
            $this->order = $this->orderFactory->create()->load($this->getOrderId());
        }
        return $this->order;
    }
}

步骤 2:ResourceModel 自动设置 order_id

<?php
namespace Florida\Sales\Model\ResourceModel\Order;

class Event extends AbstractDb
{
    protected function _beforeSave(\Magento\Framework\Model\AbstractModel $object)
    {
        parent::_beforeSave($object);

        // 自动设置 order_id(如果未设置)
        if (!$object->getOrderId() && $object->getOrder()) {
            $object->setOrderId($object->getOrder()->getId());
        }

        return $this;
    }
}

步骤 3:在订单提交前添加 event 为 relatedObject

<!-- app/code/Florida/Sales/etc/events.xml -->
<event name="sales_model_service_quote_submit_before">
    <observer name="addOrderEventAsRelatedObject"
              instance="Florida\Sales\Observer\AddOrderEventAsRelatedObject"/>
</event>
<?php
namespace Florida\Sales\Observer;

class AddOrderEventAsRelatedObject implements ObserverInterface
{
    public function execute(\Magento\Framework\Event\Observer $observer)
    {
        $order = $observer->getEvent()->getOrder();
        $quote = $observer->getEvent()->getQuote();

        // 获取 quote event 数据
        $quoteEvent = $this->quoteEventRepository->getByQuoteId($quote->getId());

        // 创建 order event 对象
        $orderEvent = $this->orderEventFactory->create();
        $orderEvent->setDate($quoteEvent->getDate());
        $orderEvent->setType($quoteEvent->getType());
        $orderEvent->setCity($quoteEvent->getCity());
        $orderEvent->setCounty($quoteEvent->getCounty());
        // ... 其他字段

        // ✨ 关键:设置 order 引用并添加为 related object
        $orderEvent->setOrder($order);
        $order->addRelatedObject($orderEvent);
    }
}

步骤 4:保留兜底机制

虽然主方案已经能解决问题,但我们保留原 Observer 作为兜底,并添加幂等性检查:

<?php
namespace Florida\Sales\Observer;

class SaveOrderEventFromQuote implements ObserverInterface
{
    public function execute(\Magento\Framework\Event\Observer $observer)
    {
        $order = $observer->getEvent()->getOrder();
        $quote = $observer->getEvent()->getQuote();

        $orderId = $order->getId() ?: $order->getEntityId();

        $quoteEvent = $this->quoteEventRepository->getByQuoteId($quote->getId());
        if ($quoteEvent) {
            // 幂等性检查:如果已存在,说明主机制成功
            $orderEvent = $this->orderEventRepository->getByOrderId((int)$orderId);
            if ($orderEvent && $orderEvent->getId()) {
                // 主机制成功,无需兜底
                return $this;
            }

            // 兜底:如果主机制失败,手动保存
            $orderEvent = $this->orderEventFactory->create();
            $orderEvent->setOrderId((int)$orderId);
            // ... 复制数据

            $this->orderEventRepository->save($orderEvent);
        }
    }
}

方案对比

时序对比

旧方案(失败)

1. OrderRepository::save()
   ↓
2. processRelation()
   ↓
3. sales_order_process_relation
   ↓
4. Grid::refresh()
   ├─ LEFT JOIN sales_order_event
   └─ ❌ county = NULL(event 还没保存)
   ↓
5. sales_model_service_quote_submit_success
   └─ SaveOrderEventFromQuote(太晚了)

新方案(成功)

1. sales_model_service_quote_submit_before
   ↓
2. AddOrderEventAsRelatedObject::execute()
   ├─ 创建 event 对象
   ├─ $event->setOrder($order)
   └─ $order->addRelatedObject($event)
   ↓
3. OrderRepository::save()
   ↓
4. processRelation()
   ├─ 遍历 relatedObjects
   ├─ $event->setOrder($order)
   └─ $event->save()
       └─ ResourceModel::_beforeSave()
           └─ 自动设置 order_id
   ↓
5. sales_order_process_relation
   ↓
6. Grid::refresh()
   ├─ LEFT JOIN sales_order_event
   └─ ✅ county 有值(event 已保存)
   ↓
7. sales_model_service_quote_submit_success
   └─ SaveOrderEventFromQuote(幂等检查,跳过)

关键差异

对比项 旧方案 新方案
Event 写入时机 Order 保存后 Order 保存中
order_id 设置 手动设置 自动设置
Grid 更新结果 county = NULL county 有值
事务一致性 分离 同一事务
与 Magento 一致性 ❌ 不一致 ✅ 完全一致

技术要点

Magento 提供了 addRelatedObject() 方法用于管理子对象:

// 添加 related object
$order->addRelatedObject($event);

// 获取 all related objects
$relatedObjects = $order->getRelatedObjects();

Magento\Sales\Model\ResourceModel\Order\Relation::processRelation() 中会自动保存所有 relatedObjects。

懒加载机制

public function getOrder(): ?SalesOrder
{
    if (!$this->order) {
        // 仅在需要时从数据库加载
        $this->order = $this->orderFactory->create()->load($this->getOrderId());
    }
    return $this->order;
}

自动设置关联 ID

在 ResourceModel 的 _beforeSave() 中自动设置:

if (!$object->getOrderId() && $object->getOrder()) {
    $object->setOrderId($object->getOrder()->getId());
}

总结

核心改进

通过从失败方案到成功方案的演进,我们学到了:

  1. 理解 Magento 的执行流程:分析 Grid 更新和数据保存的时序关系
  2. 借鉴核心实现:研究 sales_order_address 的处理方式
  3. 使用 Related Object 机制:将自定义对象作为 Order 的子对象
  4. 自动关联管理:利用 ORM 自动设置关联 ID
  5. 保留兜底机制:通过幂等性检查确保可靠性

方案优势

  • 解决时序问题:Event 在 Grid 更新前保存
  • 符合 Magento 架构:与核心实现一致
  • 自动管理关联:无需手动设置 ID
  • 事务一致性:在同一事务中完成
  • 性能最优:无额外查询
  • 可维护性强:代码清晰易懂

适用场景

  • 需要在订单创建时同步自定义数据到 Grid
  • 遇到 Grid 更新时序问题
  • 需要确保数据完整性
  • 希望代码符合 Magento 最佳实践