PAYATHON 2026

如何通过 XML-RPC 在 Odoo 创建退款并登记付款

支付小周

结论

客户退款单 out_refund 表示向客户付款,因此应创建:

  • account.voucher.type = 'payment',不能使用 'receipt'
  • voucher 金额使用正数,不要将 amount_total 取负
  • account.voucher.line.type = 'dr'
  • voucher line 必须填写本次实际核销金额 amount
  • move_line_id 必须指向退款单生成的应收账款贷方行,不能直接取 account.move.line 查询结果的第一行

处理时,先执行退款单的 invoice_open,再执行 voucher 的 proforma_voucher。不要重复调用 account.move.button_validate,因为退款单执行 invoice_open 时已经生成并过账会计凭证。

以下方案适用于仍在使用 account.invoice、account.voucher 和 workflow 的旧版 Odoo。不同版本或定制模块的字段名称可能有所区别,请以目标系统的模型字段为准。

当前代码为什么无法关闭退款单

1. 付款方向写反了

代码创建的是客户退款单:

'type' => 'out_refund'

这表示公司欠客户款项,实际资金流向是公司向客户付款,所以 voucher 应使用:

'type' => 'payment'

当前使用的是:

'type' => 'receipt'

receipt 表示收款,适用于客户普通销售发票 out_invoice,不适用于客户退款。

2. voucher 金额不应为负数

退款性质由会计凭证中的借贷方向表示,voucher 的 amount 通常仍传入正数:

$paymentAmount = abs((float) $refund['residual']);

不应写成:

$amount_total = -$invoiceDatas['amount_total'];

3. voucher line 的方向错误

客户退款单的应收账款行通常在贷方。付款时,需要生成一条借方应收账款分录与其核销,所以 voucher line 应设置为:

'type' => 'dr'

当前使用的 'cr' 是普通客户发票收款时的核销方向。

4. 缺少实际核销金额

amount_original 和 amount_unreconciled 主要用于描述原始金额和未核销金额。真正决定本次付款分配金额的是:

'amount' => $paymentAmount

如果不填写 amount,即使 voucher 成功过账,也可能没有核销退款单。

5. 选错了 account.move.line

退款单过账后会生成多条会计分录,包括应收账款、收入或退款收入、税额等。下面这种写法并不可靠:

$invoice_object_line[0]["id"]

查询结果的第一行不一定是应收账款行。应按以下条件定位:

  • move_id 等于退款凭证
  • partner_id 等于退款客户
  • account_id 等于退款单的应收账款科目
  • reconciled = false
  • 对于 out_refund,通常还应满足 credit > 0

推荐的处理顺序

  1. 创建 out_refund。
  2. 创建完整的退款明细行。
  3. 通过 invoice_open 确认并过账退款单。
  4. 重新读取退款单,取得 move_id、account_id 和未付余额。
  5. 找到退款凭证中的应收账款贷方行。
  6. 创建 type = 'payment' 的 voucher。
  7. 创建 type = 'dr' 的 voucher line,并设置 amount。
  8. 执行 voucher 的 proforma_voucher。
  9. 重新读取退款单,检查 state、residual 或该版本对应的剩余金额字段。

修正后的核心代码

下面沿用现有 XML-RPC 包装器的调用方式,只展示退款单过账后的付款部分:

// 退款单此前已经创建并包含正确的明细行
$OERP->workflow('account.invoice', 'invoice_open', intval($createRefund));

// 重新读取已过账退款单
$refundRows = $OERP->searchread(
    array(array('id', '=', intval($createRefund))),
    'account.invoice'
);

if (empty($refundRows)) {
    throw new RuntimeException('Refund invoice was not found.');
}

$refund = $refundRows[0];

$refundMoveId = intval($refund['move_id'][0]);
$partnerId = intval($refund['partner_id'][0]);
$receivableAccountId = intval($refund['account_id'][0]);

// 旧版通常使用 residual;部分版本或定制模块可能使用 amount_residual
if (isset($refund['residual'])) {
    $paymentAmount = abs((float) $refund['residual']);
} elseif (isset($refund['amount_residual'])) {
    $paymentAmount = abs((float) $refund['amount_residual']);
} else {
    $paymentAmount = abs((float) $refund['amount_total']);
}

if ($paymentAmount <= 0) {
    throw new RuntimeException('Refund has no residual amount to pay.');
}

// 精确查找退款单的应收账款贷方行
$refundReceivableLines = $OERP->searchread(
    array(
        array('move_id', '=', $refundMoveId),
        array('partner_id', '=', $partnerId),
        array('account_id', '=', $receivableAccountId),
        array('reconciled', '=', false),
        array('credit', '>', 0)
    ),
    'account.move.line'
);

if (empty($refundReceivableLines)) {
    throw new RuntimeException(
        'No unreconciled receivable credit line was found for the refund.'
    );
}

$refundMoveLine = $refundReceivableLines[0];

// journal_id 必须是能够执行付款的银行或现金日记账
$paymentJournalId = 7;

