PAYATHON 2026

X-Cart 5 Checkout 添加 Preferred Delivery Date 字段

支付阿杰

结论

不要只在 Checkout 模板中添加一个 <input>。HTML 字段只能收集数据,X-Cart 不会自动把任意请求参数写入 Order。

完整实现需要处理四个部分:

  1. 扩展 Order 模型并创建数据库字段。
  2. 把日期输入框加入 Checkout 的 Review 区域,确保它位于实际提交的表单内。
  3. 在执行 place_order 前读取并校验请求参数,再写入当前 Cart/Order。
  4. 扩展 invoice 模板和后台订单列表,读取并显示这个属性。

购物车本身使用订单模型保存数据,所以日期必须在下单动作完成前写入当前 Cart。下单完成后,可以通过订单实体的 getter 读取。

以下代码适用于使用 Doctrine annotation 和 Twig 的 X-Cart 5。不同小版本的命名空间、模板目录和 View List 名称可能不同,请以当前安装版本中的 Customer notes、Checkout controller 和 Order ItemsList 实现为准。

1. 扩展 Order 模型

这项功能涉及模型、控制器和后台列表,不只是模板样式调整,因此更适合放在独立业务模块中,而不是仅通过 CustomSkin 覆盖模板。

<?php

namespace XLite\Module\YourVendor\DeliveryDate\Model;

/**
 * @MappedSuperClass
 */
abstract class Order extends \XLite\Model\Order implements \XLite\Base\IDecorator
{
    /**
     * @Column(type="date", nullable=true)
     *
     * @var \DateTime|null
     */
    protected $preferredDeliveryDate;

    /**
     * @return \DateTime|null
     */
    public function getPreferredDeliveryDate()
    {
        return $this->preferredDeliveryDate;
    }

    /**
     * @param \DateTime|null $date
     *
     * @return $this
     */
    public function setPreferredDeliveryDate(\DateTime $date = null)
    {
        $this->preferredDeliveryDate = $date;

        return $this;
    }
}

如果只需要保存日期,不需要时间,数据库类型应使用 date,不要使用 datetime。这样可以避免时区转换造成日期前后偏移。

添加属性后,需要执行当前 X-Cart 版本对应的缓存重建和数据库 schema 更新流程。只清除模板缓存不会创建数据库列。

如果当前版本已经使用 YAML/XML 实体映射或新版模块结构,应按照该版本的实体扩展方式定义字段,不要同时使用两套映射机制。

2. 在 Checkout Review 区域加入输入框

字段必须放在 Place order 按钮所属的实际 <form> 中。示例模板如下:

<div class="preferred-delivery-date">
  <label for="preferred_delivery_date">
    {{ t('Preferred delivery date') }}
  </label>

  <input
    type="date"
    id="preferred_delivery_date"
    name="preferred_delivery_date"
    value="{% if this.getCart().getPreferredDeliveryDate() %}{{ this.getCart().getPreferredDeliveryDate()|date('Y-m-d') }}{% endif %}"
  />
</div>

应通过 View List 把模板加入 Review/Place order 区域,不要直接修改核心模板。不同 X-Cart 5 版本使用的 View List 名称可能不同,可以按下面的方式确认:

  1. 找到当前版本用于渲染 Customer notes 的 widget 或模板。
  2. 查看其中使用的 @ListChild,或者模板里的 list 名称。
  3. 将日期字段注册到同一个列表,并调整 weight,让它出现在 Place order 按钮之前。

下面是示意代码,其中的列表名称必须替换为当前版本中实际存在的名称:

<?php

namespace XLite\Module\YourVendor\DeliveryDate\View\Checkout;

/**
 * @ListChild(
 *     list="checkout.review.selected.placeOrder",
 *     zone="customer",
 *     weight="90"
 * )
 */
class PreferredDeliveryDate extends \XLite\View\AView
{
    protected function getDefaultTemplate()
    {
        return 'modules/YourVendor/DeliveryDate/checkout/preferred_delivery_date.twig';
    }
}

字段的显示位置不是判断实现是否正确的唯一依据,还要确认 Place order 请求确实提交了它。可以在浏览器开发者工具的 Network 面板中查看下单请求,确认请求参数包含:

preferred_delivery_date=2026-09-15

如果请求中没有这个参数,通常说明字段不在提交表单内,或者当前版本的 Checkout JavaScript 只会序列化特定容器。此时需要把字段移入对应表单,或者使用与 Customer notes 相同的 AJAX 保存机制。

3. 在 Place order 前保存日期

扩展 Checkout controller,在父类真正创建订单之前读取并保存日期:

<?php

namespace XLite\Module\YourVendor\DeliveryDate\Controller\Customer;

abstract class Checkout extends \XLite\Controller\Customer\Checkout implements \XLite\Base\IDecorator
{
    protected function doActionPlaceOrder()
    {
        $request = \XLite\Core\Request::getInstance();
        $rawDate = trim((string) $request->preferred_delivery_date);

        $date = $this->parsePreferredDeliveryDate($rawDate);

        if ($rawDate !== '' && $date === null) {
            \XLite\Core\TopMessage::addError(
                static::t('Please enter a valid preferred delivery date')
            );

            return;
        }

        if ($date !== null) {
            $today = new \DateTime('today');

            if ($date < $today) {
                \XLite\Core\TopMessage::addError(
                    static::t('Preferred delivery date cannot be in the past')
                );

                return;
            }
        }

        $this->getCart()->setPreferredDeliveryDate($date);

        parent::doActionPlaceOrder();
    }

