PAYATHON 2026

如何在 Odoo 14 payment form 中选择 invoices 并付款

支付小周

结论

Odoo 14 不再通过 account.payment.invoice_ids 维护付款与发票之间的业务关系。付款过账后,系统通过 account.move.line 中的应收、应付分录完成核销。因此,只在 account.payment 上重新添加一个 Many2many 字段,并不能自动完成付款。

Odoo 14 的标准做法是:在发票列表中选择一张或多张发票,执行“登记付款”,再由 account.payment.register 创建付款并完成核销。

如果业务要求必须在 payment form 中选择发票,还需要完成以下工作:

  1. 自定义 invoice_ids 字段;
  2. 按客户、公司、币种和发票类型限制可选范围;
  3. 根据所选发票计算付款金额;
  4. 付款过账后,显式核销付款分录和发票分录。

推荐的标准实现

Odoo 14 通过 account.payment.register 向导处理多张发票付款:

  1. 打开客户发票或供应商账单列表;
  2. 选择需要付款的发票;
  3. 点击“登记付款”;
  4. 填写付款日、日记账、付款方式和金额;
  5. 确认付款。

向导会从上下文的 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 上重新实现整套付款登记流程。

备注:内容仅供参考。