// 不要硬编码为退款单的应收账款科目。
// 此处应使用付款日记账配置的银行/现金对方科目。
// 对于 outbound payment,通常应检查日记账的 default_credit_account_id。
$paymentAccountId = 25;

$voucherData = array(
    'date' => date('Y-m-d'),
    'partner_id' => $partnerId,
    'amount' => $paymentAmount,
    'journal_id' => $paymentJournalId,
    'account_id' => $paymentAccountId,
    'type' => 'payment',
    'payment_option' => 'without_writeoff'
);

// 如果目标版本要求 period_id,应根据付款日期确定期间,
// 不要直接假定原发票期间仍然有效。
if (!empty($refund['period_id'])) {
    $voucherData['period_id'] = intval($refund['period_id'][0]);
}

$voucherId = $OERP->create($voucherData, 'account.voucher');

$voucherLineData = array(
    'voucher_id' => intval($voucherId),
    'move_line_id' => intval($refundMoveLine['id']),
    'account_id' => intval($refundMoveLine['account_id'][0]),
    'name' => 'Payment for refund',

    // 客户退款是贷方应收账款,付款时使用借方进行核销
    'type' => 'dr',

    // 这是本次实际分配并核销的金额,不能省略
    'amount' => $paymentAmount,

    'amount_original' => abs(
        (float) $refundMoveLine['debit']
        - (float) $refundMoveLine['credit']
    ),
    'amount_unreconciled' => $paymentAmount
);

if (!empty($refundMoveLine['date'])) {
    $voucherLineData['date_original'] = $refundMoveLine['date'];
}

if (!empty($refundMoveLine['date_maturity'])) {
    $voucherLineData['date_due'] = $refundMoveLine['date_maturity'];
}

$voucherLineId = $OERP->create(
    $voucherLineData,
    'account.voucher.line'
);

// 过账 voucher,同时触发对应应收账款行的核销
$OERP->workflow(
    'account.voucher',
    'proforma_voucher',
    intval($voucherId)
);

// 不需要再次调用 account.move.button_validate

更稳妥的做法:使用 voucher 的 onchange 结果

通过 XML-RPC 直接调用 create() 时,不会像网页表单一样自动执行 onchange。手动填写 account.voucher.line 很容易在以下内容上出错:

  • 借贷方向
  • 原始金额
  • 未核销金额
  • 币种换算
  • 到期日
  • write-off
  • 付款日记账的默认科目

如果目标 Odoo 版本提供 account.voucher.onchange_partner_id 或类似方法,可以采用下面的流程:

  1. 先调用 onchange。
  2. 从返回值中取得 line_dr_ids、line_cr_ids、account_id 等默认值。
  3. 找到 move_line_id 等于退款应收账款行的记录。
  4. 将该行的 amount 设置为需要核销的金额。
  5. 使用 one2many command,连同 voucher 一起创建。

不过,各旧版 Odoo 的 onchange 方法签名差异较大。在不知道具体版本的情况下,无法给出统一的参数列表。调用前应查看目标实例的模型定义或服务端源码,确认方法签名。

退款明细也要完整

当前退款明细只填写了:

'invoice_id'
'product_id'
'name'
'price_unit'

通过 XML-RPC 创建记录时,不会自动执行网页表单中的产品 onchange,因此可能还需要明确提供:

'quantity'
'account_id'
'invoice_line_tax_id'
'uos_id'

实际需要哪些字段取决于 Odoo 版本。字段不完整可能导致退款单使用错误科目、缺少税额,或者退款总额与原发票不一致。

原代码还在循环中反复覆盖 $invoiceLine,最终只会保留原发票的最后一条明细。全额退款多行发票时,应逐行创建退款明细,或者使用该版本提供的退款向导模型,例如 account.invoice.refund,由系统根据原发票生成贷项通知单。

如果只是用退款冲销原发票

如果没有实际向客户退款,只是用贷项通知单抵扣原客户发票,就不应创建银行付款 voucher。此时应核销:

  • 原销售发票的应收账款借方行
  • 客户退款单的应收账款贷方行

两者金额相同并完成 reconciliation 后,相关单据会更新为已支付或已核销状态。只有实际向客户付款时,才应使用 type = 'payment' 的 voucher。

核对要点

如果 voucher 过账后退款单仍为 open,应依次检查:

  • voucher 是否为 payment
  • voucher 和 voucher line 的金额是否为正数
  • voucher line 是否设置了 amount
  • voucher line 是否为 dr
  • move_line_id 是否确实指向退款单的应收账款贷方行
  • voucher 与退款单的 partner_id、应收账款科目和公司是否一致
  • 付款币种与退款币种是否一致
  • 是否只支付了部分余额
  • voucher 过账后,相关 account.move.line 是否已标记为 reconciled
  • 是否存在几分钱的税额或汇率尾差,需要配置 write-off

voucher 正确过账且退款单的应收账款行得到全额核销后,退款单就不会继续保持 open。

备注:内容仅供参考。