diff --git a/purchase_product_configurator/README.rst b/purchase_product_configurator/README.rst new file mode 100644 index 000000000..66bacba88 --- /dev/null +++ b/purchase_product_configurator/README.rst @@ -0,0 +1,39 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: https://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +Purchase Product Configurator +============================= +The module adds the product configurator feature to the purchase module, +which is only present in sale module by default. + +Configuration +============= +* No additional configurations are required +* `Cybrosys Techno Solutions `__ + +Credits +------- +* Developers: (V17) Unnimaya C O , Contact: odoo@cybrosys.com + +Contacts +-------- +* Mail Contact : odoo@cybrosys.com +* Website : https://cybrosys.com + +Bug Tracker +----------- +Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. + +Maintainer +========== +.. image:: https://cybrosys.com/images/logo.png + :target: https://cybrosys.com + +This module is maintained by Cybrosys Technologies. + +For support and more information, please visit `Our Website `__ + +Further information +=================== +HTML Description: ``__ diff --git a/purchase_product_configurator/__init__.py b/purchase_product_configurator/__init__.py new file mode 100644 index 000000000..369d3f940 --- /dev/null +++ b/purchase_product_configurator/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2024-TODAY Cybrosys Technologies(). +# Author: Unnimaya C O (odoo@cybrosys.com) +# +# You can modify it under the terms of the GNU AFFERO +# GENERAL PUBLIC LICENSE (AGPL v3), Version 3. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details. +# +# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE +# (AGPL v3) along with this program. +# If not, see . +# +################################################################################ +from . import controllers +from . import models diff --git a/purchase_product_configurator/__manifest__.py b/purchase_product_configurator/__manifest__.py new file mode 100644 index 000000000..2687b6c43 --- /dev/null +++ b/purchase_product_configurator/__manifest__.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2024-TODAY Cybrosys Technologies(). +# Author: Unnimaya C O (odoo@cybrosys.com) +# +# You can modify it under the terms of the GNU AFFERO +# GENERAL PUBLIC LICENSE (AGPL v3), Version 3. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details. +# +# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE +# (AGPL v3) along with this program. +# If not, see . +# +################################################################################ +{ + 'name': 'Purchase Product Configurator', + 'version': '17.0.1.0.0', + 'category': 'Purchases', + 'summary': """Helps to configure the product in purchase order line""", + 'description': """The module helps you to override purchase_order_line to add + the product configurate in an RFQ """, + 'author': 'Cybrosys Techno Solutions', + 'company': 'Cybrosys Techno Solutions', + 'maintainer': 'Cybrosys Techno Solutions', + 'website': 'https://www.cybrosys.com', + 'images': ['static/description/banner.jpg'], + 'license': 'AGPL-3', + 'depends': ['purchase_product_matrix'], + 'data': [ + 'views/optional_product_template.xml', + 'views/purchase_order_views.xml', + 'views/product_template_views.xml' + ], + 'assets': { + 'web.assets_backend': [ + 'purchase_product_configurator/static/src/js/purchase_product_field.js', + 'purchase_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.js', + 'purchase_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.xml', + 'purchase_product_configurator/static/src/js/product_list/product_list.js', + 'purchase_product_configurator/static/src/js/product_list/product_list.xml', + 'purchase_product_configurator/static/src/js/product/product.js', + 'purchase_product_configurator/static/src/js/product/product_template.xml', + 'purchase_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.js', + 'purchase_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.xml', + 'purchase_product_configurator/static/src/js/product/product.scss', + 'purchase_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.scss' + ], + }, + 'installable': True, + 'auto_install': False, + 'application': False +} diff --git a/purchase_product_configurator/controllers/__init__.py b/purchase_product_configurator/controllers/__init__.py new file mode 100755 index 000000000..b54e530c3 --- /dev/null +++ b/purchase_product_configurator/controllers/__init__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2024-TODAY Cybrosys Technologies(). +# Author: Unnimaya C O (odoo@cybrosys.com) +# +# You can modify it under the terms of the GNU AFFERO +# GENERAL PUBLIC LICENSE (AGPL v3), Version 3. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details. +# +# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE +# (AGPL v3) along with this program. +# If not, see . +# +################################################################################ +from . import purchase_product_configurator diff --git a/purchase_product_configurator/controllers/purchase_product_configurator.py b/purchase_product_configurator/controllers/purchase_product_configurator.py new file mode 100755 index 000000000..993fe8e42 --- /dev/null +++ b/purchase_product_configurator/controllers/purchase_product_configurator.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2024-TODAY Cybrosys Technologies(). +# Author: Unnimaya C O (odoo@cybrosys.com) +# +# You can modify it under the terms of the GNU AFFERO +# GENERAL PUBLIC LICENSE (AGPL v3), Version 3. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details. +# +# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE +# (AGPL v3) along with this program. +# If not, see . +# +################################################################################ +from odoo.http import Controller, request, route + + +class ProductConfiguratorController(Controller): + + @route('/purchase_product_configurator/get_values', type='json', auth='user') + def get_product_configurator_values( + self, + product_template_id, + quantity, + currency_id, + product_uom_id=None, + company_id=None, + ptav_ids=None, + only_main_product=False, + ): + """ Return all product information needed for the product configurator. + """ + if company_id: + request.update_context(allowed_company_ids=[company_id]) + product_template = request.env['product.template'].browse(product_template_id) + combination = request.env['product.template.attribute.value'] + if ptav_ids: + combination = request.env['product.template.attribute.value'].browse(ptav_ids).filtered( + lambda ptav: ptav.product_tmpl_id.id == product_template_id + ) + # Set missing attributes (unsaved no_variant attributes, or new attribute on existing product) + unconfigured_ptals = ( + product_template.attribute_line_ids - combination.attribute_line_id).filtered( + lambda ptal: ptal.attribute_id.display_type != 'multi') + combination += unconfigured_ptals.mapped( + lambda ptal: ptal.product_template_value_ids._only_active()[:1] + ) + if not combination: + combination = product_template._get_first_possible_combination() + return dict( + products=[ + dict( + **self._get_product_information( + product_template, + combination, + currency_id, + quantity=quantity, + product_uom_id=product_uom_id + ), + parent_product_tmpl_ids=[], + ) + ], + optional_products=[ + dict( + **self._get_product_information( + optional_product_template, + optional_product_template._get_first_possible_combination( + parent_combination=combination + ), + currency_id, + # giving all the ptav of the parent product to get all the exclusions + parent_combination=product_template.attribute_line_ids. \ + product_template_value_ids, + ), + parent_product_tmpl_ids=[product_template.id], + ) for optional_product_template in product_template.optional_product_ids + ] if not only_main_product else [] + ) + + @route('/purchase_product_configurator/create_product', type='json', auth='user') + def purchase_product_configurator_create_product(self, product_template_id, combination): + """ Create the product when there is a dynamic attribute in the combination. + """ + product_template = request.env['product.template'].browse(product_template_id) + combination = request.env['product.template.attribute.value'].browse(combination) + product = product_template._create_product_variant(combination) + return product.id + + @route('/purchase_product_configurator/update_combination', type='json', auth='user') + def purchase_product_configurator_update_combination( + self, + product_template_id, + combination, + currency_id, + quantity, + product_uom_id=None, + company_id=None, + ): + """ Return the updated combination information. + """ + if company_id: + request.update_context(allowed_company_ids=[company_id]) + product_template = request.env['product.template'].browse(product_template_id) + product_uom = request.env['uom.uom'].browse(product_uom_id) + currency = request.env['res.currency'].browse(currency_id) + combination = request.env['product.template.attribute.value'].browse(combination) + product = product_template._get_variant_for_combination(combination) + return self._get_basic_product_information( + product or product_template, + combination, + quantity=quantity or 0.0, + uom=product_uom, + currency=currency, + ) + + @route('/purchase_product_configurator/get_optional_products', type='json', auth='user') + def purchase_product_configurator_get_optional_products( + self, + product_template_id, + combination, + parent_combination, + currency_id, + company_id=None, + ): + """ Return information about optional products for the given `product.template`. + """ + if company_id: + request.update_context(allowed_company_ids=[company_id]) + product_template = request.env['product.template'].browse(product_template_id) + parent_combination = request.env['product.template.attribute.value'].browse( + parent_combination + combination + ) + return [ + dict( + **self._get_product_information( + optional_product_template, + optional_product_template._get_first_possible_combination( + parent_combination=parent_combination + ), + currency_id, + parent_combination=parent_combination + ), + parent_product_tmpl_ids=[product_template.id], + ) for optional_product_template in product_template.optional_product_ids + ] + + def _get_product_information( + self, + product_template, + combination, + currency_id, + quantity=1, + product_uom_id=None, + parent_combination=None, + ): + """ Return complete information about a product. + """ + product_uom = request.env['uom.uom'].browse(product_uom_id) + currency = request.env['res.currency'].browse(currency_id) + product = product_template._get_variant_for_combination(combination) + attribute_exclusions = product_template._get_attribute_exclusions( + parent_combination=parent_combination, + combination_ids=combination.ids, + ) + return dict( + product_tmpl_id=product_template.id, + **self._get_basic_product_information( + product or product_template, + combination, + quantity=quantity, + uom=product_uom, + currency=currency + ), + quantity=quantity, + attribute_lines=[dict( + id=ptal.id, + attribute=dict(**ptal.attribute_id.read(['id', 'name', 'display_type'])[0]), + attribute_values=[ + dict( + **ptav.read(['name', 'html_color', 'image', 'is_custom'])[0], + ) for ptav in ptal.product_template_value_ids + if ptav.ptav_active or combination and ptav.id in combination.ids + ], + selected_attribute_value_ids=combination.filtered( + lambda c: ptal in c.attribute_line_id + ).ids, + create_variant=ptal.attribute_id.create_variant, + ) for ptal in product_template.attribute_line_ids], + exclusions=attribute_exclusions['exclusions'], + archived_combinations=attribute_exclusions['archived_combinations'], + parent_exclusions=attribute_exclusions['parent_exclusions'], + ) + + def _get_basic_product_information(self, product_or_template, combination, **kwargs): + """ Return basic information about a product + """ + basic_information = dict( + **product_or_template.read(['description_sale', 'display_name'])[0] + ) + # If the product is a template, check the combination to compute the name to take dynamic + # and no_variant attributes into account. Also, drop the id which was auto-included by the + # search but isn't relevant since it is supposed to be the id of a `product.product` record. + if not product_or_template.is_product_variant: + basic_information['id'] = False + combination_name = combination._get_combination_name() + if combination_name: + basic_information.update( + display_name=f"{basic_information['display_name']} ({combination_name})" + ) + return dict( + **basic_information, + price=product_or_template.standard_price + ) diff --git a/purchase_product_configurator/doc/RELEASE_NOTES.md b/purchase_product_configurator/doc/RELEASE_NOTES.md new file mode 100644 index 000000000..c792430db --- /dev/null +++ b/purchase_product_configurator/doc/RELEASE_NOTES.md @@ -0,0 +1,8 @@ +## Module + +#### 24.05.2024 +#### Version 17.0.1.0.0 +#### ADD +- Initial Commit for Purchase Product Configurator. + + diff --git a/purchase_product_configurator/models/__init__.py b/purchase_product_configurator/models/__init__.py new file mode 100644 index 000000000..b07e44a5c --- /dev/null +++ b/purchase_product_configurator/models/__init__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2024-TODAY Cybrosys Technologies(). +# Author: Unnimaya C O (odoo@cybrosys.com) +# +# You can modify it under the terms of the GNU AFFERO +# GENERAL PUBLIC LICENSE (AGPL v3), Version 3. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details. +# +# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE +# (AGPL v3) along with this program. +# If not, see . +# +################################################################################ +from . import product_attribute_custom_value +from . import product_template +from . import purchase_order_line diff --git a/purchase_product_configurator/models/product_attribute_custom_value.py b/purchase_product_configurator/models/product_attribute_custom_value.py new file mode 100644 index 000000000..aecaede76 --- /dev/null +++ b/purchase_product_configurator/models/product_attribute_custom_value.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2024-TODAY Cybrosys Technologies(). +# Author: Unnimaya C O (odoo@cybrosys.com) +# +# You can modify it under the terms of the GNU AFFERO +# GENERAL PUBLIC LICENSE (AGPL v3), Version 3. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details. +# +# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE +# (AGPL v3) along with this program. +# If not, see . +# +################################################################################ +from odoo import fields, models + + +class ProductAttributeCustomValue(models.Model): + """ + Model for representing custom attribute values for a purchase order line. + Inherits from 'product.attribute.custom.value' model. + """ + _inherit = "product.attribute.custom.value" + + purchase_order_line_id = fields.Many2one('purchase.order.line', + string="Purchase Order Line", + required=True, ondelete='cascade', + help="purchase order lines") diff --git a/purchase_product_configurator/models/product_template.py b/purchase_product_configurator/models/product_template.py new file mode 100644 index 000000000..681c404ac --- /dev/null +++ b/purchase_product_configurator/models/product_template.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2024-TODAY Cybrosys Technologies(). +# Author: Unnimaya C O (odoo@cybrosys.com) +# +# You can modify it under the terms of the GNU AFFERO +# GENERAL PUBLIC LICENSE (AGPL v3), Version 3. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details. +# +# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE +# (AGPL v3) along with this program. +# If not, see . +# +################################################################################ +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + _check_company_auto = True + + product_config_mode = fields.Selection(selection=[('configurator', + "Product Configurator"), + ('matrix', + "Order Grid Entry")], + string="Product Mode", + default='configurator', + help="Configurator: choose " + "attribute values to add " + "the matching product variant" + " to the order. " + "\nGrid: add several variants" + " at once from the grid " + "of attribute values") + optional_product_ids = fields.Many2many( + comodel_name='product.template', + relation='product_optional_rel', + column1='src_id', + column2='dest_id', + string="Optional Products", + help="Optional Products are suggested " + "whenever the customer hits *Add to Cart* (cross-sell strategy, " + "e.g. for computers: warranty, software, etc.).", + check_company=True) + + @api.depends('attribute_line_ids.value_ids.is_custom', 'attribute_line_ids.attribute_id.create_variant') + def _compute_has_configurable_attributes(self): + """ A product is considered configurable if: + - It has dynamic attributes + - It has any attribute line with at least 2 attribute values configured + - It has at least one custom attribute value """ + for product in self: + product.has_configurable_attributes = ( + any(attribute.create_variant == 'dynamic' for attribute in product.attribute_line_ids.attribute_id) + or any(len(attribute_line_id.value_ids) >= 2 for attribute_line_id in product.attribute_line_ids) + or any(attribute_value.is_custom for attribute_value in product.attribute_line_ids.value_ids) + ) + + def get_single_product_variant(self): + """ Method used by the product configurator to check if the product is configurable or not. + + We need to open the product configurator if the product: + - is configurable (see has_configurable_attributes) + - has optional products """ + res = super().get_single_product_variant() + if res.get('product_id', False): + has_optional_products = False + for optional_product in self.product_variant_id.optional_product_ids: + if optional_product.has_dynamic_attributes() or optional_product._get_possible_variants( + self.product_variant_id.product_template_attribute_value_ids + ): + has_optional_products = True + break + res.update({ + 'has_optional_products': has_optional_products, + }) + return res diff --git a/purchase_product_configurator/models/purchase_order_line.py b/purchase_product_configurator/models/purchase_order_line.py new file mode 100644 index 000000000..242546028 --- /dev/null +++ b/purchase_product_configurator/models/purchase_order_line.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2024-TODAY Cybrosys Technologies(). +# Author: Unnimaya C O (odoo@cybrosys.com) +# +# You can modify it under the terms of the GNU AFFERO +# GENERAL PUBLIC LICENSE (AGPL v3), Version 3. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details. +# +# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE +# (AGPL v3) along with this program. +# If not, see . +# +################################################################################ +from odoo import api, fields, models + + +class PurchaseOrderLine(models.Model): + """ + Model for representing purchase order lines with additional fields and + methods. + Inherits from 'purchase.order.line' model. + """ + _inherit = 'purchase.order.line' + + product_config_mode = fields.Selection( + related='product_template_id.product_config_mode', + depends=['product_template_id'], + help="product configuration mode") + product_custom_attribute_value_ids = fields.One2many( + comodel_name='product.attribute.custom.value', + inverse_name='purchase_order_line_id', + string="Custom Values", + compute='_compute_custom_attribute_values', + help="product custom attribute values", + store=True, readonly=False, precompute=True, copy=True) + + @api.depends('product_id') + def _compute_custom_attribute_values(self): + """ + Checks if the product has custom attribute values associated with it, + and if those values belong to the valid values of the product template. + """ + for line in self: + if not line.product_id: + line.product_custom_attribute_value_ids = False + continue + if not line.product_custom_attribute_value_ids: + continue + valid_values = line.product_id.product_tmpl_id. \ + valid_product_template_attribute_line_ids. \ + product_template_value_ids + # remove the is_custom values that don't belong to this template + for attribute in line.product_custom_attribute_value_ids: + if attribute.custom_product_template_attribute_value_id not in \ + valid_values: + line.product_custom_attribute_value_ids -= attribute diff --git a/purchase_product_configurator/static/description/assets/icons/capture (1).png b/purchase_product_configurator/static/description/assets/icons/capture (1).png new file mode 100644 index 000000000..8824deafc Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/capture (1).png differ diff --git a/purchase_product_configurator/static/description/assets/icons/check.png b/purchase_product_configurator/static/description/assets/icons/check.png new file mode 100644 index 000000000..c8e85f51d Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/check.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/chevron.png b/purchase_product_configurator/static/description/assets/icons/chevron.png new file mode 100644 index 000000000..2089293d6 Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/chevron.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/cogs.png b/purchase_product_configurator/static/description/assets/icons/cogs.png new file mode 100644 index 000000000..95d0bad62 Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/cogs.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/consultation.png b/purchase_product_configurator/static/description/assets/icons/consultation.png new file mode 100644 index 000000000..8319d4baa Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/consultation.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/ecom-black.png b/purchase_product_configurator/static/description/assets/icons/ecom-black.png new file mode 100644 index 000000000..a9385ff13 Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/ecom-black.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/education-black.png b/purchase_product_configurator/static/description/assets/icons/education-black.png new file mode 100644 index 000000000..3eb09b27b Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/education-black.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/hotel-black.png b/purchase_product_configurator/static/description/assets/icons/hotel-black.png new file mode 100644 index 000000000..130f613be Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/hotel-black.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/img.png b/purchase_product_configurator/static/description/assets/icons/img.png new file mode 100644 index 000000000..70197f477 Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/img.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/license.png b/purchase_product_configurator/static/description/assets/icons/license.png new file mode 100644 index 000000000..a5869797e Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/license.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/lifebuoy.png b/purchase_product_configurator/static/description/assets/icons/lifebuoy.png new file mode 100644 index 000000000..658d56ccc Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/lifebuoy.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/manufacturing-black.png b/purchase_product_configurator/static/description/assets/icons/manufacturing-black.png new file mode 100644 index 000000000..697eb0e9f Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/manufacturing-black.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/photo-capture.png b/purchase_product_configurator/static/description/assets/icons/photo-capture.png new file mode 100644 index 000000000..06c111758 Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/photo-capture.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/pos-black.png b/purchase_product_configurator/static/description/assets/icons/pos-black.png new file mode 100644 index 000000000..97c0f90c1 Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/pos-black.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/puzzle.png b/purchase_product_configurator/static/description/assets/icons/puzzle.png new file mode 100644 index 000000000..65cf854e7 Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/puzzle.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/restaurant-black.png b/purchase_product_configurator/static/description/assets/icons/restaurant-black.png new file mode 100644 index 000000000..4a35eb939 Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/restaurant-black.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/service-black.png b/purchase_product_configurator/static/description/assets/icons/service-black.png new file mode 100644 index 000000000..301ab51cb Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/service-black.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/trading-black.png b/purchase_product_configurator/static/description/assets/icons/trading-black.png new file mode 100644 index 000000000..9398ba2f1 Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/trading-black.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/training.png b/purchase_product_configurator/static/description/assets/icons/training.png new file mode 100644 index 000000000..884ca024d Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/training.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/update.png b/purchase_product_configurator/static/description/assets/icons/update.png new file mode 100644 index 000000000..ecbc5a01a Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/update.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/user.png b/purchase_product_configurator/static/description/assets/icons/user.png new file mode 100644 index 000000000..6ffb23d9f Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/user.png differ diff --git a/purchase_product_configurator/static/description/assets/icons/wrench.png b/purchase_product_configurator/static/description/assets/icons/wrench.png new file mode 100644 index 000000000..6c04dea0f Binary files /dev/null and b/purchase_product_configurator/static/description/assets/icons/wrench.png differ diff --git a/purchase_product_configurator/static/description/assets/misc/Cybrosys R.png b/purchase_product_configurator/static/description/assets/misc/Cybrosys R.png new file mode 100644 index 000000000..da4058087 Binary files /dev/null and b/purchase_product_configurator/static/description/assets/misc/Cybrosys R.png differ diff --git a/purchase_product_configurator/static/description/assets/misc/email.svg b/purchase_product_configurator/static/description/assets/misc/email.svg new file mode 100644 index 000000000..15291cdc3 --- /dev/null +++ b/purchase_product_configurator/static/description/assets/misc/email.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/purchase_product_configurator/static/description/assets/misc/phone.svg b/purchase_product_configurator/static/description/assets/misc/phone.svg new file mode 100644 index 000000000..b7bd7f251 --- /dev/null +++ b/purchase_product_configurator/static/description/assets/misc/phone.svg @@ -0,0 +1,3 @@ + + + diff --git a/purchase_product_configurator/static/description/assets/misc/star (1) 2.svg b/purchase_product_configurator/static/description/assets/misc/star (1) 2.svg new file mode 100644 index 000000000..5ae9f507a --- /dev/null +++ b/purchase_product_configurator/static/description/assets/misc/star (1) 2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/purchase_product_configurator/static/description/assets/misc/support (1) 1.svg b/purchase_product_configurator/static/description/assets/misc/support (1) 1.svg new file mode 100644 index 000000000..7d37a8f30 --- /dev/null +++ b/purchase_product_configurator/static/description/assets/misc/support (1) 1.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/purchase_product_configurator/static/description/assets/misc/support-email.svg b/purchase_product_configurator/static/description/assets/misc/support-email.svg new file mode 100644 index 000000000..eb70370d6 --- /dev/null +++ b/purchase_product_configurator/static/description/assets/misc/support-email.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/purchase_product_configurator/static/description/assets/misc/tick-mark.svg b/purchase_product_configurator/static/description/assets/misc/tick-mark.svg new file mode 100644 index 000000000..2dbb40187 --- /dev/null +++ b/purchase_product_configurator/static/description/assets/misc/tick-mark.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/purchase_product_configurator/static/description/assets/misc/whatsapp 1.svg b/purchase_product_configurator/static/description/assets/misc/whatsapp 1.svg new file mode 100644 index 000000000..0bfaf8fc6 --- /dev/null +++ b/purchase_product_configurator/static/description/assets/misc/whatsapp 1.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/purchase_product_configurator/static/description/assets/misc/whatsapp.svg b/purchase_product_configurator/static/description/assets/misc/whatsapp.svg new file mode 100644 index 000000000..b618aea1d --- /dev/null +++ b/purchase_product_configurator/static/description/assets/misc/whatsapp.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/purchase_product_configurator/static/description/assets/modules/1.png b/purchase_product_configurator/static/description/assets/modules/1.png new file mode 100644 index 000000000..859618aec Binary files /dev/null and b/purchase_product_configurator/static/description/assets/modules/1.png differ diff --git a/purchase_product_configurator/static/description/assets/modules/2.png b/purchase_product_configurator/static/description/assets/modules/2.png new file mode 100644 index 000000000..89456bc9e Binary files /dev/null and b/purchase_product_configurator/static/description/assets/modules/2.png differ diff --git a/purchase_product_configurator/static/description/assets/modules/3.png b/purchase_product_configurator/static/description/assets/modules/3.png new file mode 100644 index 000000000..510620225 Binary files /dev/null and b/purchase_product_configurator/static/description/assets/modules/3.png differ diff --git a/purchase_product_configurator/static/description/assets/modules/4.png b/purchase_product_configurator/static/description/assets/modules/4.png new file mode 100644 index 000000000..6f1e79c74 Binary files /dev/null and b/purchase_product_configurator/static/description/assets/modules/4.png differ diff --git a/purchase_product_configurator/static/description/assets/modules/5.png b/purchase_product_configurator/static/description/assets/modules/5.png new file mode 100755 index 000000000..08863c967 Binary files /dev/null and b/purchase_product_configurator/static/description/assets/modules/5.png differ diff --git a/purchase_product_configurator/static/description/assets/modules/6.png b/purchase_product_configurator/static/description/assets/modules/6.png new file mode 100644 index 000000000..01f968837 Binary files /dev/null and b/purchase_product_configurator/static/description/assets/modules/6.png differ diff --git a/purchase_product_configurator/static/description/assets/screenshots/0.png b/purchase_product_configurator/static/description/assets/screenshots/0.png new file mode 100644 index 000000000..fee8aac5d Binary files /dev/null and b/purchase_product_configurator/static/description/assets/screenshots/0.png differ diff --git a/purchase_product_configurator/static/description/assets/screenshots/1.png b/purchase_product_configurator/static/description/assets/screenshots/1.png new file mode 100644 index 000000000..695bbdb27 Binary files /dev/null and b/purchase_product_configurator/static/description/assets/screenshots/1.png differ diff --git a/purchase_product_configurator/static/description/assets/screenshots/2.png b/purchase_product_configurator/static/description/assets/screenshots/2.png new file mode 100644 index 000000000..a62c231ac Binary files /dev/null and b/purchase_product_configurator/static/description/assets/screenshots/2.png differ diff --git a/purchase_product_configurator/static/description/assets/screenshots/3.png b/purchase_product_configurator/static/description/assets/screenshots/3.png new file mode 100644 index 000000000..c67f18b46 Binary files /dev/null and b/purchase_product_configurator/static/description/assets/screenshots/3.png differ diff --git a/purchase_product_configurator/static/description/assets/screenshots/hero.gif b/purchase_product_configurator/static/description/assets/screenshots/hero.gif new file mode 100644 index 000000000..2f0b39e3e Binary files /dev/null and b/purchase_product_configurator/static/description/assets/screenshots/hero.gif differ diff --git a/purchase_product_configurator/static/description/banner.jpg b/purchase_product_configurator/static/description/banner.jpg new file mode 100644 index 000000000..fe3a7c8d2 Binary files /dev/null and b/purchase_product_configurator/static/description/banner.jpg differ diff --git a/purchase_product_configurator/static/description/icon.png b/purchase_product_configurator/static/description/icon.png new file mode 100644 index 000000000..e5426085d Binary files /dev/null and b/purchase_product_configurator/static/description/icon.png differ diff --git a/purchase_product_configurator/static/description/index.html b/purchase_product_configurator/static/description/index.html new file mode 100644 index 000000000..fcdb470ed --- /dev/null +++ b/purchase_product_configurator/static/description/index.html @@ -0,0 +1,702 @@ + + + + + + Odoo App 3 Index + + + + + + + + +
+
+
+
+
+ +
+
+
+ Community +
+
+ Enterprise +
+
+
+
+
+
+

