PAYATHON 2026

prestashop 1.6 管理员部分退款时如何调用 Hook

支付小周

结论

PrestaShop 1.6 已提供 actionOrderSlipAdd Hook。管理员执行标准退款或部分退款,并且系统成功创建 credit slip(退款单)后,就会调用这个 Hook。一般不需要修改核心文件,也不建议覆盖 AdminOrdersController。

安装模块时注册 Hook:

public function install()
{
    return parent::install()
        && $this->registerHook('actionOrderSlipAdd');
}

然后在模块主类中实现:

public function hookActionOrderSlipAdd(array $params)
{
    // 处理退款
}

注意,actionOrderSlipAdd 只表示退款单已经创建,不代表支付网关已经成功退款。支付模块仍需自行调用网关退款接口,并处理幂等、异常记录和重试。

Hook 能覆盖哪些情况

管理员在订单页面执行部分退款时,系统最终会创建 OrderSlip。以下操作通常都会触发 actionOrderSlipAdd:

  • 退还部分商品数量;
  • 退还某个商品的部分金额;
  • 退还部分运费;
  • 通过退款流程处理优惠金额。

这些操作不会分别触发不同的 Hook,而是统一进入 actionOrderSlipAdd。

PrestaShop 1.6 各小版本对 Hook 参数和退款单字段的处理有所不同。常见参数包括:

$params['order']
$params['productList']
$params['qtyList']

在部分退款流程中,productList 通常以 id_order_detail 为键,其中包含退款数量、退款金额等信息。普通退款流程则可能把数量放在 qtyList 中。因此,不能假设所有 1.6 版本的参数结构都相同,需要根据实际安装的版本做兼容处理。

模块实现示例

下面的代码先验证订单对象,再统一整理退款商品,最后将支付网关退款交给独立方法处理:

public function hookActionOrderSlipAdd(array $params)
{
    if (
        empty($params['order'])
        || !($params['order'] instanceof Order)
        || !Validate::isLoadedObject($params['order'])
    ) {
        return;
    }

    /** @var Order $order */
    $order = $params['order'];

    $productList = isset($params['productList'])
        && is_array($params['productList'])
        ? $params['productList']
        : array();

    $qtyList = isset($params['qtyList'])
        && is_array($params['qtyList'])
        ? $params['qtyList']
        : array();

    $refundedProducts = array();

    foreach ($productList as $idOrderDetail => $refundData) {
        $idOrderDetail = (int) $idOrderDetail;

        if (is_array($refundData)) {
            // 部分退款常见结构
            $quantity = isset($refundData['quantity'])
                ? (int) $refundData['quantity']
                : 0;

            $amount = isset($refundData['amount'])
                ? (float) $refundData['amount']
                : 0.0;
        } else {
            // 某些退款路径只传商品列表,数量放在 qtyList
            $quantity = isset($qtyList[$idOrderDetail])
                ? (int) $qtyList[$idOrderDetail]
                : (int) $refundData;

            $amount = null;
        }

        $orderDetail = new OrderDetail($idOrderDetail);

        if (
            !Validate::isLoadedObject($orderDetail)
            || (int) $orderDetail->id_order !== (int) $order->id
        ) {
            continue;
        }

        $refundedProducts[] = array(
            'id_order_detail' => $idOrderDetail,
            'id_product'      => (int) $orderDetail->product_id,
            'quantity'        => $quantity,
            'amount'          => $amount,
        );
    }

    $this->processGatewayRefund($order, $refundedProducts, $params);
}

不要把所有退款逻辑都写在 Hook 方法中,可以将其单独封装:

protected function processGatewayRefund(
    Order $order,
    array $refundedProducts,
    array $hookParams
) {
    $transactionId = $this->findTransactionIdByOrder($order);

    if (!$transactionId) {
        PrestaShopLogger::addLog(
            sprintf(
                'Refund skipped: transaction not found for order %d.',
                (int) $order->id
            ),
            3
        );

        return;
    }

    $refundData = $this->buildRefundData(
        $order,
        $refundedProducts,
        $hookParams
    );

    if ($refundData['amount'] <= 0) {
        return;
    }

    $idempotencyKey = sprintf(
        'order-%d-refund-%s',
        (int) $order->id,
        $refundData['reference']
    );

    if ($this->refundWasAlreadyProcessed($idempotencyKey)) {
        return;
    }

    try {
        $result = $this->refundPayment(
            $transactionId,
            $refundData['amount'],
            $order->id_currency,
            $idempotencyKey
        );

        $this->saveRefundResult(
            $order,
            $idempotencyKey,
            $refundData,
            $result
        );
    } catch (Exception $e) {
        PrestaShopLogger::addLog(
            sprintf(
                'Payment refund failed for order %d: %s',
                (int) $order->id,
                $e->getMessage()
            ),
            3
        );
    }
}

