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..26423f691 --- /dev/null +++ b/contract_split/models/contract.py @@ -0,0 +1,26 @@ +# Copyright 2023 Damien Crier - Foodles +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class Contract(models.Model): + _inherit = "contract.contract" + + original_contract_id = fields.Many2one( + comodel_name="contract.contract", + readonly=True, + ) + + +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, + ) 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/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..e69de29bb diff --git a/contract_split/tests/test_contract_split.py b/contract_split/tests/test_contract_split.py new file mode 100644 index 000000000..02f21820b --- /dev/null +++ b/contract_split/tests/test_contract_split.py @@ -0,0 +1,123 @@ +# 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_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 + 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 + 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.partner2.id, new_contract.partner_id.id) + self.assertEqual(1, len(new_contract.contract_line_ids.ids)) + self.assertEqual( + self.contract3.id, + 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 + 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 partner2 as partner_id + self.assertEqual(self.partner2.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.id, + 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 + wizard.split_line_ids[0].quantity_to_split = ( + wizard.split_line_ids[0].original_qty + 2 + ) + # confirm wizard with setting to_split quantities that should raise an error + with self.assertRaises(ValidationError): + wizard.action_split_contract() 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..d74da61fd --- /dev/null +++ b/contract_split/wizard/wizard_split_contract.py @@ -0,0 +1,171 @@ +# 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" + + 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(self._get_default_values_from_contract(contract)) + return vals + + def _get_default_values_from_contract(self, contract) -> dict: + return { + "main_contract_id": contract.id, + "partner_id": contract.partner_id.id, + "invoice_partner_id": contract.invoice_partner_id.id, + "split_line_ids": [ + (0, 0, self._get_default_split_line_values(line)) + for line in contract.contract_line_ids + ], + } + + def _get_default_split_line_values(self, line) -> list: + return { + "original_contract_line_id": line.id, + } + + def _get_contract_name(self): + return self.main_contract_id.name + + def _get_values_create_contract(self): + self.ensure_one() + return { + "name": self._get_contract_name(), + "partner_id": self.partner_id.id, + "invoice_partner_id": self.invoice_partner_id.id, + "original_contract_id": self.main_contract_id.id, + "line_recurrence": True, + } + + 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 + ): + new_contract = self.env["contract.contract"].create( + self._get_values_create_contract() + ) + # TODO: play onchange on partner_id. use onchange_helper from OCA ? + 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=2 + ) + == 0 + ): + # only move because new_qty = original_qty + original_line.write( + line._get_write_values_when_moving_line(new_contract) + ) + elif ( + float_compare( + line.quantity_to_split, line.original_qty, precision_digits=2 + ) + < 0 + ): + # need to split and move + new_line = original_line.copy() + new_line.write( + line._get_write_values_when_splitting_and_moving_line( + new_contract, line + ) + ) + + original_line.quantity -= new_line.quantity + return new_contract + return True + + +class SplitContractLine(models.TransientModel): + _name = "split.contract.line" + + 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, + ) + original_contract_id = fields.Many2one( + comodel_name="contract.contract", + related="original_contract_line_id.contract_id", + 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): + for rec in self: + if ( + float_compare( + rec.quantity_to_split, rec.original_qty, precision_digits=2 + ) + > 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." + ) + ) + + def _get_write_values_when_moving_line(self, new_contract): + return { + "contract_id": new_contract.id, + "splitted_from_contract_id": self.original_contract_id.id, + } + + def _get_write_values_when_splitting_and_moving_line(self, new_contract, line): + return { + "contract_id": new_contract.id, + "splitted_from_contract_id": self.original_contract_id.id, + "splitted_from_line_id": self.original_contract_line_id.id, + "quantity": line.quantity_to_split, + } 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, +)