From 61e45b88043c201fbf7e2c9f95a4f99026f6fe25 Mon Sep 17 00:00:00 2001 From: sbejaoui Date: Thu, 2 Jan 2025 16:58:24 +0100 Subject: [PATCH] [16.0][ADD] product_merge This module allows users to efficiently merge multiple product templates into one. This merge process ensures that attributes and variants from all selected products are consolidated into the primary product template, without creating any new variants. This approach is particularly important for maintaining data integrity and avoiding unnecessary database load. By not creating new variants during the merge, the module helps to prevent heavy updates on existing tables, making it ideal for large-scale databases. --- product_merge/README.rst | 93 ++++ product_merge/__init__.py | 2 + product_merge/__manifest__.py | 18 + product_merge/models/__init__.py | 1 + product_merge/models/product_template.py | 15 + product_merge/readme/CONTRIBUTORS.md | 2 + product_merge/readme/DESCRIPTION.md | 8 + product_merge/readme/USAGE.md | 3 + product_merge/security/groups.xml | 14 + product_merge/security/ir.model.access.csv | 3 + product_merge/static/description/icon.png | Bin 0 -> 9455 bytes product_merge/static/description/icon.png.oca | Bin 0 -> 9455 bytes product_merge/static/description/index.html | 441 ++++++++++++++++++ product_merge/tests/__init__.py | 1 + product_merge/tests/test_product_merge.py | 243 ++++++++++ product_merge/wizards/__init__.py | 2 + product_merge/wizards/product_merge_wizard.py | 208 +++++++++ .../wizards/product_merge_wizard.xml | 62 +++ .../wizards/product_merge_wizard_line.py | 33 ++ setup/product_merge/odoo/addons/product_merge | 1 + setup/product_merge/setup.py | 6 + 21 files changed, 1156 insertions(+) create mode 100644 product_merge/README.rst create mode 100644 product_merge/__init__.py create mode 100644 product_merge/__manifest__.py create mode 100644 product_merge/models/__init__.py create mode 100644 product_merge/models/product_template.py create mode 100644 product_merge/readme/CONTRIBUTORS.md create mode 100644 product_merge/readme/DESCRIPTION.md create mode 100644 product_merge/readme/USAGE.md create mode 100644 product_merge/security/groups.xml create mode 100644 product_merge/security/ir.model.access.csv create mode 100644 product_merge/static/description/icon.png create mode 100644 product_merge/static/description/icon.png.oca create mode 100644 product_merge/static/description/index.html create mode 100644 product_merge/tests/__init__.py create mode 100644 product_merge/tests/test_product_merge.py create mode 100644 product_merge/wizards/__init__.py create mode 100644 product_merge/wizards/product_merge_wizard.py create mode 100644 product_merge/wizards/product_merge_wizard.xml create mode 100644 product_merge/wizards/product_merge_wizard_line.py create mode 120000 setup/product_merge/odoo/addons/product_merge create mode 100644 setup/product_merge/setup.py diff --git a/product_merge/README.rst b/product_merge/README.rst new file mode 100644 index 00000000000..6932060ccea --- /dev/null +++ b/product_merge/README.rst @@ -0,0 +1,93 @@ +============= +Product Merge +============= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f61a26838528b39a7681c2417bec10f1a57716112590f61aab0608f392d26c04 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fproduct--attribute-lightgray.png?logo=github + :target: https://github.com/OCA/product-attribute/tree/16.0/product_merge + :alt: OCA/product-attribute +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/product-attribute-16-0/product-attribute-16-0-product_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/product-attribute&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows users to efficiently merge multiple product templates +into one. This merge process ensures that attributes and variants from +all selected products are consolidated into the primary product +template, without creating any new variants. This approach is +particularly important for maintaining data integrity and avoiding +unnecessary database load. + +By not creating new variants during the merge, the module helps to +prevent heavy updates on existing tables, making it ideal for +large-scale databases. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +- In the product template tree view, select multiple products. +- Chose "Merge products" action +- At least two products must be selected, and each should have at most + one variant. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub 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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ACSONE SA/NV + +Contributors +------------ + +- Souheil Bejaoui souheil.bejaoui@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/product-attribute `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/product_merge/__init__.py b/product_merge/__init__.py new file mode 100644 index 00000000000..976591c996e --- /dev/null +++ b/product_merge/__init__.py @@ -0,0 +1,2 @@ +from . import wizards +from . import models diff --git a/product_merge/__manifest__.py b/product_merge/__manifest__.py new file mode 100644 index 00000000000..31712c6fb57 --- /dev/null +++ b/product_merge/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Product Merge", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/product-attribute", + "depends": ["product"], + "data": [ + "security/groups.xml", + "security/ir.model.access.csv", + "wizards/product_merge_wizard.xml", + ], + "demo": [], + "external_dependencies": {"python": ["openupgradelib"]}, +} diff --git a/product_merge/models/__init__.py b/product_merge/models/__init__.py new file mode 100644 index 00000000000..e8fa8f6bf1e --- /dev/null +++ b/product_merge/models/__init__.py @@ -0,0 +1 @@ +from . import product_template diff --git a/product_merge/models/product_template.py b/product_merge/models/product_template.py new file mode 100644 index 00000000000..6f1dc2fc4e4 --- /dev/null +++ b/product_merge/models/product_template.py @@ -0,0 +1,15 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ProductTemplate(models.Model): + + _inherit = "product.template" + + def _create_variant_ids(self): + """prevent variant creation at product merge process""" + if self.env.context.get("product_merge"): + return + return super()._create_variant_ids() diff --git a/product_merge/readme/CONTRIBUTORS.md b/product_merge/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..136fa1e17e3 --- /dev/null +++ b/product_merge/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Souheil Bejaoui + diff --git a/product_merge/readme/DESCRIPTION.md b/product_merge/readme/DESCRIPTION.md new file mode 100644 index 00000000000..1df2e409b07 --- /dev/null +++ b/product_merge/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +This module allows users to efficiently merge multiple product templates into one. +This merge process ensures that attributes and variants from all selected products +are consolidated into the primary product template, without creating any new variants. +This approach is particularly important for maintaining data integrity and +avoiding unnecessary database load. + +By not creating new variants during the merge, the module helps to prevent +heavy updates on existing tables, making it ideal for large-scale databases. diff --git a/product_merge/readme/USAGE.md b/product_merge/readme/USAGE.md new file mode 100644 index 00000000000..0c255642cfa --- /dev/null +++ b/product_merge/readme/USAGE.md @@ -0,0 +1,3 @@ +- In the product template tree view, select multiple products. +- Chose "Merge products" action +- At least two products must be selected, and each should have at most one variant. diff --git a/product_merge/security/groups.xml b/product_merge/security/groups.xml new file mode 100644 index 00000000000..d67216e98e5 --- /dev/null +++ b/product_merge/security/groups.xml @@ -0,0 +1,14 @@ + + + + + Can merge products + + + + + diff --git a/product_merge/security/ir.model.access.csv b/product_merge/security/ir.model.access.csv new file mode 100644 index 00000000000..475e62f0954 --- /dev/null +++ b/product_merge/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_product_merge_wizard,access_product_merge_wizard,model_product_merge_wizard,group_can_merge_products,1,1,1,1 +access_product_merge_wizard_line,access_product_merge_wizard_line,model_product_merge_wizard_line,group_can_merge_products,1,1,1,1 diff --git a/product_merge/static/description/icon.png b/product_merge/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/product_merge/static/description/icon.png.oca b/product_merge/static/description/icon.png.oca new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/product_merge/static/description/index.html b/product_merge/static/description/index.html new file mode 100644 index 00000000000..bc0f3cf7ff3 --- /dev/null +++ b/product_merge/static/description/index.html @@ -0,0 +1,441 @@ + + + + + +Product Merge + + + +
+

