diff --git a/sale_attached_product/README.rst b/sale_attached_product/README.rst new file mode 100644 index 00000000000..cb0f6cdeff2 --- /dev/null +++ b/sale_attached_product/README.rst @@ -0,0 +1,126 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +========================== +Attached products in sales +========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:9316cb7862a90982b04e6faac0d3a0b2cd682291e5ded41e5f420d58ccbf72ff + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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%2Fsale--workflow-lightgray.png?logo=github + :target: https://github.com/OCA/sale-workflow/tree/17.0/sale_attached_product + :alt: OCA/sale-workflow +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/sale-workflow-17-0/sale-workflow-17-0-sale_attached_product + :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/sale-workflow&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to define a set of products which will be added +automatically to the sales order whenever that product is present on it. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure attached products: + +1. Go to *Sales > Products > Products* and choose on you want to attach + products to. +2. Go to the *Sales* tab and then to the *Attached products* section. +3. Add as many products as you want to. + +If you want to autoupdate the products when they are added, set this +config parameter: + + - sale_attached_product.auto_update_attached_lines + +Otherwise, the lines will be added, but they can be modified, deleted, +etc. + +Usage +===== + +Now that you have your product configured: + +1. Place a new sale order and then add that product in a new line. +2. Once you save your order, the attached products will be added in new + lines to the order with as many quantities as the main one. + +If the global sale_attached_product.auto_update_attached_lines setting +is on: + +1. Update the main product quantity and the attached product quantities + will be updated in the same amount as well. +2. If we delete the main line, the attached ones will go away in any + case. + +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 +------- + +* Tecnativa + +Contributors +------------ + +- `Tecnativa `__: + + - David Vidal + +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. + +.. |maintainer-chienandalu| image:: https://github.com/chienandalu.png?size=40px + :target: https://github.com/chienandalu + :alt: chienandalu + +Current `maintainer `__: + +|maintainer-chienandalu| + +This module is part of the `OCA/sale-workflow `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sale_attached_product/__init__.py b/sale_attached_product/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/sale_attached_product/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_attached_product/__manifest__.py b/sale_attached_product/__manifest__.py new file mode 100644 index 00000000000..494ba7258c3 --- /dev/null +++ b/sale_attached_product/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2022 Tecnativa - David Vidal +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Attached products in sales", + "summary": "Define products that will be added automatically when adding another " + "in a sales order", + "version": "18.0.1.0.0", + "development_status": "Beta", + "category": "Sale", + "website": "https://github.com/OCA/sale-workflow", + "author": "Tecnativa, Odoo Community Association (OCA)", + "maintainers": ["chienandalu"], + "license": "AGPL-3", + "depends": ["sale"], + "data": ["views/product_template_views.xml"], +} diff --git a/sale_attached_product/i18n/es.po b/sale_attached_product/i18n/es.po new file mode 100644 index 00000000000..99ed8a6f998 --- /dev/null +++ b/sale_attached_product/i18n/es.po @@ -0,0 +1,78 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_attached_product +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2023-08-07 12:10+0000\n" +"Last-Translator: Ivorra78 \n" +"Language-Team: none\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: sale_attached_product +#: model:ir.model.fields,field_description:sale_attached_product.field_sale_order_line__attached_from_line_id +msgid "Attached From Line" +msgstr "Adjunto desde la línea" + +#. module: sale_attached_product +#: model:ir.model.fields,field_description:sale_attached_product.field_sale_order_line__attached_line_ids +msgid "Attached Line" +msgstr "Línea adjunta" + +#. module: sale_attached_product +#: model:ir.model.fields,field_description:sale_attached_product.field_product_product__attached_product_ids +#: model:ir.model.fields,field_description:sale_attached_product.field_product_template__attached_product_ids +#: model_terms:ir.ui.view,arch_db:sale_attached_product.product_template_form_view +msgid "Attached Products" +msgstr "Productos adjuntos" + +#. module: sale_attached_product +#: model:ir.model.fields,help:sale_attached_product.field_sale_order_line__is_attached_line +msgid "Flag products that are attached to their main counterpart" +msgstr "Marcar productos que están unidos a su contraparte principal" + +#. module: sale_attached_product +#: model:ir.model.fields,field_description:sale_attached_product.field_sale_order_line__is_attached_line +msgid "Is Attached Line" +msgstr "es línea adjunta" + +#. module: sale_attached_product +#: model:ir.model,name:sale_attached_product.model_sale_attached_product_mixin +msgid "Mixin class for sale attached product features" +msgstr "Clase Mixta para la venta de productos adjuntos" + +#. module: sale_attached_product +#: model:ir.model,name:sale_attached_product.model_product_template +msgid "Product" +msgstr "" + +#. module: sale_attached_product +#: model:ir.model,name:sale_attached_product.model_sale_order +msgid "Sales Order" +msgstr "Órdenes de venta" + +#. module: sale_attached_product +#: model:ir.model,name:sale_attached_product.model_sale_order_line +msgid "Sales Order Line" +msgstr "Línea de Orden de Venta" + +#. module: sale_attached_product +#: model:ir.model.fields,help:sale_attached_product.field_product_product__attached_product_ids +#: model:ir.model.fields,help:sale_attached_product.field_product_template__attached_product_ids +msgid "" +"Similar to optional products, although they're added automatically to " +"thesale order and optionally removed when the main product goes away." +msgstr "" +"Similares a los productos opcionales, aunque se añaden automáticamente al " +"pedido de venta y se eliminan opcionalmente cuando desaparece el producto " +"principal." + +#~ msgid "Product Template" +#~ msgstr "Plantilla del Producto" diff --git a/sale_attached_product/i18n/it.po b/sale_attached_product/i18n/it.po new file mode 100644 index 00000000000..7c976c2d9b7 --- /dev/null +++ b/sale_attached_product/i18n/it.po @@ -0,0 +1,76 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_attached_product +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-06-14 17:42+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: sale_attached_product +#: model:ir.model.fields,field_description:sale_attached_product.field_sale_order_line__attached_from_line_id +msgid "Attached From Line" +msgstr "Allegato dalla riga" + +#. module: sale_attached_product +#: model:ir.model.fields,field_description:sale_attached_product.field_sale_order_line__attached_line_ids +msgid "Attached Line" +msgstr "Riga allegata" + +#. module: sale_attached_product +#: model:ir.model.fields,field_description:sale_attached_product.field_product_product__attached_product_ids +#: model:ir.model.fields,field_description:sale_attached_product.field_product_template__attached_product_ids +#: model_terms:ir.ui.view,arch_db:sale_attached_product.product_template_form_view +msgid "Attached Products" +msgstr "Prodotti allegati" + +#. module: sale_attached_product +#: model:ir.model.fields,help:sale_attached_product.field_sale_order_line__is_attached_line +msgid "Flag products that are attached to their main counterpart" +msgstr "" +"Prodotti di riferimento che sono allegati alle loro controparti principali" + +#. module: sale_attached_product +#: model:ir.model.fields,field_description:sale_attached_product.field_sale_order_line__is_attached_line +msgid "Is Attached Line" +msgstr "È una riga allegata" + +#. module: sale_attached_product +#: model:ir.model,name:sale_attached_product.model_sale_attached_product_mixin +msgid "Mixin class for sale attached product features" +msgstr "Classe mixin per caratteristiche prodotto allegato alla vendita" + +#. module: sale_attached_product +#: model:ir.model,name:sale_attached_product.model_product_template +msgid "Product" +msgstr "Prodotto" + +#. module: sale_attached_product +#: model:ir.model,name:sale_attached_product.model_sale_order +msgid "Sales Order" +msgstr "Ordine di vendita" + +#. module: sale_attached_product +#: model:ir.model,name:sale_attached_product.model_sale_order_line +msgid "Sales Order Line" +msgstr "Riga ordine di vendita" + +#. module: sale_attached_product +#: model:ir.model.fields,help:sale_attached_product.field_product_product__attached_product_ids +#: model:ir.model.fields,help:sale_attached_product.field_product_template__attached_product_ids +msgid "" +"Similar to optional products, although they're added automatically to " +"thesale order and optionally removed when the main product goes away." +msgstr "" +"Simile ai prodotti opzionali, sebbene vengano aggiunti automaticamente " +"all'ordine di vendita e rimossi opzionalmente quando il prodotto principale " +"va via." diff --git a/sale_attached_product/i18n/sale_attached_product.pot b/sale_attached_product/i18n/sale_attached_product.pot new file mode 100644 index 00000000000..ccf78766775 --- /dev/null +++ b/sale_attached_product/i18n/sale_attached_product.pot @@ -0,0 +1,69 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_attached_product +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: sale_attached_product +#: model:ir.model.fields,field_description:sale_attached_product.field_sale_order_line__attached_from_line_id +msgid "Attached From Line" +msgstr "" + +#. module: sale_attached_product +#: model:ir.model.fields,field_description:sale_attached_product.field_sale_order_line__attached_line_ids +msgid "Attached Line" +msgstr "" + +#. module: sale_attached_product +#: model:ir.model.fields,field_description:sale_attached_product.field_product_product__attached_product_ids +#: model:ir.model.fields,field_description:sale_attached_product.field_product_template__attached_product_ids +#: model_terms:ir.ui.view,arch_db:sale_attached_product.product_template_form_view +msgid "Attached Products" +msgstr "" + +#. module: sale_attached_product +#: model:ir.model.fields,help:sale_attached_product.field_sale_order_line__is_attached_line +msgid "Flag products that are attached to their main counterpart" +msgstr "" + +#. module: sale_attached_product +#: model:ir.model.fields,field_description:sale_attached_product.field_sale_order_line__is_attached_line +msgid "Is Attached Line" +msgstr "" + +#. module: sale_attached_product +#: model:ir.model,name:sale_attached_product.model_sale_attached_product_mixin +msgid "Mixin class for sale attached product features" +msgstr "" + +#. module: sale_attached_product +#: model:ir.model,name:sale_attached_product.model_product_template +msgid "Product" +msgstr "" + +#. module: sale_attached_product +#: model:ir.model,name:sale_attached_product.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: sale_attached_product +#: model:ir.model,name:sale_attached_product.model_sale_order_line +msgid "Sales Order Line" +msgstr "" + +#. module: sale_attached_product +#: model:ir.model.fields,help:sale_attached_product.field_product_product__attached_product_ids +#: model:ir.model.fields,help:sale_attached_product.field_product_template__attached_product_ids +msgid "" +"Similar to optional products, although they're added automatically to " +"thesale order and optionally removed when the main product goes away." +msgstr "" diff --git a/sale_attached_product/models/__init__.py b/sale_attached_product/models/__init__.py new file mode 100644 index 00000000000..b3ebd3e2d97 --- /dev/null +++ b/sale_attached_product/models/__init__.py @@ -0,0 +1,3 @@ +from . import product_template +from . import sale_attached_product_mixin +from . import sale_order diff --git a/sale_attached_product/models/product_template.py b/sale_attached_product/models/product_template.py new file mode 100644 index 00000000000..f0e61498d9a --- /dev/null +++ b/sale_attached_product/models/product_template.py @@ -0,0 +1,15 @@ +# Copyright 2022 Tecnativa - David Vidal +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + attached_product_ids = fields.Many2many( + comodel_name="product.product", + relation="product_attached_rel", + string="Attached Products", + help="Similar to optional products, although they're added automatically to the" + "sale order and optionally removed when the main product goes away.", + ) diff --git a/sale_attached_product/models/sale_attached_product_mixin.py b/sale_attached_product/models/sale_attached_product_mixin.py new file mode 100644 index 00000000000..d8444e7f4a8 --- /dev/null +++ b/sale_attached_product/models/sale_attached_product_mixin.py @@ -0,0 +1,40 @@ +# Copyright 2022 Tecnativa - David Vidal +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class SaleAttachedProductMixin(models.AbstractModel): + _name = "sale.attached.product.mixin" + _description = "Mixin class for sale attached product features" + + @api.model + def _get_auto_refresh_attached_product_triggers(self) -> set: + """Returns set of fields which trigger the recomputation. + The method is overriden in the proper modules to set the proper triggers though + """ + return set() + + def _get_recs_data(self) -> list: + """Allows to optimize the comparison before and after the write for the + minimum possible set of fields""" + triggers = self._get_auto_refresh_attached_product_triggers() + recs_data = [] + for rec in self: + data = {} + for dotted_field_name in triggers: + val = rec.mapped(dotted_field_name) + if isinstance(val, models.AbstractModel): + val = val.ids + data[dotted_field_name] = val + recs_data.append({rec: data}) + return recs_data + + def _check_skip_attached_product_refresh(self): + """Checks whether refresh should be skipped + + Hook method to be overridden if necessary + :return: True if auto-refresh should be skipped + """ + ctx = self.env.context + return ctx.get("skip_auto_refresh_attached_product") diff --git a/sale_attached_product/models/sale_order.py b/sale_attached_product/models/sale_order.py new file mode 100644 index 00000000000..cf015264363 --- /dev/null +++ b/sale_attached_product/models/sale_order.py @@ -0,0 +1,216 @@ +# Copyright 2022 Tecnativa - David Vidal +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import api, fields, models + + +class SaleOrder(models.Model): + _name = "sale.order" + _inherit = ["sale.order", "sale.attached.product.mixin"] + + @api.model + def _get_auto_refresh_attached_product_triggers(self) -> set: + """Normally, we won't be needing any field from sale.order but order lines + but it's configurable anyway.""" + triggers = super()._get_auto_refresh_attached_product_triggers() + order_line_triggers = ( + self.order_line._get_auto_refresh_attached_product_triggers() + ) + for trigger in order_line_triggers: + triggers.update({f"order_line.{trigger}"}) + return triggers + + def _get_attached_line_values_product(self, line, product): + """Prepare the values for the attached line. This is used for creating or + updating.""" + + def _execute_onchanges(records, field_name): + """Helper methods that executes all onchanges associated to a field.""" + for onchange in records._onchange_methods.get(field_name, []): + for record in records: + onchange(record) + + # We prepare a new line and trigger the proper onchanges to ensure we get the + # right line values (price unit according to the customer pricelist, taxes, ect) + order_line = self.order_line.new( + {"order_id": self.id, "product_id": product.id} + ) + _execute_onchanges(order_line, "product_id") + order_line.update({"product_uom_qty": line.product_uom_qty}) + _execute_onchanges(order_line, "product_uom_qty") + vals = order_line._convert_to_write(order_line._cache) + vals.update({"is_attached_line": True, "attached_from_line_id": line.id}) + return vals + + def _create_attached_line(self, lines): + """We create all the lines at once. This should be more performant""" + vals_list = [] + for line in lines: + for product in line._get_attached_products(): + vals_list.append(self._get_attached_line_values_product(line, product)) + if vals_list: + self.with_context(skip_auto_refresh_attached_product=True).write( + {"order_line": [(0, False, value) for value in vals_list]} + ) + + def _cleanup_attached_lines(self): + """Remove those line which main line is already removed or wich main line + product attachment won't match its lines.""" + self.ensure_one() + auto_update_attached_lines = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("sale_attached_product.auto_update_attached_lines") + ) + lines = self.order_line.filtered("is_attached_line") + lines.filtered(lambda x: not x.attached_from_line_id).unlink() + if not auto_update_attached_lines: + return + # We also want to remove those lines which main product doesn't match anymore + # but only when auto update is on + lines_with_attachements = self.order_line.filtered("attached_line_ids") + for line in lines_with_attachements: + attached_products = line._get_attached_products() + line.attached_line_ids.with_context( + skip_auto_refresh_attached_product=True + ).filtered( + lambda x, attached_products=attached_products: x.product_id + not in attached_products + ).unlink() + + def _create_attached_lines(self): + """New attached lines. After this, they'll be updated if there are changes in + the main line.""" + self.ensure_one() + self._create_attached_line( + self.order_line.filtered( + lambda x: not x.attached_line_ids and x._get_attached_products() + ) + ) + + def _update_attached_lines(self): + """Update attached lines values related to their main line.""" + self.ensure_one() + lines_with_attachements = self.order_line.filtered("attached_line_ids") + lines_to_remove = self.env["sale.order.line"] + missing_list = [] + for line in lines_with_attachements: + # Lines with no qty can be considered to be removed. + if not line.product_uom_qty: + lines_to_remove += line.attached_line_ids + continue + # For every unit of the main line there will another of the attached one + attached_line_qtys = set(line.attached_line_ids.mapped("product_uom_qty")) + if any(q != line.product_uom_qty for q in attached_line_qtys): + line.attached_line_ids.update({"product_uom_qty": line.product_uom_qty}) + # Trigger possible pricelist changes + for attached_line in line.attached_line_ids: + attached_line._compute_price_unit() + attached_products = line._get_attached_products() + # Create missing products, for example in the case of a deleted attached + # line. + missing_products = { + p + for p in attached_products + if p not in line.attached_line_ids.product_id + } + for product in missing_products: + missing_list.append( + self._get_attached_line_values_product(line, product) + ) + lines_to_remove.with_context(skip_auto_refresh_attached_product=True).unlink() + self.with_context(skip_auto_refresh_attached_product=True).write( + {"order_line": [(0, False, value) for value in missing_list]} + ) + + def recompute_attached_products(self): + """Recurrent method for recomputing attached lines. Always done in these three + steps: + + 1. A cleanup of orphaned attached lines or attached lines that doesn't match + their parent attached products anymore. + 2. Creating new attached lines from lines which don't have them. + 3. Updating existing attached lines. Mainly for quantity""" + auto_update_attached_lines = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("sale_attached_product.auto_update_attached_lines") + ) + for order in self.filtered(lambda x: x.state not in {"done", "cancel"}): + order._cleanup_attached_lines() + order._create_attached_lines() + auto_update_attached_lines and order._update_attached_lines() + + @api.model_create_multi + def create(self, vals_list): + if self._check_skip_attached_product_refresh(): + return super().create(vals_list) + orders = super().create(vals_list) + orders.recompute_attached_products() + return orders + + def write(self, vals): + if self._check_skip_attached_product_refresh(): + return super().write(vals) + old_data = self._get_recs_data() + self_ctx = self.with_context(skip_auto_refresh_attached_product=True) + res = super(SaleOrder, self_ctx).write(vals) + new_data = self._get_recs_data() + if old_data != new_data: + self.recompute_attached_products() + return res + + +class SaleOrderLine(models.Model): + _name = "sale.order.line" + _inherit = ["sale.order.line", "sale.attached.product.mixin"] + + is_attached_line = fields.Boolean( + help="Flag products that are attached to their main counterpart" + ) + attached_from_line_id = fields.Many2one(comodel_name="sale.order.line") + attached_line_ids = fields.One2many( + comodel_name="sale.order.line", + inverse_name="attached_from_line_id", + ) + + def _get_attached_products(self): + return self.product_id.product_tmpl_id.attached_product_ids.filtered( + lambda x: not x.company_id or x.company_id == self.company_id + ) + + @api.model_create_multi + def create(self, vals_list): + if self._check_skip_attached_product_refresh(): + return super().create(vals_list) + self_ctx = self.with_context(skip_auto_refresh_attached_product=True) + lines = super(SaleOrderLine, self_ctx).create(vals_list) + lines.mapped("order_id").recompute_attached_products() + return lines + + def write(self, vals): + if self._check_skip_attached_product_refresh(): + return super().write(vals) + old_data = self._get_recs_data() + old_orders = self.mapped("order_id") + self_ctx = self.with_context(skip_auto_refresh_attached_product=True) + res = super(SaleOrderLine, self_ctx).write(vals) + new_data = self._get_recs_data() + new_orders = self.mapped("order_id") + if old_data != new_data: + (old_orders | new_orders).recompute_attached_products() + return res + + def unlink(self): + if self._check_skip_attached_product_refresh(): + return super().unlink() + orders = self.mapped("order_id") + self_ctx = self.with_context(skip_auto_refresh_attached_product=True) + res = super(SaleOrderLine, self_ctx).unlink() + orders.recompute_attached_products() + return res + + @api.model + def _get_auto_refresh_attached_product_triggers(self) -> set: + triggers = super()._get_auto_refresh_attached_product_triggers() + triggers.update({"product_id", "product_uom", "product_uom_qty"}) + return triggers diff --git a/sale_attached_product/pyproject.toml b/sale_attached_product/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/sale_attached_product/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/sale_attached_product/readme/CONFIGURE.md b/sale_attached_product/readme/CONFIGURE.md new file mode 100644 index 00000000000..27eddf231ee --- /dev/null +++ b/sale_attached_product/readme/CONFIGURE.md @@ -0,0 +1,14 @@ +To configure attached products: + +1. Go to *Sales \> Products \> Products* and choose on you want to + attach products to. +2. Go to the *Sales* tab and then to the *Attached products* section. +3. Add as many products as you want to. + +If you want to autoupdate the products when they are added, set this +config parameter: + +> - sale_attached_product.auto_update_attached_lines + +Otherwise, the lines will be added, but they can be modified, deleted, +etc. diff --git a/sale_attached_product/readme/CONTRIBUTORS.md b/sale_attached_product/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..6c85ccbcff1 --- /dev/null +++ b/sale_attached_product/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Tecnativa](https://www.tecnativa.com): + - David Vidal diff --git a/sale_attached_product/readme/DESCRIPTION.md b/sale_attached_product/readme/DESCRIPTION.md new file mode 100644 index 00000000000..393de0ed391 --- /dev/null +++ b/sale_attached_product/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module allows to define a set of products which will be added +automatically to the sales order whenever that product is present on it. diff --git a/sale_attached_product/readme/USAGE.md b/sale_attached_product/readme/USAGE.md new file mode 100644 index 00000000000..6af5fbf9242 --- /dev/null +++ b/sale_attached_product/readme/USAGE.md @@ -0,0 +1,13 @@ +Now that you have your product configured: + +1. Place a new sale order and then add that product in a new line. +2. Once you save your order, the attached products will be added in new + lines to the order with as many quantities as the main one. + +If the global sale_attached_product.auto_update_attached_lines setting +is on: + +1. Update the main product quantity and the attached product quantities + will be updated in the same amount as well. +2. If we delete the main line, the attached ones will go away in any + case. diff --git a/sale_attached_product/static/description/icon.png b/sale_attached_product/static/description/icon.png new file mode 100644 index 00000000000..3a0328b516c Binary files /dev/null and b/sale_attached_product/static/description/icon.png differ diff --git a/sale_attached_product/static/description/index.html b/sale_attached_product/static/description/index.html new file mode 100644 index 00000000000..30fd2471bb1 --- /dev/null +++ b/sale_attached_product/static/description/index.html @@ -0,0 +1,473 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Attached products in sales