+ Purchase Product Configurator

+

+ Configure the Product in Purchase Order Line +

+
+ +
+
+
+
+
+

+ Key Highlights +

+
+
+
+
+
+ +
+
+

+ Add products using product configurator

+
+
+
+
+
+
+ +
+
+

+ Switch between product configurator and grid view

+
+
+
+
+
+
+ +
+
+

+ Choose variants, custom attributes and optional products

+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+

+ Navigate to the Attributes & Variants tab of any configurable product and choose the + PURCHASE VARIANT SELECTION as Order Grid Entry.

+
+
+
+
+
+
+ +
+
+

+ When selecting the product in the purchase order line, the Order Grid Entry window + opens. +

+
+
+
+
+
+
+ +
+
+

+ Navigate to the Attributes & Variants tab of any configurable product and choose the + PURCHASE VARIANT SELECTION as Product Configurator.

+
+
+
+
+
+
+ +
+
+

+ When selecting the product in the purchase order line, the Open Product Configurator + window + opens. +

+
+
+
+
+
+
+
    +
  • + Add products using the product configurator, a tool that + allows you to customize and select specific product variant. +
  • +
  • + Easily switch between the product configurator for + customized product selection and the grid view for quick, tabular product entry to + efficiently add products according to your need. +
  • +
  • + Select variants, customize attributes, and add optional + products to tailor your order precisely to your needs. +
  • +