Product Merge

+ + +

Beta License: AGPL-3 OCA/product-attribute Translate me on Weblate Try me on Runboat

+

This module allows users to efficiently merge multiple product templates +into one. This merge process ensures that attributes and variants from +all selected products are consolidated into the primary product +template, without creating any new variants. This approach is +particularly important for maintaining data integrity and avoiding +unnecessary database load.

+

By not creating new variants during the merge, the module helps to +prevent heavy updates on existing tables, making it ideal for +large-scale databases.

+

Table of contents

+ +
+

Usage

+
    +
  • In the product template tree view, select multiple products.
  • +
  • Chose “Merge products” action
  • +
  • At least two products must be selected, and each should have at most +one variant.
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub 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.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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/product-attribute project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/product_merge/tests/__init__.py b/product_merge/tests/__init__.py new file mode 100644 index 00000000000..40822643a7a --- /dev/null +++ b/product_merge/tests/__init__.py @@ -0,0 +1 @@ +from . import test_product_merge diff --git a/product_merge/tests/test_product_merge.py b/product_merge/tests/test_product_merge.py new file mode 100644 index 00000000000..125719b851a --- /dev/null +++ b/product_merge/tests/test_product_merge.py @@ -0,0 +1,243 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from psycopg2.errors import UniqueViolation + +from odoo import Command +from odoo.exceptions import ValidationError +from odoo.tests.common import Form, TransactionCase + + +class TestProductMerge(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create test products + cls.product_r = cls.env["product.template"].create( + {"name": "Product: Color RED"} + ) + cls.product_b = cls.env["product.template"].create( + {"name": "Product: Color Blue"} + ) + + # Create test attributes + cls.color_attr = cls.env["product.attribute"].create( + {"name": "Color", "create_variant": "dynamic"} + ) + cls.color_attr_value_r = cls.env["product.attribute.value"].create( + {"name": "Red", "attribute_id": cls.color_attr.id} + ) + cls.color_attr_value_b = cls.env["product.attribute.value"].create( + {"name": "Blue", "attribute_id": cls.color_attr.id} + ) + cls.variant_r = cls.product_r.product_variant_ids + cls.variant_b = cls.product_b.product_variant_ids + cls.existing_variants = cls.variant_r | cls.variant_b + cls.supplierinfo_r = cls.env["product.supplierinfo"].create( + { + "partner_id": cls.env["res.partner"].create({"name": "Supplier A"}).id, + "product_tmpl_id": cls.product_r.id, + } + ) + cls.supplierinfo_b = cls.env["product.supplierinfo"].create( + { + "partner_id": cls.env["res.partner"].create({"name": "Supplier B"}).id, + "product_tmpl_id": cls.product_b.id, + } + ) + cls.pricelist = cls.env["product.pricelist"].create( + { + "name": "Test Pricelist", + "item_ids": [ + Command.create( + { + "applied_on": "1_product", + "product_tmpl_id": cls.product_r.id, + "fixed_price": 70.0, + } + ), + Command.create( + { + "applied_on": "1_product", + "product_tmpl_id": cls.product_b.id, + "fixed_price": 50.0, + } + ), + ], + } + ) + + def test_0(self): + """ + Test the default_get method of the product merge wizard. + + This test validates that: + - `product_ids` is correctly populated from the context. + - `line_ids` is computed correctly based on the selected products. + - Attribute values are assigned correctly to the wizard lines. + """ + with Form( + self.env["product.merge.wizard"].with_context( + active_model="product.template", + active_ids=[self.product_r.id, self.product_b.id], + ) + ) as wizard_form: + wizard_form.product_tmpl_id = self.product_r + wizard = wizard_form.save() + self.assertEqual(wizard.product_ids, self.product_r | self.product_b) + self.assertEqual(len(wizard.line_ids), 2) + self.assertEqual(wizard.line_ids.product_id, self.existing_variants) + line_r = wizard.line_ids.filtered( + lambda line: line.product_id == self.variant_r + ) + line_b = wizard.line_ids.filtered( + lambda line: line.product_id == self.variant_b + ) + line_r.attribute_value_ids = self.color_attr_value_r + line_b.attribute_value_ids = self.color_attr_value_b + self.wizard = wizard + self.line_r = line_r + self.line_b = line_b + self.wizard.attribute_ids = self.color_attr + self.line_r.attribute_value_ids = self.color_attr_value_r + self.line_b.attribute_value_ids = self.color_attr_value_b + + def test_action_merge_products(self): + """ + Test the `action_merge_products` method of the wizard: + - The primary product template remains active after the merge. + - The other product template is deactivated. + - The attributes and variants are correctly merged without creating duplicates. + - Variants are assigned the correct attribute values after the merge. + """ + self.test_0() + # Execute the merge + self.wizard.action_merge_products() + + # Check that product_tmpl_id retains active status + self.assertTrue(self.product_r.active) + + # Check that other products are archived + self.assertFalse(self.product_b.active) + + # Check that attributes are updated in product_tmpl_id + attribute_lines = self.product_r.attribute_line_ids + self.assertEqual(len(attribute_lines), 1) + variants = self.product_r.product_variant_ids + # check variants are now related to on template + self.assertEqual(len(variants), 2) + # check variants are the same and the system didn't create new one + self.assertEqual(self.existing_variants, variants) + # check values are assigned to variants according to the mapping + variant_r_tmpl_value = self.variant_r.product_template_attribute_value_ids + variant_b_tmpl_value = self.variant_b.product_template_attribute_value_ids + self.assertEqual( + variant_r_tmpl_value.product_attribute_value_id, self.color_attr_value_r + ) + self.assertEqual( + variant_b_tmpl_value.product_attribute_value_id, self.color_attr_value_b + ) + + def test_action_merge_products_same_attribute_value(self): + """ + ensures that an error is raised when two variants are merged with the same + attribute value combination + """ + self.test_0() + self.wizard.attribute_ids = self.color_attr + self.line_b.attribute_value_ids = self.color_attr_value_r + # Execute the merge + with self.assertRaises(UniqueViolation, msg="Combination exists"): + self.wizard.action_merge_products() + + def test_minimum_two_products_constraint(self): + wizard = self.env["product.merge.wizard"].create( + { + "product_tmpl_id": self.product_r.id, + "product_ids": [Command.link(self.product_r.id)], + } + ) + with self.assertRaises( + ValidationError, + msg="At least two products must be added to the wizard to perform a merge.", + ): + wizard.action_merge_products() + + def test_products_with_max_one_variant(self): + # Create a product template with multiple variants + self.color_attr.create_variant = "always" + product_multi_variant = self.env["product.template"].create( + { + "name": "Product with Multiple Variants", + "attribute_line_ids": [ + Command.create( + { + "attribute_id": self.color_attr.id, + "value_ids": [ + Command.set( + [ + self.color_attr_value_r.id, + self.color_attr_value_b.id, + ], + ) + ], + }, + ) + ], + } + ) + + # Attempt to create a wizard with a multi-variant product + wizard = self.env["product.merge.wizard"].create( + { + "product_tmpl_id": self.product_r.id, + "product_ids": [ + Command.link(self.product_r.id), + Command.link(product_multi_variant.id), + ], + } + ) + with self.assertRaises( + ValidationError, msg="All added products must have at most one variant." + ): + wizard.action_merge_products() + + def test_update_supplier_info(self): + """ + Test the `_update_supplier_info` method: + - Ensures that supplier information is correctly updated when products are merged. + - Checks that the supplierinfo records are transferred from the merged product + templates to the target product template and variant. + """ + self.test_0() + self.wizard.action_merge_products() + + # Check supplierinfo for product_r + updated_supplierinfo_r = self.env["product.supplierinfo"].search( + [("product_id", "=", self.variant_r.id)] + ) + self.assertEqual(updated_supplierinfo_r, self.supplierinfo_r) + self.assertEqual(updated_supplierinfo_r.product_tmpl_id, self.product_r) + updated_supplierinfo_b = self.env["product.supplierinfo"].search( + [("product_id", "=", self.variant_b.id)] + ) + self.assertEqual(updated_supplierinfo_b, self.supplierinfo_b) + self.assertEqual(updated_supplierinfo_b.product_tmpl_id, self.product_r) + self.assertEqual(len(self.product_r.seller_ids), 2) + + def test_different_type_merge(self): + """ + Test that merging products of different types raises a ValidationError. + """ + self.product_r.type = "service" + with self.assertRaises(ValidationError): + self.test_0() + + def test_update_price_list(self): + self.test_0() + self.wizard.action_merge_products() + self.assertEqual(self.pricelist.item_ids.product_tmpl_id, self.product_r) + self.assertEqual(self.pricelist.item_ids[0].applied_on, "0_product_variant") + self.assertEqual( + self.pricelist.item_ids.product_id, self.variant_r | self.variant_b + ) diff --git a/product_merge/wizards/__init__.py b/product_merge/wizards/__init__.py new file mode 100644 index 00000000000..7d60e64b697 --- /dev/null +++ b/product_merge/wizards/__init__.py @@ -0,0 +1,2 @@ +from . import product_merge_wizard +from . import product_merge_wizard_line diff --git a/product_merge/wizards/product_merge_wizard.py b/product_merge/wizards/product_merge_wizard.py new file mode 100644 index 00000000000..ecaf879d915 --- /dev/null +++ b/product_merge/wizards/product_merge_wizard.py @@ -0,0 +1,208 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from openupgradelib.openupgrade_merge_records import merge_records + +from odoo import Command, _, api, fields, models +from odoo.exceptions import ValidationError + + +class ProductMergeWizard(models.TransientModel): + _name = "product.merge.wizard" + _description = "Merge Products Wizard" + + product_tmpl_id = fields.Many2one( + comodel_name="product.template", + string="Product Model", + domain="[('id', 'in', product_ids)]", + required=True, + ondelete="cascade", + ) + product_ids = fields.Many2many( + comodel_name="product.template", string="Products to Merge", required=True + ) + attribute_ids = fields.Many2many( + comodel_name="product.attribute", string="Attributes", required=True + ) + line_ids = fields.One2many( + comodel_name="product.merge.wizard.line", + inverse_name="wizard_id", + string="Attribute Mapping", + compute="_compute_line_ids", + store=True, + readonly=False, + ) + + def _check_minimum_products(self): + """ + Ensure that at least two products are selected for merging. + """ + for wizard in self: + if len(wizard.product_ids) < 2: + raise ValidationError( + _( + "At least two products must be added to the wizard to perform a merge." + ) + ) + + def _check_products_max_one_variant(self): + for wizard in self: + for product in wizard.product_ids: + if len(product.product_variant_ids) > 1: + raise ValidationError( + _( + "All added products must have at most one variant. " + "Product '%(product)s' has multiple variants.", + product=product.name, + ) + ) + + @api.constrains("product_ids") + def _check_product_types(self): + for wizard in self: + if len(wizard.product_ids) > 1: + types = wizard.product_ids.mapped("type") + if len(set(types)) > 1: + raise ValidationError( + _( + "All products to merge must be of the same type " + "(e.g., consumable, service, or storable)." + ) + ) + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + if self.env.context.get( + "active_model" + ) != "product.template" or not self.env.context.get("active_ids"): + return res + res["product_ids"] = [Command.set(self.env.context["active_ids"])] + return res + + @api.model + def _get_merge_field_spec(self): + """This method defines the merge strategy for each field. By default, we choose + to force the target value, but this method can be inherited to change the + behavior of the merge. + See the _adjust_merged_values_orm method documentation in OpenUpgradeLib for + more details.""" + return { + field_name: "target" for field_name in self.product_tmpl_id._fields.keys() + } + + def action_merge_products(self): + self.ensure_one() + self._check_minimum_products() + self._check_products_max_one_variant() + self.product_tmpl_id.with_context(product_merge=True).write( + { + "attribute_line_ids": [ + Command.create( + { + "attribute_id": attribute.id, + "value_ids": [ + Command.link(value.id) for value in attribute.value_ids + ], + } + ) + for attribute in self.attribute_ids + ], + } + ) + other_templates = ( + self.line_ids.product_id.product_tmpl_id - self.product_tmpl_id + ) + for line in self.line_ids: + product_variant = line.product_id + self._update_pricelist_item(product_variant) + self._update_supplier_info(product_variant) + self._move_variant_to_template(product_variant, line.attribute_value_ids) + merge_records( + self.env, + self.product_tmpl_id._name, + other_templates.ids, + self.product_tmpl_id.id, + field_spec=self._get_merge_field_spec(), + delete=False, + ) + other_templates.write({"active": False}) + archived_links = "
  • ".join( + f'{template.name}
  • ' + for template in other_templates + ) + self.product_tmpl_id.message_post( + body=_( + "The following products were merged and archived:" + "
      %(archived_links)s
        ", + archived_links=archived_links, + ), + subtype_xmlid="mail.mt_note", + ) + return { + "type": "ir.actions.act_window", + "res_model": "product.template", + "res_id": self.product_tmpl_id.id, + "view_mode": "form", + "target": "current", + } + + def _move_variant_to_template(self, product_variant, attribute_values): + template_attribute_value = self.env["product.template.attribute.value"].search( + [ + ("product_tmpl_id", "=", self.product_tmpl_id.id), + ("product_attribute_value_id", "in", attribute_values.ids), + ] + ) + product_variant.write( + { + "product_tmpl_id": self.product_tmpl_id.id, + "product_template_attribute_value_ids": [ + Command.set(template_attribute_value.ids) + ], + } + ) + product_variant._compute_combination_indices() + + @api.depends("product_ids") + def _compute_line_ids(self): + for rec in self: + rec.update( + { + "line_ids": [ + Command.create({"product_id": p.id}) + for p in rec.product_ids.product_variant_ids + ] + } + ) + + def _update_pricelist_item(self, product_variant): + pricelist_items = self.env["product.pricelist.item"].search( + [ + ("applied_on", "=", "1_product"), + ("product_tmpl_id", "=", product_variant.product_tmpl_id.id), + ] + ) + pricelist_items.write( + { + "applied_on": "0_product_variant", + "product_id": product_variant.id, + "product_tmpl_id": self.product_tmpl_id.id, + } + ) + + def _update_supplier_info(self, product_variant): + """ + Updates supplier information by transferring supplierinfo from the merged + products to the target product template. + """ + self.ensure_one() + supplier_infos = self.env["product.supplierinfo"].search( + [("product_tmpl_id", "=", product_variant.product_tmpl_id.id)] + ) + supplier_infos.write({"product_id": product_variant.id}) + supplier_infos = self.env["product.supplierinfo"].search( + [("product_id", "=", product_variant.id)] + ) + supplier_infos.write({"product_tmpl_id": self.product_tmpl_id.id}) diff --git a/product_merge/wizards/product_merge_wizard.xml b/product_merge/wizards/product_merge_wizard.xml new file mode 100644 index 00000000000..4ce83325521 --- /dev/null +++ b/product_merge/wizards/product_merge_wizard.xml @@ -0,0 +1,62 @@ + + + + + + product.merge.wizard + +
        + + + + + + + + + + + + + + + + +
        +
        +
        +
        +
        + + + Merge products + product.merge.wizard + form + {} + new + + + +
        diff --git a/product_merge/wizards/product_merge_wizard_line.py b/product_merge/wizards/product_merge_wizard_line.py new file mode 100644 index 00000000000..fd69aaf781c --- /dev/null +++ b/product_merge/wizards/product_merge_wizard_line.py @@ -0,0 +1,33 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ProductMergeWizardLine(models.TransientModel): + + _name = "product.merge.wizard.line" + _description = "Merge Products Wizard Line" + + wizard_id = fields.Many2one( + "product.merge.wizard", string="Wizard", required=True, ondelete="cascade" + ) + product_id = fields.Many2one( + comodel_name="product.product", + string="Product", + required=True, + ondelete="cascade", + ) + attribute_value_ids = fields.Many2many( + comodel_name="product.attribute.value", + string="Attribute Values", + domain="attribute_value_domain", + ) + attribute_value_domain = fields.Binary(compute="_compute_attribute_value_domain") + + @api.depends("wizard_id.attribute_ids", "wizard_id.line_ids.attribute_value_ids") + def _compute_attribute_value_domain(self): + for rec in self: + rec.attribute_value_domain = [ + ("attribute_id", "in", rec.wizard_id.attribute_ids.ids) + ] diff --git a/setup/product_merge/odoo/addons/product_merge b/setup/product_merge/odoo/addons/product_merge new file mode 120000 index 00000000000..e615d0ce089 --- /dev/null +++ b/setup/product_merge/odoo/addons/product_merge @@ -0,0 +1 @@ +../../../../product_merge \ No newline at end of file diff --git a/setup/product_merge/setup.py b/setup/product_merge/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/product_merge/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)