+ +

Beta License: AGPL-3 OCA/sale-workflow Translate me on Weblate Try me on Runboat

+

This module allows to define a set of products which will be added +automatically to the sales order whenever that product is present on it.

+

Table of contents

+ +
+

Configuration

+

To configure attached products:

+
    +
  1. Go to Sales > Products > Products and choose on you want to attach +products to.
  2. +
  3. Go to the Sales tab and then to the Attached products section.
  4. +
  5. Add as many products as you want to.
  6. +
+

If you want to autoupdate the products when they are added, set this +config parameter:

+
+
    +
  • sale_attached_product.auto_update_attached_lines
  • +
+
+

Otherwise, the lines will be added, but they can be modified, deleted, +etc.

+
+
+

Usage

+

Now that you have your product configured:

+
    +
  1. Place a new sale order and then add that product in a new line.
  2. +
  3. Once you save your order, the attached products will be added in new +lines to the order with as many quantities as the main one.
  4. +
+

If the global sale_attached_product.auto_update_attached_lines setting +is on:

+
    +
  1. Update the main product quantity and the attached product quantities +will be updated in the same amount as well.
  2. +
  3. If we delete the main line, the attached ones will go away in any +case.
  4. +
+
+
+

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

+
    +
  • Tecnativa
  • +
