diff --git a/contract_exception/__init__.py b/contract_exception/__init__.py
new file mode 100644
index 0000000000..b046ff82fc
--- /dev/null
+++ b/contract_exception/__init__.py
@@ -0,0 +1 @@
+from . import models, wizard
diff --git a/contract_exception/__manifest__.py b/contract_exception/__manifest__.py
new file mode 100644
index 0000000000..4a856a513e
--- /dev/null
+++ b/contract_exception/__manifest__.py
@@ -0,0 +1,22 @@
+# Copyright 2024 Foodles (http://www.foodles.co/)
+# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html
+{
+ "name": "Contract Exception",
+ "version": "14.0.1.0.0",
+ "category": "Contract Management",
+ "author": "Odoo Community Association (OCA), Foodles",
+ "maintainers": [""],
+ "website": "https://github.com/OCA/contract",
+ "depends": [
+ "base_exception",
+ "contract",
+ ],
+ "data": [
+ "security/ir.model.access.csv",
+ "data/contract_exception_data.xml",
+ "views/contract_views.xml",
+ "wizard/contract_exception_confirm_view.xml",
+ ],
+ "license": "AGPL-3",
+ "installable": True,
+}
diff --git a/contract_exception/data/contract_exception_data.xml b/contract_exception/data/contract_exception_data.xml
new file mode 100644
index 0000000000..1031ca2d41
--- /dev/null
+++ b/contract_exception/data/contract_exception_data.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ Test Contracts
+
+
+ 20
+ minutes
+ -1
+
+
+ code
+ model.test_all_contracts()
+
+
diff --git a/contract_exception/models/__init__.py b/contract_exception/models/__init__.py
new file mode 100644
index 0000000000..0c523d8527
--- /dev/null
+++ b/contract_exception/models/__init__.py
@@ -0,0 +1,5 @@
+from . import (
+ contract,
+ contract_line,
+ exception_rule,
+)
diff --git a/contract_exception/models/contract.py b/contract_exception/models/contract.py
new file mode 100644
index 0000000000..5a2b904c2f
--- /dev/null
+++ b/contract_exception/models/contract.py
@@ -0,0 +1,48 @@
+from odoo import api, models
+
+
+class Contract(models.Model):
+ _inherit = ["contract.contract", "base.exception"]
+ _name = "contract.contract"
+
+ @api.model
+ def create(self, vals):
+ record = super().create(vals)
+ record._contract_check_exception(vals)
+ return record
+
+ def write(self, vals):
+ result = super().write(vals)
+ self._contract_check_exception(vals)
+ return result
+
+ @api.model
+ def _reverse_field(self):
+ return "contract_ids"
+
+ def _fields_trigger_check_exception(self):
+ return ["ignore_exception", "contract_line_ids"]
+
+ def detect_exceptions(self):
+ all_exceptions = super().detect_exceptions()
+ lines = self.mapped("contract_line_ids")
+ all_exceptions += lines.detect_exceptions()
+ return all_exceptions
+
+ def _contract_check_exception(self, vals):
+ check_exceptions = any(
+ field in vals for field in self._fields_trigger_check_exception()
+ )
+ if check_exceptions:
+ self.detect_exceptions()
+
+ @api.model
+ def test_all_contracts(self):
+ contract_set = self.search([])
+ contract_set.detect_exceptions()
+ return True
+
+ @api.model
+ def _get_popup_action(self):
+ action = self.env.ref("contract_exception.action_contract_exception_confirm")
+ return action
diff --git a/contract_exception/models/contract_line.py b/contract_exception/models/contract_line.py
new file mode 100644
index 0000000000..917172ed0a
--- /dev/null
+++ b/contract_exception/models/contract_line.py
@@ -0,0 +1,49 @@
+# Copyright 2021 ForgeFlow (http://www.forgeflow.com)
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
+
+import html
+
+from odoo import api, fields, models
+
+
+class ContractLIne(models.Model):
+ _inherit = ["contract.line", "base.exception.method"]
+ _name = "contract.line"
+
+ ignore_exception = fields.Boolean(
+ related="contract_id.ignore_exception", store=True, string="Ignore Exceptions"
+ )
+ exception_ids = fields.Many2many(
+ "exception.rule", string="Exceptions", copy=False, readonly=True
+ )
+ exceptions_summary = fields.Html(
+ readonly=True, compute="_compute_exceptions_summary"
+ )
+
+ @api.depends("exception_ids", "ignore_exception")
+ def _compute_exceptions_summary(self):
+ for rec in self:
+ if rec.exception_ids and not rec.ignore_exception:
+ rec.exceptions_summary = rec._get_exception_summary()
+ else:
+ rec.exceptions_summary = False
+
+ def _get_exception_summary(self):
+ return "
" % "".join(
+ [
+ "%s: %s"
+ % tuple(map(html.escape, (e.name, e.description)))
+ for e in self.exception_ids
+ ]
+ )
+
+ def _get_main_records(self):
+ return self.mapped("contract_id")
+
+ @api.model
+ def _reverse_field(self):
+ return "contract_ids"
+
+ def _detect_exceptions(self, rule):
+ records = super()._detect_exceptions(rule)
+ return records.mapped("contract_id")
diff --git a/contract_exception/models/exception_rule.py b/contract_exception/models/exception_rule.py
new file mode 100644
index 0000000000..73f832634a
--- /dev/null
+++ b/contract_exception/models/exception_rule.py
@@ -0,0 +1,19 @@
+from odoo import fields, models
+
+
+class ExceptionRule(models.Model):
+ _inherit = "exception.rule"
+
+ contract_ids = fields.Many2many(
+ comodel_name="contract.contract", string="Contracts"
+ )
+ model = fields.Selection(
+ selection_add=[
+ ("contract.contract", "Contract"),
+ ("contract.line", "Contract line"),
+ ],
+ ondelete={
+ "contract.contract": "cascade",
+ "contract.line": "cascade",
+ },
+ )
diff --git a/contract_exception/security/ir.model.access.csv b/contract_exception/security/ir.model.access.csv
new file mode 100644
index 0000000000..e2598f5721
--- /dev/null
+++ b/contract_exception/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_contract_exception_confirm,contract.exception.confirm,model_contract_exception_confirm,base_exception.group_exception_rule_manager,1,1,1,1
diff --git a/contract_exception/views/contract_views.xml b/contract_exception/views/contract_views.xml
new file mode 100644
index 0000000000..826a6c4fe5
--- /dev/null
+++ b/contract_exception/views/contract_views.xml
@@ -0,0 +1,104 @@
+
+
+
+
+ Contract Exception Rules
+ exception.rule
+ tree,form
+
+ [('model', 'in', ['contract.contract', 'contract.line'])]
+ {'active_test': False, 'default_model' : 'contract.contract'}
+
+
+
+ contract_exception.view_contract_form
+ contract.contract
+
+
+
+
+
+ There are exceptions on this contract:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ contract_exception.view_order_tree
+ contract.contract
+
+
+
+
+
+
+
+
+
+ contract_exception.view_contracts_filter
+ contract.contract
+
+
+
+
+
+
+
+
+
diff --git a/contract_exception/wizard/__init__.py b/contract_exception/wizard/__init__.py
new file mode 100644
index 0000000000..f12ac31969
--- /dev/null
+++ b/contract_exception/wizard/__init__.py
@@ -0,0 +1 @@
+from . import contract_exception_confirm, contract_manually_single_invoice
diff --git a/contract_exception/wizard/contract_exception_confirm.py b/contract_exception/wizard/contract_exception_confirm.py
new file mode 100644
index 0000000000..49658c9308
--- /dev/null
+++ b/contract_exception/wizard/contract_exception_confirm.py
@@ -0,0 +1,20 @@
+from odoo import fields, models
+
+
+class ContractExceptionConfirm(models.TransientModel):
+ _name = "contract.exception.confirm"
+ _description = "Contract exception wizard"
+ _inherit = ["exception.rule.confirm"]
+
+ related_model_id = fields.Many2one(
+ comodel_name="contract.contract", string="Contract"
+ )
+
+ date = fields.Date(required=True)
+
+ def action_confirm(self):
+ self.ensure_one()
+ if self.ignore:
+ self.related_model_id.ignore_exception = True
+ self.related_model_id.generate_invoices_manually(self.date)
+ return super().action_confirm()
diff --git a/contract_exception/wizard/contract_exception_confirm_view.xml b/contract_exception/wizard/contract_exception_confirm_view.xml
new file mode 100644
index 0000000000..43c700b00d
--- /dev/null
+++ b/contract_exception/wizard/contract_exception_confirm_view.xml
@@ -0,0 +1,43 @@
+
+
+
+ Contract Exceptions
+ contract.exception.confirm
+
+
+
+
+
+ Outstanding exceptions to manage
+ ir.actions.act_window
+ contract.exception.confirm
+ form
+
+ new
+
+
diff --git a/contract_exception/wizard/contract_manually_single_invoice.py b/contract_exception/wizard/contract_manually_single_invoice.py
new file mode 100644
index 0000000000..8e1360dcb1
--- /dev/null
+++ b/contract_exception/wizard/contract_manually_single_invoice.py
@@ -0,0 +1,15 @@
+from odoo import models
+
+
+class ContractManuallySingleInvoice(models.TransientModel):
+ _inherit = "contract.manually.single.invoice"
+
+ def create_invoice(self):
+ if (
+ self.contract_id.detect_exceptions()
+ and not self.contract_id.ignore_exception
+ ):
+ action = self.contract_id._popup_exceptions()
+ action.get("context").update({"default_date": self.date})
+ return action
+ return self.contract_id.generate_invoices_manually(date=self.date)
diff --git a/setup/contract_exception/odoo/addons/contract_exception b/setup/contract_exception/odoo/addons/contract_exception
new file mode 120000
index 0000000000..bdc4a2808f
--- /dev/null
+++ b/setup/contract_exception/odoo/addons/contract_exception
@@ -0,0 +1 @@
+../../../../contract_exception
\ No newline at end of file
diff --git a/setup/contract_exception/setup.py b/setup/contract_exception/setup.py
new file mode 100644
index 0000000000..28c57bb640
--- /dev/null
+++ b/setup/contract_exception/setup.py
@@ -0,0 +1,6 @@
+import setuptools
+
+setuptools.setup(
+ setup_requires=['setuptools-odoo'],
+ odoo_addon=True,
+)