+
+
+
+
+
+
Version + 17.0.1.0.0|Released on:22nd May 2024 +
+

+ Initial Commit for Purchase Product Configurator. +

+
+
+
+
+
+
+
+

+ Related Products

+
+
+ +
+
+

+ Our Services

+
+
+
+
+
+
+
+
+ service-icon +
+
+

Odoo + Customization

+
+
+
+
+
+
+ service-icon +
+
+

Odoo + Implementation

+
+
+
+
+
+
+ service-icon +
+
+

Odoo + Support

+
+
+
+
+
+
+ service-icon +
+
+

Hire + Odoo Developer

+
+
+
+
+ +
+
+ service-icon +
+
+

Odoo + Integration

+
+
+
+
+
+
+ service-icon +
+
+

Odoo + Migration

+
+
+
+
+
+
+ service-icon +
+
+

Odoo + Consultancy

+
+
+
+
+
+
+ service-icon +
+
+

Odoo + Implementation

+
+
+
+
+
+
+ service-icon +
+
+

Odoo + Licensing Consultancy

+
+
+
+
+
+
+

+ Our Industries

+
+
+
+
+
+
+ +

Trading

+

Easily procure and sell your products

+
+
+
+
+ +

POS

+

Easy configuration and convivial experience

+
+
+
+
+ +

