@ -0,0 +1,48 @@ |
|||||
|
.. image:: https://img.shields.io/badge/license-AGPL--3-blue.svg |
||||
|
:target: https://www.gnu.org/licenses/agpl-3.0-standalone.html |
||||
|
:alt: License: AGPL-3 |
||||
|
|
||||
|
Purchase Product Configurator |
||||
|
============================= |
||||
|
This module helps you to configure product variant selection in the purchase order lines. |
||||
|
|
||||
|
Configuration |
||||
|
============= |
||||
|
* No additional configuration is needed. |
||||
|
|
||||
|
Company |
||||
|
------- |
||||
|
* `Cybrosys Techno Solutions <https://cybrosys.com/>`__ |
||||
|
|
||||
|
License |
||||
|
------- |
||||
|
Affero General Public License, v3.0 (AGPL v3). |
||||
|
(https://www.gnu.org/licenses/agpl-3.0-standalone.html) |
||||
|
|
||||
|
Credits |
||||
|
------- |
||||
|
* Developers: (V17) Unnimaya C O, |
||||
|
(V18) Aysha Shalin |
||||
|
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 <https://cybrosys.com/>`__ |
||||
|
|
||||
|
Further information |
||||
|
=================== |
||||
|
HTML Description: `<static/description/index.html>`__ |
@ -0,0 +1,23 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
############################################################################### |
||||
|
# |
||||
|
# Cybrosys Technologies Pvt. Ltd. |
||||
|
# |
||||
|
# Copyright (C) 2024-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) |
||||
|
# Author: Aysha Shalin (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 <http://www.gnu.org/licenses/>. |
||||
|
# |
||||
|
############################################################################### |
||||
|
from . import controllers |
||||
|
from . import models |
@ -0,0 +1,59 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
############################################################################### |
||||
|
# |
||||
|
# Cybrosys Technologies Pvt. Ltd. |
||||
|
# |
||||
|
# Copyright (C) 2024-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) |
||||
|
# Author: Aysha Shalin (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 <http://www.gnu.org/licenses/>. |
||||
|
# |
||||
|
############################################################################### |
||||
|
{ |
||||
|
'name': 'Purchase Product Configurator', |
||||
|
'version': '18.0.1.0.0', |
||||
|
'category': 'Purchases', |
||||
|
'summary': """Purchase variant selection options for products.""", |
||||
|
'description': """This module helps you to configure product variant |
||||
|
selection in the purchase order lines.""", |
||||
|
'author': 'Cybrosys Techno Solutions', |
||||
|
'company': 'Cybrosys Techno Solutions', |
||||
|
'maintainer': 'Cybrosys Techno Solutions', |
||||
|
'website': 'https://www.cybrosys.com', |
||||
|
'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' |
||||
|
], |
||||
|
}, |
||||
|
'images': ['static/description/banner.png'], |
||||
|
'license': 'AGPL-3', |
||||
|
'installable': True, |
||||
|
'auto_install': False, |
||||
|
'application': False |
||||
|
} |
@ -0,0 +1,22 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
############################################################################### |
||||
|
# |
||||
|
# Cybrosys Technologies Pvt. Ltd. |
||||
|
# |
||||
|
# Copyright (C) 2024-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) |
||||
|
# Author: Aysha Shalin (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 <http://www.gnu.org/licenses/>. |
||||
|
# |
||||
|
############################################################################### |
||||
|
from . import purchase_product_configurator |
@ -0,0 +1,220 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
############################################################################### |
||||
|
# |
||||
|
# Cybrosys Technologies Pvt. Ltd. |
||||
|
# |
||||
|
# Copyright (C) 2024-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) |
||||
|
# Author: Aysha Shalin (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 <http://www.gnu.org/licenses/>. |
||||
|
# |
||||
|
############################################################################### |
||||
|
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 |
||||
|
) |
@ -0,0 +1,6 @@ |
|||||
|
## Module <purchase_product_configurator> |
||||
|
|
||||
|
#### 16.10.2024 |
||||
|
#### Version 18.0.1.0.0 |
||||
|
##### ADD |
||||
|
- Initial Commit for Purchase Product Configurator. |
@ -0,0 +1,24 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
############################################################################### |
||||
|
# |
||||
|
# Cybrosys Technologies Pvt. Ltd. |
||||
|
# |
||||
|
# Copyright (C) 2024-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) |
||||
|
# Author: Aysha Shalin (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 <http://www.gnu.org/licenses/>. |
||||
|
# |
||||
|
############################################################################### |
||||
|
from . import product_attribute_custom_value |
||||
|
from . import product_template |
||||
|
from . import purchase_order_line |
@ -0,0 +1,35 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
############################################################################### |
||||
|
# |
||||
|
# Cybrosys Technologies Pvt. Ltd. |
||||
|
# |
||||
|
# Copyright (C) 2024-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) |
||||
|
# Author: Aysha Shalin (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 <http://www.gnu.org/licenses/>. |
||||
|
# |
||||
|
############################################################################### |
||||
|
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") |
@ -0,0 +1,78 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
############################################################################### |
||||
|
# |
||||
|
# Cybrosys Technologies Pvt. Ltd. |
||||
|
# |
||||
|
# Copyright (C) 2024-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) |
||||
|
# Author: Aysha Shalin (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 <http://www.gnu.org/licenses/>. |
||||
|
# |
||||
|
############################################################################### |
||||
|
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 |
@ -0,0 +1,64 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
############################################################################### |
||||
|
# |
||||
|
# Cybrosys Technologies Pvt. Ltd. |
||||
|
# |
||||
|
# Copyright (C) 2024-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) |
||||
|
# Author: Aysha Shalin (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 <http://www.gnu.org/licenses/>. |
||||
|
# |
||||
|
############################################################################### |
||||
|
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 |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 628 KiB |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 210 KiB |
After Width: | Height: | Size: 209 KiB |
After Width: | Height: | Size: 109 KiB |
After Width: | Height: | Size: 495 B |
After Width: | Height: | Size: 1.0 KiB |
After Width: | Height: | Size: 624 B |
After Width: | Height: | Size: 136 KiB |
After Width: | Height: | Size: 214 KiB |
After Width: | Height: | Size: 36 KiB |
After Width: | Height: | Size: 3.6 KiB |
After Width: | Height: | Size: 310 B |
After Width: | Height: | Size: 929 B |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 3.3 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 542 B |
After Width: | Height: | Size: 576 B |
After Width: | Height: | Size: 733 B |
After Width: | Height: | Size: 4.3 KiB |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 4.0 KiB |
After Width: | Height: | Size: 1.7 KiB |
After Width: | Height: | Size: 738 KiB |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 911 B |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 600 B |
After Width: | Height: | Size: 673 B |
After Width: | Height: | Size: 2.0 KiB |
After Width: | Height: | Size: 462 B |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 926 B |
After Width: | Height: | Size: 9.0 KiB |
After Width: | Height: | Size: 23 KiB |
After Width: | Height: | Size: 7.0 KiB |
After Width: | Height: | Size: 878 B |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 653 B |
After Width: | Height: | Size: 800 B |
After Width: | Height: | Size: 905 B |
After Width: | Height: | Size: 189 KiB |
After Width: | Height: | Size: 4.3 KiB |
After Width: | Height: | Size: 839 B |
After Width: | Height: | Size: 1.7 KiB |
After Width: | Height: | Size: 5.9 KiB |
After Width: | Height: | Size: 1.6 KiB |
After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 26 KiB |
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 23 KiB |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 427 B |
After Width: | Height: | Size: 627 B |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 988 B |
After Width: | Height: | Size: 3.7 KiB |
After Width: | Height: | Size: 5.0 KiB |
After Width: | Height: | Size: 875 B |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 142 KiB |
After Width: | Height: | Size: 138 KiB |
After Width: | Height: | Size: 90 KiB |
After Width: | Height: | Size: 90 KiB |
After Width: | Height: | Size: 91 KiB |
After Width: | Height: | Size: 86 KiB |
After Width: | Height: | Size: 116 KiB |
After Width: | Height: | Size: 82 KiB |
After Width: | Height: | Size: 121 KiB |
After Width: | Height: | Size: 101 KiB |
After Width: | Height: | Size: 142 KiB |
After Width: | Height: | Size: 880 KiB |
After Width: | Height: | Size: 93 KiB |
After Width: | Height: | Size: 12 KiB |
@ -0,0 +1,67 @@ |
|||||
|
/** @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) { |
||||
|
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); |
||||
|
} |
||||
|
} |
@ -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; |
||||
|
} |
@ -0,0 +1,79 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8" ?> |
||||
|
<!--Template for Product--> |
||||
|
<templates xml:space="preserve"> |
||||
|
<t t-name="purchase_product_configurator.product"> |
||||
|
<td class="o_purchase_product_configurator_img py-3 px-0"> |
||||
|
<img |
||||
|
t-if="this.props.id" |
||||
|
class="w-100" |
||||
|
t-att-src="'/web/image/product.product/'+this.props.id+'/image_128'" |
||||
|
alt="Product Image"/> |
||||
|
<img |
||||
|
t-else="" |
||||
|
class="w-100" |
||||
|
t-att-src="'/web/image/product.template/'+this.props.product_tmpl_id+'/image_128'" |
||||
|
alt="Product Image"/> |
||||
|
</td> |
||||
|
<td class="p-3" t-att-colspan="this.props.optional ? 2:false"> |
||||
|
<div class="mb-4 text-break" name="o_purchase_product_configurator_name"> |
||||
|
<h5 t-out="this.props.display_name"/> |
||||
|
<div |
||||
|
t-if="this.props.description_sale" |
||||
|
t-out="this.props.description_sale" |
||||
|
class="text-muted small"/> |
||||
|
<div t-if="!this.env.isPossibleCombination(this.props)" class="alert alert-warning mt-3"> |
||||
|
<span>This option or combination of options is not available</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<t t-foreach="this.props.attribute_lines" t-as="ptal" t-key="ptal.id"> |
||||
|
<PTAL t-props="ptal" productTmplId="this.props.product_tmpl_id"/> |
||||
|
</t> |
||||
|
</td> |
||||
|
<td class="o_purchase_product_configurator_qty py-3 px-0 text-end"> |
||||
|
<div t-if="!this.props.optional" class="input-group justify-content-end"> |
||||
|
<button |
||||
|
class="btn btn-secondary d-none d-md-inline-block" |
||||
|
aria-label="Remove one" |
||||
|
t-on-click="decreaseQuantity"> |
||||
|
<i class="fa fa-minus"/> |
||||
|
</button> |
||||
|
<input |
||||
|
class="form-control quantity border-bottom border-top text-center" |
||||
|
name="product_quantity" |
||||
|
type="number" |
||||
|
t-att-value="this.props.quantity" |
||||
|
t-on-change="setQuantity"/> |
||||
|
<button |
||||
|
class="btn btn-secondary d-none d-md-inline-block" |
||||
|
aria-label="Add one" |
||||
|
t-on-click="increaseQuantity"> |
||||
|
<i class="fa fa-plus"/> |
||||
|
</button> |
||||
|
</div> |
||||
|
<div t-else=""> |
||||
|
<h5 class="text-nowrap" t-out="getFormattedPrice()"/> |
||||
|
</div> |
||||
|
<a |
||||
|
class="d-block mt-2" |
||||
|
role="button" |
||||
|
t-if="!this.props.optional && this.env.mainProductTmplId !== this.props.product_tmpl_id" |
||||
|
t-on-click="() => this.env.removeProduct(this.props.product_tmpl_id)"> |
||||
|
Remove product |
||||
|
</a> |
||||
|
</td> |
||||
|
<td class="o_purchase_product_configurator_price py-3 px-0 text-end" name="price"> |
||||
|
<div t-if="!this.props.optional" class="input-group justify-content-end"> |
||||
|
<h5 class="text-nowrap" t-out="getFormattedPrice()"/> |
||||
|
</div> |
||||
|
<div t-else=""> |
||||
|
<button |
||||
|
t-if="this.props.optional" |
||||
|
class="btn btn-secondary" |
||||
|
t-att-class="{'disabled': !this.env.isPossibleCombination(this.props)}" |
||||
|
t-on-click="() => this.env.addProduct(this.props.product_tmpl_id)"> |
||||
|
<i class="fa fa-plus"/> Add |
||||
|
</button> |
||||
|
</div> |
||||
|
</td> |
||||
|
</t> |
||||
|
</templates> |
@ -0,0 +1,375 @@ |
|||||
|
/** @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 { rpc } from "@web/core/network/rpc"; |
||||
|
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 = 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(); |
||||
|
} |
||||
|
} |
@ -0,0 +1,24 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8" ?> |
||||
|
<!--Product configurator dialog template--> |
||||
|
<templates xml:space="preserve"> |
||||
|
<t t-name="purchase_product_configurator.dialog"> |
||||
|
<Dialog size="size" title="title" contentClass="this.state.optionalProducts.length ? 'h-75' : ''"> |
||||
|
<PurchaseProductList t-if="this.state.products.length" products="this.state.products"/> |
||||
|
<PurchaseProductList |
||||
|
t-if="this.state.optionalProducts.length" |
||||
|
products="this.state.optionalProducts" |
||||
|
areProductsOptional="true"/> |
||||
|
<t t-set-slot="footer"> |
||||
|
<button |
||||
|
class="btn btn-primary" |
||||
|
t-on-click="onConfirm" |
||||
|
t-att-disabled="!isPossibleConfiguration()"> |
||||
|
Confirm |
||||
|
</button> |
||||
|
<button class="btn btn-secondary" t-on-click="onDiscard"> |
||||
|
Cancel |
||||
|
</button> |
||||
|
</t> |
||||
|
</Dialog> |
||||
|
</t> |
||||
|
</templates> |
@ -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, |
||||
|
) |
||||
|
} |
||||
|
} |
@ -0,0 +1,26 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8" ?> |
||||
|
<!--PurchaseProductList template--> |
||||
|
<templates xml:space="preserve"> |
||||
|
<t t-name="purchaseProductConfigurator.PurchaseProductList"> |
||||
|
<h4 class="mt-4 mb-3" t-if="this.props.areProductsOptional">Add optional products</h4> |
||||
|
<table class="o_purchase_product_configurator_table table table-sm position-relative mb-0" t-att-class="{'o_purchase_product_configurator_table_optional': this.props.areProductsOptional}"> |
||||
|
<thead t-if="!this.props.areProductsOptional"> |
||||
|
<tr> |
||||
|
<th class="px-0 border-bottom-0" colspan="2">Product</th> |
||||
|
<th class="px-0 text-end border-bottom-0">Quantity</th> |
||||
|
<th class="px-0 text-end border-bottom-0">Price</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody class="border-top-0"> |
||||
|
<tr t-foreach="this.props.products" t-as="product" t-key="product.product_tmpl_id"> |
||||
|
<Product t-props="product" optional="this.props.areProductsOptional"/> |
||||
|
</tr> |
||||
|
<tr t-if="!this.props.areProductsOptional"> |
||||
|
<td colspan="4" class="border-bottom-0 text-end"> |
||||
|
<h4>Total: <span t-out="getFormattedTotal()"/></h4> |
||||
|
</td> |
||||
|
</tr> |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</t> |
||||
|
</templates> |