diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..42333fc93 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,2 @@ +odoo-addon-product_pack @ git+https://github.com/OCA/product-pack.git@refs/pull/223/head#subdirectory=product_pack +odoo-addon-sale_product_pack @ git+https://github.com/OCA/product-pack.git@refs/pull/225/head#subdirectory=sale_product_pack diff --git a/website_sale_product_pack/README.rst b/website_sale_product_pack/README.rst new file mode 100644 index 000000000..2d2657af8 --- /dev/null +++ b/website_sale_product_pack/README.rst @@ -0,0 +1,111 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +========================= +Website Sale Product Pack +========================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b253d0a32950c83c03fbde45ce4e7b7335fc773f15534306c65a0ec5344121b8 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fproduct--pack-lightgray.png?logo=github + :target: https://github.com/OCA/product-pack/tree/19.0/website_sale_product_pack + :alt: OCA/product-pack +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/product-pack-19-0/product-pack-19-0-website_sale_product_pack + :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-pack&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module introduces compatibility of product packs with e-commerce: + +- In the cart summary, the components aren't editable and are shown + hanging from the main pack reference. +- When we remove a pack from the cart, their components are removed as + well. +- The cart popup summary only shows the main pack line and discards the + sublines in the units count. +- The cart summary shows the component lines hanging from the main one + as well. +- It's ensured the the prices are shown correctly for the whole pack and + that they're correctly summarized depending on the pack type. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +There are several demo packs to test the module. Publish them and add +them to the cart from the frontend. You should have the same quotation +as if you do it in the backend. + +Known issues / Roadmap +====================== + +- Improve pack cart display. +- When we have subpacks (a pack inside a pack) we should improve + visually how it's shown in the cart. + +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 + +- `ADHOC SA `__: + + - Nicolas Mac Rouillon + +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-pack `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/website_sale_product_pack/__init__.py b/website_sale_product_pack/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/website_sale_product_pack/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/website_sale_product_pack/__manifest__.py b/website_sale_product_pack/__manifest__.py new file mode 100644 index 000000000..6b7008f7c --- /dev/null +++ b/website_sale_product_pack/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2021 Tecnativa - David Vidal +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Website Sale Product Pack", + "category": "E-Commerce", + "summary": "Compatibility module of product pack with e-commerce", + "version": "19.0.1.0.0", + "license": "AGPL-3", + "depends": ["website_sale", "sale_product_pack"], + "data": ["views/templates.xml"], + "assets": { + "web.assets_tests": [ + "website_sale_product_pack/static/tests/tours/website_sale_product_pack_tour.esm.js", + ], + }, + "author": "Tecnativa, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/product-pack", + "installable": True, +} diff --git a/website_sale_product_pack/i18n/it.po b/website_sale_product_pack/i18n/it.po new file mode 100644 index 000000000..d51b99049 --- /dev/null +++ b/website_sale_product_pack/i18n/it.po @@ -0,0 +1,89 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_product_pack +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-10-09 09:06+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 5.6.2\n" + +#. module: website_sale_product_pack +#: model_terms:ir.ui.view,arch_db:website_sale_product_pack.cart_lines +#: model_terms:ir.ui.view,arch_db:website_sale_product_pack.checkout_layout +msgid "" +msgstr "" + +#. module: website_sale_product_pack +#: model:ir.model,name:website_sale_product_pack.model_product_template +msgid "Product" +msgstr "Prodotto" + +#. module: website_sale_product_pack +#: model:ir.model,name:website_sale_product_pack.model_product_product +msgid "Product Variant" +msgstr "Variante prodotto" + +#. module: website_sale_product_pack +#: model:ir.model,name:website_sale_product_pack.model_sale_order +msgid "Sales Order" +msgstr "Ordine di vendita" + +#. module: website_sale_product_pack +#: model:ir.model,name:website_sale_product_pack.model_sale_order_line +msgid "Sales Order Line" +msgstr "Riga ordine di vendita" + +#. module: website_sale_product_pack +#: model:ir.model,name:website_sale_product_pack.model_website +msgid "Website" +msgstr "Sito web" + +#. module: website_sale_product_pack +#. odoo-python +#: code:addons/website_sale_product_pack/models/product_product.py:0 +#, python-format +msgid "" +"You can't add unpublished products (%(unpublished_products)s)to a published " +"pack (%(pack_name)s)" +msgstr "" +"Non si possono aggiungere prodotti non pubblicati (%(unpublished_products)s) " +"ad un pacco pubblicato (%(pack_name)s)" + +#. module: website_sale_product_pack +#. odoo-python +#: code:addons/website_sale_product_pack/models/product_template.py:0 +#, python-format +msgid "" +"You can't unpublished product (%(product_name)s) for apublished pack parents " +"(%(pack_parents)s)" +msgstr "" +"Non si può non pubblicare un prodotto (%(product_name)s) per un collo padre " +"pubblicato (%(pack_parents)s)" + +#. module: website_sale_product_pack +#. odoo-python +#: code:addons/website_sale_product_pack/models/product_template.py:0 +#, python-format +msgid "" +"You can't unpublished products (%(unpublished_products)s) to apublished pack " +"(%(pack_name)s)" +msgstr "" +"Non si può non pubblicare un prodotto (%(unpublished_products)s) di un collo " +"pubblicato (%(pack_name)s)" + +#, python-format +#~ msgid "" +#~ "You can't add unpublished products (%(unpublished_products)s) toa " +#~ "published pack (%(pack_name)s)" +#~ msgstr "" +#~ "Non si possono aggiungere prodotti non pubblicati " +#~ "(%(unpublished_products)s) ad un collo pubblicato (%(pack_name)s)" diff --git a/website_sale_product_pack/i18n/website_sale_product_pack.pot b/website_sale_product_pack/i18n/website_sale_product_pack.pot new file mode 100644 index 000000000..c5b4d319c --- /dev/null +++ b/website_sale_product_pack/i18n/website_sale_product_pack.pot @@ -0,0 +1,72 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_sale_product_pack +# +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: website_sale_product_pack +#: model_terms:ir.ui.view,arch_db:website_sale_product_pack.cart_lines +#: model_terms:ir.ui.view,arch_db:website_sale_product_pack.checkout_layout +msgid "" +msgstr "" + +#. module: website_sale_product_pack +#: model:ir.model,name:website_sale_product_pack.model_product_template +msgid "Product" +msgstr "" + +#. module: website_sale_product_pack +#: model:ir.model,name:website_sale_product_pack.model_product_product +msgid "Product Variant" +msgstr "" + +#. module: website_sale_product_pack +#: model:ir.model,name:website_sale_product_pack.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: website_sale_product_pack +#: model:ir.model,name:website_sale_product_pack.model_sale_order_line +msgid "Sales Order Line" +msgstr "" + +#. module: website_sale_product_pack +#: model:ir.model,name:website_sale_product_pack.model_website +msgid "Website" +msgstr "" + +#. module: website_sale_product_pack +#. odoo-python +#: code:addons/website_sale_product_pack/models/product_product.py:0 +#, python-format +msgid "" +"You can't add unpublished products (%(unpublished_products)s)to a published " +"pack (%(pack_name)s)" +msgstr "" + +#. module: website_sale_product_pack +#. odoo-python +#: code:addons/website_sale_product_pack/models/product_template.py:0 +#, python-format +msgid "" +"You can't unpublished product (%(product_name)s) for apublished pack parents" +" (%(pack_parents)s)" +msgstr "" + +#. module: website_sale_product_pack +#. odoo-python +#: code:addons/website_sale_product_pack/models/product_template.py:0 +#, python-format +msgid "" +"You can't unpublished products (%(unpublished_products)s) to apublished pack" +" (%(pack_name)s)" +msgstr "" diff --git a/website_sale_product_pack/models/__init__.py b/website_sale_product_pack/models/__init__.py new file mode 100644 index 000000000..cf9631c7d --- /dev/null +++ b/website_sale_product_pack/models/__init__.py @@ -0,0 +1,3 @@ +from . import sale_order +from . import product_product +from . import product_template diff --git a/website_sale_product_pack/models/product_product.py b/website_sale_product_pack/models/product_product.py new file mode 100644 index 000000000..b21023297 --- /dev/null +++ b/website_sale_product_pack/models/product_product.py @@ -0,0 +1,26 @@ +# Copyright 2019 Tecnativa - Ernesto Tejeda +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, models +from odoo.exceptions import ValidationError + + +class ProductProduct(models.Model): + _inherit = "product.product" + + @api.constrains("pack_line_ids") + def check_website_published(self): + for rec in self.filtered("is_published"): + unpublished = rec.pack_line_ids.mapped("product_id").filtered( + lambda x: not x.is_published + ) + if unpublished: + raise ValidationError( + self.env._( + "You can't add unpublished products " + "(%(unpublished_products)s) " + "to a published pack (%(pack_name)s)", + unpublished_products=", ".join(unpublished.mapped("name")), + pack_name=rec.name, + ) + ) diff --git a/website_sale_product_pack/models/product_template.py b/website_sale_product_pack/models/product_template.py new file mode 100644 index 000000000..dec2620e3 --- /dev/null +++ b/website_sale_product_pack/models/product_template.py @@ -0,0 +1,155 @@ +# Copyright 2019 Tecnativa - Ernesto Tejeda +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models +from odoo.exceptions import ValidationError +from odoo.http import request + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + @api.constrains("is_published") + def check_website_published(self): + """For keep the consistent and prevent bugs within the e-commerce, + we force that all childs of a parent pack + stay publish when the parent is published. + Also if any of the childs of the parent pack became unpublish, + we unpublish the parent.""" + for rec in self.filtered(lambda x: x.pack_ok and x.is_published): + unpublished = rec.pack_line_ids.mapped("product_id").filtered( + lambda p: not p.is_published + ) + if unpublished: + raise ValidationError( + self.env._( + "You can't unpublished products (%(unpublished_products)s) " + "to a published pack (%(pack_name)s)", + unpublished_products=", ".join(unpublished.mapped("name")), + pack_name=rec.name, + ) + ) + + for rec in self.filtered( + lambda x: not x.is_published and x.used_in_pack_line_ids + ): + published = rec.used_in_pack_line_ids.mapped("parent_product_id").filtered( + "is_published" + ) + if published: + raise ValidationError( + self.env._( + "You can't unpublished product (%(product_name)s) for a " + "published pack parents (%(pack_parents)s)", + product_name=rec.name, + pack_parents=", ".join(published.mapped("name")), + ) + ) + + def _get_combination_info( + self, + combination=False, + product_id=False, + add_qty=1.0, + uom_id=False, + only_template=False, + ): + """Override to add the information about packs with whole_pack_price context""" + return super( + ProductTemplate, self.with_context(whole_pack_price=True) + )._get_combination_info( + combination=combination, + product_id=product_id, + add_qty=add_qty, + uom_id=uom_id, + only_template=only_template, + ) + + def _get_additionnal_combination_info( + self, product_or_template, quantity, uom, date, website + ): + """Override to add the information about packs with whole_pack_price context""" + res = super()._get_additionnal_combination_info( + product_or_template, quantity, uom, date, website + ) + + if product_or_template.pack_ok: + pricelist = request.pricelist + currency = website.currency_id + # Get the price with whole_pack_price context using _get_product_price_rule + # which includes pack components calculation + pricelist_price, _ = pricelist.with_context( + whole_pack_price=True + )._get_product_price_rule( + product=product_or_template, + quantity=quantity, + uom=uom, + date=date, + target_currency=currency, + ) + + # Apply taxes + product_taxes = ( + product_or_template.sudo().taxes_id._filter_taxes_by_company( + self.env.company + ) + ) + taxes = self.env["account.tax"] + if product_taxes: + taxes = request.fiscal_position.map_tax(product_taxes) + res["price"] = self._apply_taxes_to_price( + pricelist_price, + currency, + product_taxes, + taxes, + product_or_template, + website=website, + ) + else: + res["price"] = pricelist_price + + return res + + def _get_sales_prices(self, website): + """Override to add the price of the pack itself""" + packs, no_packs = self.with_context(whole_pack_price=True).split_pack_products() + prices = super(ProductTemplate, no_packs)._get_sales_prices(website) + if packs: + pricelist = request.pricelist + currency = website.currency_id + fiscal_position_sudo = request.fiscal_position + date = fields.Date.context_today(self) + + for pack in packs: + # Get the price with whole_pack_price context using _get_product_price + # which includes pack components calculation + pricelist_price, _ = pricelist.with_context( + whole_pack_price=True + )._get_product_price_rule( + product=pack, + quantity=1.0, + uom=pack.uom_id, + date=date, + target_currency=currency, + ) + + # Apply taxes + product_taxes = pack.sudo().taxes_id._filter_taxes_by_company( + self.env.company + ) + taxes = self.env["account.tax"] + if product_taxes: + taxes = fiscal_position_sudo.map_tax(product_taxes) + price_reduce = self._apply_taxes_to_price( + pricelist_price, + currency, + product_taxes, + taxes, + pack, + website=website, + ) + else: + price_reduce = pricelist_price + + prices[pack.id] = {"price_reduce": price_reduce} + return prices diff --git a/website_sale_product_pack/models/sale_order.py b/website_sale_product_pack/models/sale_order.py new file mode 100644 index 000000000..63f069df7 --- /dev/null +++ b/website_sale_product_pack/models/sale_order.py @@ -0,0 +1,66 @@ +# Copyright 2021 Tecnativa - David Vidal +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import api, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + def _cart_update_line_quantity( + self, line_id: int, quantity: float, **kwargs + ) -> dict: + """We need to keep the discount defined on the components when checking out. + Also when a line comes from a totalized pack, we should flag it to avoid + changing it's price in a cart step.""" + line = self.env["sale.order.line"].browse(line_id) + if line and line.pack_parent_line_id: + pack = line.pack_parent_line_id.product_id + detailed_totalized_pack = ( + pack.pack_type == "detailed" + and pack.pack_component_price in {"totalized", "ignored"} + ) + return super( + SaleOrder, + self.with_context( + pack_discount=line.discount, + detailed_totalized_pack=detailed_totalized_pack, + ), + )._cart_update_line_quantity(line_id, quantity, **kwargs) + return super()._cart_update_line_quantity(line_id, quantity, **kwargs) + + def _prepare_order_line_update_values(self, order_line, quantity, **kwargs): + """Preserve pack discount and handle detailed totalized packs""" + values = super()._prepare_order_line_update_values( + order_line, quantity, **kwargs + ) + + # If we have pack_discount in context, preserve it + pack_discount = self.env.context.get("pack_discount") + if pack_discount is not None and order_line.pack_parent_line_id: + values["discount"] = pack_discount + + return values + + @api.depends("order_line.product_uom_qty", "order_line.product_id") + def _compute_cart_info(self): + """We only want to count the main pack line, not the component lines""" + res = super()._compute_cart_info() + for order in self: + order.cart_quantity = int( + sum( + order.website_order_line.filtered( + lambda x: not x.pack_parent_line_id + ).mapped("product_uom_qty") + ) + ) + return res + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + def unlink(self): + """The website calls this method specifically. We want to get rid of + the children lines so the user doesn't have to""" + join_pack_children = self + self.mapped("pack_child_line_ids") + return super(SaleOrderLine, join_pack_children.exists()).unlink() diff --git a/website_sale_product_pack/pyproject.toml b/website_sale_product_pack/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/website_sale_product_pack/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/website_sale_product_pack/readme/CONTRIBUTORS.md b/website_sale_product_pack/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..4080f2d34 --- /dev/null +++ b/website_sale_product_pack/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +- [Tecnativa](https://www.tecnativa.com): + - David Vidal +- [ADHOC SA](https://www.adhoc.com.ar): + - Nicolas Mac Rouillon diff --git a/website_sale_product_pack/readme/DESCRIPTION.md b/website_sale_product_pack/readme/DESCRIPTION.md new file mode 100644 index 000000000..e493be77f --- /dev/null +++ b/website_sale_product_pack/readme/DESCRIPTION.md @@ -0,0 +1,12 @@ +This module introduces compatibility of product packs with e-commerce: + +- In the cart summary, the components aren't editable and are shown + hanging from the main pack reference. +- When we remove a pack from the cart, their components are removed as + well. +- The cart popup summary only shows the main pack line and discards the + sublines in the units count. +- The cart summary shows the component lines hanging from the main one + as well. +- It's ensured the the prices are shown correctly for the whole pack and + that they're correctly summarized depending on the pack type. diff --git a/website_sale_product_pack/readme/ROADMAP.md b/website_sale_product_pack/readme/ROADMAP.md new file mode 100644 index 000000000..47cd568ff --- /dev/null +++ b/website_sale_product_pack/readme/ROADMAP.md @@ -0,0 +1,3 @@ +- Improve pack cart display. +- When we have subpacks (a pack inside a pack) we should improve + visually how it's shown in the cart. diff --git a/website_sale_product_pack/readme/USAGE.md b/website_sale_product_pack/readme/USAGE.md new file mode 100644 index 000000000..f5f9b1eff --- /dev/null +++ b/website_sale_product_pack/readme/USAGE.md @@ -0,0 +1,3 @@ +There are several demo packs to test the module. Publish them and add +them to the cart from the frontend. You should have the same quotation +as if you do it in the backend. diff --git a/website_sale_product_pack/static/description/icon.png b/website_sale_product_pack/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/website_sale_product_pack/static/description/icon.png differ diff --git a/website_sale_product_pack/static/description/index.html b/website_sale_product_pack/static/description/index.html new file mode 100644 index 000000000..a8dca8f05 --- /dev/null +++ b/website_sale_product_pack/static/description/index.html @@ -0,0 +1,464 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Website Sale Product Pack

