如何通过 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
推荐的处理顺序
- 创建
out_refund。 - 创建完整的退款明细行。
- 通过
invoice_open确认并过账退款单。 - 重新读取退款单,取得
move_id、account_id和未付余额。 - 找到退款凭证中的应收账款贷方行。
- 创建
type = 'payment'的 voucher。 - 创建
type = 'dr'的 voucher line,并设置amount。 - 执行 voucher 的
proforma_voucher。 - 重新读取退款单,检查
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 或类似方法,可以采用下面的流程:
- 先调用 onchange。
- 从返回值中取得
line_dr_ids、line_cr_ids、account_id等默认值。 - 找到
move_line_id等于退款应收账款行的记录。 - 将该行的
amount设置为需要核销的金额。 - 使用 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。
备注:内容仅供参考。