findTransactionIdByOrder()、buildRefundData() 和 refundPayment() 等方法,需要根据支付网关 API 及模块自身的交易表实现。

如何取得商品、运费和优惠退款金额

不要直接使用:

$order->total_paid

这个字段表示订单支付总额,不是本次退款金额。

本次退款数据可能来自两个地方:

  1. productList 中记录的商品退款数量或金额;
  2. 新创建的 OrderSlip 及其明细中保存的商品、税费和运费退款结果。

应优先使用退款单最终保存的金额。管理员填写的含税金额经过 PrestaShop 计算后,可能会出现舍入和税额差异。

不过,部分 PrestaShop 1.6 小版本不会通过 actionOrderSlipAdd 参数直接传入 OrderSlip 对象或退款单 ID。遇到这种情况,应先检查当前版本中 OrderSlip::createOrderSlip() 和 OrderSlip::createPartialOrderSlip() 调用 Hook 时传入的具体参数,再确定如何取得对应的退款单。

不要只查询该订单最新的一条退款单来关联记录。并发请求可能导致查询到错误的退款单。更稳妥的处理方式包括:

  • 使用 Hook 已提供的退款单 ID 或对象;
  • 如果当前版本没有提供,则在模块中保存能够唯一关联退款请求的记录;
  • 或者使用范围尽可能小的 OrderSlip 类覆盖,为 Hook 补充明确的退款单 ID。

处理优惠券退款时,还要区分两种情况:

  • 退款金额实际计入 credit slip;
  • 系统为客户重新生成 voucher/cart rule。

重新生成的优惠券不一定对应支付网关需要退回的现金金额,不能直接把新优惠券的面值作为网关退款金额。

幂等处理

后台管理员可能重复提交,页面也可能因超时而重试。如果网关请求已经成功,但页面响应失败,Hook 还可能再次触发。

建议为每次退款保存独立记录,例如:

id_order
id_order_slip
gateway_transaction_id
refund_amount
currency
idempotency_key
gateway_refund_id
status
request_payload
response_payload
date_add
date_upd

调用网关前先检查 idempotency_key。如果退款已经成功,就不应再次向网关发送请求。

还需要明确退款失败后的处理方式。actionOrderSlipAdd 执行时,PrestaShop 通常已经把退款单写入数据库。即使支付网关退款失败,仅从 Hook 返回 false 也不一定能撤销后台退款操作。因此,模块应记录失败状态,并提供后台重试或人工处理入口。

是否需要 override

只有在当前 PrestaShop 1.6 小版本没有提供足够的 Hook 参数,无法可靠取得本次 OrderSlip 时,才需要考虑 override。

应优先覆盖 OrderSlip,不要覆盖 AdminOrdersController::postProcess()。后者包含大量订单后台逻辑,复制整个方法难以维护,升级 PrestaShop 或安装其他模块后也容易发生冲突。

模块可以按照 PrestaShop 1.6 的 override 目录结构提供文件,例如:

modules/yourpayment/
└── override/
    └── classes/
        └── order/
            └── OrderSlip.php

基本形式如下:

<?php

class OrderSlip extends OrderSlipCore
{
    public static function createPartialOrderSlip(/* 使用当前版本的原始签名 */)
    {
        $result = parent::createPartialOrderSlip(/* 原样传递所有参数 */);

        if ($result) {
            // 仅补充原 Hook 缺少的信息,或调用模块服务。
            // 不要再次创建退款单。
        }

        return $result;
    }
}

这里无法给出适用于所有 PrestaShop 1.6 小版本的固定参数列表。不同版本的 createPartialOrderSlip() 方法签名和内部字段可能不同,必须复制当前安装版本的准确方法签名,并保持所有参数的默认值一致。

使用 override 时还要注意:

  • PrestaShop override 是类级覆盖,并非只覆盖某个调用点;
  • 多个模块覆盖同一个类时可能发生冲突;
  • 安装或卸载 override 后,通常需要删除或重建 cache/class_index.php;
  • 升级 PrestaShop 后,必须重新核对父类方法签名;
  • 不要在 override 中复制整段核心退款逻辑,应尽量调用 parent;
  • 卸载模块时应正确移除自身的 override,同时不能破坏其他模块的代码。

推荐的处理顺序是先使用 actionOrderSlipAdd,并兼容不同的参数结构。只有确实无法唯一确定本次退款单时,才增加一个尽可能简单的 OrderSlip override。

备注:内容仅供参考。