+ +

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

+

This module introduces compatibility of product packs with e-commerce:

+
    +
  • In the cart summary, the components aren’t editable and are shown +hanging from the main pack reference.
  • +
  • When we remove a pack from the cart, their components are removed as +well.
  • +
  • The cart popup summary only shows the main pack line and discards the +sublines in the units count.
  • +
  • The cart summary shows the component lines hanging from the main one +as well.
  • +
  • It’s ensured the the prices are shown correctly for the whole pack and +that they’re correctly summarized depending on the pack type.
  • +
+

Table of contents

+ +
+

Usage

+

There are several demo packs to test the module. Publish them and add +them to the cart from the frontend. You should have the same quotation +as if you do it in the backend.

+
+
+

Known issues / Roadmap

+
    +
  • Improve pack cart display.
  • +
  • When we have subpacks (a pack inside a pack) we should improve +visually how it’s shown in the cart.
  • +
+
+
+

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.

+

This module is part of the OCA/product-pack project on GitHub.

+

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

+
+
+
+
+ + diff --git a/website_sale_product_pack/static/tests/tours/website_sale_product_pack_tour.esm.js b/website_sale_product_pack/static/tests/tours/website_sale_product_pack_tour.esm.js new file mode 100644 index 000000000..0ddadcf9f --- /dev/null +++ b/website_sale_product_pack/static/tests/tours/website_sale_product_pack_tour.esm.js @@ -0,0 +1,60 @@ +/* Copyright 2021 Tecnativa - David Vidal + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +import * as tourUtils from "@website_sale/js/tours/tour_utils"; +import {registry} from "@web/core/registry"; + +registry.category("web_tour.tours").add("create_components_price_order_line", { + url: "/shop", + steps: () => [ + ...tourUtils.addToCart({ + productName: "Pack CPU (Detailed - Displayed Components Price)", + expectUnloadPage: true, + }), + tourUtils.goToCart({quantity: 1}), + ], +}); + +registry.category("web_tour.tours").add("create_ignored_price_order_line", { + url: "/shop", + steps: () => [ + ...tourUtils.addToCart({ + productName: "Pack CPU (Detailed - Ignored Components Price)", + expectUnloadPage: true, + }), + tourUtils.goToCart({quantity: 1}), + ], +}); + +registry.category("web_tour.tours").add("create_totalized_price_order_line", { + url: "/shop", + steps: () => [ + ...tourUtils.addToCart({ + productName: "Pack CPU (Detailed - Totalized Components Price)", + expectUnloadPage: true, + }), + tourUtils.goToCart({quantity: 1}), + ], +}); + +registry.category("web_tour.tours").add("create_non_detailed_price_order_line", { + url: "/shop", + steps: () => [ + ...tourUtils.addToCart({ + productName: "Non Detailed - Totalized Components Price", + expectUnloadPage: true, + }), + tourUtils.goToCart({quantity: 1}), + ], +}); + +registry.category("web_tour.tours").add("update_pack_qty", { + url: "/shop", + steps: () => [ + ...tourUtils.addToCart({ + productName: "Pack CPU (Detailed - Displayed Components Price)", + expectUnloadPage: true, + }), + tourUtils.goToCart({quantity: 1}), + ], +}); diff --git a/website_sale_product_pack/tests/__init__.py b/website_sale_product_pack/tests/__init__.py new file mode 100644 index 000000000..05aebdc1c --- /dev/null +++ b/website_sale_product_pack/tests/__init__.py @@ -0,0 +1 @@ +from . import test_website_sale_product_pack diff --git a/website_sale_product_pack/tests/test_website_sale_product_pack.py b/website_sale_product_pack/tests/test_website_sale_product_pack.py new file mode 100644 index 000000000..91a9ad299 --- /dev/null +++ b/website_sale_product_pack/tests/test_website_sale_product_pack.py @@ -0,0 +1,310 @@ +# Copyright 2021 Tecnativa - David Vidal +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.exceptions import ValidationError +from odoo.tests.common import HttpCase, tagged + + +@tagged("post_install", "-at_install") +class WebsiteSaleHttpCase(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create portal user + cls.user_portal = cls.env["res.users"].create( + { + "name": "Portal User Test", + "login": "portal_test", + "email": "portal@test.com", + "group_ids": [(6, 0, [cls.env.ref("base.group_portal").id])], + } + ) + + # Create component products + cls.component_1 = cls.env["product.product"].create( + { + "name": "Component 1", + "list_price": 100.0, + "is_published": True, + "website_sequence": 10, + } + ) + cls.component_2 = cls.env["product.product"].create( + { + "name": "Component 2", + "list_price": 200.0, + "is_published": True, + "website_sequence": 11, + } + ) + cls.component_3 = cls.env["product.product"].create( + { + "name": "Component 3", + "list_price": 50.0, + "is_published": True, + "website_sequence": 12, + } + ) + + # Create pack products + cls.product_pdc = cls.env["product.template"].create( + { + "name": "Pack CPU (Detailed - Displayed Components Price)", + "list_price": 0.0, + "pack_ok": True, + "pack_type": "detailed", + "pack_component_price": "detailed", + "is_published": True, + "website_sequence": 0, + } + ) + cls.env["product.pack.line"].create( + [ + { + "parent_product_id": cls.product_pdc.product_variant_id.id, + "product_id": cls.component_1.id, + "quantity": 1, + }, + { + "parent_product_id": cls.product_pdc.product_variant_id.id, + "product_id": cls.component_2.id, + "quantity": 2, + }, + { + "parent_product_id": cls.product_pdc.product_variant_id.id, + "product_id": cls.component_3.id, + "quantity": 1, + }, + ] + ) + + cls.product_pdi = cls.env["product.template"].create( + { + "name": "Pack CPU (Detailed - Ignored Components Price)", + "list_price": 30.75, + "pack_ok": True, + "pack_type": "detailed", + "pack_component_price": "ignored", + "is_published": True, + "website_sequence": 1, + } + ) + cls.env["product.pack.line"].create( + [ + { + "parent_product_id": cls.product_pdi.product_variant_id.id, + "product_id": cls.component_1.id, + "quantity": 1, + }, + { + "parent_product_id": cls.product_pdi.product_variant_id.id, + "product_id": cls.component_2.id, + "quantity": 1, + }, + { + "parent_product_id": cls.product_pdi.product_variant_id.id, + "product_id": cls.component_3.id, + "quantity": 1, + }, + ] + ) + + cls.product_pdt = cls.env["product.template"].create( + { + "name": "Pack CPU (Detailed - Totalized Components Price)", + "list_price": 0.0, + "pack_ok": True, + "pack_type": "detailed", + "pack_component_price": "totalized", + "is_published": True, + "website_sequence": 2, + } + ) + cls.env["product.pack.line"].create( + [ + { + "parent_product_id": cls.product_pdt.product_variant_id.id, + "product_id": cls.component_1.id, + "quantity": 2, + "sale_discount": 15.0, + }, + { + "parent_product_id": cls.product_pdt.product_variant_id.id, + "product_id": cls.component_2.id, + "quantity": 5, + "sale_discount": 10.0, + }, + { + "parent_product_id": cls.product_pdt.product_variant_id.id, + "product_id": cls.component_3.id, + "quantity": 10, + }, + ] + ) + + cls.product_pnd = cls.env["product.template"].create( + { + "name": "Non Detailed - Totalized Components Price", + "list_price": 0.0, + "pack_ok": True, + "pack_type": "non_detailed", + "pack_component_price": "totalized", + "is_published": True, + "website_sequence": 3, + } + ) + cls.env["product.pack.line"].create( + [ + { + "parent_product_id": cls.product_pnd.product_variant_id.id, + "product_id": cls.component_1.id, + "quantity": 2, + "sale_discount": 15.0, + }, + { + "parent_product_id": cls.product_pnd.product_variant_id.id, + "product_id": cls.component_2.id, + "quantity": 5, + "sale_discount": 10.0, + }, + { + "parent_product_id": cls.product_pnd.product_variant_id.id, + "product_id": cls.component_3.id, + "quantity": 10, + }, + ] + ) + + cls.packs = ( + cls.product_pdc + cls.product_pdi + cls.product_pdt + cls.product_pnd + ) + + # Create and select a specific pricelist + website = cls.env["website"].get_current_website() + pricelist = cls.env["product.pricelist"].create( + { + "name": "website_sale_product_pack public", + "currency_id": website.company_id.currency_id.id, + "selectable": True, + } + ) + cls.user_portal.property_product_pricelist = pricelist + website.user_id.property_product_pricelist = pricelist + admin = cls.env.ref("base.user_admin") + admin.property_product_pricelist = pricelist + + def _get_component_prices_sum(self, product_pack): + component_prices = 0.0 + for pack_line in product_pack.get_pack_lines(): + product_line_price = pack_line.product_id.list_price * ( + 1 - (pack_line.sale_discount or 0.0) / 100.0 + ) + component_prices += product_line_price * pack_line.quantity + return component_prices + + def test_create_components_price_order_line(self): + """Test with the same premise that in sale_product_pack but in a + frontend tour""" + self.start_tour( + "/shop", "create_components_price_order_line", login="portal_test" + ) + sale = self.env["sale.order"].search([], limit=1, order="id desc") + # After create, there will be four lines + self.assertEqual(len(sale.order_line), 4) + # The products of those four lines are the main product pack and its + # product components + product_variant = self.product_pdc.product_variant_id + self.assertEqual( + sale.order_line.mapped("product_id"), + product_variant | product_variant.get_pack_lines().mapped("product_id"), + ) + + def test_create_ignored_price_order_line(self): + """Test with the same premise that in sale_product_pack but in a frontend + tour""" + self.start_tour("/shop", "create_ignored_price_order_line", login="portal_test") + sale = self.env["sale.order"].search([], limit=1, order="id desc") + product_variant = self.product_pdi.product_variant_id + line = sale.order_line.filtered(lambda x: x.product_id == product_variant) + # After create, there will be four lines + self.assertEqual(len(sale.order_line), 4) + # The products of those four lines are the main product pack and its + # product components + self.assertEqual( + sale.order_line.mapped("product_id"), + product_variant | product_variant.get_pack_lines().mapped("product_id"), + ) + # All component lines have zero as subtotal + self.assertEqual((sale.order_line - line).mapped("price_subtotal"), [0, 0, 0]) + # Pack price is different from the sum of component prices + self.assertEqual(line.price_subtotal, 30.75) + self.assertNotEqual(self._get_component_prices_sum(product_variant), 30.75) + + def test_create_totalized_price_order_line(self): + """Test with the same premise that in sale_product_pack but in a frontend tour + with a detailed totalized pack""" + self.start_tour( + "/shop", "create_totalized_price_order_line", login="portal_test" + ) + sale = self.env["sale.order"].search([], limit=1, order="id desc") + product_variant = self.product_pdt.product_variant_id + line = sale.order_line.filtered(lambda x: x.product_id == product_variant) + # After create, there will be four lines + self.assertEqual(len(sale.order_line), 4) + # The products of those four lines are the main product pack and its + # product components + self.assertEqual( + sale.order_line.mapped("product_id"), + product_variant | product_variant.get_pack_lines().mapped("product_id"), + ) + # All component lines have zero as subtotal + self.assertEqual((sale.order_line - line).mapped("price_subtotal"), [0, 0, 0]) + # Pack price is equal to the sum of component prices + # Component 1: 100 * 2 * (1 - 0.15) = 170.0 + # Component 2: 200 * 5 * (1 - 0.10) = 900.0 + # Component 3: 50 * 10 = 500.0 + # Total: 1570.0 + self.assertEqual(line.price_subtotal, 1570.0) + self.assertEqual(self._get_component_prices_sum(product_variant), 1570.0) + + def test_create_non_detailed_price_order_line(self): + """Test with the same premise that in sale_product_pack but in a frontend + tour""" + self.start_tour( + "/shop", "create_non_detailed_price_order_line", login="portal_test" + ) + sale = self.env["sale.order"].search([], limit=1, order="id desc") + product_variant = self.product_pnd.product_variant_id + line = sale.order_line.filtered(lambda x: x.product_id == product_variant) + # After create, there will be only one line, because product_type is + # set to 'non_detailed' + self.assertEqual(len(sale.order_line), 1) + # After create, there will be only one line, because product_type is + # set to 'non_detailed' + self.assertEqual(line.product_id, product_variant) + # Pack price is equal to the sum of component prices + # Component 1: 100 * 2 * (1 - 0.15) = 170.0 + # Component 2: 200 * 5 * (1 - 0.10) = 900.0 + # Component 3: 50 * 10 = 500.0 + # Total: 1570.0 + self.assertEqual(line.price_subtotal, 1570.0) + self.assertEqual(self._get_component_prices_sum(product_variant), 1570.0) + + def test__check_to_add_pack_component_pusblished(self): + """ + Test when create a product pack with only published products as components. + """ + with self.assertRaises(ValidationError): + # Create an unpublished product + unpublished_component = self.env["product.product"].create( + { + "name": "Unpublished Component", + "list_price": 50.0, + "is_published": False, + } + ) + vals = { + "product_id": unpublished_component.id, + "parent_product_id": self.product_pdc.product_variant_id.id, + } + pack_line = self.env["product.pack.line"].create(vals) + self.product_pdc.write({"pack_line_ids": [(4, pack_line.id)]}) diff --git a/website_sale_product_pack/views/templates.xml b/website_sale_product_pack/views/templates.xml new file mode 100644 index 000000000..dd66e2eef --- /dev/null +++ b/website_sale_product_pack/views/templates.xml @@ -0,0 +1,93 @@ + + + + + + + +