    /**
     * @param string $value
     *
     * @return \DateTime|null
     */
    protected function parsePreferredDeliveryDate($value)
    {
        if ($value === '') {
            return null;
        }

        $date = \DateTime::createFromFormat('!Y-m-d', $value);
        $errors = \DateTime::getLastErrors();

        if (
            !$date
            || (
                is_array($errors)
                && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)
            )
            || $date->format('Y-m-d') !== $value
        ) {
            return null;
        }

        return $date;
    }
}

这里要先调用 setter,再调用父类的 doActionPlaceOrder()。当前 Cart 通常会在父类流程中被确认并持久化为订单。如果等父方法执行结束后再写入,原来的购物车对象可能已经被清理或替换。

一般不需要单独执行 SQL。当前 Cart/Order 是由 Doctrine 管理的实体,正常的下单事务会保存它。不过,仍应根据当前版本的下单流程确认这一行为。如果该版本在调用父方法前不会自动 flush,可以在写入后显式调用:

\XLite\Core\Database::getEM()->persist($this->getCart());

不要为了确保保存而随意在下单事务中提前调用 flush(),否则可能破坏 X-Cart 原有的事务处理和错误回滚流程。

如果日期为必填项,还要在服务器端拒绝空值,不能只依赖 HTML 的 required:

if ($rawDate === '') {
    \XLite\Core\TopMessage::addError(
        static::t('Preferred delivery date is required')
    );

    return;
}

4. 下单后访问日期

取得订单实体后,可以直接调用:

$date = $order->getPreferredDeliveryDate();

if ($date !== null) {
    $formattedDate = $date->format('Y-m-d');
}

不要直接访问:

$order->preferredDeliveryDate

这个属性通常是 protected,模板和其他模块应通过 getter 读取。

如果需要根据订单编号加载订单,可以这样写:

$order = \XLite\Core\Database::getRepo('XLite\Model\Order')
    ->findOneByOrderNumber($orderNumber);

if ($order && $order->getPreferredDeliveryDate()) {
    $formattedDate = $order
        ->getPreferredDeliveryDate()
        ->format('Y-m-d');
}

5. 在 invoice 页面显示

通过 invoice 使用的 View List 添加模板片段,不要直接修改核心 invoice 模板。

Twig 示例:

{% if this.getOrder().getPreferredDeliveryDate() %}
  <div class="preferred-delivery-date">
    <strong>{{ t('Preferred delivery date') }}:</strong>
    {{ this.getOrder().getPreferredDeliveryDate()|date('Y-m-d') }}
  </div>
{% endif %}

部分版本会直接向 invoice 模板提供 order 变量,此时可以写成:

{% if order.getPreferredDeliveryDate() %}
  <div class="preferred-delivery-date">
    <strong>{{ t('Preferred delivery date') }}:</strong>
    {{ order.getPreferredDeliveryDate()|date('Y-m-d') }}
  </div>
{% endif %}

先查看附近模板如何访问订单,再使用相同的变量。还要确认 invoice 是否包含网页、邮件和 PDF 三条不同的渲染路径。如果它们使用不同的 View List,就需要分别注册模板。

6. 在后台 Order list 添加列

后台订单列表通常由 \XLite\View\ItemsList\Model\Order\Admin\Search 或当前版本对应的类生成。可以装饰该 ItemsList,并添加一列:

<?php

namespace XLite\Module\YourVendor\DeliveryDate\View\ItemsList\Model\Order\Admin;

abstract class Search extends \XLite\View\ItemsList\Model\Order\Admin\Search implements \XLite\Base\IDecorator
{
    protected function defineColumns()
    {
        $columns = parent::defineColumns();

        $columns['preferredDeliveryDate'] = [
            static::COLUMN_NAME     => static::t('Preferred delivery date'),
            static::COLUMN_ORDERBY  => 500,
            static::COLUMN_TEMPLATE =>
                'modules/YourVendor/DeliveryDate/order/preferred_delivery_date.twig',
        ];

        return $columns;
    }
}

列模板示例:

{% if this.entity.getPreferredDeliveryDate() %}
  {{ this.entity.getPreferredDeliveryDate()|date('Y-m-d') }}
{% else %}
  —
{% endif %}

模板上下文使用 this.entity、this.getEntity() 还是其他变量,取决于当前版本的 ItemsList。可以参考同一订单列表中已有的自定义模板列。

如果只需要在后台订单详情页显示日期,不必加入列表页。扩展订单详情页的 View List 更简单,也不会增加列表查询和渲染负担。

7. 常见问题

输入框显示了,但数据库中没有值

优先检查以下几项:

  • Place order 请求是否真的包含 preferred_delivery_date。
  • 扩展 controller 的 doActionPlaceOrder() 是否被调用。
  • getter 和 setter 是否属于实际生效的 Order decorator。
  • 数据库是否已经创建对应的列。
  • 模块缓存是否已经完成重建。
  • 父类下单前是否发生了验证失败或页面重载。

日期在提交后丢失

Checkout 可能会在地址、运费或支付方式发生变化后重新渲染。如果希望字段在这些刷新过程中继续保留,就不能只在最终 Place order 时保存。可以增加一个 Checkout 更新动作,在字段变化或步骤刷新时先把日期写入当前 Cart,处理方式与 Customer notes 的保存流程类似。

无论是否使用 AJAX,最终执行 Place order 时仍建议重新读取并验证字段,以免客户端漏传参数或参数被篡改。

前端使用 type="date" 是否足够

不够。浏览器提交的仍然是字符串,服务器端必须验证:

  • 是否严格符合 Y-m-d。
  • 是否允许空值。
  • 日期是否早于当天。
  • 是否符合最短备货期。
  • 是否属于可配送日期。
  • 是否与所选配送方式匹配。

数据是否有效,应由服务器端校验决定,不能依赖 JavaScript 或 HTML 属性。

备注:内容仅供参考。