如何在 Odoo 14 payment form 中选择 invoices 并付款
结论
Odoo 14 不再通过 account.payment.invoice_ids 维护付款与发票之间的业务关系。付款过账后,系统通过 account.move.line 中的应收、应付分录完成核销。因此,只在 account.payment 上重新添加一个 Many2many 字段,并不能自动完成付款。
Odoo 14 的标准做法是:在发票列表中选择一张或多张发票,执行“登记付款”,再由 account.payment.register 创建付款并完成核销。
如果业务要求必须在 payment form 中选择发票,还需要完成以下工作:
- 自定义
invoice_ids字段; - 按客户、公司、币种和发票类型限制可选范围;
- 根据所选发票计算付款金额;
- 付款过账后,显式核销付款分录和发票分录。
推荐的标准实现
Odoo 14 通过 account.payment.register 向导处理多张发票付款:
- 打开客户发票或供应商账单列表;
- 选择需要付款的发票;
- 点击“登记付款”;
- 填写付款日、日记账、付款方式和金额;
- 确认付款。
向导会从上下文的 active_ids 中读取发票,并处理付款创建、差额、币种和核销。如果需求只是一次支付多张发票,通常不必修改 account.payment。
必须从 payment form 选择发票时
下面的基础实现适用于这些情况:
- 所选发票属于同一公司;
- 发票属于同一个商业伙伴;
- 发票使用同一币种;
- 客户付款只选择客户发票;
- 供应商付款只选择供应商账单;
- 不处理贷项通知单和复杂的付款差额。
定义字段
不要沿用旧版本的 account_invoice_payment_rel。这个表名保留了旧模型的语义,可能在升级时或与其他模块发生冲突。应改用自定义关系表名。
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class AccountPayment(models.Model):
_inherit = 'account.payment'
invoice_ids = fields.Many2many(
comodel_name='account.move',
relation='custom_account_payment_invoice_rel',
column1='payment_id',
column2='move_id',
string='Invoices',
copy=False,
check_company=True,
help='Invoices selected for reconciliation after this payment is posted.',
)
这个字段只保存用户选择的发票,不会自行触发核销。
限制可选发票
可以在 onchange 中返回动态 domain:
@api.onchange(
'partner_id',
'payment_type',
'partner_type',
'company_id',
'currency_id',
)
def _onchange_selected_invoice_domain(self):
self.invoice_ids = [(5, 0, 0)]
domain = [
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('company_id', '=', self.company_id.id),
]
if self.partner_id:
domain.append((
'commercial_partner_id',
'=',
self.partner_id.commercial_partner_id.id,
))
if self.currency_id:
domain.append(('currency_id', '=', self.currency_id.id))
if self.partner_type == 'customer' and self.payment_type == 'inbound':
domain.append(('move_type', '=', 'out_invoice'))
elif self.partner_type == 'supplier' and self.payment_type == 'outbound':
domain.append(('move_type', '=', 'in_invoice'))
else:
domain.append(('id', '=', False))
return {'domain': {'invoice_ids': domain}}
如果当前数据库中的 account.move 没有可直接搜索的 commercial_partner_id,可以改用:
('partner_id', 'child_of', self.partner_id.commercial_partner_id.id)
选择哪一种写法,取决于项目采用的合作伙伴层级规则。
根据发票计算付款金额
在同币种、全额支付的简单场景中,可以把发票剩余金额相加:
@api.onchange('invoice_ids')
def _onchange_invoice_ids(self):
if self.invoice_ids:
self.amount = sum(self.invoice_ids.mapped('amount_residual'))
这段代码只是为金额字段赋值,并不表示核销已经完成。如果允许用户修改 amount,付款可能变成部分付款,此时还要明确付款金额在多张发票之间的分配方式。
增加服务端校验
前端 domain 不能代替服务端校验,因为导入、RPC 和其他模块同样可以写入这些数据。
@api.constrains(
'invoice_ids',
'partner_id',
'company_id',
'currency_id',
'payment_type',
'partner_type',
)
def _check_selected_invoices(self):
for payment in self:
invoices = payment.invoice_ids
if not invoices:
continue
invalid_state = invoices.filtered(
lambda move:
move.state != 'posted'
or move.payment_state not in ('not_paid', 'partial')
)
if invalid_state:
raise ValidationError(
_('Only posted and unpaid or partially paid invoices can be selected.')
)
if any(move.company_id != payment.company_id for move in invoices):
raise ValidationError(
_('All invoices must belong to the payment company.')
)
payment_partner = payment.partner_id.commercial_partner_id
if any(
move.partner_id.commercial_partner_id != payment_partner
for move in invoices
):
raise ValidationError(
_('All invoices must belong to the same commercial partner.')
)
if any(move.currency_id != payment.currency_id for move in invoices):
raise ValidationError(
_('All invoices must use the payment currency.')
)
expected_type = False
if payment.partner_type == 'customer' \
and payment.payment_type == 'inbound':
expected_type = 'out_invoice'
elif payment.partner_type == 'supplier' \
and payment.payment_type == 'outbound':
expected_type = 'in_invoice'
if not expected_type or any(
move.move_type != expected_type for move in invoices
):
raise ValidationError(
_('The selected invoice type does not match the payment direction.')
)
付款过账后进行核销
付款过账并生成会计分录后,才能调用 reconcile():
def action_post(self):
result = super().action_post()
for payment in self.filtered('invoice_ids'):
payment._reconcile_selected_invoices()
return result
def _reconcile_selected_invoices(self):
self.ensure_one()
invoice_lines = self.invoice_ids.mapped('line_ids').filtered(
lambda line:
line.account_id.user_type_id.type in ('receivable', 'payable')
and not line.reconciled
)
payment_lines = self.move_id.line_ids.filtered(
lambda line:
line.account_id.user_type_id.type in ('receivable', 'payable')
and not line.reconciled
)
accounts = (invoice_lines | payment_lines).mapped('account_id')
for account in accounts:
lines = (invoice_lines | payment_lines).filtered(
lambda line: line.account_id == account
)
has_invoice_line = bool(lines & invoice_lines)
has_payment_line = bool(lines & payment_lines)
if has_invoice_line and has_payment_line:
lines.reconcile()
这里只能核销应收、应付科目,不能核销付款使用的银行或现金科目。reconcile() 会根据余额完成全额核销或部分核销。
配置 payment form
完整的继承视图可以这样编写:
<odoo>
<record id="view_account_payment_form_inherit_payment"
model="ir.ui.view">
<field name="name">
account.payment.form.inherit.invoice.selection
</field>
<field name="model">account.payment</field>
<field name="inherit_id"
ref="account.view_account_payment_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']"
position="after">
<field name="invoice_ids"
widget="many2many_tags"
options="{'no_create': True, 'no_create_edit': True}"
attrs="{
'invisible': [('partner_id', '=', False)],
'readonly': [('state', '!=', 'draft')]
}"/>
</xpath>
</field>
</record>
</odoo>
模块的 __manifest__.py 至少要依赖 account:
{
'name': 'Payment Invoice Selection',
'version': '14.0.1.0.0',
'depends': ['account'],
'data': [
'views/account_payment_views.xml',
],
'installable': True,
}
注意事项
在这个方案中,invoice_ids 表示用户选择的发票,不一定始终等于付款最终核销的发票。付款过账后,用户仍可通过手工核销修改对应关系。如果需要展示实际核销结果,应根据 account.move.line.matched_debit_ids、matched_credit_ids 或完整核销关系计算,不能只读取这个自定义字段。
多币种、贷项通知单、预付款、付款差额、现金折扣,以及多张发票的部分付款,都会让金额分配逻辑复杂得多。遇到这些需求时,更稳妥的方案是扩展 account.payment.register 向导,不要在 account.payment 上重新实现整套付款登记流程。
备注:内容仅供参考。
