Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions contract_sale_invoice_merge/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
===========================
Contract Sale Invoice Merge
===========================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:158cb8c1138bb1073a163e0eeb87344c70ea0716ba8ccbc7f6e485daeb3f8ddd
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fcontract-lightgray.png?logo=github
:target: https://github.com/OCA/contract/tree/18.0/contract_sale_invoice_merge
:alt: OCA/contract
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/contract-18-0/contract-18-0-contract_sale_invoice_merge
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/contract&target_branch=18.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

Allows to group invoice lines in a minimal amount of invoices, whether
they are generated from a Sale Order or from a Contract, according to
grouping fields.

**Table of contents**

.. contents::
:local:

Configuration
=============

The field do_not_group_move allows to disable grouping of specific
records. The default value can be configured in the config parameters.

Usage
=====

1. Create Sale Orders ready to be invoiced (confirmed).
2. Create Contracts ready to be invoiced (recurring_next_date <= today).
3. Run the contract_sale_invoice_merge_cron.
4. All invoices are created, and grouped according to the grouping
fields.

Known issues / Roadmap
======================

- When a contract line is invoiced several times in a row, it still
creates several invoices. An improvement could be to group these
invoices together.
- Refactor the method \_merge_invoices_values to allow flexible
configuration of the merged fields.

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/contract/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/contract/issues/new?body=module:%20contract_sale_invoice_merge%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
-------

* ACSONE SA/NV

Contributors
------------

- `Acsone SA/NV <https://www.acsone.eu>`__:

- Alexis Anthonissen <alexis.anthonissen@acsone.eu>

Maintainers
-----------

This module is maintained by the OCA.

.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org

OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.