+
+
+

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.

+

Current maintainer:

+

chienandalu

+

This module is part of the OCA/sale-workflow project on GitHub.

+

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

+
+
+
+
+ + diff --git a/sale_attached_product/tests/__init__.py b/sale_attached_product/tests/__init__.py new file mode 100644 index 00000000000..83a19a219d2 --- /dev/null +++ b/sale_attached_product/tests/__init__.py @@ -0,0 +1 @@ +from . import test_sale_attached_product diff --git a/sale_attached_product/tests/test_sale_attached_product.py b/sale_attached_product/tests/test_sale_attached_product.py new file mode 100644 index 00000000000..d63eec42c14 --- /dev/null +++ b/sale_attached_product/tests/test_sale_attached_product.py @@ -0,0 +1,176 @@ +# Copyright 2022 Tecnativa - David Vidal +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.tests import Form, TransactionCase + + +class TestSaleAttachedProduct(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # No need for tracking and we scratch some seconds + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.pricelist = cls.env["product.pricelist"].create( + { + "name": "Test pricelist", + "item_ids": [ + ( + 0, + 0, + { + "applied_on": "3_global", + "compute_price": "formula", + "base": "list_price", + }, + ) + ], + } + ) + cls.partner = cls.env["res.partner"].create( + {"name": "Mr. Odoo", "property_product_pricelist": cls.pricelist.id} + ) + cls.product_1 = cls.env["product.product"].create( + {"name": "Test 1", "sale_ok": True, "list_price": 50} + ) + cls.product_2 = cls.env["product.product"].create( + {"name": "Test 2", "sale_ok": False, "list_price": 60} + ) + cls.product_3 = cls.env["product.product"].create( + {"name": "Test 3", "sale_ok": False, "list_price": 70} + ) + cls.product_4 = cls.env["product.product"].create( + {"name": "Test 4", "sale_ok": False, "list_price": 80} + ) + cls.product_5 = cls.env["product.product"].create( + {"name": "Test 4", "sale_ok": False, "list_price": 80} + ) + cls.product_1.product_tmpl_id.attached_product_ids = ( + cls.product_2 + cls.product_3 + ) + # We'll be using this sale order + sale_form = Form(cls.env["sale.order"]) + sale_form.partner_id = cls.partner + cls.sale = sale_form.save() + + def _add_product(self, sale, product, qty=1): + """Auxiliar method to quickly add products to a sale order""" + sale_form = Form(sale) + with sale_form.order_line.new() as line_form: + line_form.product_id = product + line_form.product_uom_qty = qty + sale_form.save() + + def _get_attached_lines(self, sale): + return sale.order_line.filtered("is_attached_line") + + def test_01_attach_product_modifiable(self): + """Every time we add a product with attached products we'll adding extra lines + automatically""" + # When we add a product with attached products defined on it the module will add + # as many lines as attached products + self._add_product(self.sale, self.product_1) + product_1_line = self.sale.order_line.filtered( + lambda x: x.product_id == self.product_1 + ) + self.assertEqual( + len(self.sale.order_line), + 3, + "Two extra lines should have been added automatically", + ) + self.assertEqual( + self._get_attached_lines(self.sale).product_id, + self.product_1.product_tmpl_id.attached_product_ids, + "The attached lines products should correspond with those defined in the " + "product", + ) + # Once added, we can edit the lines independetly + product_1_line.product_uom_qty = 3 + self.assertTrue( + all(x.product_uom_qty == 1 for x in self._get_attached_lines(self.sale)) + ) + # We can delete attached lines in this mode + self.sale.order_line.filtered(lambda x: x.product_id == self.product_2).unlink() + self.assertEqual( + len(self.sale.order_line), + 2, + "The line should stay removed", + ) + # Removing the main line will kill the optional ones anyway + product_1_line.unlink() + self.assertFalse( + self._get_attached_lines(self.sale), "There should be no attached lines" + ) + + def test_02_attach_product_auto_update(self): + """Every time we add a product with attached products we'll adding extra lines + automatically. Those lines will be persistent""" + self.env["ir.config_parameter"].sudo().set_param( + "sale_attached_product.auto_update_attached_lines", True + ) + # When we add a product with attached products defined on it the module will add + # as many lines as attached products + self._add_product(self.sale, self.product_1) + product_1_line = self.sale.order_line.filtered( + lambda x: x.product_id == self.product_1 + ) + self.assertEqual( + len(self.sale.order_line), + 3, + "Two extra lines should have been added automatically", + ) + self.assertEqual( + self._get_attached_lines(self.sale).product_id, + self.product_1.product_tmpl_id.attached_product_ids, + "The attached lines products should correspond with those defined in the " + "product", + ) + # When we change the line quantity, the lines change theirs as well + product_1_line.product_uom_qty = 3 + self.assertTrue( + all( + x.product_uom_qty == product_1_line.product_uom_qty + for x in self._get_attached_lines(self.sale) + ) + ) + # When we delete an attached line, the module will recreate it to keep the + # attached lines consitency + self.sale.order_line.filtered(lambda x: x.product_id == self.product_2).unlink() + self.assertEqual( + len(self.sale.order_line), + 3, + "The removed line should be recreated", + ) + # Adding another product doesn't have any effect on the rest + self._add_product(self.sale, self.product_4) + self.assertEqual( + len(self.sale.order_line), + 4, + "Product 4 doesn't have any attached products", + ) + # Changing the main line product will invalidate the attached lines + product_1_line.product_id = self.product_5 + self.assertEqual( + len(self.sale.order_line), + 2, + "The new product doesn't have any attached products", + ) + self.assertFalse( + self._get_attached_lines(self.sale), "There should be no attached lines" + ) + # If we change it back, the attached lines will be added back as well + product_1_line.product_id = self.product_1 + self.assertEqual( + len(self.sale.order_line), + 4, + "The attached lines should be added again", + ) + self.assertEqual( + self._get_attached_lines(self.sale).product_id, + self.product_1.product_tmpl_id.attached_product_ids, + "The attached lines products should correspond with those defined in the " + "product", + ) + # Removing the main line, removes the attached lines + product_1_line.unlink() + self.assertFalse( + self._get_attached_lines(self.sale), "There should be no attached lines" + ) diff --git a/sale_attached_product/views/product_template_views.xml b/sale_attached_product/views/product_template_views.xml new file mode 100644 index 00000000000..7c71a4c8fbf --- /dev/null +++ b/sale_attached_product/views/product_template_views.xml @@ -0,0 +1,21 @@ + + + + product.template + + + + + + + + + + + +