+ Education

+

A platform for educational management

+
+
+
+
+ +

+ Manufacturing

+

Plan, track and schedule your operations

+
+
+
+
+ +

E-commerce & + Website

+

Mobile friendly, awe-inspiring product pages

+
+
+
+
+ +

Service + Management

+

Keep track of services and invoice

+
+
+
+
+ +

+ Restaurant

+

Run your bar or restaurant methodically

+
+
+
+
+ +

Hotel + Management

+

An all-inclusive hotel management application

+
+
+
+
+
+
+

+ Support

+
+
+
+
+
+
+
+ +
+ Need + Help? +

Got + questions or need help? Get in touch.

+
odoo@cybrosys.com +
+
+
+
+
+
+
+
+ +
+ WhatsApp +

Say hi to + us on WhatsApp!

+
+91 + 99456767686 +
+
+
+
+
+
+
+
+
+ + + + + + diff --git a/purchase_product_configurator/static/src/js/product/product.js b/purchase_product_configurator/static/src/js/product/product.js new file mode 100755 index 000000000..c727fdd22 --- /dev/null +++ b/purchase_product_configurator/static/src/js/product/product.js @@ -0,0 +1,68 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { formatCurrency } from "@web/core/currency"; +import { + ProductTemplateAttributeLine as PTAL +} from "../product_template_attribute_line/product_template_attribute_line"; + +export class Product extends Component { + static components = { PTAL }; + static template = "purchase_product_configurator.product"; + static props = { + id: { type: [Number, {value: false}], optional: true }, + product_tmpl_id: Number, + display_name: String, + description_sale: [Boolean, String], // backend sends 'false' when there is no description + price: { type: [Number, {value: false}], optional: true }, + quantity: Number, + attribute_lines: Object, + optional: Boolean, + imageURL: { type: String, optional: true }, + archived_combinations: Array, + exclusions: Object, + parent_exclusions: Object, + parent_product_tmpl_ids: { type: Array, element: Number, optional: true }, + }; + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Increase the quantity of the product in the state. + */ + increaseQuantity() { + this.env.setQuantity(this.props.product_tmpl_id, this.props.quantity+1); + } + + /** + * Set the quantity of the product in the state. + * + * @param {Event} event + */ + setQuantity(event) { + console.log('parseFloat(event.target.value)',parseFloat(event.target.value)) + this.env.setQuantity(this.props.product_tmpl_id, parseFloat(event.target.value)); + } + + /** + * Decrease the quantity of the product in the state. + */ + decreaseQuantity() { + this.env.setQuantity(this.props.product_tmpl_id, this.props.quantity-1); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Return the price, in the format of the given currency. + * + * @return {String} - The price, in the format of the given currency. + */ + getFormattedPrice() { + return formatCurrency(this.props.price, this.env.currencyId); + } +} diff --git a/purchase_product_configurator/static/src/js/product/product.scss b/purchase_product_configurator/static/src/js/product/product.scss new file mode 100755 index 000000000..b68529bf7 --- /dev/null +++ b/purchase_product_configurator/static/src/js/product/product.scss @@ -0,0 +1,36 @@ +.table.o_purchase_product_configurator_table { + & tr:first-child > td { + padding-top: 0 !important; + } + + &.o_purchase_product_configurator_table_optional > :not(caption) > *:last-child > * { + border-bottom: 0; + } +} + +.o_purchase_product_configurator_img { + width: 120px; + max-height: 240px; +} + +.o_purchase_product_configurator_qty { + width: 160px; + + input { + max-width: 4rem; + //removing input field=number arrows as their size might + //change depending on browser default styling and shift input's position + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + &[type=number] { + -moz-appearance: textfield; + } + } +} + +.o_purchase_product_configurator_price { + width: 160px; +} diff --git a/purchase_product_configurator/static/src/js/product/product_template.xml b/purchase_product_configurator/static/src/js/product/product_template.xml new file mode 100755 index 000000000..08faeea1a --- /dev/null +++ b/purchase_product_configurator/static/src/js/product/product_template.xml @@ -0,0 +1,79 @@ + + + + + + Product Image + Product Image + + +
+
+
+
+ This option or combination of options is not available +
+
+ + + + + +
+ + + +
+
+
+
+ + Remove product + + + +
+
+
+
+ +
+ + + diff --git a/purchase_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.js b/purchase_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.js new file mode 100755 index 000000000..4a9c3200c --- /dev/null +++ b/purchase_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.js @@ -0,0 +1,376 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { Component, onWillStart, useState, useSubEnv } from "@odoo/owl"; +import { Dialog } from '@web/core/dialog/dialog'; +import { PurchaseProductList } from "../product_list/product_list"; +import { useService } from "@web/core/utils/hooks"; + +export class PurchaseProductConfiguratorDialog extends Component { + static components = { Dialog, PurchaseProductList}; + static template = 'purchase_product_configurator.dialog'; + static props = { + productTemplateId: Number, + ptavIds: { type: Array, element: Number }, + customAttributeValues: { + type: Array, + element: Object, + shape: { + ptavId: Number, + value: String, + } + }, + quantity: Number, + productUOMId: { type: Number, optional: true }, + companyId: { type: Number, optional: true }, + currencyId: Number, + edit: { type: Boolean, optional: true }, + save: Function, + discard: Function, + close: Function, // This is the close from the env of the Dialog Component + }; + static defaultProps = { + edit: false, + } + setup() { + this.title = _t("Configure your product"); + this.rpc = useService("rpc"); + this.state = useState({ + products: [], + optionalProducts: [], + + }); + /** + * Initializes sub-environment for product customization. + */ + useSubEnv({ + mainProductTmplId: this.props.productTemplateId, + currencyId: this.props.currencyId, + addProduct: this._addProduct.bind(this), + removeProduct: this._removeProduct.bind(this), + setQuantity: this._setQuantity.bind(this), + updateProductTemplateSelectedPTAV: this._updateProductTemplateSelectedPTAV.bind(this), + updatePTAVCustomValue: this._updatePTAVCustomValue.bind(this), + isPossibleCombination: this._isPossibleCombination, + }); + /** + * Initializes data and performs setup actions before starting. + * Loads data, sets state, updates custom values, and checks exclusions. + */ + onWillStart(async () => { + const { products, optional_products } = await this._loadData(this.props.edit); + this.state.products = products; + this.state.optionalProducts = optional_products; + for (const customValue of this.props.customAttributeValues) { + this._updatePTAVCustomValue( + this.env.mainProductTmplId, + customValue.ptavId, + customValue.value + ); + } + this._checkExclusions(this.state.products[0]); + }); + } + /** + * Loads data for the product configurator. + */ + async _loadData(onlyMainProduct) { + return this.rpc('/purchase_product_configurator/get_values', { + product_template_id: this.props.productTemplateId, + currency_id: this.props.currencyId, + quantity: this.props.quantity, + product_uom_id: this.props.productUOMId, + company_id: this.props.companyId, + ptav_ids: this.props.ptavIds, + only_main_product: onlyMainProduct, + }); + } + /** + * Creates a product using the provided data. + */ + async _createProduct(product) { + return this.rpc('/purchase_product_configurator/create_product', { + product_template_id: product.product_tmpl_id, + combination: this._getCombination(product), + }); + } + /** + * Updates a product combination with the provided quantity. + */ + async _updateCombination(product, quantity) { + return this.rpc('/purchase_product_configurator/update_combination', { + product_template_id: product.product_tmpl_id, + combination: this._getCombination(product), + currency_id: this.props.currencyId, + so_date: this.props.soDate, + quantity: quantity, + product_uom_id: this.props.productUOMId, + company_id: this.props.companyId, + pricelist_id: this.props.pricelistId, + }); + } + /** + * Retrieves optional products available for the given product. + */ + async _getOptionalProducts(product) { + return this.rpc('/purchase_product_configurator/get_optional_products', { + product_template_id: product.product_tmpl_id, + combination: this._getCombination(product), + parent_combination: this._getParentsCombination(product), + currency_id: this.props.currencyId, + so_date: this.props.soDate, + company_id: this.props.companyId, + pricelist_id: this.props.pricelistId, + }); + } + /** + * Add the product to the list of products and fetch his optional products. + */ + async _addProduct(productTmplId) { + const index = this.state.optionalProducts.findIndex( + p => p.product_tmpl_id === productTmplId + ); + if (index >= 0) { + this.state.products.push(...this.state.optionalProducts.splice(index, 1)); + // Fetch optional product from the server with the parent combination. + const product = this._findProduct(productTmplId); + let newOptionalProducts = await this._getOptionalProducts(product); + for(const newOptionalProductDict of newOptionalProducts) { + // If the optional product is already in the list, add the id of the parent product + // template in his list of `parent_product_tmpl_ids` instead of adding a second time + // the product. + const newProduct = this ._findProduct(newOptionalProductDict.product_tmpl_id); + if (newProduct) { + newOptionalProducts = newOptionalProducts.filter( + (p) => p.product_tmpl_id != newOptionalProductDict.product_tmpl_id + ); + newProduct.parent_product_tmpl_ids.push(productTmplId); + } + } + if (newOptionalProducts) this.state.optionalProducts.push(...newOptionalProducts); + } + } + /** + * Remove the product and his optional products from the list of products. + */ + _removeProduct(productTmplId) { + const index = this.state.products.findIndex(p => p.product_tmpl_id === productTmplId); + if (index >= 0) { + this.state.optionalProducts.push(...this.state.products.splice(index, 1)); + for (const childProduct of this._getChildProducts(productTmplId)) { + // Optional products might have multiple parents so we don't want to remove them if + // any of their parents are still on the list of products. + childProduct.parent_product_tmpl_ids = childProduct.parent_product_tmpl_ids.filter( + id => id !== productTmplId + ); + if (!childProduct.parent_product_tmpl_ids.length) { + this._removeProduct(childProduct.product_tmpl_id); + this.state.optionalProducts.splice( + this.state.optionalProducts.findIndex( + p => p.product_tmpl_id === childProduct.product_tmpl_id + ), 1 + ); + } + } + } + } + /** + * Set the quantity of the product to a given value. + */ + async _setQuantity(productTmplId, quantity) { + if (quantity <= 0) { + if (productTmplId === this.env.mainProductTmplId) { + const product = this._findProduct(productTmplId); + const { price } = await this._updateCombination(product, 1); + product.quantity = 1; + product.price = parseFloat(price); + return; + }; + this._removeProduct(productTmplId); + } else { + const product = this._findProduct(productTmplId); + const { price } = await this._updateCombination(product, quantity); + product.quantity = quantity; + product.price = parseFloat(price); + } + } + /** + * Change the value of `selected_attribute_value_ids` on the given PTAL in the product. + */ + async _updateProductTemplateSelectedPTAV(productTmplId, ptalId, ptavId, multiIdsAllowed) { + const product = this._findProduct(productTmplId); + let selectedIds = product.attribute_lines.find(ptal => ptal.id === ptalId).selected_attribute_value_ids; + if (multiIdsAllowed) { + const ptavID = parseInt(ptavId); + if (!selectedIds.includes(ptavID)){ + selectedIds.push(ptavID); + } else { + selectedIds = selectedIds.filter(ptav => ptav !== ptavID); + } + + } else { + selectedIds = [parseInt(ptavId)]; + } + product.attribute_lines.find(ptal => ptal.id === ptalId).selected_attribute_value_ids = selectedIds; + this._checkExclusions(product); + if (this._isPossibleCombination(product)) { + const updatedValues = await this._updateCombination(product, product.quantity); + Object.assign(product, updatedValues); + // When a combination should exist but was deleted from the database, it should not be + // selectable and considered as an exclusion. + if (!product.id && product.attribute_lines.every(ptal => ptal.create_variant === "always")) { + const combination = this._getCombination(product); + product.archived_combinations = product.archived_combinations.concat([combination]); + this._checkExclusions(product); + } + } + } + /** + * Set the custom value for a given custom PTAV. + */ + _updatePTAVCustomValue(productTmplId, ptavId, customValue) { + const product = this._findProduct(productTmplId); + product.attribute_lines.find( + ptal => ptal.selected_attribute_value_ids.includes(ptavId) + ).customValue = customValue; + } + /** + * Check the exclusions of a given product and his child. + */ + _checkExclusions(product, checked=undefined) { + const combination = this._getCombination(product); + const exclusions = product.exclusions; + const parentExclusions = product.parent_exclusions; + const archivedCombinations = product.archived_combinations; + const parentCombination = this._getParentsCombination(product); + const childProducts = this._getChildProducts(product.product_tmpl_id) + const ptavList = product.attribute_lines.flat().flatMap(ptal => ptal.attribute_values) + ptavList.map(ptav => ptav.excluded = false); // Reset all the values + if (exclusions) { + for(const ptavId of combination) { + for(const excludedPtavId of exclusions[ptavId]) { + ptavList.find(ptav => ptav.id === excludedPtavId).excluded = true; + } + } + } + if (parentCombination) { + for(const ptavId of parentCombination) { + for(const excludedPtavId of (parentExclusions[ptavId]||[])) { + ptavList.find(ptav => ptav.id === excludedPtavId).excluded = true; + } + } + } + if (archivedCombinations) { + for(const excludedCombination of archivedCombinations) { + const ptavCommon = excludedCombination.filter((ptav) => combination.includes(ptav)); + if (ptavCommon.length === combination.length) { + for(const excludedPtavId of ptavCommon) { + ptavList.find(ptav => ptav.id === excludedPtavId).excluded = true; + } + } else if (ptavCommon.length === (combination.length - 1)) { + // In this case we only need to disable the remaining ptav + const disabledPtavId = excludedCombination.find( + (ptav) => !combination.includes(ptav) + ); + const excludedPtav = ptavList.find(ptav => ptav.id === disabledPtavId) + if (excludedPtav) { + excludedPtav.excluded = true; + } + } + } + } + const checkedProducts = checked || []; + for(const optionalProductTmpl of childProducts) { + // if the product is not checked for exclusions + if (!checkedProducts.includes(optionalProductTmpl)) { + checkedProducts.push(optionalProductTmpl); // remember that this product is checked + this._checkExclusions(optionalProductTmpl, checkedProducts); + } + } + } + /** + * Return the product given his template id. + */ + _findProduct(productTmplId) { + // The product might be in either of the two lists `products` or `optional_products`. + return this.state.products.find(p => p.product_tmpl_id === productTmplId) || + this.state.optionalProducts.find(p => p.product_tmpl_id === productTmplId); + } + /** + * Return the list of dependents products for a given product. + */ + _getChildProducts(productTmplId) { + return [ + ...this.state.products.filter(p => p.parent_product_tmpl_ids?.includes(productTmplId)), + ...this.state.optionalProducts.filter(p => p.parent_product_tmpl_ids?.includes(productTmplId)) + ] + } + /** + * Return the selected PTAV of the product, as a list of `product.template.attribute.value` id. + */ + _getCombination(product) { + return product.attribute_lines.flatMap(ptal => ptal.selected_attribute_value_ids); + } + /** + * Return the selected PTAV of all the product parents, as a list of + * `product.template.attribute.value` id. + */ + _getParentsCombination(product) { + let parentsCombination = []; + for(const parentProductTmplId of product.parent_product_tmpl_ids || []) { + parentsCombination.push(this._getCombination(this._findProduct(parentProductTmplId))); + } + return parentsCombination.flat(); + } + /** + * Check if a product has a valid combination. + */ + _isPossibleCombination(product) { + return product.attribute_lines.every(ptal => !ptal.attribute_values.find( + ptav => ptal.selected_attribute_value_ids.includes(ptav.id) + )?.excluded); + } + + /** + * Check if all the products selected have a valid combination. + */ + isPossibleConfiguration() { + return [...this.state.products].every( + p => this._isPossibleCombination(p) + ); + } + /** + * Confirm the current combination(s). + */ + async onConfirm() { + if (!this.isPossibleConfiguration()) return; + // Create the products with dynamic attributes + for (const product of this.state.products) { + if ( + !product.id && + product.attribute_lines.some(ptal => ptal.create_variant === "dynamic") + ) { + const productId = await this._createProduct(product); + product.id = parseInt(productId); + } + } + await this.props.save( + this.state.products.find( + p => p.product_tmpl_id === this.env.mainProductTmplId + ), + this.state.products.filter( + p => p.product_tmpl_id !== this.env.mainProductTmplId + ), + ); + this.props.close(); + } + /** + * Discard the modal. + */ + onDiscard() { + if (!this.props.edit) { + this.props.discard(); // clear the line + } + this.props.close(); + } +} diff --git a/purchase_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.xml b/purchase_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.xml new file mode 100755 index 000000000..cc7628ef5 --- /dev/null +++ b/purchase_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + diff --git a/purchase_product_configurator/static/src/js/product_list/product_list.js b/purchase_product_configurator/static/src/js/product_list/product_list.js new file mode 100755 index 000000000..d0e00fbdc --- /dev/null +++ b/purchase_product_configurator/static/src/js/product_list/product_list.js @@ -0,0 +1,31 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { formatCurrency } from "@web/core/currency"; +import { Product } from "../product/product"; + +export class PurchaseProductList extends Component { + static components = { Product }; + static template = "purchaseProductConfigurator.PurchaseProductList"; + static props = { + products: Array, + areProductsOptional: { type: Boolean, optional: true }, + }; + static defaultProps = { + areProductsOptional: false, + }; + + /** + * Return the total of the product in the list, in the currency of the `sale.order`. + * + * @return {String} - The sum of all items in the list, in the currency of the `sale.order`. + */ + getFormattedTotal() { + return formatCurrency( + this.props.products.reduce( + (totalPrice, product) => totalPrice + product.price * product.quantity, 0 + ), + this.env.currencyId, + ) + } +} diff --git a/purchase_product_configurator/static/src/js/product_list/product_list.xml b/purchase_product_configurator/static/src/js/product_list/product_list.xml new file mode 100755 index 000000000..111309da6 --- /dev/null +++ b/purchase_product_configurator/static/src/js/product_list/product_list.xml @@ -0,0 +1,26 @@ + + + + +

Add optional products

+ + + + + + + + + + + + + + + + +
ProductQuantityPrice
+

Total:

+
+
+
diff --git a/purchase_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.js b/purchase_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.js new file mode 100755 index 000000000..71d7a43e7 --- /dev/null +++ b/purchase_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.js @@ -0,0 +1,102 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { formatCurrency } from "@web/core/currency"; + +export class ProductTemplateAttributeLine extends Component { + static template = "purchaseProductConfigurator.ptal"; + static props = { + productTmplId: Number, + id: Number, + attribute: { + type: Object, + shape: { + id: Number, + name: String, + display_type: { + type: String, + validate: type => ["color", "multi", "pills", "radio", "select"].includes(type), + }, + }, + }, + attribute_values: { + type: Array, + element: { + type: Object, + shape: { + id: Number, + name: String, + html_color: [Boolean, String], // backend sends 'false' when there is no color + image: [Boolean, String], // backend sends 'false' when there is no image set + is_custom: Boolean, + excluded: { type: Boolean, optional: true }, + }, + }, + }, + selected_attribute_value_ids: { type: Array, element: Number }, + create_variant: { + type: String, + validate: type => ["always", "dynamic", "no_variant"].includes(type), + }, + customValue: {type: [{value: false}, String], optional: true}, + }; + /** + * Update the selected PTAV in the state. + */ + updateSelectedPTAV(event) { + this.env.updateProductTemplateSelectedPTAV( + this.props.productTmplId, this.props.id, event.target.value, this.props.attribute.display_type == 'multi' + ); + } + + /** + * Update in the state the custom value of the selected PTAV. + */ + updateCustomValue(event) { + this.env.updatePTAVCustomValue( + this.props.productTmplId, this.props.selected_attribute_value_ids[0], event.target.value + ); + } + /** + * Return template name to use by checking the display type in the props. + */ + getPTAVTemplate() { + switch(this.props.attribute.display_type) { + case 'color': + return 'purchaseProductConfigurator.ptav-color'; + case 'multi': + return 'purchaseProductConfigurator.ptav-multi'; + case 'pills': + return 'purchaseProductConfigurator.ptav-pills'; + case 'radio': + return 'purchaseProductConfigurator.ptav-radio'; + case 'select': + return 'purchaseProductConfigurator.ptav-select'; + } + } + + /** + * Return the name of the PTAV + */ + getPTAVSelectName(ptav) { + return ptav.name; + } + + /** + * Check if the selected ptav is custom or not. + */ + isSelectedPTAVCustom() { + return this.props.attribute_values.find( + ptav => this.props.selected_attribute_value_ids.includes(ptav.id) + )?.is_custom; + } + + /** + * Check if the line has a custom ptav or not. + */ + hasPTAVCustom() { + return this.props.attribute_values.some( + ptav => ptav.is_custom + ); + } + } diff --git a/purchase_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.scss b/purchase_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.scss new file mode 100755 index 000000000..0a45d5f36 --- /dev/null +++ b/purchase_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.scss @@ -0,0 +1,65 @@ +.o_purchase_product_configurator_ptav_color { + border: 5px solid $border-color; + transition: $input-transition; + + @include o-field-pointer(); + + &:before { + content: ""; + display: block; + @include o-position-absolute(-3px, -3px, -3px, -3px); + border: 4px solid $o-view-background-color; + border-radius: 50%; + box-shadow: inset 0 0 3px rgba(black, 0.3); + } + + input { + margin: 8px; + height: 13px; + width: 13px; + opacity: 0; + } + + &.active { + border: 5px solid map-get($theme-colors, 'primary'); + } + + &.custom_value { + background-image: linear-gradient(to bottom right, #FF0000, #FFF200, #1E9600); + } + + &.transparent { + background-image: url(/web/static/img/transparent.png); + } + + &.css_not_available { + opacity: 1; + + &:after { + content: ""; + @include o-position-absolute(-5px, -5px, -5px, -5px); + border: 2px solid map-get($theme-colors, 'danger'); + border-radius: 50%; + background: str-replace(url("data:image/svg+xml;utf8,"), "#", "%23") ; + background-position: center; + background-repeat: no-repeat; + } + } +} + +.o_purchase_product_configurator_ptav_pills.active label { + $-btn-secondary-design: map-get($o-btns-bs-override, "secondary"); + + background-color: map-get($-btn-secondary-design, active-background); + border-color: map-get($-btn-secondary-design, active-border); + color: map-get($-btn-secondary-design, active-color); +} + +.css_not_available { + opacity: 0.6; +} + +option.css_not_available { + opacity: 1; + color: #ccc; +} diff --git a/purchase_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.xml b/purchase_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.xml new file mode 100755 index 000000000..defdd6c47 --- /dev/null +++ b/purchase_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.xml @@ -0,0 +1,135 @@ + + + + +
+ +
+
+ +
+
+ + + + + +
    +
  • +
    + + +
    +
  • +
+
+ +
    +
  • + + +
  • +
+
+ +
    +
  • + + + +
  • +
+
+ +
    +
  • +
    + + +
    +
  • +
+
+
diff --git a/purchase_product_configurator/static/src/js/purchase_product_field.js b/purchase_product_configurator/static/src/js/purchase_product_field.js new file mode 100755 index 000000000..8ee32204d --- /dev/null +++ b/purchase_product_configurator/static/src/js/purchase_product_field.js @@ -0,0 +1,149 @@ +/** @odoo-module */ + +import { PurchaseOrderLineProductField } from '@purchase_product_matrix/js/purchase_product_field'; +import { serializeDateTime } from "@web/core/l10n/dates"; +import { x2ManyCommands } from "@web/core/orm_service"; +import { useService } from "@web/core/utils/hooks"; +import { patch } from "@web/core/utils/patch"; +import { PurchaseProductConfiguratorDialog } from "./product_configurator_dialog/product_configurator_dialog"; +import { ormService } from "@web/core/orm_service"; +import { jsonrpc } from "@web/core/network/rpc_service"; + +async function applyProduct(record, product) { + // handle custom values & no variants + const customAttributesCommands = [ + x2ManyCommands.set([]), // Command.clear isn't supported in static_list/_applyCommands + ]; + for (const ptal of product.attribute_lines) { + const selectedCustomPTAV = ptal.attribute_values.find( + ptav => ptav.is_custom && ptal.selected_attribute_value_ids.includes(ptav.id) + ); + if (selectedCustomPTAV) { + customAttributesCommands.push( + x2ManyCommands.create(undefined, { + custom_product_template_attribute_value_id: [selectedCustomPTAV.id, "we don't care"], + custom_value: ptal.customValue, + }) + ); + }; + } + const noVariantPTAVIds = product.attribute_lines.filter( + ptal => ptal.create_variant === "no_variant" && ptal.attribute_values.length > 1 + ).flatMap(ptal => ptal.selected_attribute_value_ids); + await record.update({ + product_id: [product.id, product.display_name], + product_no_variant_attribute_value_ids: [x2ManyCommands.set(noVariantPTAVIds)], + product_custom_attribute_value_ids: customAttributesCommands, + }); + await record.update({ + product_qty: product.quantity, + }); + }; + +patch(PurchaseOrderLineProductField.prototype, { + setup() { + super.setup(...arguments); + this.dialog = useService("dialog"); + this.orm = useService("orm"); + }, + async _onProductTemplateUpdate() { + const result = await this.orm.call( + 'product.template', + 'get_single_product_variant', + [this.props.record.data.product_template_id[0]], + ); + const product_config_mode = await this.orm.read( + 'product.template', + [this.props.record.data.product_template_id[0]], + ["product_config_mode"] + ); + if(result && result.product_id) { + if (this.props.record.data.product_id != result.product_id.id) { + this.props.record.update({ + // TODO right name get (same problem as configurator) + product_id: [result.product_id, 'whatever'], + }); + } + } + else { + if (!product_config_mode[0].product_config_mode || product_config_mode[0].product_config_mode === 'configurator') { + this._openProductConfigurator(); + } else { + // only triggered when purchase_product_matrix is installed. + this._openGridConfigurator(false); + } + } + }, + + /** + * Checks if the template is configurable. + */ + get isConfigurableTemplate() { + return super.isConfigurableTemplate || this.props.record.data.is_configurable_product; + }, + /** + * Opens the product configurator. + */ + async _openProductConfigurator(jsonInfo, productTemplateId, editedCellAttributes,edit=false) { + const purchaseOrderRecord = this.props.record.model.root; + let ptavIds = this.props.record.data.product_template_attribute_value_ids.records.map( + record => record.resId + ); + let customAttributeValues = []; + if (edit) { + /** + * no_variant and custom attribute don't need to be given to the configurator for new + * products. + */ + ptavIds = ptavIds.concat(this.props.record.data.product_no_variant_attribute_value_ids.records.map( + record => record.resId + )); + /** + * `product_custom_attribute_value_ids` records are not loaded in the view bc sub templates + * are not loaded in list views. Therefore, we fetch them from the server if the record is + * saved. Else we use the value stored on the line. + */ + customAttributeValues = + this.props.record.data.product_custom_attribute_value_ids.records[0]?.isNew ? + this.props.record.data.product_custom_attribute_value_ids.records.map( + record => record.data + ) : + await this.orm.read( + 'product.attribute.custom.value', + this.props.record.data.product_custom_attribute_value_ids.currentIds, + ["custom_product_template_attribute_value_id", "custom_value"] + ) + } + this.dialog.add(PurchaseProductConfiguratorDialog, { + productTemplateId: this.props.record.data.product_template_id[0], + ptavIds: ptavIds, + customAttributeValues: customAttributeValues.map( + data => { + return { + ptavId: data.custom_product_template_attribute_value_id[0], + value: data.custom_value, + } + } + ), + quantity:1.0, + productUOMId: this.props.record.data.product_uom[0], + companyId: purchaseOrderRecord.data.company_id[0], + currencyId: this.props.record.data.currency_id[0], + edit: edit, + save: async (mainProduct, optionalProducts) => { + await applyProduct(this.props.record, mainProduct); + purchaseOrderRecord.data.order_line.leaveEditMode(); + for (const optionalProduct of optionalProducts) { + const line = await purchaseOrderRecord.data.order_line.addNewRecord({ + position: 'bottom', + mode: "readonly", + }); + await applyProduct(line, optionalProduct); + } + }, + discard: () => { + purchaseOrderRecord.data.order_line.delete(this.props.record); + }, + }); + }, +}); diff --git a/purchase_product_configurator/views/optional_product_template.xml b/purchase_product_configurator/views/optional_product_template.xml new file mode 100644 index 000000000..d825199e0 --- /dev/null +++ b/purchase_product_configurator/views/optional_product_template.xml @@ -0,0 +1,254 @@ + + + + + + + + + + + + + diff --git a/purchase_product_configurator/views/product_template_views.xml b/purchase_product_configurator/views/product_template_views.xml new file mode 100644 index 000000000..23a1d7d42 --- /dev/null +++ b/purchase_product_configurator/views/product_template_views.xml @@ -0,0 +1,22 @@ + + + + + product.template.view.form.inherit.purchase.product.configurator + product.template + + + + + + + + + + + + + diff --git a/purchase_product_configurator/views/purchase_order_views.xml b/purchase_product_configurator/views/purchase_order_views.xml new file mode 100644 index 000000000..97cba9f72 --- /dev/null +++ b/purchase_product_configurator/views/purchase_order_views.xml @@ -0,0 +1,18 @@ + + + + + purchase.order.view.form.inherit.purchase.product.configurator + purchase.order + + + + + + + + +