This module is part of the `OCA/contract <https://github.com/OCA/contract/tree/18.0/contract_sale_invoice_merge>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions contract_sale_invoice_merge/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
24 changes: 24 additions & 0 deletions contract_sale_invoice_merge/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright 2026 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

{
"name": "Contract Sale Invoice Merge",
"summary": """Adds a cron that creates merged invoices according to grouping
fields.""",
"version": "18.0.1.0.0",
"license": "AGPL-3",
"author": "ACSONE SA/NV, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/contract",
"depends": [
"product_contract",
"queue_job_cron",
],
"data": [
"security/ir.model.access.csv",
"data/contract_sale_invoice_merge_cron.xml",
"data/queue_job_channel.xml",
"data/queue_job_function.xml",
"views/contract_contract.xml",
"views/res_config_settings.xml",
],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding='UTF-8' ?>
<!-- Copyright 2026 ACSONE SA/NV
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo noupdate="1">
<record model="ir.cron" id="contract_sale_invoice_merge_cron">
<field
name="name"
>Generate Merged Recurring Invoices from Contracts and Sale Orders</field>
<field
name="model_id"
ref="contract_sale_invoice_merge.model_account_move_group"
/>
<field name="state">code</field>
<field name="code">model.cron_group_recurring_create_invoice()</field>
<field name="user_id" ref="base.user_root" />
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="run_as_queue_job" eval="True" />
</record>
</odoo>
9 changes: 9 additions & 0 deletions contract_sale_invoice_merge/data/queue_job_channel.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2026 ACSONE SA/NV
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="contract_invoice_merge_queue_job_channel" model="queue.job.channel">
<field name="name">CONTRACT_MERGE_INVOICE</field>
<field name="parent_id" ref="queue_job.channel_root" />
</record>
</odoo>
16 changes: 16 additions & 0 deletions contract_sale_invoice_merge/data/queue_job_function.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2026 ACSONE SA/NV
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record
id="job_function_cron_group_recurring_create_invoice_merge"
model="queue.job.function"
>
<field
name="model_id"
ref="contract_sale_invoice_merge.model_account_move_group"
/>
<field name="method">cron_group_recurring_create_invoice</field>
<field name="channel_id" ref="contract_invoice_merge_queue_job_channel" />
</record>
</odoo>
6 changes: 6 additions & 0 deletions contract_sale_invoice_merge/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from . import account_move_group
from . import account_move_group_mixin
from . import contract
from . import res_company
from . import res_config_settings
from . import sale_order
128 changes: 128 additions & 0 deletions contract_sale_invoice_merge/models/account_move_group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Copyright 2026 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from odoo import Command, api, fields, models

from odoo.addons.account.models.account_move import AccountMove

from .account_move_group_mixin import AccountMoveGroupMixin


class AccountMoveGroup(models.TransientModel):
_name = "account.move.group"
_description = "Account Move Group"

partner_invoice_id = fields.Many2one(comodel_name="res.partner")
payment_term_id = fields.Many2one(comodel_name="account.payment.term")
user_id = fields.Many2one(comodel_name="res.users")
fiscal_position_id = fields.Many2one(comodel_name="account.fiscal.position")
journal_id = fields.Many2one(comodel_name="account.journal")

@api.model
def _get_groupable_models(self):
return list(self.env["account.move.group.mixin"]._inherit_children)

def _get_groups_by_recordset(self, date_ref):
groups = {}

for model_name in self._get_groupable_models():
Model = self.env[model_name]
if not isinstance(Model, AccountMoveGroupMixin):
continue

domain = Model._get_group_invoice_domain(date_ref)
records = Model.search(domain=domain)
for rec in records:
if rec.do_not_group_move:
# Create separate groups for records that cannot be grouped
groups[model_name + str(rec.id)] = {model_name: rec}

grouping_vals = rec._get_invoice_grouping_dict()
grouping_key = tuple(sorted(grouping_vals.items()))
if grouping_key not in groups:
# Initialize empty recordsets for all registered models
groups[grouping_key] = {
m: self.env[m] for m in self._get_groupable_models()
}

groups[grouping_key][model_name] |= rec

return groups

@api.model
def _merge_invoices_values(self, invoices_values, date_ref=False):
"""
This method merges the given invoices values list into one.
All invoices are merged into the first one of the list.
:param invoices_values: list of dictionaries (invoices values)
:return: list containing the dictionary of values of the result invoice
"""
if not invoices_values:
return []
if not date_ref:
date_ref = fields.Date.context_today(self)
init_vals = invoices_values[0]
init_vals.update({"invoice_date": date_ref})
for invoice_values in invoices_values[1:]:
# Merge invoice lines
for inv_line in invoice_values.get("invoice_line_ids", []):
init_vals.setdefault("invoice_line_ids", [])
# Ignore tuples that would empty the set of lines
if inv_line == Command.set([]):
continue
init_vals["invoice_line_ids"].append(inv_line)
# Merge origin
init_origin = init_vals.get("origin", "")
invoice_origin = invoice_values.get("origin")
if invoice_origin:
init_vals["origin"] = f"{init_origin}, {invoice_origin}"
# Merge reference
init_reference = init_vals.get("reference", "")
invoice_reference = invoice_values.get("reference")
if invoice_reference:
init_vals["reference"] = f"{init_reference}, {invoice_reference}"
# Theoretical invoice date: set only if same on all merged invoices
init_theoretical_date_invoice = init_vals.get("theoretical_date_invoice")
theoretical_date_invoice = invoice_values.get("theoretical_date_invoice")
if (
init_theoretical_date_invoice
and init_theoretical_date_invoice != theoretical_date_invoice
):
init_vals["theoretical_date_invoice"] = False
return init_vals

def _get_grouped_invoice_vals(self, group_records, date_ref):
invoices_vals = []
for model in group_records.values():
invoices_vals += model._prepare_group_invoices_values(date_ref)

final_invoice_vals = self._merge_invoices_values(invoices_vals, date_ref)
return final_invoice_vals

def _post_create_group_invoices(
self, models: AccountMoveGroupMixin, moves: AccountMove
):
for model in models:
model._hook_post_create_group_invoices(moves)

def _create_grouped_invoice(self, group_records, date_ref):
invoices_vals = self._get_grouped_invoice_vals(group_records, date_ref)
invoices = self.env["account.move"].create(invoices_vals)
if invoices:
self._post_create_group_invoices(group_records.values(), invoices)
return invoices

@api.model
def _cron_group_recurring_create_invoice(self, date_ref=None):
if not date_ref:
date_ref = fields.Date.context_today(self)
invoices = True
while invoices:
invoices = self.env["account.move"]
groups = self._get_groups_by_recordset(date_ref)
for group_records in groups.values():
invoices |= self._create_grouped_invoice(group_records, date_ref)

@api.model
def cron_group_recurring_create_invoice(self):
return self._cron_group_recurring_create_invoice()
35 changes: 35 additions & 0 deletions contract_sale_invoice_merge/models/account_move_group_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright 2026 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from odoo import api, fields, models

from odoo.addons.account.models.account_move import AccountMove


class AccountMoveGroupMixin(models.AbstractModel):
_name = "account.move.group.mixin"
_description = (
"Interface for models that participate in account move grouped invoicing"
)

do_not_group_move = fields.Boolean(
string="Don't group this model in one invoice",
default=lambda self: self.env["res.config.settings"].do_not_group_move,
)

def _get_invoice_grouping_dict(self):
"""Return grouping values."""
return {}

@api.model
def _get_group_invoice_domain(self, date_ref):
"""Return the domain for records to invoice."""
return []

def _prepare_group_invoices_values(self, date_ref):
"""Return recurring invoice values."""
raise NotImplementedError

def _hook_post_create_group_invoices(self, moves: AccountMove):
"""Hook right after the creation of grouped invoices."""
return
31 changes: 31 additions & 0 deletions contract_sale_invoice_merge/models/contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copyright 2026 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from odoo import api, models


class ContractContract(models.Model):
_name = "contract.contract"
_inherit = ["contract.contract", "account.move.group.mixin"]

@api.model
def _get_invoice_grouping_dict(self):
self.ensure_one()
return {
"partner_invoice_id": self.invoice_partner_id.id,
"payment_term_id": self.payment_term_id.id,
"user_id": self.user_id.id,
"fiscal_position_id": self.fiscal_position_id.id,
"journal_id": self.journal_id.id,
}

def _get_group_invoice_domain(self, date_ref):
return self._get_contracts_to_invoice_domain(date_ref)

def _prepare_group_invoices_values(self, date_ref):
return self._prepare_recurring_invoices_values(date_ref)

def _hook_post_create_group_invoices(self, moves):
self._add_contract_origin(moves)
self._invoice_followers(moves)
self._compute_recurring_next_date()
13 changes: 13 additions & 0 deletions contract_sale_invoice_merge/models/res_company.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright 2026 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from odoo import fields, models


class ResCompany(models.Model):
_inherit = "res.company"

do_not_group_move = fields.Boolean(
string="Do not group moves together",
default=False,
)
Loading
Loading