diff --git a/contract_split/README.rst b/contract_split/README.rst new file mode 100644 index 000000000..e69de29bb diff --git a/contract_split/__init__.py b/contract_split/__init__.py new file mode 100644 index 000000000..aed16e28f --- /dev/null +++ b/contract_split/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models, wizard diff --git a/contract_split/__manifest__.py b/contract_split/__manifest__.py new file mode 100644 index 000000000..dede94c11 --- /dev/null +++ b/contract_split/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2023 Damien Crier - Foodles +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +{ + "name": "Contract Split", + "version": "14.0.1.0.0", + "category": "Sales", + "license": "AGPL-3", + "summary": "Split contract", + "depends": ["contract"], + "author": "Foodles, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/contract", + "data": [ + "security/ir.model.access.csv", + "wizard/wizard_split_contract.xml", + "views/contract.xml", + ], + "installable": True, +} diff --git a/contract_split/models/__init__.py b/contract_split/models/__init__.py new file mode 100644 index 000000000..a51c4d1fb --- /dev/null +++ b/contract_split/models/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import contract diff --git a/contract_split/models/contract.py b/contract_split/models/contract.py new file mode 100644 index 000000000..a8f610e3b --- /dev/null +++ b/contract_split/models/contract.py @@ -0,0 +1,78 @@ +# Copyright 2023 Damien Crier - Foodles +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class Contract(models.Model): + _inherit = "contract.contract" + + original_contract_ids = fields.Many2many( + comodel_name="contract.contract", + relation="contract_split_contract_rel", + column1="contract_id", + column2="split_contract_id", + readonly=True, + ) + + @api.model + def _get_contract_split_name(self, split_wizard): + return split_wizard.main_contract_id.name + + @api.model + def _get_values_create_split_contract(self, split_wizard): + return { + "name": self._get_contract_split_name(split_wizard), + "partner_id": split_wizard.partner_id.id, + "invoice_partner_id": split_wizard.invoice_partner_id.id, + "original_contract_ids": [split_wizard.main_contract_id.id], + "line_recurrence": True, + } + + def _get_default_split_values(self) -> dict: + self.ensure_one() + return { + "main_contract_id": self.id, + "partner_id": self.partner_id.id, + "invoice_partner_id": self.invoice_partner_id.id, + "split_line_ids": [ + (0, 0, line._get_default_split_line_values()) + for line in self.contract_line_ids + ], + } + + +class ContractLine(models.Model): + _inherit = "contract.line" + + splitted_from_line_id = fields.Many2one( + comodel_name="contract.line", + readonly=True, + ) + splitted_from_contract_id = fields.Many2one( + comodel_name="contract.contract", + readonly=True, + ) + + def _get_write_values_when_moving_line(self, new_contract): + self.ensure_one() + return { + "contract_id": new_contract.id, + "splitted_from_contract_id": self.contract_id.id, + } + + def _get_write_values_when_splitting_and_moving_line(self, new_contract, qty): + self.ensure_one() + return { + "contract_id": new_contract.id, + "splitted_from_contract_id": self.contract_id.id, + "splitted_from_line_id": self.id, + "quantity": qty, + } + + def _get_default_split_line_values(self) -> dict: + self.ensure_one() + return { + "original_contract_line_id": self.id, + "quantity_to_split": self.quantity, + } diff --git a/contract_split/readme/CONTRIBUTORS.rst b/contract_split/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..8c134ee83 --- /dev/null +++ b/contract_split/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Foodles `_: + + * Damien Crier diff --git a/contract_split/readme/DESCRIPTION.rst b/contract_split/readme/DESCRIPTION.rst new file mode 100644 index 000000000..2e448fb1b --- /dev/null +++ b/contract_split/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Adds a new button "Split" on a contract to be able to split lines across several contracts. diff --git a/contract_split/readme/ROADMAP.rst b/contract_split/readme/ROADMAP.rst new file mode 100644 index 000000000..ac1aef37f --- /dev/null +++ b/contract_split/readme/ROADMAP.rst @@ -0,0 +1 @@ +* Allow to choose between move and plan successor (+ date) diff --git a/contract_split/readme/USAGE.rst b/contract_split/readme/USAGE.rst new file mode 100644 index 000000000..c52036494 --- /dev/null +++ b/contract_split/readme/USAGE.rst @@ -0,0 +1 @@ +#. On a contract, hit the button "Split" and select which lines and/or quantities must be spitted to another contract. diff --git a/contract_split/security/ir.model.access.csv b/contract_split/security/ir.model.access.csv new file mode 100644 index 000000000..11ae43093 --- /dev/null +++ b/contract_split/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_split_contract,access_split_contract,model_split_contract,base.group_user,1,1,1,1 +access_split_contract_line,access_split_contract_line,model_split_contract_line,base.group_user,1,1,1,1 diff --git a/contract_split/tests/__init__.py b/contract_split/tests/__init__.py new file mode 100644 index 000000000..595abd956 --- /dev/null +++ b/contract_split/tests/__init__.py @@ -0,0 +1 @@ +from . import test_contract_split diff --git a/contract_split/tests/test_contract_split.py b/contract_split/tests/test_contract_split.py new file mode 100644 index 000000000..49eefe090 --- /dev/null +++ b/contract_split/tests/test_contract_split.py @@ -0,0 +1,150 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.exceptions import ValidationError +from odoo.tests import tagged + +from odoo.addons.contract.tests.test_contract import TestContractBase + + +@tagged("post_install", "-at_install") +class TestContractSplit(TestContractBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_default_get(self): + wizard = ( + self.env["split.contract"] + .with_context(active_id=self.contract3.id) + .create({}) + ) + self.assertEqual(self.contract3.partner_id.id, wizard.partner_id.id) + self.assertEqual( + self.contract3.invoice_partner_id.id, wizard.invoice_partner_id.id + ) + self.assertEqual(self.contract3.id, wizard.main_contract_id.id) + self.assertEqual(3, len(wizard.split_line_ids.ids)) + + def test_contract_default_get_method_1(self): + wizard = ( + self.env["split.contract"] + .with_context(active_id=self.contract3.id) + .create({}) + ) + contract_split_name = self.contract3._get_contract_split_name(wizard) + self.assertEqual(contract_split_name, self.contract3.name) + expected_result = { + "main_contract_id": self.contract3.id, + "partner_id": self.contract3.partner_id.id, + "invoice_partner_id": self.contract3.invoice_partner_id.id, + "split_line_ids": [ + ( + 0, + 0, + { + "original_contract_line_id": line.id, + "quantity_to_split": line.quantity, + }, + ) + for line in self.contract3.contract_line_ids + ], + } + self.assertEqual(self.contract3._get_default_split_values(), expected_result) + + def test_no_split_because_no_qty_set(self): + wizard = ( + self.env["split.contract"] + .with_context(active_id=self.contract3.id) + .create({}) + ) + wizard.partner_id = self.partner_2.id + wizard.split_line_ids.quantity_to_split = 0 + initial_contracts_length = self.env["contract.contract"].search_count([]) + # confirm wizard without setting to_split quantities + wizard.action_split_contract() + # nothing should have changed. No new contract created and original + # contract remains untouched + self.assertEqual(3, len(self.contract3.contract_line_ids.ids)) + self.assertEqual( + initial_contracts_length, self.env["contract.contract"].search_count([]) + ) + + def test_split_one_line_full_qty(self): + wizard = ( + self.env["split.contract"] + .with_context(active_id=self.contract3.id) + .create({}) + ) + wizard.partner_id = self.partner_2.id + wizard.split_line_ids.quantity_to_split = 0 + initial_contracts_length = self.env["contract.contract"].search_count([]) + # set quantity to split in the wizard + wizard.split_line_ids[0].quantity_to_split = wizard.split_line_ids[ + 0 + ].original_qty + # confirm wizard with setting to_split quantities + new_contract = wizard.action_split_contract() + # A new contract must have been created. + self.assertEqual( + initial_contracts_length + 1, self.env["contract.contract"].search_count([]) + ) + # new contract has now the splitted line + self.assertEqual(self.partner_2.id, new_contract.partner_id.id) + self.assertEqual(1, len(new_contract.contract_line_ids.ids)) + self.assertEqual( + self.contract3, + new_contract.contract_line_ids.mapped("splitted_from_contract_id"), + ) + # Original contract has now only 2 lines (3 at the beginning) + self.assertEqual(2, len(self.contract3.contract_line_ids.ids)) + + def test_split_one_line_one_qty(self): + # Set a qty = 2 in one line of a contract + self.contract3.contract_line_ids.filtered( + lambda line: line.name == "Line" + ).quantity = 2 + wizard = ( + self.env["split.contract"] + .with_context(active_id=self.contract3.id) + .create({}) + ) + wizard.partner_id = self.partner_2.id + wizard.split_line_ids.quantity_to_split = 0 + initial_contracts_length = self.env["contract.contract"].search_count([]) + # set quantity to split in the wizard + wizard.split_line_ids.filtered(lambda l: l.name == "Line").quantity_to_split = 1 + # confirm wizard with setting to_split quantities + new_contract = wizard.action_split_contract() + # A new contract must have been created. + self.assertEqual( + initial_contracts_length + 1, self.env["contract.contract"].search_count([]) + ) + # new contract has partner_2 as partner_id + self.assertEqual(self.partner_2.id, new_contract.partner_id.id) + # new contract has now the splitted line with a qty of one + self.assertEqual(1, len(new_contract.contract_line_ids.ids)) + self.assertEqual(1, new_contract.contract_line_ids.quantity) + self.assertEqual( + self.contract3, + new_contract.contract_line_ids.mapped("splitted_from_contract_id"), + ) + # Original contract still has 3 lines but with a qty=1 in the last line named "Line" + self.assertEqual(3, len(self.contract3.contract_line_ids.ids)) + self.assertEqual( + 1, + self.contract3.contract_line_ids.filtered( + lambda l: l.name == "Line" + ).quantity, + ) + + def test_split_with_more_quantity_should_raise_error(self): + wizard = ( + self.env["split.contract"] + .with_context(active_id=self.contract3.id) + .create({}) + ) + # set quantity to split in the wizard + with self.assertRaises(ValidationError): + wizard.split_line_ids[0].quantity_to_split = ( + wizard.split_line_ids[0].original_qty + 2 + ) diff --git a/contract_split/views/contract.xml b/contract_split/views/contract.xml new file mode 100644 index 000000000..27a252444 --- /dev/null +++ b/contract_split/views/contract.xml @@ -0,0 +1,19 @@ + + + + contract.contract.form + contract.contract + + +
+
+
+
+
diff --git a/contract_split/wizard/__init__.py b/contract_split/wizard/__init__.py new file mode 100644 index 000000000..b6b9c16b1 --- /dev/null +++ b/contract_split/wizard/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import wizard_split_contract diff --git a/contract_split/wizard/wizard_split_contract.py b/contract_split/wizard/wizard_split_contract.py new file mode 100644 index 000000000..f45c3c7cc --- /dev/null +++ b/contract_split/wizard/wizard_split_contract.py @@ -0,0 +1,139 @@ +# Copyright 2023 Damien Crier - Foodles +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools import float_compare + + +class SplitContract(models.TransientModel): + _name = "split.contract" + _description = "Contract split transient model" + + split_line_ids = fields.One2many( + comodel_name="split.contract.line", inverse_name="split_contract_id" + ) + main_contract_id = fields.Many2one(comodel_name="contract.contract") + partner_id = fields.Many2one(comodel_name="res.partner") + invoice_partner_id = fields.Many2one(comodel_name="res.partner") + + @api.model + def default_get(self, fields) -> dict: + vals = super().default_get(fields) + contract_id = self.env.context.get("active_id") + contract = self.env["contract.contract"].browse(contract_id) + vals.update(contract._get_default_split_values()) + return vals + + def action_split_contract(self): + """ + If lines exists in the wizard, create a new contract + For all lines that are kept in the wizard lines : + - check if it needs to be split or only moved to the new contract + - if original_qty == qty_to_split: just move the contract_id + - if original_qty < qty_to_split: split the line + (eg: duplicate and change qties) + - if qty_to_split == 0: do nothing + """ + self.ensure_one() + if self.split_line_ids and any( + line.quantity_to_split for line in self.split_line_ids + ): + contract_obj = self.env["contract.contract"] + new_contract = contract_obj.create( + contract_obj._get_values_create_split_contract(self) + ) + # TODO: play onchange on partner_id. use onchange_helper from OCA ? + precision = self.env["decimal.precision"].precision_get( + "Product Unit of Measure" + ) + for line in self.split_line_ids: + original_line = line.original_contract_line_id + if not line.quantity_to_split: + continue + if ( + float_compare( + line.quantity_to_split, + line.original_qty, + precision_digits=line.uom_id and None or precision, + precision_rounding=line.uom_id.rounding or None, + ) + == 0 + ): + # only move because new_qty = original_qty + original_line.write( + original_line._get_write_values_when_moving_line(new_contract) + ) + elif ( + float_compare( + line.quantity_to_split, + line.original_qty, + precision_digits=line.uom_id and None or precision, + precision_rounding=line.uom_id.rounding or None, + ) + < 0 + ): + # need to split and move + new_line = original_line.copy() + new_line.write( + original_line._get_write_values_when_splitting_and_moving_line( + new_contract, line.quantity_to_split + ) + ) + original_line.quantity -= new_line.quantity + return new_contract + return True + + +class SplitContractLine(models.TransientModel): + _name = "split.contract.line" + _description = "Contract split line transient model" + + split_contract_id = fields.Many2one(comodel_name="split.contract") + original_contract_line_id = fields.Many2one(comodel_name="contract.line") + original_qty = fields.Float( + related="original_contract_line_id.quantity", + readonly=True, + store=False, + ) + product_id = fields.Many2one( + comodel_name="product.product", + related="original_contract_line_id.product_id", + readonly=True, + store=False, + ) + uom_id = fields.Many2one( + comodel_name="uom.uom", + related="original_contract_line_id.uom_id", + readonly=True, + store=False, + ) + name = fields.Text( + comodel_name="product.product", + related="original_contract_line_id.name", + readonly=True, + ) + quantity_to_split = fields.Float(string="Quantity to move", default=0) + + @api.constrains("quantity_to_split") + def _check_quantity_to_move(self): + precision = self.env["decimal.precision"].precision_get( + "Product Unit of Measure" + ) + for rec in self: + if ( + float_compare( + rec.quantity_to_split, + rec.original_qty, + precision_digits=rec.uom_id and None or precision, + precision_rounding=rec.uom_id.rounding or None, + ) + > 0 + ): + # we try to move more qty than present in the initial contract line + raise ValidationError( + _( + "You cannot split more quantities than the " + "original quantity of the initial contract line." + ) + ) diff --git a/contract_split/wizard/wizard_split_contract.xml b/contract_split/wizard/wizard_split_contract.xml new file mode 100644 index 000000000..175a75685 --- /dev/null +++ b/contract_split/wizard/wizard_split_contract.xml @@ -0,0 +1,46 @@ + + + + split.contract.form + split.contract + +
+ + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Split Contract + split.contract + form + new + +
diff --git a/setup/contract_split/odoo/addons/contract_split b/setup/contract_split/odoo/addons/contract_split new file mode 120000 index 000000000..8ccc6624e --- /dev/null +++ b/setup/contract_split/odoo/addons/contract_split @@ -0,0 +1 @@ +../../../../contract_split \ No newline at end of file diff --git a/setup/contract_split/setup.py b/setup/contract_split/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/contract_split/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)