@ -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> |