diff options
| author | Aurélien Warnon <awa@odoo.com> | 2018-08-30 11:59:54 +0200 |
|---|---|---|
| committer | Aurélien Warnon <awa@odoo.com> | 2018-09-25 18:39:32 +0200 |
| commit | 856c2e9008f1af7bc1327d9c2900db1e109ab0fa (patch) | |
| tree | 7ec14601948c0dab4607417c0043d75cd83c0549 /addons | |
| parent | fd07990b85cfc87ee0c9a8353bb6c966d403f6bf (diff) | |
[IMP] product,(website_)sale: Move product options configurator into sale module
Task #1871557
Purpose
=======
- Configure a product from a sales order as easily as in the ecommerce
The backend uses the same template as the frontend to configure a products and its options
- Display optionnal products of optionnal products in the "sale options" section
- Allow adding exlusions to some combinations of the product and/or
to some combinations of the optionnal and accessory products
Specification
=============
PHASE I
1. Improve the "Add to cart" with optional products
- When you add an option to cart, move the product to the cart & show the options of this option
- Don't open the option wizard if no option available (in case this module becomes generic,
it's better to skip this step when it's not needed)
- Display the options right under their related product in the cart
2. Do not allow to add the product as an optional product for itself
Why ?
- No functional sense
- Creates issues in the add to cart wizard
3. Exclude some attribute combinations within the product or with related options and accessory products
- Rename VARIANT PRICES button -> CONFIGURE VARIANTS
- When you click this button and select a value, open form view rather than inline edition
- Form view of variant values:
- Attribute
- Value
- HTML Color Index
- Attribute Price Extra
- Not Compatible with: o2m tab with 2 fields: Product AND Attribute Value (of this product)
[m2m tag selection]
- in any new line, autocomplete the current product by default
- Tooltip: A list of product and attribute values that you want to exclude for
this product's attribue value. Also applies on optionnal and accessory products.
4. New option to configure a product in a sales order line
- This option is only visible if the corresponding option is activated in the sales settings
- It opens a wizard to select a product template, configure related attribute values
and select optional products (based on frontend view)
- Attributes not compatible with other selected attributes should not be selectable
- Must work like in the frontend
Diffstat (limited to 'addons')
83 files changed, 2891 insertions, 1144 deletions
diff --git a/addons/point_of_sale/tests/test_frontend.py b/addons/point_of_sale/tests/test_frontend.py index 6f604e13451..64dba64d84b 100644 --- a/addons/point_of_sale/tests/test_frontend.py +++ b/addons/point_of_sale/tests/test_frontend.py @@ -31,15 +31,14 @@ class TestUi(odoo.tests.HttpCase): pear = env.ref('point_of_sale.whiteboard') attribute_value = env['product.attribute.value'].create({ 'name': 'add 2', - 'product_ids': [(6, 0, [pear.id])], 'attribute_id': env['product.attribute'].create({ 'name': 'add 2', }).id, }) - env['product.attribute.price'].create({ + env['product.product.attribute.value'].create({ 'product_tmpl_id': pear.product_tmpl_id.id, 'price_extra': 2, - 'value_id': attribute_value.id, + 'product_attribute_value_id': attribute_value.id, }) fixed_pricelist = env['product.pricelist'].create({ diff --git a/addons/product/data/product_demo.xml b/addons/product/data/product_demo.xml index dd730d92904..0a2ac250e55 100644 --- a/addons/product/data/product_demo.xml +++ b/addons/product/data/product_demo.xml @@ -212,16 +212,6 @@ <field name="attribute_line_ids" eval="[(6,0,[ref('product.product_attribute_line_1'), ref('product.product_attribute_line_2')])]"/> </record> - <record id="product_product_4d" model="product.product"> - <field name="active" eval="False"/> - </record> - - <record id="product_attribute_price_1" model="product.attribute.price"> - <field name="product_tmpl_id" ref="product_product_4_product_template"/> - <field name="value_id" ref="product_attribute_value_2"/> - <field name="price_extra">50.40</field> - </record> - <record id="product_product_5" model="product.product"> <field name="name">Corner Desk Right Sit</field> <field name="categ_id" ref="product_category_5"/> @@ -320,14 +310,34 @@ <field name="attribute_id" ref="product_attribute_1"/> <field name="value_ids" eval="[(6,0,[ref('product.product_attribute_value_1'), ref('product.product_attribute_value_2')])]"/> </record> - + <record id="product_product_11_product_template" model="product.template"> <field name="attribute_line_ids" eval="[(6,0,[ref('product.product_attribute_line_4')])]"/> </record> - <record id="product_attribute_price_2" model="product.attribute.price"> - <field name="product_tmpl_id" ref="product_product_11_product_template"/> - <field name="value_id" ref="product_attribute_value_2"/> + <!-- + Handle automatically created product.product.attribute.value. + Meaning that the combination between the "customizable desk" and the attribute value "black" will be materialized + into a "product.product.attribute.value" with the ref "product.product_product_attribute_value_1". + This will allow setting fields like "price_extra" and "exclude_for" + --> + <function model="ir.model.data" name="_update_xmlids"> + <value model="base" eval="[{ + 'xml_id': 'product.product_product_attribute_value_1', + 'record': obj().env.ref('product.product_attribute_line_1').product_value_ids[1], + 'noupdate': True, + }, { + 'xml_id': 'product.product_product_attribute_value_2', + 'record': obj().env.ref('product.product_attribute_line_4').product_value_ids[1], + 'noupdate': True, + }]"/> + </function> + + <record id="product_product_attribute_value_1" model="product.product.attribute.value"> + <field name="price_extra">50.40</field> + </record> + + <record id="product_product_attribute_value_2" model="product.product.attribute.value"> <field name="price_extra">6.40</field> </record> diff --git a/addons/product/models/product.py b/addons/product/models/product.py index 3318c9a075a..78cb392dcf2 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -93,7 +93,7 @@ class ProductProduct(models.Model): lst_price = fields.Float( 'Sale Price', compute='_compute_product_lst_price', digits=dp.get_precision('Product Price'), inverse='_set_product_lst_price', - help="The sale price is managed from the product template. Click on the 'Variant Prices' button to set the extra attribute prices.") + help="The sale price is managed from the product template. Click on the 'Configure Variants' button to set the extra attribute prices.") default_code = fields.Char('Internal Reference', index=True) code = fields.Char('Reference', compute='_compute_product_code') @@ -110,6 +110,8 @@ class ProductProduct(models.Model): help="International Article Number used for product identification.") attribute_value_ids = fields.Many2many( 'product.attribute.value', string='Attributes', ondelete='restrict') + product_attribute_value_ids = fields.Many2many( + 'product.product.attribute.value', string='Attribute Values', compute="_compute_product_attribute_value_ids") # image: all image fields are base64 encoded and PIL-supported image_variant = fields.Binary( "Variant Image", attachment=True, @@ -193,15 +195,10 @@ class ProductProduct(models.Model): value -= product.price_extra product.write({'list_price': value}) - @api.depends('attribute_value_ids.price_ids.price_extra', 'attribute_value_ids.price_ids.product_tmpl_id') + @api.depends('product_attribute_value_ids.price_extra') def _compute_product_price_extra(self): - # TDE FIXME: do a real multi and optimize a bit ? for product in self: - price_extra = 0.0 - for attribute_price in product.mapped('attribute_value_ids.price_ids'): - if attribute_price.product_tmpl_id == product.product_tmpl_id: - price_extra += attribute_price.price_extra - product.price_extra = price_extra + product.price_extra = sum(product.mapped('product_attribute_value_ids.price_extra')) @api.depends('list_price', 'price_extra') def _compute_product_lst_price(self): @@ -276,6 +273,12 @@ class ProductProduct(models.Model): else: self.product_tmpl_id.image = image + def _compute_product_attribute_value_ids(self): + for product in self: + product.product_attribute_value_ids = self.env['product.product.attribute.value']._search([ + ('product_tmpl_id', '=', product.product_tmpl_id.id), + ('product_attribute_value_id', 'in', product.attribute_value_ids.ids)]) + @api.one def _get_pricelist_items(self): self.pricelist_item_ids = self.env['product.pricelist.item'].search([ diff --git a/addons/product/models/product_attribute.py b/addons/product/models/product_attribute.py index e2c20145455..0a348370199 100644 --- a/addons/product/models/product_attribute.py +++ b/addons/product/models/product_attribute.py @@ -19,75 +19,67 @@ class ProductAttribute(models.Model): create_variant = fields.Boolean(default=True, help="Check this if you want to create multiple variants for this attribute.") -class ProductAttributevalue(models.Model): +class ProductAttributeValue(models.Model): _name = "product.attribute.value" _order = 'attribute_id, sequence, id' _description = 'Product Attribute Value' - name = fields.Char('Attribute Value', required=True, translate=True) - sequence = fields.Integer('Sequence', help="Determine the display order") - attribute_id = fields.Many2one('product.attribute', 'Attribute', ondelete='cascade', required=True) - product_ids = fields.Many2many('product.product', string='Variants', readonly=True) - price_extra = fields.Float( - 'Attribute Price Extra', compute='_compute_price_extra', inverse='_set_price_extra', - default=0.0, digits=dp.get_precision('Product Price'), - help="Price Extra: Extra price for the variant with this attribute value on sale price. eg. 200 price extra, 1000 + 200 = 1200.") - price_ids = fields.One2many('product.attribute.price', 'value_id', 'Attribute Prices', readonly=True) + name = fields.Char(string='Value', required=True, translate=True) + sequence = fields.Integer(string='Sequence', help="Determine the display order") + attribute_id = fields.Many2one('product.attribute', string='Attribute', ondelete='cascade', required=True) _sql_constraints = [ - ('value_company_uniq', 'unique (name,attribute_id)', 'This attribute value already exists !') + ('value_company_uniq', 'unique (name, attribute_id)', 'This attribute value already exists !') ] - @api.one - def _compute_price_extra(self): - if self._context.get('active_id'): - price = self.price_ids.filtered(lambda price: price.product_tmpl_id.id == self._context['active_id']) - self.price_extra = price.price_extra - else: - self.price_extra = 0.0 - - def _set_price_extra(self): - if not self._context.get('active_id'): - return - - AttributePrice = self.env['product.attribute.price'] - prices = AttributePrice.search([('value_id', 'in', self.ids), ('product_tmpl_id', '=', self._context['active_id'])]) - updated = prices.mapped('value_id') - if prices: - prices.write({'price_extra': self.price_extra}) - else: - for value in self - updated: - AttributePrice.create({ - 'product_tmpl_id': self._context['active_id'], - 'value_id': value.id, - 'price_extra': self.price_extra, - }) - @api.multi def name_get(self): if not self._context.get('show_attribute', True): # TDE FIXME: not used - return super(ProductAttributevalue, self).name_get() + return super(ProductAttributeValue, self).name_get() return [(value.id, "%s: %s" % (value.attribute_id.name, value.name)) for value in self] @api.multi + def _variant_name(self, variable_attributes): + return ", ".join([v.name for v in self if v.attribute_id in variable_attributes]) + + @api.multi def unlink(self): linked_products = self.env['product.product'].with_context(active_test=False).search([('attribute_value_ids', 'in', self.ids)]) if linked_products: raise UserError(_('The operation cannot be completed:\nYou are trying to delete an attribute value with a reference on a product variant.')) - return super(ProductAttributevalue, self).unlink() + return super(ProductAttributeValue, self).unlink() + + +class ProductProductAttributeValue(models.Model): + _name = "product.product.attribute.value" + + name = fields.Char('Value', related="product_attribute_value_id.name") + product_attribute_value_id = fields.Many2one('product.attribute.value', string='Attribute Value', required=True, ondelete='cascade', index=True) + product_tmpl_id = fields.Many2one('product.template', string='Product Template', required=True, ondelete='cascade', index=True) + attribute_id = fields.Many2one('product.attribute', string='Attribute', related="product_attribute_value_id.attribute_id") + sequence = fields.Integer('product.sequence', related="product_attribute_value_id.sequence") + price_extra = fields.Float( + string='Attribute Price Extra', default=0.0, digits=dp.get_precision('Product Price'), + help="Price Extra: Extra price for the variant with this attribute value on sale price. eg. 200 price extra, 1000 + 200 = 1200.") + exclude_for = fields.Many2many( + 'product.attribute.filter.line', string="Exclude for", relation="product_attribute_value_exclusion", + help="""A list of product and attribute values that you want to exclude for this product's attribue value. + Also applies on optionnal and accessory products.""") @api.multi - def _variant_name(self, variable_attributes): - return ", ".join([v.name for v in self if v.attribute_id in variable_attributes]) + def name_get(self): + if not self._context.get('show_attribute', True): # TDE FIXME: not used + return super(ProductAttributeValue, self).name_get() + return [(value.id, "%s: %s" % (value.attribute_id.name, value.name)) for value in self] -class ProductAttributePrice(models.Model): - _name = "product.attribute.price" - _description = 'Product Attribute Price' +class ProductAttributeFilterLine(models.Model): + _name = "product.attribute.filter.line" - product_tmpl_id = fields.Many2one('product.template', 'Product Template', ondelete='cascade', required=True) - value_id = fields.Many2one('product.attribute.value', 'Product Attribute Value', ondelete='cascade', required=True) - price_extra = fields.Float('Price Extra', digits=dp.get_precision('Product Price')) + product_tmpl_id = fields.Many2one('product.template', string='Product Template', ondelete='cascade', required=True) + value_ids = fields.Many2many( + 'product.product.attribute.value', relation="product_attr_filter_line_value_ids_rel", + string='Attribute Values', domain="[('product_tmpl_id', '=', product_tmpl_id), ('attribute_id.create_variant', '=', True)]") class ProductAttributeLine(models.Model): @@ -95,9 +87,10 @@ class ProductAttributeLine(models.Model): _rec_name = 'attribute_id' _description = 'Product Attribute Line' - product_tmpl_id = fields.Many2one('product.template', 'Product Template', ondelete='cascade', required=True) - attribute_id = fields.Many2one('product.attribute', 'Attribute', ondelete='restrict', required=True) + product_tmpl_id = fields.Many2one('product.template', string='Product Template', ondelete='cascade', required=True) + attribute_id = fields.Many2one('product.attribute', string='Attribute', ondelete='restrict', required=True) value_ids = fields.Many2many('product.attribute.value', string='Attribute Values') + product_value_ids = fields.Many2many('product.product.attribute.value', string='Product Attribute Values', compute="_set_product_value_ids") @api.constrains('value_ids', 'attribute_id') def _check_valid_attribute(self): @@ -106,6 +99,64 @@ class ProductAttributeLine(models.Model): return True @api.model + def create(self, values): + res = super(ProductAttributeLine, self).create(values) + res._update_product_product_attribute_values() + return res + + def write(self, values): + res = super(ProductAttributeLine, self).write(values) + self._update_product_product_attribute_values() + return res + + @api.depends('value_ids') + def _set_product_value_ids(self): + for product_attribute_line in self: + product_attribute_line.product_value_ids = self.env['product.product.attribute.value'].search([ + ('product_tmpl_id', 'in', product_attribute_line.product_tmpl_id.ids), + ('product_attribute_value_id.attribute_id', 'in', product_attribute_line.value_ids.mapped('attribute_id').ids)]) + + @api.multi + def unlink(self): + for product_attribute_line in self: + self.env['product.product.attribute.value'].search([ + ('product_tmpl_id', 'in', product_attribute_line.product_tmpl_id.ids), + ('product_attribute_value_id.attribute_id', 'in', product_attribute_line.value_ids.mapped('attribute_id').ids)]).unlink() + + return super(ProductAttributeLine, self).unlink() + + def _update_product_product_attribute_values(self): + """ + Create or unlink product.product.attribute.value based on the attribute lines. + If the product.attribute.value is removed, remove the corresponding product.product.attribute.value + If no product.product.attribute.value exists for the newly added product.attribute.value, create it. + """ + for attribute_line in self: + # All existing product.product.attribute.value for this template + product_product_attribute_values_to_remove = self.env['product.product.attribute.value'].search([ + ('product_tmpl_id', '=', attribute_line.product_tmpl_id.id), + ('product_attribute_value_id.attribute_id', 'in', attribute_line.value_ids.mapped('attribute_id').ids)]) + # All existing product.attribute.value shared by all products + # eg (Yellow, Red, Blue, Small, Large) + existing_product_attribute_values = product_product_attribute_values_to_remove.mapped('product_attribute_value_id') + + # Loop on product.attribute.values for the line (eg: Yellow, Red, Blue) + for product_attribute_value in attribute_line.value_ids: + if product_attribute_value in existing_product_attribute_values: + # property is already existing: don't touch, remove it from list to avoid unlinking it + product_product_attribute_values_to_remove = product_product_attribute_values_to_remove.filtered( + lambda value: product_attribute_value not in value.mapped('product_attribute_value_id') + ) + else: + # property does not exist: create it + self.env['product.product.attribute.value'].create({ + 'product_attribute_value_id': product_attribute_value.id, + 'product_tmpl_id': attribute_line.product_tmpl_id.id}) + # at this point, existing properties can be removed to reflect the modifications on value_ids + if product_product_attribute_values_to_remove: + product_product_attribute_values_to_remove.unlink() + + @api.model def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): # TDE FIXME: currently overriding the domain; however as it includes a # search on a m2o and one on a m2m, probably this will quickly become diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index e035e68cffe..2255403b4df 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -479,6 +479,59 @@ class ProductTemplate(models.Model): return True + @api.multi + def get_filtered_variants(self, reference_product=None): + """ + Will filter availability (excluded) for the product + combinations (ex: color: white excludes size: large). + + Will also filter availability (excluded) for the parent + product if specified (meaning that this product is a an optionnal or + accessory product of the reference_product). + Args: + reference_product (product.product): The reference product that has + the current product as an option or accessory product. + Returns: + The filtered list of product variants + """ + self.ensure_one() + product_product_attribute_values = self.env['product.product.attribute.value'].search([('product_tmpl_id', '=', self.id)]) + if reference_product: + # append the reference_product if provided + product_product_attribute_values |= reference_product.product_attribute_value_ids + product_variants = self.product_variant_ids + + for product_product_attribute_value in product_product_attribute_values: + # CASE 1: The whole product is excluded when no attribute values are selected in the parent product + # returns empty recordset of product.product if so. What is checked is: + # If the product_attribute value doesn't belong to self (i.e. belongs to the reference product) + # and self is the excluded product template on the exclusion lines + # and the exclusions is on the product without specified product attribute values (i.e. the whole product is excluded) + if product_product_attribute_value.product_tmpl_id != self \ + and self in product_product_attribute_value.exclude_for.mapped('product_tmpl_id') \ + and not product_product_attribute_value.exclude_for.filtered( + lambda excluded_product_attribute_value: excluded_product_attribute_value.product_tmpl_id == self).value_ids: + return self.env['product.product'] + + # CASE 2: Check if some of the product.product.attribute.value of the product are excluded + # for this prodcut. A variant could be excluded: + # - Either by itself (eg: The office chair with iron legs excludes the color white) + # - Or by the reference product (eg: The customizable desk with iron legs excludes the office chair with aluminium legs) + for excluded in product_product_attribute_value.exclude_for.filtered( + lambda excluded_product_attribute_value: excluded_product_attribute_value.product_tmpl_id == self): + product_variants -= product_variants.filtered( + lambda variant: + # 1/ Check the applicability of the exlusion + # i.e: the restiction comes from the parent + # OR the restriction is on a product_attribute_value that this variant has, eg: + # if the office chair with iron legs excludes the color white, we must check + # that this variant has iron legs to check the exclusion + (product_product_attribute_value.product_tmpl_id != self or product_product_attribute_value in variant.product_attribute_value_ids) and + # 2/ Check the variant has one of the exluded attribute values + any(attribute_value in excluded.value_ids for attribute_value in variant.product_attribute_value_ids)) + + return product_variants + @api.model def get_empty_list_help(self, help): self = self.with_context( diff --git a/addons/product/models/res_config_settings.py b/addons/product/models/res_config_settings.py index 67988dd6fe2..0750136085e 100644 --- a/addons/product/models/res_config_settings.py +++ b/addons/product/models/res_config_settings.py @@ -13,7 +13,7 @@ class ResConfigSettings(models.TransientModel): " * Checked : Product are visible for every company, even if a company is defined on the partner.\n" " * Unchecked : Each company can see only its product (product where company is defined). Product not related to a company are visible for all companies.") group_uom = fields.Boolean("Units of Measure", implied_group='uom.group_uom') - group_product_variant = fields.Boolean("Attributes and Variants", implied_group='product.group_product_variant') + group_product_variant = fields.Boolean("Variants and Options", implied_group='product.group_product_variant') group_stock_packaging = fields.Boolean('Product Packagings', implied_group='product.group_stock_packaging') group_sale_pricelist = fields.Boolean("Use pricelists to adapt your price per customers", diff --git a/addons/product/security/ir.model.access.csv b/addons/product/security/ir.model.access.csv index 8518bb1fe64..4c76d40b2f6 100644 --- a/addons/product/security/ir.model.access.csv +++ b/addons/product/security/ir.model.access.csv @@ -10,7 +10,8 @@ access_product_product_employee,product.product employee,model_product_product,b access_product_price_history_employee,prices.history employee,model_product_price_history,base.group_user,1,0,0,0
access_product_attribute,product.attribute,model_product_attribute,base.group_user,1,0,0,0
access_product_attribute_value,product.attribute value,model_product_attribute_value,base.group_user,1,0,0,0
-access_product_attribute_price,product.attribute price,model_product_attribute_price,base.group_user,1,0,0,0
+access_product_product_attribute,product.product.attribute value,model_product_product_attribute_value,base.group_user,1,0,0,0
+access_product_attribute_filter_line,product.attribute filter line,model_product_attribute_filter_line,base.group_user,1,0,0,0
access_product_attribute_line,product.attribute line,model_product_attribute_line,base.group_user,1,0,0,0
access_product_category_manager,product.category.manager,model_product_category,base.group_system,1,1,1,1
access_product_template_manager,product.template.manager,model_product_template,base.group_system,1,1,1,1
@@ -22,5 +23,6 @@ access_product_product_manager,product.product manager,model_product_product,bas access_product_price_history_manager,prices.history manager,model_product_price_history,base.group_system,1,1,1,1
access_product_attribute_manager,product.attribute manager,model_product_attribute,base.group_system,1,1,1,1
access_product_attribute_value_manager,product.attribute value manager,model_product_attribute_value,base.group_system,1,1,1,1
-access_product_attribute_price_manager,product.attribute price manager,model_product_attribute_price,base.group_system,1,1,1,1
+access_product_product_attribute_manager,product.product.attribute value manager,model_product_product_attribute_value,base.group_system,1,1,1,1
+access_product_attribute_filter_line_manager,product.attribute filter line manager,model_product_attribute_filter_line,base.group_system,1,1,1,1
access_product_attribute_line_manager,product.attribute line manager,model_product_attribute_line,base.group_system,1,1,1,1
diff --git a/addons/product/tests/__init__.py b/addons/product/tests/__init__.py index 96caad34928..2dfb724036d 100644 --- a/addons/product/tests/__init__.py +++ b/addons/product/tests/__init__.py @@ -1,4 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import test_variants, test_pricelist, test_product_pricelist, test_seller +from . import test_variants +from . import test_pricelist +from . import test_product_pricelist +from . import test_seller +from . import test_product_attribute_value_config diff --git a/addons/product/tests/test_product_attribute_value_config.py b/addons/product/tests/test_product_attribute_value_config.py new file mode 100644 index 00000000000..8a290aa9c02 --- /dev/null +++ b/addons/product/tests/test_product_attribute_value_config.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import TransactionCase + + +class TestProductAttributeValueConfig(TransactionCase): + def setUp(self): + super(TestProductAttributeValueConfig, self).setUp() + + self.computer = self.env['product.template'].create({ + 'name': 'Super Computer' + }) + + self._add_ssd_attribute() + self._add_ram_attribute() + self._add_hdd_attribute() + + self.computer.create_variant_ids() + + self.computer_case = self.env['product.template'].create({ + 'name': 'Super Computer Case' + }) + + self._add_size_attribute() + + self.computer_case.create_variant_ids() + + def test_product_product_attribute_values_creation(self): + self.assertEqual(len(self.computer_ssd_attribute_lines.product_value_ids), 2, 'Product attribute values (ssd) were not automatically created') + self.assertEqual(len(self.computer_ram_attribute_lines.product_value_ids), 3, 'Product attribute values (ram) were not automatically created') + self.assertEqual(len(self.computer_hdd_attribute_lines.product_value_ids), 3, 'Product attribute values (hdd) were not automatically created') + self.assertEqual(len(self.computer_case_size_attribute_lines.product_value_ids), 3, 'Product attribute values (size) were not automatically created') + + def test_product_filtered_exclude_for(self): + """ + Super Computer has 18 variants total (2 ssd * 3 ram * 3 hdd) + RAM 16 excudes HDD 1, that matches 2 variants: + - SSD 256 RAM 16 HDD 1 + - SSD 512 RAM 16 HDD 1 + + => There has to be 16 variants left when filtered + """ + self._add_ram_exclude_for() + self.assertEqual(len(self.computer.get_filtered_variants()), 16) + self.assertFalse(self._get_variant_for_attribute_values(self.computer, [self.ssd_256, self.ram_16, self.hdd_1])) + self.assertFalse(self._get_variant_for_attribute_values(self.computer, [self.ssd_512, self.ram_16, self.hdd_1])) + + def test_children_product_filtered_exclude_for(self): + """ + Super Computer Case has 3 variants total (3 size) + Reference product Computer with HDD 4 excludes Size M + The following variant will be excluded: + - Size M + + => There has to be 2 variants left when filtered + """ + + self._add_hdd_excludes_computer_case() + hdd_4_variant = self.computer.product_variant_ids.filtered( + lambda variant: self.hdd_4 in variant.product_attribute_value_ids.mapped('product_attribute_value_id'))[0] + self.assertEqual(len(self.computer_case.get_filtered_variants(hdd_4_variant)), 2) + self.assertFalse(self._get_variant_for_attribute_values(self.computer_case, [self.size_m], hdd_4_variant)) + + def _add_ssd_attribute(self): + self.ssd_attribute = self.env['product.attribute'].create({'name': 'Memory'}) + self.ssd_256 = self.env['product.attribute.value'].create({ + 'name': '256 GB', + 'attribute_id': self.ssd_attribute.id + }) + self.ssd_512 = self.env['product.attribute.value'].create({ + 'name': '512 GB', + 'attribute_id': self.ssd_attribute.id + }) + self.computer_ssd_attribute_lines = self.env['product.attribute.line'].create({ + 'product_tmpl_id': self.computer.id, + 'attribute_id': self.ssd_attribute.id, + 'value_ids': [(6, 0, [self.ssd_256.id, self.ssd_512.id])] + }) + + def _add_ram_attribute(self): + self.ram_attribute = self.env['product.attribute'].create({'name': 'RAM'}) + self.ram_8 = self.env['product.attribute.value'].create({ + 'name': '8 GB', + 'attribute_id': self.ram_attribute.id + }) + self.ram_16 = self.env['product.attribute.value'].create({ + 'name': '16 GB', + 'attribute_id': self.ram_attribute.id + }) + self.ram_32 = self.env['product.attribute.value'].create({ + 'name': '32 GB', + 'attribute_id': self.ram_attribute.id + }) + self.computer_ram_attribute_lines = self.env['product.attribute.line'].create({ + 'product_tmpl_id': self.computer.id, + 'attribute_id': self.ram_attribute.id, + 'value_ids': [(6, 0, [self.ram_8.id, self.ram_16.id, self.ram_32.id])] + }) + + def _add_hdd_attribute(self): + self.hdd_attribute = self.env['product.attribute'].create({'name': 'HDD'}) + self.hdd_1 = self.env['product.attribute.value'].create({ + 'name': '1 To', + 'attribute_id': self.hdd_attribute.id + }) + self.hdd_2 = self.env['product.attribute.value'].create({ + 'name': '2 To', + 'attribute_id': self.hdd_attribute.id + }) + self.hdd_4 = self.env['product.attribute.value'].create({ + 'name': '4 To', + 'attribute_id': self.hdd_attribute.id + }) + self.computer_hdd_attribute_lines = self.env['product.attribute.line'].create({ + 'product_tmpl_id': self.computer.id, + 'attribute_id': self.hdd_attribute.id, + 'value_ids': [(6, 0, [self.hdd_1.id, self.hdd_2.id, self.hdd_4.id])] + }) + + def _add_ram_exclude_for(self): + self.ram_16_excludes_hdd_1 = self.env['product.attribute.filter.line'].create({ + 'product_tmpl_id': self.computer.id, + 'value_ids': [(6, 0, [self._get_product_value_id(self.computer_hdd_attribute_lines, self.hdd_1).id])] + }) + self._get_product_value_id(self.computer_ram_attribute_lines, self.ram_16).update({ + 'exclude_for': [(6, 0, [self.ram_16_excludes_hdd_1.id])] + }) + + def _add_size_attribute(self): + self.size_attribute = self.env['product.attribute'].create({'name': 'Size'}) + self.size_m = self.env['product.attribute.value'].create({ + 'name': 'M', + 'attribute_id': self.size_attribute.id + }) + self.size_l = self.env['product.attribute.value'].create({ + 'name': 'L', + 'attribute_id': self.size_attribute.id + }) + self.size_xl = self.env['product.attribute.value'].create({ + 'name': 'XL', + 'attribute_id': self.size_attribute.id + }) + self.computer_case_size_attribute_lines = self.env['product.attribute.line'].create({ + 'product_tmpl_id': self.computer_case.id, + 'attribute_id': self.size_attribute.id, + 'value_ids': [(6, 0, [self.size_m.id, self.size_l.id, self.size_xl.id])] + }) + + def _add_hdd_excludes_computer_case(self): + self.hdd_4_excludes_computer_case_m = self.env['product.attribute.filter.line'].create({ + 'product_tmpl_id': self.computer_case.id, + 'value_ids': [(6, 0, [self._get_product_value_id(self.computer_case_size_attribute_lines, self.size_m).id])] + }) + self._get_product_value_id(self.computer_hdd_attribute_lines, self.hdd_4).update({ + 'exclude_for': [(6, 0, [self.hdd_4_excludes_computer_case_m.id])] + }) + + def _get_product_value_id(self, product_attribute_lines, product_attribute_value): + return product_attribute_lines.product_value_ids.filtered( + lambda product_value_id: product_value_id.product_attribute_value_id == product_attribute_value)[0] + + def _get_variant_for_attribute_values(self, product, attribute_values, reference_product=None): + return product.get_filtered_variants(reference_product).filtered( + lambda variant: + all(attribute_value in variant.attribute_value_ids for attribute_value in attribute_values)) diff --git a/addons/product/views/product_attribute_views.xml b/addons/product/views/product_attribute_views.xml index 0cc4c019f13..e3f9df657c4 100644 --- a/addons/product/views/product_attribute_views.xml +++ b/addons/product/views/product_attribute_views.xml @@ -1,95 +1,119 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> - <record id="product_attribute_value_view_tree" model="ir.ui.view"> - <field name="name">product.attribute.value.view.tree</field> - <field name="model">product.attribute.value</field> + <record id="product_product_attribute_value_view_tree" model="ir.ui.view"> + <field name="name">product.product.attribute.value.view.tree</field> + <field name="model">product.product.attribute.value</field> + <field name="type">tree</field> <field name="arch" type="xml"> - <tree string="Attributes" editable="top"> - <field name="sequence" widget="handle"/> + <tree string="Attributes" create="0" delete="0"> <field name="attribute_id"/> <field name="name"/> - <field name="price_extra"/> + <field name="price_extra" /> </tree> </field> </record> + + <record id="product_product_attribute_value_view_form" model="ir.ui.view"> + <field name="name">product.product.attribute.value.view.form.</field> + <field name="model">product.product.attribute.value</field> + <field name="type">form</field> + <field name="arch" type="xml"> + <form string="Product Attribute" create="0" delete="0"> + <group> + <field name="name" /> + <field name="price_extra" /> + <field name="exclude_for" widget="one2many_list" mode="tree"> + <tree editable="top"> + <field name="product_tmpl_id" /> + <field name="value_ids" widget="many2many_tags" options="{'no_create': true}" /> + </tree> + </field> + </group> + </form> + </field> + </record> + <record id="product_attribute_value_action" model="ir.actions.act_window"> - <field name="name">Variant Values</field> - <field name="type">ir.actions.act_window</field> - <field name="res_model">product.attribute.value</field> - <field name="view_mode">tree</field> - <field name="view_type">form</field> - <field name="view_id" ref="product_attribute_value_view_tree"/> - <field name="domain">[('product_ids.product_tmpl_id', '=', active_id)]</field> - <field name="context">{'default_product_tmpl_id': active_id}</field> + <field name="name">Product Variant Values</field> + <field name="res_model">product.product.attribute.value</field> + <field name="view_mode">tree,form</field> + <field name="domain">[('product_tmpl_id', '=', active_id), ('attribute_id.create_variant', '=', True)]</field> + <field name="view_ids" + eval="[(5, 0, 0), + (0, 0, {'view_mode': 'tree', 'view_id': ref('product.product_product_attribute_value_view_tree')}), + (0, 0, {'view_mode': 'form', 'view_id': ref('product.product_product_attribute_value_view_form')})]" /> + <field name="context">{ + 'default_product_tmpl_id': active_id + }</field> </record> - <record id="attribute_tree_view" model="ir.ui.view"> - <field name="name">product.attribute.tree</field> - <field name="model">product.attribute</field> - <field name="arch" type="xml"> - <tree string="Variant Values" editable="top"> - <field name="sequence" widget="handle"/> - <field name="name"/> - <field name="create_variant"/> - </tree> - </field> - </record> + <record id="attribute_tree_view" model="ir.ui.view"> + <field name="name">product.attribute.tree</field> + <field name="model">product.attribute</field> + <field name="arch" type="xml"> + <tree string="Variant Values" editable="top"> + <field name="sequence" widget="handle"/> + <field name="name"/> + <field name="create_variant"/> + </tree> + </field> + </record> - <record id="product_attribute_view_form" model="ir.ui.view"> - <field name="name">product.attribute.form</field> - <field name="model">product.attribute</field> - <field name="arch" type="xml"> - <form string="Product Attribute"> - <group> - <group name="main_fields"> - <label for="name" string="Attribute Name"/> - <field name="name" nolabel="1"/> - </group> - </group> - <group name="values_ids"> - <label for="value_ids" string="Attribute Values"/> - <field name="value_ids" widget="one2many_list" nolabel="1"> - <tree string="Values" editable="bottom"> - <field name="name"/> - </tree> - <form string="Values"> - <field name="name"/> - </form> - </field> - <field name="create_variant"/> + <record id="product_attribute_view_form" model="ir.ui.view"> + <field name="name">product.attribute.form</field> + <field name="model">product.attribute</field> + <field name="arch" type="xml"> + <form string="Product Attribute"> + <group> + <group name="main_fields"> + <label for="name" string="Attribute Name"/> + <field name="name" nolabel="1"/> </group> - </form> - </field> - </record> + </group> + <group name="values_ids"> + <label for="value_ids" string="Attribute Values"/> + <field name="value_ids" widget="one2many_list" nolabel="1"> + <tree string="Values" editable="bottom"> + <field name="name"/> + </tree> + <form string="Values"> + <field name="name"/> + </form> + </field> + <field name="create_variant"/> + </group> + </form> + </field> + </record> - <record id="attribute_action" model="ir.actions.act_window"> - <field name="name">Attributes</field> - <field name="type">ir.actions.act_window</field> - <field name="res_model">product.attribute</field> - <field name="view_mode">tree</field> - <field name="view_type">form</field> - </record> + <record id="attribute_action" model="ir.actions.act_window"> + <field name="name">Attributes</field> + <field name="type">ir.actions.act_window</field> + <field name="res_model">product.attribute</field> + <field name="view_mode">tree</field> + <field name="view_type">form</field> + </record> - <record id="variants_tree_view" model="ir.ui.view"> - <field name="name">product.attribute.value.tree</field> - <field name="model">product.attribute.value</field> - <field name="arch" type="xml"> - <tree string="Variant Values" editable="top"> - <field name="sequence" widget="handle"/> - <field name="attribute_id"/> - <field name="name"/> - </tree> - </field> - </record> + <record id="variants_tree_view" model="ir.ui.view"> + <field name="name">product.attribute.value.tree</field> + <field name="model">product.attribute.value</field> + <field name="arch" type="xml"> + <tree string="Variant Values" editable="top"> + <field name="sequence" widget="handle"/> + <field name="attribute_id"/> + <field name="name"/> + </tree> + </field> + </record> - <record id="variants_action" model="ir.actions.act_window"> - <field name="name">Attribute Values</field> - <field name="type">ir.actions.act_window</field> - <field name="res_model">product.attribute.value</field> - <field name="view_mode">tree</field> - <field name="view_type">form</field> - </record> + <record id="variants_action" model="ir.actions.act_window"> + <field name="name">Attribute Values</field> + <field name="type">ir.actions.act_window</field> + <field name="res_model">product.attribute.value</field> + <field name="view_mode">tree</field> + <field name="view_type">form</field> + </record> <record id="product_attribute_line_form" model="ir.ui.view"> <field name="name">product.attribute.line.form</field> diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml index 48e2714d99c..afeab2387f8 100644 --- a/addons/product/views/product_views.xml +++ b/addons/product/views/product_views.xml @@ -9,7 +9,7 @@ <field name="arch" type="xml"> <form string="Product"> <header> - <button string="Variant Prices" type="action" + <button string="Configure Variants" type="action" name="%(product_attribute_value_action)d" attrs="{'invisible': [('product_variant_count', '<=', 1)]}" groups="product.group_product_variant"/> diff --git a/addons/sale/__manifest__.py b/addons/sale/__manifest__.py index 03ea9b45f96..ec9faf38bd2 100644 --- a/addons/sale/__manifest__.py +++ b/addons/sale/__manifest__.py @@ -28,9 +28,12 @@ This module contains all the common features of Sales Management and eCommerce. 'views/mail_activity_views.xml', 'views/assets.xml', 'views/sale_portal_templates.xml', + 'views/sale_product_configurator_templates.xml', 'views/sale_onboarding_views.xml', 'views/res_config_settings_views.xml', 'views/payment_views.xml', + 'views/product_attribute_views.xml', + 'wizard/sale_product_configurator_views.xml', ], 'demo': [ 'data/sale_demo.xml', @@ -38,5 +41,5 @@ This module contains all the common features of Sales Management and eCommerce. ], 'uninstall_hook': "uninstall_hook", 'installable': True, - 'auto_install': False, + 'auto_install': False } diff --git a/addons/sale/controllers/__init__.py b/addons/sale/controllers/__init__.py index 1a195b71756..ac5f1269f82 100644 --- a/addons/sale/controllers/__init__.py +++ b/addons/sale/controllers/__init__.py @@ -3,3 +3,4 @@ from . import onboarding from . import portal +from . import product_configurator diff --git a/addons/sale/controllers/product_configurator.py b/addons/sale/controllers/product_configurator.py new file mode 100644 index 00000000000..19145abbefa --- /dev/null +++ b/addons/sale/controllers/product_configurator.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import http, fields +from odoo.http import request + + +class ProductConfiguratorController(http.Controller): + @http.route(['/product_configurator/configure'], type='json', auth="user", methods=['POST']) + def configure(self, product_id, pricelist_id, **kw): + product_template = request.env['product.template'].browse(int(product_id)) + to_currency = product_template.currency_id + pricelist = self._get_pricelist(pricelist_id) + if pricelist: + product_template = product_template.with_context(pricelist=pricelist.id, partner=request.env.user.partner_id) + to_currency = pricelist.currency_id + + return request.env['ir.ui.view'].render_template("sale.product_configurator_configure", { + 'product': product_template, + 'to_currency': to_currency, + 'pricelist': pricelist, + 'get_attribute_value_ids': self._get_attribute_value_ids + }) + + @http.route(['/product_configurator/show_optional_products'], type='json', auth="user", methods=['POST']) + def show_optional_products(self, product_id, pricelist_id, **kw): + return self._show_optional_products(product_id, self._get_pricelist(pricelist_id), False, **kw) + + @http.route(['/product_configurator/optional_product_items'], type='json', auth="user", methods=['POST']) + def optional_product_items(self, product_id, pricelist_id, **kw): + return self._optional_product_items(product_id, self._get_pricelist(pricelist_id), **kw) + + @http.route(['/product_configurator/get_unit_price'], type='json', auth="user", methods=['POST']) + def get_unit_price(self, product_ids, add_qty, pricelist_id, **kw): + return self._get_unit_price(product_ids, add_qty, self._get_pricelist(pricelist_id)) + + def _optional_product_items(self, product_id, pricelist, **kw): + product = request.env['product.product'].with_context(self._get_product_context(pricelist, **kw)).browse(int(product_id)) + + return request.env['ir.ui.view'].render_template("sale.optional_product_items", { + 'product': product, + 'reference_product': product, + 'pricelist': pricelist, + 'get_attribute_value_ids': self._get_attribute_value_ids, + 'main_product_attr_ids': self._get_main_product_attr_ids(product), + }) + + def _show_optional_products(self, product_id, pricelist, handle_stock, **kw): + # quantity = kw['kwargs']['context']['quantity'] + + product = request.env['product.product'].browse(int(product_id)) + from_currency = request.env.user.company_id.currency_id + to_currency = pricelist.currency_id if pricelist else product.currency_id + company = request.env['res.company'].browse(request.env.context.get('company_id')) or request.env['res.users']._get_company() + date = request.env.context.get('date') or fields.Date.today() + compute_currency = lambda price: from_currency._convert(price, to_currency, company, date) + + product = product.with_context(self._get_product_context(pricelist, **kw)) + + has_optional_products = False + for optional_product in product.optional_product_ids: + if optional_product.get_filtered_variants(product): + has_optional_products = True + break + + if not has_optional_products: + return False + else: + return request.env['ir.ui.view'].render_template("sale.optional_products_modal", { + 'product': product, + 'reference_product': product, + 'pricelist': pricelist, + 'compute_currency': compute_currency, + 'to_currency': to_currency, + 'handle_stock': handle_stock, + 'get_attribute_value_ids': self._get_attribute_value_ids, + 'main_product_attr_ids': self._get_main_product_attr_ids(product), + }) + + def _get_attribute_value_ids(self, product, reference_product=None, pricelist=None): + """ list of selectable attributes of a product + + Args: + - product (product.template): The base product template that will be split into variants + - reference_product (product.product): The reference product from which 'product' is an optional or accessory product + - pricelist (product.pricelist): A pricelist that will be used to compute the product price + + :return: list of product variant description + (variant id, [visible attribute ids], variant price, variant sale price) + """ + # product attributes with at least two choices + quantity = product._context.get('quantity') or 1 + product = product.with_context(quantity=quantity, partner=request.env.user.partner_id) + to_currency = None + if pricelist: + product = product.with_context(pricelist=pricelist.id) + to_currency = pricelist.currency_id + + visible_attrs_ids = product.attribute_line_ids.filtered(lambda l: len(l.value_ids) > 1).mapped('attribute_id').ids + attribute_value_ids = [] + for variant in product.get_filtered_variants(reference_product): + public_price = variant.lst_price + if to_currency and to_currency != product.currency_id: + public_price = variant.currency_id._convert( + public_price, to_currency, + request.env.user.company_id, fields.Date.today() + ) + + visible_attribute_ids = [v.id for v in variant.product_attribute_value_ids if v.attribute_id.id in visible_attrs_ids] + attribute_value_ids.append([variant.id, visible_attribute_ids, variant.price if variant.price else variant.lst_price, public_price, variant.display_name, len(variant.optional_product_ids) > 0]) + + return attribute_value_ids + + def _get_main_product_attr_ids(self, product): + main_product_attr_ids = self._get_attribute_value_ids(product.product_tmpl_id) + for variant in main_product_attr_ids: + if variant[0] == product.id: + # We indeed need a list of lists (even with only 1 element) + main_product_attr_ids = [variant] + break + + return main_product_attr_ids + + def _get_product_context(self, pricelist=None, **kw): + product_context = dict(request.context) + if pricelist: + if not product_context.get('pricelist'): + product_context['pricelist'] = pricelist.id + product_context.update(kw.get('kwargs', {}).get('context', {})) + + return product_context + + def _get_unit_price(self, product_ids, add_qty, pricelist, **kw): + products = request.env['product.product'].with_context({'quantity': add_qty, 'pricelist': pricelist.id if pricelist else None}).browse(product_ids) + return {product.id: product.price if product.price else product.lst_price for product in products} + + def _get_pricelist(self, pricelist_id): + return request.env['product.pricelist'].browse(int(pricelist_id)) if pricelist_id and pricelist_id != '0' else None diff --git a/addons/sale/data/sale_demo.xml b/addons/sale/data/sale_demo.xml index 7f8f5a1693b..496a58721af 100644 --- a/addons/sale/data/sale_demo.xml +++ b/addons/sale/data/sale_demo.xml @@ -691,6 +691,19 @@ Thanks!</field> <field name="price_unit">38.25</field> </record> + <record id="product.product_attribute_2" model="product.attribute"> + <field name="type">color</field> + </record> + <record id="product.product_attribute_3" model="product.attribute"> + <field name="type">select</field> + </record> + + <record id="product.product_attribute_value_3" model="product.attribute.value"> + <field name="html_color">#FFFFFF</field> + </record> + <record id="product.product_attribute_value_4" model="product.attribute.value"> + <field name="html_color">#000000</field> + </record> <!-- Confirm some Sales Orders--> <function model="sale.order" name="action_confirm" eval="[[ref('portal_sale_order_2')]]"/> @@ -700,5 +713,72 @@ Thanks!</field> <function model="crm.team" name="message_subscribe" eval="[ref('sales_team.team_sales_department'),ref('sales_team.salesteam_website_sales')], [ref('base.partner_root')]"/> + <record id="product_product_1_product_template" model="product.template"> + <field name="name">Chair floor protection</field> + <field name="categ_id" ref="product.product_category_5"/> + <field name="list_price">12.0</field> + <field name="uom_id" ref="uom.product_uom_unit"/> + <field name="uom_po_id" ref="uom.product_uom_unit"/> + <field name="description_sale">Office chairs can harm your floor: protect it.</field> + <field name="image" type="base64" file="sale/static/img/floor_protection-image.png"/> + </record> + <record id="product.product_product_4_product_template" model="product.template"> + <field name="optional_product_ids" eval="[(6,0,[ref('product.product_product_11_product_template')])]"/> + </record> + <record id="product.product_product_11_product_template" model="product.template"> + <field name="optional_product_ids" eval="[(6,0,[ref('product_product_1_product_template')])]"/> + </record> + + <!-- + Handle automatically created product.product.attribute.value. + Check "product.product_product_attribute_value_1" for more information about this + --> + <function model="ir.model.data" name="_update_xmlids"> + <value model="base" eval="[{ + 'xml_id': 'sale.product_product_attribute_value_3', + 'record': obj().env.ref('product.product_attribute_line_1').product_value_ids[0], + 'noupdate': True, + }, { + 'xml_id': 'sale.product_product_attribute_value_4', + 'record': obj().env.ref('product.product_attribute_line_4').product_value_ids[0], + 'noupdate': True, + }, { + 'xml_id': 'sale.product_product_attribute_value_5', + 'record': obj().env.ref('product.product_attribute_line_2').product_value_ids[1], + 'noupdate': True, + }]"/> + </function> + + <record id="product_attribute_filter_line_1" model="product.attribute.filter.line"> + <field name="product_tmpl_id" ref="product.product_product_4_product_template" /> + <field name="value_ids" eval="[(6,0,[ref('sale.product_product_attribute_value_5')])]"/> + </record> + + <record id="product_attribute_filter_line_2" model="product.attribute.filter.line"> + <field name="product_tmpl_id" ref="product.product_product_11_product_template" /> + <field name="value_ids" eval="[(6,0,[ref('sale.product_product_attribute_value_4')])]"/> + </record> + + <!-- + The "Customizable Desk's Aluminium" attribute value will excude: + - The "Customizable Desk's Black" attribute + - The "Office Chair's Steel" attribute + --> + <record id="product.product_product_attribute_value_1" model="product.product.attribute.value"> + <field name="exclude_for" eval="[(6,0,[ref('sale.product_attribute_filter_line_1') ,ref('sale.product_attribute_filter_line_2')])]" /> + </record> + + <record id="product_attribute_filter_line_3" model="product.attribute.filter.line"> + <field name="product_tmpl_id" ref="product.product_product_11_product_template" /> + <field name="value_ids" eval="[(6,0,[ref('product.product_product_attribute_value_2')])]"/> + </record> + + <!-- + The "Customizable Desk's Steel" attribute value will excude: + - The "Office Chair's Aluminium" attribute + --> + <record id="product_product_attribute_value_3" model="product.product.attribute.value"> + <field name="exclude_for" eval="[(6,0,[ref('sale.product_attribute_filter_line_3')])]" /> + </record> </data> </odoo> diff --git a/addons/sale/models/product_product.py b/addons/sale/models/product_product.py index 4061ecea987..02096def606 100644 --- a/addons/sale/models/product_product.py +++ b/addons/sale/models/product_product.py @@ -6,6 +6,16 @@ from odoo import api, fields, models from odoo.tools.float_utils import float_round +class ProductTemplate(models.Model): + _inherit = "product.template" + + optional_product_ids = fields.Many2many( + 'product.template', 'product_optional_rel', 'src_id', '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.).") + + class ProductProduct(models.Model): _inherit = 'product.product' @@ -42,3 +52,29 @@ class ProductProduct(models.Model): def _get_invoice_policy(self): return self.invoice_policy + + +class ProductAttribute(models.Model): + _inherit = "product.attribute" + + # YTI FIX ME: PLEASE RENAME ME INTO attribute_type + type = fields.Selection([ + ('radio', 'Radio'), + ('select', 'Select'), + ('color', 'Color')], default='radio') + + +class ProductAttributeValue(models.Model): + _inherit = "product.attribute.value" + + html_color = fields.Char( + string='HTML Color Index', oldname='color', + help="""Here you can set a + specific HTML color index (e.g. #ff0000) to display the color if the + attibute type is 'Color'.""") + + +class ProductProductAttributeValue(models.Model): + _inherit = "product.product.attribute.value" + + html_color = fields.Char('HTML Color Index', related="product_attribute_value_id.html_color")
\ No newline at end of file diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index aa317ebff59..df3cedc0cde 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -159,6 +159,7 @@ class SaleOrder(models.Model): partner_shipping_id = fields.Many2one('res.partner', string='Delivery Address', readonly=True, required=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)], 'sale': [('readonly', False)]}, help="Delivery address for current sales order.") pricelist_id = fields.Many2one('product.pricelist', string='Pricelist', required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="Pricelist for current sales order.") + pricelist_id_id = fields.Integer(string="Pricelist ID", related='pricelist_id.id', readonly=True) currency_id = fields.Many2one("res.currency", related='pricelist_id.currency_id', string="Currency", readonly=True, required=True) analytic_account_id = fields.Many2one('account.analytic.account', 'Analytic Account', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="The analytic account related to a sales order.", copy=False, oldname='project_id') @@ -928,6 +929,16 @@ class SaleOrderLine(models.Model): else: line.invoice_status = 'no' + @api.model + def default_get(self, fields): + res = super(SaleOrderLine, self).default_get(fields) + if self._context.get('product_id'): + res['product_id'] = int(self._context.get('product_id')) + if self._context.get('quantity'): + res['product_uom_qty'] = float(self._context.get('quantity')) + + return res + @api.depends('product_uom_qty', 'discount', 'price_unit', 'tax_id') def _compute_amount(self): """ @@ -1384,7 +1395,7 @@ class SaleOrderLine(models.Model): domain = {'product_uom': [('category_id', '=', self.product_id.uom_id.category_id.id)]} if not self.product_uom or (self.product_id.uom_id.id != self.product_uom.id): vals['product_uom'] = self.product_id.uom_id - vals['product_uom_qty'] = 1.0 + vals['product_uom_qty'] = self.product_uom_qty or 1.0 product = self.product_id.with_context( lang=self.order_id.partner_id.lang, diff --git a/addons/sale/security/ir.model.access.csv b/addons/sale/security/ir.model.access.csv index eeb1e148252..71eb9fa6e41 100644 --- a/addons/sale/security/ir.model.access.csv +++ b/addons/sale/security/ir.model.access.csv @@ -41,7 +41,8 @@ access_product_template_sale_manager,product.template salemanager,model_product_ access_product_product_sale_manager,product.product salemanager,model_product_product,sales_team.group_sale_manager,1,1,1,1
access_product_attribute_sale_manager,product.attribute manager,product.model_product_attribute,sales_team.group_sale_manager,1,1,1,1
access_product_attribute_value_sale_manager,product.attribute manager value,product.model_product_attribute_value,sales_team.group_sale_manager,1,1,1,1
-access_product_attribute_price_sale_manager,product.attribute manager price,product.model_product_attribute_price,sales_team.group_sale_manager,1,1,1,1
+access_product_product_attribute_sale_manager,product.product.attribute manager value,product.model_product_product_attribute_value,sales_team.group_sale_manager,1,1,1,1
+access_product_attribute_filter_line_sale_manager,product.attribute manager filter line,product.model_product_attribute_filter_line,sales_team.group_sale_manager,1,1,1,1
access_product_attribute_line_sale_manager,product.attribute manager line,product.model_product_attribute_line,sales_team.group_sale_manager,1,1,1,1
access_account_tax_sale_manager,account.tax sale manager,account.model_account_tax,sales_team.group_sale_salesman,1,0,0,0
access_account_journal_sale_manager,account.journal sale manager,account.model_account_journal,sales_team.group_sale_salesman,1,0,0,0
diff --git a/addons/sale/static/img/floor_protection-image.png b/addons/sale/static/img/floor_protection-image.png Binary files differnew file mode 100644 index 00000000000..ef540b90c91 --- /dev/null +++ b/addons/sale/static/img/floor_protection-image.png diff --git a/addons/sale/static/src/js/product_configurator.js b/addons/sale/static/src/js/product_configurator.js new file mode 100644 index 00000000000..0be903d31d3 --- /dev/null +++ b/addons/sale/static/src/js/product_configurator.js @@ -0,0 +1,168 @@ +odoo.define('sale.ProductConfiguratorFormRenderer', function (require) { + "use strict"; + + var FormRenderer = require('web.FormRenderer'); + var ProductConfiguratorUtils = require('sale.product_configurator_utils'); + + var ProductConfiguratorFormRenderer = FormRenderer.extend({ + /** + * @override + */ + start: function () { + this._super.apply(this, arguments); + this.$el.append('<div class="configurator_container"></div>'); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @public + * Renders the product configurator within the form + */ + render_configurator: function (configurator) { + var $configurator_container = this.$el.find('.configurator_container'); + $configurator_container.empty(); + + var $configurator = $(configurator); + $configurator.appendTo($configurator_container); + + ProductConfiguratorUtils.addConfiguratorEvents($configurator_container, "", false, $('.js_sale_order_pricelist_id').html()); + + $('.js_add_cart_variants', $configurator_container).each(function () { + $('input.js_variant_change, select.js_variant_change', this).first().trigger('change'); + }); + } + }); + + return ProductConfiguratorFormRenderer; +}); + +odoo.define('sale.ProductConfiguratorFormController', function (require) { + "use strict"; + + var core = require('web.core'); + var _t = core._t; + var ajax = require('web.ajax'); + var FormController = require('web.FormController'); + var OptionalProductsModal = require('sale.OptionalProductsModal'); + + var ProductConfiguratorFormController = FormController.extend({ + custom_events: _.extend({}, FormController.prototype.custom_events, { + field_changed: '_onFieldChanged' + }), + className: 'o_product_configurator', + /** + * @override + * We need to override the default click behavior for our "Add" button + * because there is a possibility that this product has optional products. + * If so, we need to display an extra modal to choose the options + */ + _onButtonClicked: function (event) { + if(event.stopPropagation){ + event.stopPropagation(); + } + var attrs = event.data.attrs; + if (attrs.special === 'cancel') { + this._super.apply(this, arguments); + } else { + if(!this.$el.parents('.modal').find('.o_sale_product_configurator_add').hasClass('disabled')){ + this._handleAdd(this.$el); + } + } + }, + /** + * @override + * This is overriden to allow catching the "select" event on our product template select field. + * This will not work anymore if more fields are added to the form. + * TODO: Find a better way to catch that event. + */ + _onFieldChanged: function (event) { + var self = this; + + self.$el.parents('.modal').find('.o_sale_product_configurator_add').removeClass('disabled'); + + ajax.jsonRpc("/product_configurator/configure", 'call', { + 'product_id': event.data.changes.product_template_id.id, + 'pricelist_id': $('.js_sale_order_pricelist_id').html() + }).then(function (configurator) { + self.renderer.render_configurator(configurator); + }); + + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * When the user adds a product that has optional products, we need to display + * a window to allow the user to choose these extra options + */ + _handleAdd: function ($modal) { + var self = this; + var quantity = parseFloat($modal.find('input[name="add_qty"]').val() || 1); + var product_id = parseInt($modal.find('input[type="hidden"][name="product_id"], input[type="radio"][name="product_id"]:checked').first().val(), 10); + + var modal = new OptionalProductsModal(quantity, product_id, $('body'), $('.js_sale_order_pricelist_id').html()); + + // No optional products found for this product, + // only add the root product + modal.on('options_empty', null, function () { + self._addProducts([{ + product_id: product_id, + quantity: quantity + }]); + }); + + // Add all the selected products + modal.on('confirm', null, function () { + self._addProducts(modal.getSelectedProducts()); + }); + + // Change the buttons translation when the modal is ready + modal.on('modal_ready', null, function ($modal) { + $modal.find('a.go-back').attr('title', _t('Back')); + $modal.find('a.go-back .d-md-inline').html(_t('Back')); + $modal.find('a.confirm').attr('title', _t('Confirm')); + $modal.find('a.confirm .d-md-inline').html(_t('Confirm')); + }); + + modal.appendTo($('body')); + }, + + /** + * @private + * This triggers the close action for the window and adds the product as the "infos" parameter. + * It will allow the caller of this window to handle the added products. + */ + _addProducts: function(products) { + this.do_action({type: 'ir.actions.act_window_close', infos: products}); + } + }); + + return ProductConfiguratorFormController; +}); + +odoo.define('sale.ProductConfiguratorFormView', function (require) { + "use strict"; + + var ProductConfiguratorFormController = require('sale.ProductConfiguratorFormController'); + var ProductConfiguratorFormRenderer = require('sale.ProductConfiguratorFormRenderer'); + var FormView = require('web.FormView'); + var viewRegistry = require('web.view_registry'); + + var ProductConfiguratorFormView = FormView.extend({ + config: _.extend({}, FormView.prototype.config, { + Controller: ProductConfiguratorFormController, + Renderer: ProductConfiguratorFormRenderer, + }), + }); + + viewRegistry.add('product_configurator_form', ProductConfiguratorFormView); + + return ProductConfiguratorFormView; +});
\ No newline at end of file diff --git a/addons/sale/static/src/js/product_configurator_list_editable_renderer.js b/addons/sale/static/src/js/product_configurator_list_editable_renderer.js new file mode 100644 index 00000000000..f69fb47a4af --- /dev/null +++ b/addons/sale/static/src/js/product_configurator_list_editable_renderer.js @@ -0,0 +1,71 @@ +/** + * This adds the possibility to open the product configurator when the control + * "Configure a product" is clicked + */ +odoo.define('sale.ProductConfiguratorEditableListRenderer', function (require) { + "use strict"; + + var core = require('web.core'); + var _t = core._t; + var ListRenderer = require('web.ListRenderer'); + + ListRenderer.include({ + /** + * @override + */ + _onAddRecord: function (ev) { + // we don't want the browser to navigate to a the # url + ev.preventDefault(); + + // we don't want the click to cause other effects, such as unselecting + // the row that we are creating, because it counts as a click on a tr + ev.stopPropagation(); + + // but we do want to unselect current row + var self = this; + this.unselectRow().then(function () { + var open_product_configurator = false; + var context = ev.currentTarget.dataset.context; + + var context_json = false; + if(context){ + context = context.replace(/\'/g, '"'); + context_json = JSON.parse(context); + + open_product_configurator = context_json.open_product_configurator; + } + + if(open_product_configurator){ + self._rpc({ + model: 'ir.model.data', + method: 'xmlid_to_res_id', + kwargs: {xmlid: 'sale.sale_product_configurator_view_form'}, + }).then(function(res_id) { + self.do_action({ + name: _t('Configure a product'), + type: 'ir.actions.act_window', + res_model: 'sale.product.configurator', + views: [[res_id, 'form']], + target: 'new' + }, { + on_close: function (products) { + if(products && products != 'special'){ + self.trigger_up('add_record', { + context: products, + forceEditable: "bottom" , + allowWarning: true, + onSuccess: function(){ + self.unselectRow(); + } + }); + } + } + }); + }); + } else { + self.trigger_up('add_record', {context: context && [context]}); // TODO write a test, the deferred was not considered + } + }); + } + }); +});
\ No newline at end of file diff --git a/addons/sale/static/src/js/product_configurator_modal.js b/addons/sale/static/src/js/product_configurator_modal.js new file mode 100644 index 00000000000..0c0c1c422ba --- /dev/null +++ b/addons/sale/static/src/js/product_configurator_modal.js @@ -0,0 +1,298 @@ +odoo.define('sale.OptionalProductsModal', function (require) { + "use strict"; + +var ajax = require('web.ajax'); +var Widget = require('web.Widget'); +var ServicesMixin = require('web.ServicesMixin'); +var weContext = require('web_editor.context'); +var ProductConfiguratorUtils = require('sale.product_configurator_utils'); + +var product_name_map = {}; +var optional_products_map = {}; + +var OptionalProductsModal = Widget.extend(ServicesMixin, { + events: _.extend({}, Widget.prototype.events, { + 'click .css_attribute_color input': '_onColorClick', + }), + /** + * @override + */ + init: function (quantity, product_id, container, pricelistId, isWebsite) { + this._super.apply(this, arguments); + this.quantity = quantity; + this.product_id = product_id; + this.container = container; + this.pricelistId = pricelistId; + this.isWebsite = isWebsite; + }, + /** + * @override + */ + willStart: function () { + var self = this; + + var getModalContent = ajax.jsonRpc(this._getUri("/product_configurator/show_optional_products"), 'call', { + product_id: self.product_id, + pricelist_id: self.pricelistId, + kwargs: { + context: _.extend({'quantity': self.quantity}, weContext.get()), + } + }) + .then(function (modal_content) { + self.modal_content = modal_content; + }); + + var parentInit = self._super.apply(self, arguments); + return $.when(getModalContent, parentInit); + }, + /** + * @override + */ + start: function() { + this._renderModal(); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @public + * Returns the list of selected products as products[{product_id, quantity}] + */ + getSelectedProducts: function() { + var self = this; + var products = []; + this.$modal.find('.js_product.in_cart').each(function(){ + var quantity = 0; + var $item = $(this); + if($item.find('input[name="add_qty"]').length){ + quantity = $item.find('input[name="add_qty"]').val(); + } else { + quantity = parseInt($item.find('.optional_product_quantity span.add_qty').html().trim()); + } + + products.push({ + product_id: $item.find('input.product_id').length ? $item.find('input.product_id').val() : self.product_id, + quantity: quantity + }); + }); + + return products; + }, + + // ------------------------------------------ + // Private + // ------------------------------------------ + + _renderModal: function(){ + var self = this; + if (self.modal_content){ + var $modal = $(self.modal_content); + $modal.find('img:first').attr("src", "/web/image/product.product/" + self.product_id + "/image_medium"); + + $modal + .modal() + .appendTo(self.container) + .addClass('oe_optional_products_modal' + (self.isWebsite ? ' oe_website_sale' : '')) + .on('hidden.bs.modal', function () { + $(this).remove(); + }) + .on('shown.bs.modal', function () { + self.trigger('modal_ready', $modal); + }); + + self.$modal = $modal; + self._addModalEvents(); + } else { + self.trigger('options_empty'); + } + }, + + /** + * Website behavior is slighlty different from backend so we append "_website" to URLs to lead to a different route + */ + _getUri: function(uri) { + if (this.isWebsite){ + return uri + '_website'; + } else { + return uri; + } + }, + + _addModalEvents: function() { + this.$modal.on('click', '.a-submit', {self: this}, this._onSubmit); + this.$modal.on('click', '.css_attribute_color input', this._onColorClick); + this.$modal.on("click", 'a.js_add, a.js_remove', {self: this}, this._onAddOrRemoveOption); + this.$modal.on("change", "input.js_quantity", {self: this}, this._onChangeQuantity); + this.$modal.on("change", 'input[name="add_qty"]', this._onChangeMainProductQuantity); + ProductConfiguratorUtils.addConfiguratorEvents(this.$modal, "small", this.isWebsite, this.pricelistId); + + // trigger add_qty change to synchronise quantity with previous window + this.$modal.find('input[name="add_qty"]').val(this.quantity).change(); + + // trigger input change to validate fields and display custom prices + $('.js_add_cart_variants', this.$modal).each(function () { + $('input.js_variant_change, select.js_variant_change', this).first().trigger('change'); + }); + }, + + /** + * Handles the "Back" and "Confirm" buttons and triggers the according events + */ + _onSubmit: function(ev){ + var $modal = $(this).parents('.oe_optional_products_modal'); + $modal.modal('hide'); + ev.preventDefault(); + + var $a = $(this); + ev.data.self.trigger($a.hasClass('confirm') ? 'confirm' : 'back'); + }, + + /** + * TODO (awa): check if necessary + */ + _onColorClick: function(){ + var $modal = $(this).parents('.oe_optional_products_modal'); + $modal.find('.css_attribute_color').removeClass("active"); + $modal.find('.css_attribute_color:has(input:checked)').addClass("active"); + }, + + _onAddOrRemoveOption: function(ev){ + ev.preventDefault(); + var $modal = $(this).parents('.oe_optional_products_modal'); + var $select_options_text = $modal.find('.o_select_options'); + var $main_product = $modal.find('.js_product:first'); + var $parent = $(this).parents('.js_product:first'); + $parent.find("a.js_add, span.js_remove").toggleClass('d-none'); + $parent.find("input.js_optional_same_quantity").val( $(this).hasClass("js_add") ? 1 : 0 ); + $parent.find(".js_remove"); + + var product_id = $parent.find(".product_template_id").val(); + if ($(this).hasClass('js_add')) { + // remove attribute values selection and move quantity to the correct table spot (".td-qty")) + $parent.addClass('in_cart'); + $parent.find('.td-qty').addClass('text-center'); + $parent.find('.td-qty').append($parent.find('.optional_product_quantity')); + + // change product name to display_name (containing attribute values) when it's added to cart + // the "simple" name is kept into a map to revert to it if the product is removed from the cart + product_name_map[product_id] = $parent.find(".product-name").html(); + $parent.find(".product-name").html($parent.find(".product_display_name").val()); + + // if it's an optional product of an optional product, place it after it's parent + var parent_product_id = null; + for (var produt_id_key in optional_products_map) { + if (optional_products_map[produt_id_key].indexOf(product_id) != -1) { + parent_product_id = produt_id_key; + break; + } + + $('tr:last').after($parent); + } + + if (parent_product_id) { + $modal.find('.product_template_id').filter(function () { + return parent_product_id === $(this).val(); + }).parents('.js_product:first').after($parent); + } else { + // else, place it after the main product + $main_product.after($parent); + } + + var isWebsite = ev.data.self.isWebsite; + var pricelistId = ev.data.self.pricelistId; + var product_variant_id = $parent.find('.product_id').val(); + ajax.jsonRpc(ev.data.self._getUri("/product_configurator/optional_product_items"), 'call', { + 'product_id': product_variant_id, + 'pricelist_id': ev.data.self.pricelistId + }).then(function (addedItem) { + var $addedItem = $(addedItem); + $modal.find('tr:last').after($addedItem); + + ProductConfiguratorUtils.addConfiguratorEvents($addedItem, "small", isWebsite, pricelistId); + + // trigger input change to validate fields + $('.js_add_cart_variants', $addedItem).each(function () { + $('input.js_variant_change, select.js_variant_change', this).first().trigger('change'); + }); + + // update new options quantity + $modal.find('input[name="add_qty"]').trigger('change'); + + if ($addedItem.find(".product_template_id").length > 0) { + // we need to map this product with it's optional products to be able to: + // - remove them from the cart if the parent product is removed + // - place the optional products under their parent in the interface when they're added to the cart + optional_products_map[product_id] = $addedItem.find(".product_template_id").map(function () { + return $(this).val(); + }).get(); + } + + if($select_options_text.nextAll('.js_product').length === 0){ + // no more optional products to select -> hide the header + $select_options_text.hide(); + } + }); + } else { + // restore attribute values selection + $parent.removeClass('in_cart'); + $parent.find('.td-qty').removeClass('text-center'); + $parent.find('.js_remove.d-none').prepend($parent.find('.optional_product_quantity')); + + $select_options_text.show(); + + // revert back to original product name and clean the entry + $parent.find(".product-name").html(product_name_map[product_id]); + delete product_name_map[product_id]; + + if (optional_products_map[product_id]) { + // if the removed product had optional products, remove them as well + optional_products_map[product_id].forEach(function (productId) { + $modal.find('.product_template_id').filter(function () { + return productId === $(this).val(); + }).parents('.js_product:first').remove(); + }); + delete optional_products_map[product_id]; + } + + $('tr:last').after($parent); + } + + ProductConfiguratorUtils.computePriceTotal(); + }, + + _onChangeQuantity: function(ev){ + var qty = parseFloat($(this).val()); + var $modal = $(this).parents('.oe_optional_products_modal'); + if (qty === 1) { + $modal.find(".js_items").addClass('d-none').removeClass('add_qty'); + $modal.find(".js_item").removeClass('d-none').addClass('add_qty'); + } else { + $modal.find(".js_items").removeClass('d-none').addClass('add_qty').text(qty); + $modal.find(".js_item").addClass('d-none').removeClass('add_qty'); + } + }, + + _onChangeMainProductQuantity: function(){ + var $modal = $(this).parents('.oe_optional_products_modal'); + var product_id = $modal.find('span.oe_price[data-product-id]').first().data('product-id'); + var product_ids = [product_id]; + var $products_dom = []; + $modal.find("ul.js_add_cart_variants[data-attribute_value_ids]").each(function () { + var $el = $(this); + $products_dom.push($el); + _.each($el.data("attribute_value_ids"), function (values) { + product_ids.push(values[0]); + }); + }); + + // trigger input change to validate fields and display custom prices + $('.js_add_cart_variants', $modal).each(function () { + $('input.js_variant_change, select.js_variant_change', this).first().trigger('change'); + }); + } +}); +return OptionalProductsModal; +});
\ No newline at end of file diff --git a/addons/sale/static/src/js/product_configurator_utils.js b/addons/sale/static/src/js/product_configurator_utils.js new file mode 100644 index 00000000000..c97595075c4 --- /dev/null +++ b/addons/sale/static/src/js/product_configurator_utils.js @@ -0,0 +1,272 @@ +odoo.define('sale.product_configurator_utils', function (require) { + 'use strict'; + var core = require('web.core'); + var utils = require('web.utils'); + var ajax = require('web.ajax'); + var _t = core._t; + + /** + * returns the formatted price + * @param {float} price + */ + function _priceToStr (price) { + var l10n = _t.database.parameters; + var precision = 2; + + if ($(".decimal_precision").length) { + precision = parseInt($(".decimal_precision").last().data('precision')); + } + var formatted = _.str.sprintf('%.' + precision + 'f', price).split('.'); + formatted[0] = utils.insert_thousand_seps(formatted[0]); + return formatted.join(l10n.decimal_point); + } + + /** + * When a product is added or when the quantity is changed, + * we need to refresh the total price row + * TODO (awa): add a container context to avoid global selectors ? + */ + function computePriceTotal (){ + if($('.js_price_total').length){ + var price = 0; + $('.js_product.in_cart').each(function(){ + var quantity = parseInt($('input[name="add_qty"]').first().val()); + price += parseFloat($(this).find('.js_raw_price').html()) * quantity; + }); + + $('.js_price_total .oe_currency_value').html(_priceToStr(parseFloat(price))); + } + } + + function _updateProductImage ($productContainer, product_id, imagesSize) { + var $img; + var imageSrc = '/web/image?model=product.product&id={0}&field={1}' + .replace("{0}", product_id) + .replace("{1}", imagesSize ? ('image_' + imagesSize) : 'image'); + if ($productContainer.find('#o-carousel-product').length) { + $img = $productContainer.find('img.js_variant_img'); + $img.attr("src", imageSrc); + $img.parent().attr('data-oe-model', 'product.product').attr('data-oe-id', product_id) + .data('oe-model', 'product.product').data('oe-id', product_id); + + var $thumbnail = $productContainer.find('img.js_variant_img_small'); + if ($thumbnail.length !== 0) { // if only one, thumbnails are not displayed + $thumbnail.attr("src", "/web/image/product.product/" + product_id + "/image/90x90"); + $('.carousel').carousel(0); + } + } + else { + $img = $productContainer.find('span[data-oe-model^="product."][data-oe-type="image"] img:first, img.product_detail_img, span.variant_image img'); + $img.attr("src", imageSrc); + $img.parent().attr('data-oe-model', 'product.product').attr('data-oe-id', product_id) + .data('oe-model', 'product.product').data('oe-id', product_id); + } + // reset zooming constructs + $img.filter('[data-zoom-image]').attr('data-zoom-image', $img.attr('src')); + if ($img.data('zoomOdoo') !== undefined) { + $img.data('zoomOdoo').isReady = false; + } + } + + /** + * When a variant is changed, this will compute: + * - If the selected combination is available or not + * - The extra price if applicable + * - The display name of the product ("Customizable desk (White, Steel)") + * TODO (awa): change ugly [0], [1], [2], ... to a dict + */ + function onChangeVariant (ev, imagesSize) { + var $parent = $(ev.target).closest('.js_product'); + var $ul = $parent.find('.js_add_cart_variants'); + var $product_id = $parent.find('.product_id').first(); + var $product_display_name = $parent.find('.product_display_name').first(); + var $product_raw_price = $parent.find('.js_raw_price').first(); + var $price = $parent.find(".oe_price:first .oe_currency_value"); + var $default_price = $parent.find(".oe_default_price:first .oe_currency_value"); + var $optional_price = $parent.find(".oe_optional:first .oe_currency_value"); + var variant_ids = $ul.data("attribute_value_ids"); + var values = []; + var unchanged_values = $parent.find('div.oe_unchanged_value_ids').data('unchanged_value_ids') || []; + + _.each($parent.find('input.js_variant_change:checked, select.js_variant_change'), function (el) { + values.push(+$(el).val()); + }); + values = values.concat(unchanged_values); + var list_variant_id = parseInt($parent.find('input.js_product_change:checked').val()); + + $parent.find("label").removeClass("text-muted css_not_available"); + + var product_id = false; + var display_name = false; + var product_raw_price = false; + for (var k in variant_ids) { + if (_.isEmpty(_.difference(variant_ids[k][1], values)) || variant_ids[k][0] === list_variant_id) { + $price.html(_priceToStr(variant_ids[k][2])); + $default_price.html(_priceToStr(variant_ids[k][3])); + if (variant_ids[k][3]-variant_ids[k][2]>0.01) { + $default_price.closest('.oe_website_sale').addClass("discount"); + $optional_price.closest('.oe_optional').show().css('text-decoration', 'line-through'); + $default_price.parent().removeClass('d-none'); + } else { + $optional_price.closest('.oe_optional').hide(); + $default_price.parent().addClass('d-none'); + } + product_id = variant_ids[k][0]; + product_raw_price = variant_ids[k][2]; + display_name = variant_ids[k][4]; + _updateProductImage($(ev.currentTarget).closest('tr.js_product, .oe_website_sale, .o_product_configurator'), product_id, imagesSize); + break; + } + } + + _.each($parent.find("input.js_variant_change:radio, select.js_variant_change"), function (elem) { + var $input = $(elem); + var id = +$input.val(); + var values = [id]; + + _.each($parent.find("ul:not(:has(input.js_variant_change[value='" + id + "'])) input.js_variant_change:checked, select.js_variant_change"), function (elem) { + values.push(+$(elem).val()); + }); + + for (var k in variant_ids) { + if (!_.difference(values, variant_ids[k][1]).length) { + return; + } + } + $input.closest("label").addClass("css_not_available"); + $input.find("option[value='" + id + "']").addClass("css_not_available"); + }); + + if (product_id) { + $parent.removeClass("css_not_available"); + $product_id.val(product_id); + $product_display_name.val(display_name); + $product_raw_price.html(product_raw_price); + $parent.parents('.modal').find('.o_sale_product_configurator_add').removeClass("disabled"); + + var $add_to_cart_button = $parent.find("#add_to_cart"); + if(!$add_to_cart_button.hasClass('out_of_stock')){ + $add_to_cart_button.removeClass("disabled"); + } + } else { + $parent.addClass("css_not_available"); + $product_id.val(0); + $product_display_name.val(''); + $parent.find("#add_to_cart").addClass("disabled"); + $parent.parents('.modal').find('.o_sale_product_configurator_add').addClass("disabled"); + } + + computePriceTotal(); + } + + /** + * Highlight selected color + */ + function _onChangeColorAttribute (ev) { + var $parent = $(ev.target).closest('.js_product'); + $parent.find('.css_attribute_color') + .removeClass("active") + .filter(':has(input:checked)') + .addClass("active"); + } + + /** + * Hack to add and remove from cart with json + */ + function onClickAddCartJSON (ev) { + ev.preventDefault(); + var $link = $(ev.currentTarget); + var $input = $link.closest('.input-group').find("input"); + var product_id = +$input.closest('*:has(input[name="product_id"])').find('input[name="product_id"]').val(); + var min = parseFloat($input.data("min") || 0); + var max = parseFloat($input.data("max") || Infinity); + var quantity = ($link.has(".fa-minus").length ? -1 : 1) + parseFloat($input.val() || 0, 10); + var new_qty = quantity > min ? (quantity < max ? quantity : max) : min; + // if they are more of one input for this product (eg: option modal) + $('input[name="'+$input.attr("name")+'"]').add($input).filter(function () { + var $prod = $(this).closest('*:has(input[name="product_id"])'); + return !$prod.length || +$prod.find('input[name="product_id"]').val() === product_id; + }).val(new_qty).change(); + return false; + } + + /** + * When the quantity is changed, we need to query the new price of the product. + * Based on the pricelist, the price might change when quantity exceeds X + */ + function onChangeAddQuantity (ev, isWebsite, pricelistId, no_stock_check) { + var $parent; + if($(ev.currentTarget).closest('form').length > 0){ + $parent = $(ev.currentTarget).closest('form'); + } else if($(ev.currentTarget).closest('.oe_optional_products_modal').length > 0){ + $parent = $(ev.currentTarget).closest('.oe_optional_products_modal'); + } else { + $parent = $(ev.currentTarget).closest('.o_product_configurator'); + } + + var qty = $parent.find('input[name="add_qty"]').val(); + var productIDs = []; + $parent.find(".js_product .js_add_cart_variants").each(function () { + _.each($(this).data("attribute_value_ids"), function(entry){ + productIDs.push(entry[0]); + }); + }); + + if (productIDs.length) { + ajax.jsonRpc('/product_configurator/get_unit_price' + (isWebsite ? '_website' : ''), 'call', { + product_ids: productIDs, + add_qty: parseInt(qty), + pricelist_id: pricelistId + }).then(function (data) { + $parent.find(".js_product .js_add_cart_variants").each(function () { + var currentAttributesData = $(this).data("attribute_value_ids"); + for (var j = 0 ; j < currentAttributesData.length ; j++) { + // update the price of the product (index "2") based on its id (index "0") + currentAttributesData[j][2] = data[currentAttributesData[j][0]]; + } + $(this).trigger('change', [no_stock_check]); + + computePriceTotal(); + }); + }); + } + } + + /** + * Adds all the necessary events to handle: + * - Product variant changes + * - Color changes + * - Product price based on pricelist and quantity + * - Total price + * ... + */ + function addConfiguratorEvents ($container, imagesSize, isWebsite, pricelistId) { + $container + .find('.css_attribute_color input') + .change(_onChangeColorAttribute); + + $container + .find('input.js_variant_change, select.js_variant_change, input.js_product_change, [data-attribute_value_ids]') + .change(function(ev, no_stock_check){ + onChangeVariant(ev, imagesSize, no_stock_check); + }); + + $container + .find('input[name="add_qty"]') + .change(function(ev){ + onChangeAddQuantity(ev, isWebsite, pricelistId); + }); + + $container + .find('button.js_add_cart_json') + .click(onClickAddCartJSON); + } + + return { + addConfiguratorEvents: addConfiguratorEvents, + onChangeVariant: onChangeVariant, + onChangeAddQuantity: onChangeAddQuantity, + onClickAddCartJSON: onClickAddCartJSON, + computePriceTotal: computePriceTotal + }; +});
\ No newline at end of file diff --git a/addons/sale/static/src/scss/product_configurator.scss b/addons/sale/static/src/scss/product_configurator.scss new file mode 100644 index 00000000000..91970f144e7 --- /dev/null +++ b/addons/sale/static/src/scss/product_configurator.scss @@ -0,0 +1,178 @@ +.css_attribute_color { + display: inline-block; + border: 1px solid #999999; + text-align: center; + + input { + margin: 8px; + height: 13px; + opacity: 0; + } + + &.active { + border: 3px ridge #66ee66; + } + + &.active input { + margin: 6px; + } +} + +.css_not_available_msg { + display: none; +} + +.css_not_available.js_product { + > *:nth-child(4) > * { + display: none; + } + + .css_quantity, + .product_price { + display: none; + } + + .css_not_available_msg { + display: block; + } +} + +.css_quantity { + max-width: 125px; + + input[name="add_qty"] { + text-align: center; + } +} + +option.css_not_available { + color: #ccc; +} + +label.css_not_available { + opacity: 0.6; +} + +label.css_attribute_color.css_not_available { + opacity: 1; + background-image: url("/website_sale/static/src/img/redcross.png"); + background-size: cover; +} + +.o_product_configurator { + .product_detail_img { + max-height: 240px; + } +} + +.configurator_container { + ul ul { + margin-left: 1.5rem; + } +} + +.oe_optional_products_modal { + .table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(0, 0, 0, 0.025); + } + + .o_total_row { + h5 { + padding-top: 0.4rem; + } + .js_price_total { + font-size: 1.5rem; + } + } +} + +.js_product { + &.in_cart { + .js_add_cart_variants { + display: none; + } + } + + select { + -webkit-appearance: menulist; + -moz-appearance: menulist; + appearance: menulist; + background-image: none; + } + + .remove-optionnal-item, .add-optionnal-item { + padding-left: 5px; + } + + .td-product_name { + word-wrap: break-word; + } + + .td-product_name { + min-width: 140px; + } + + .td-img { + width: 100px; + } + + .td-qty { + width: 130px; + a.input-group-addon { + background-color: transparent; + border: 0px; + } + + .input-group { + display: inline-flex; + } + } + .td-action { + width: 30px; + } + + .td-price, + .td-price-total { + width: 120px; + } + + @include media-breakpoint-down(sm) { + .td-img, + .td-price-total { + display: none; + } + + .td-qty { + width: 60px; + } + + .td-price { + width: 80px; + } + } + + @media (max-width: 476px) { + .td-qty { + width: 60px; + } + + #modal_optional_products table thead, + .oe_cart table thead { + display: none; + } + + #modal_optional_products table td.td-img, + .oe_cart table td.td-img { + display: none; + } + } +} + +.o_total_row { + height: 50px; +} + +.oe_striked_price { + text-decoration: line-through; + white-space: nowrap; +}
\ No newline at end of file diff --git a/addons/sale/static/tests/product_configurator.test.js b/addons/sale/static/tests/product_configurator.test.js new file mode 100644 index 00000000000..768722c6ac9 --- /dev/null +++ b/addons/sale/static/tests/product_configurator.test.js @@ -0,0 +1,140 @@ +odoo.define('sale.product.configurator.tests', function (require) { + "use strict"; + + var FormView = require('web.FormView'); + var ProductConfiguratorFormView = require('sale.ProductConfiguratorFormView'); + var testUtils = require('web.test_utils'); + var ajax = require('web.ajax'); + var createView = testUtils.createView; + + var getArch = function(){ + return '<form>' + + '<sheet>' + + '<field name="sale_order_line">' + + '<tree editable="top"><control>' + + '<create string="Add a product"/>' + + '<create string="Configure a product" context="{\'open_product_configurator\': \'true\'}"/>' + + '<create string="Add a section" context="{\'default_display_type\': \'line_section\'}"/>' + + '<create string="Add a note" context="{\'default_display_type\': \'line_note\'}"/>' + + '</control>' + + '<field name="product_id"/><field name="quantity"/>' + + '</tree>' + + '</field>' + + '</sheet>' + + '</form>'; + }; + + QUnit.module('Product Configurator', { + beforeEach: function () { + this.data = { + product_template: { + fields: { + id: {type: 'integer'} + }, + records: [{ + id: 42, + display_name: "Customizable Desk" + }] + }, + product: { + fields: { + id: {type: 'integer'} + } + }, + sale_order: { + fields: { + id: {type: 'integer'}, + sale_order_line: {string: 'lines', type: 'one2many', relation: 'sale_order_line'}, + } + }, + sale_order_line: { + fields: { + product_id: {string: 'product', type: 'many2one', relation: 'product'}, + quantity: {type: 'integer'} + } + }, + sale_product_configurator: { + fields: { + product_template_id: {string: 'product', type: 'many2one', relation: 'product_template'} + }, + records: [{ + product_template_id: 42 + }] + }, + }; + } + }, function(){ + QUnit.test('click on "Configure a product" and check for form loading', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'sale_order', + data: this.data, + arch: getArch(), + mockRPC: function (route) { + if (route === '/web/dataset/call_kw/ir.model.data/xmlid_to_res_id') { + assert.ok(true); + return $.Deferred().then(_.constant(1)); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$("a:contains('Configure a product')").length, 1); + + form.$("a:contains('Configure a product')").click(); + }); + + QUnit.test('trigger_up the "add_record" event and checks that rows are correctly added to the list', function (assert) { + assert.expect(1); + + var form = createView({ + View: FormView, + model: 'sale_order', + data: this.data, + arch: getArch() + }); + + var list = form.renderer.allFieldWidgets[form.handle][0]; + + list.trigger_up('add_record', { + context: [{product_id: 1, quantity: 2}, {product_id: 2, quantity: 3}], + forceEditable: "bottom" , + allowWarning: true + }); + + assert.strictEqual(list.$("tr.o_data_row").length, 2); + }); + + QUnit.test('Select a product in the list and check for template loading', function(assert){ + assert.expect(1); + + ajax.jsonRpc = function(route){ + assert.ok(true); + return $.Deferred().then(_.constant($("<div></div>"))); + }; + + var product_configurator_form = createView({ + View: ProductConfiguratorFormView, + model: 'sale_product_configurator', + data: this.data, + arch: + '<form js_class="product_configurator_form">' + + '<group>' + + '<field name="product_template_id" class="oe_product_configurator_product_template_id" />' + + '</group>' + + '<footer>' + + '<button string="Add" class="btn-primary o_sale_product_configurator_add disabled"/>' + + '<button string="Cancel" class="btn-secondary" special="cancel"/>' + + '</footer>' + + '</form>', + }); + var div = '<div>'; + var $div = $(div).addClass('js_sale_order_pricelist_id').html(43); + product_configurator_form.$('.o_input').after($div); + product_configurator_form.$('.o_input').click(); + $("ul.ui-autocomplete li a:contains('Customizable Desk')").mouseenter().click(); + }); + }); +}); diff --git a/addons/sale/views/assets.xml b/addons/sale/views/assets.xml index af1d10f08c2..81b5c92abf6 100644 --- a/addons/sale/views/assets.xml +++ b/addons/sale/views/assets.xml @@ -3,10 +3,15 @@ <template id="assets_backend_inherit_sale" inherit_id="web.assets_backend" name="Sale backend assets"> <xpath expr="link[last()]" position="after"> <link rel="stylesheet" type="text/scss" href="/sale/static/src/scss/sale_onboarding.scss"/> + <link rel="stylesheet" type="text/scss" href="/sale/static/src/scss/product_configurator.scss"/> </xpath> <xpath expr="script[last()]" position="after"> <script type="text/javascript" src="/sale/static/src/js/sale.js"></script> <script type="text/javascript" src="/sale/static/src/js/tour.js"></script> + <script type="text/javascript" src="/sale/static/src/js/product_configurator_list_editable_renderer.js"></script> + <script type="text/javascript" src="/sale/static/src/js/product_configurator.js"></script> + <script type="text/javascript" src="/sale/static/src/js/product_configurator_utils.js"></script> + <script type="text/javascript" src="/sale/static/src/js/product_configurator_modal.js"></script> </xpath> </template> <template id="assets_frontend_inherit_sale" inherit_id="web.assets_frontend" name="Sale frontend assets"> @@ -17,4 +22,9 @@ <script type="text/javascript" src="/sale/static/src/js/sale_portal_sidebar.js"></script> </xpath> </template> + <template id="qunit_suite" name="product_configurator_tests" inherit_id="web.qunit_suite"> + <xpath expr="//t[@t-set='head']" position="inside"> + <script type="text/javascript" src="/sale/static/tests/product_configurator.test.js"></script> + </xpath> + </template> </odoo> diff --git a/addons/sale/views/product_attribute_views.xml b/addons/sale/views/product_attribute_views.xml new file mode 100644 index 00000000000..781e3364bff --- /dev/null +++ b/addons/sale/views/product_attribute_views.xml @@ -0,0 +1,77 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <!-- Product attribute --> + + <record id="attribute_tree_view" model="ir.ui.view"> + <field name="name">product.attribute.tree.type</field> + <field name="model">product.attribute</field> + <field name="inherit_id" ref="product.attribute_tree_view"></field> + <field name="arch" type="xml"> + <field name="name" position="after"> + <field name="type"/> + </field> + </field> + </record> + + <record id="product_attribute_line_form" model="ir.ui.view"> + <field name="name">product.attribute.line.form.inherit</field> + <field name="model">product.attribute.line</field> + <field name="inherit_id" ref="product.product_attribute_line_form"/> + <field name="priority" eval="8"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='value_ids']/tree/field[@name='name']" position="after"> + <field name="html_color"/> + </xpath> + </field> + </record> + + <record id="product_attribute_view_form" model="ir.ui.view"> + <field name="name">product.attribute.form.inherit</field> + <field name="model">product.attribute</field> + <field name="inherit_id" ref="product.product_attribute_view_form"/> + <field name="priority" eval="8"/> + <field name="arch" type="xml"> + <xpath expr="//group[@name='main_fields']" position="after"> + <group> + <field name="type"/> + </group> + </xpath> + <xpath expr="//field[@name='value_ids']/tree/field[@name='name']" position="after"> + <field name="html_color"/> + </xpath> + </field> + </record> + + <record id="product_product_attribute_value_view_tree_inherit" model="ir.ui.view"> + <field name="name">product.product.attribute.value.view.tree.inherit</field> + <field name="model">product.product.attribute.value</field> + <field name="inherit_id" ref="product.product_product_attribute_value_view_tree"/> + <field name="arch" type="xml"> + <field name="name" position="after"> + <field name="html_color"/> + </field> + </field> + </record> + + <record id="product_product_attribute_value_view_form_inherit" model="ir.ui.view"> + <field name="name">product.product.attribute.value.view.form.inherit</field> + <field name="model">product.product.attribute.value</field> + <field name="inherit_id" ref="product.product_product_attribute_value_view_form"/> + <field name="arch" type="xml"> + <field name="name" position="after"> + <field name="html_color" readonly="True" /> + </field> + </field> + </record> + + <record id="variants_tree_view" model="ir.ui.view"> + <field name="name">variants.template.tree</field> + <field name="model">product.attribute.value</field> + <field name="inherit_id" ref="product.variants_tree_view"/> + <field name="arch" type="xml"> + <field name="name" position="after"> + <field name="html_color"/> + </field> + </field> + </record> +</odoo>
\ No newline at end of file diff --git a/addons/sale/views/res_config_settings_views.xml b/addons/sale/views/res_config_settings_views.xml index 3062f8b7814..fccc6ac8d53 100644 --- a/addons/sale/views/res_config_settings_views.xml +++ b/addons/sale/views/res_config_settings_views.xml @@ -18,7 +18,7 @@ <div class="o_setting_right_pane"> <label for="group_product_variant"/> <div class="text-muted"> - Set product attributes (e.g. color, size) to sell variants + Configure your products with variants and select optional products </div> <div class="content-group" attrs="{'invisible': [('group_product_variant','=',False)]}"> <div class="mt16"> diff --git a/addons/sale/views/sale_product_configurator_templates.xml b/addons/sale/views/sale_product_configurator_templates.xml new file mode 100644 index 00000000000..3eb6170f019 --- /dev/null +++ b/addons/sale/views/sale_product_configurator_templates.xml @@ -0,0 +1,256 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <template id="optional_products_modal" name="Optional Products"> + <div role="dialog" id="modal_optional_products" class="modal fade" tabindex="-1" aria-labelledby="myModalLabel"> + <div class="modal-dialog modal-lg"> + <div class="modal-content"> + <header class="modal-header"> + <h4 class="modal-title" id="myModalLabel">Add to cart</h4> + <button type="button" class="close" data-dismiss="modal" aria-label="Close">x</button> + </header> + <div class="modal-body"> + <t t-call="sale.product_configurator_configure_optional_products" /> + </div> + <footer class="modal-footer"> + <a href="#" role="button" class="btn btn-secondary a-submit go-back js_goto_shop" aria-label="Continue shopping" title="Continue shopping"><i class="fa fa-chevron-left"></i> <span class="d-none d-md-inline">Continue Shopping</span><span class="d-inline d-md-none">Continue</span></a> + <a href="#" role="button" class="btn btn-primary float-right confirm a-submit" aria-label="Proceed to checkout" title="Proceed to checkout"><i class="fa fa-shopping-cart fa-fw"></i> <span class="d-none d-md-inline">Proceed to Checkout</span><span class="d-inline d-md-none">Checkout</span></a> + </footer> + </div> + </div> + </div> + </template> + + <template id="product_configurator_configure" name="Configure"> + <div class="js_product"> + <input type="hidden" class="product_id" t-attf-name="product_id" t-att-value="product.product_variant_id.id if len(product.product_variant_ids) == 1 else '0'"/> + <div class="col-lg-12 text-center mt-5"> + <div class="col-lg-5 d-inline-block align-top text-left"> + <span t-field="product.image" t-options='{"widget": "image", "class": "d-block product_detail_img"}'/> + </div> + <div class="col-lg-1 d-inline-block"></div> + <div class="col-lg-5 d-inline-block text-left"> + <t t-if="len(product.product_variant_ids) > 1"> + <t t-call="sale.variants" /> + </t> + <h2 class="oe_price product_id mt-3" style="white-space: nowrap;" + t-att-data-product-id="product.id" + t-esc="product.price if product.price else product.lst_price" + t-options='{ + "widget": "monetary", + "display_currency": to_currency + }'/> + <div class="css_quantity input-group oe_website_spinner"> + <div class="input-group-prepend"> + <button t-attf-href="#" class="btn btn-primary js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one"> + <i class="fa fa-minus"></i> + </button> + </div> + <input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" value="1"/> + <div class="input-group-append"> + <button t-attf-href="#" class="btn btn-primary float_left js_add_cart_json d-none d-md-inline-block" aria-label="Add one" title="Add one"> + <i class="fa fa-plus"></i> + </button> + </div> + </div> + </div> + </div> + </div> + </template> + + <template id="product_configurator_configure_optional_products"> + <table class="table table-striped table-sm"> + <thead> + <tr> + <th class="td-img">Product</th> + <th></th> + <th class="text-center td-qty">Quantity</th> + <th class="text-center td-price">Price</th> + </tr> + </thead> + <tbody> + <tr class="js_product in_cart"> + <td class='td-img'> + <span t-field="product.image_medium" t-options='{"widget": "image" }'/> + </td> + <td class='td-product_name'> + <strong t-field="product.display_name"/> + <div class="text-muted small"> + <div t-field="product.description_sale"/> + <div class="js_attributes"/> + </div> + </td> + <td class="text-center td-qty"> + <div class="css_quantity input-group oe_website_spinner"> + <div class="input-group-prepend"> + <button t-attf-href="#" class="btn btn-primary js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one"> + <i class="fa fa-minus"></i> + </button> + </div> + <input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" value="1"/> + <div class="input-group-append"> + <button t-attf-href="#" class="btn btn-primary float_left js_add_cart_json d-none d-md-inline-block" aria-label="Add one" title="Add one"> + <i class="fa fa-plus"></i> + </button> + </div> + </div> + </td> + <td class="text-center td-price" name="price"> + <ul class="d-none js_add_cart_variants" t-att-data-attribute_value_ids="json.dumps(main_product_attr_ids)"></ul> + <div class="d-none oe_unchanged_value_ids" t-att-data-unchanged_value_ids="main_product_attr_ids[0][1]" ></div> + <div t-attf-class="text-danger oe_default_price oe_striked_price {{ (compute_currency(product.lst_price) - product.price) < 0.01 and 'd-none' }}" + t-field="product.lst_price" + t-options='{ + "widget": "monetary", + "from_currency": product.currency_id, + "display_currency": to_currency + }'/> + <span class="oe_price product_id" style="white-space: nowrap;" + t-att-data-product-id="product.id" + t-esc="product.price" + t-options='{ + "widget": "monetary", + "display_currency": to_currency + }'/> + <span class="js_raw_price d-none" t-esc="product.price" /> + </td> + </tr> + <tr class="o_total_row"> + <td colspan="2"></td> + <td class="text-right"><h5>Total:</h5></td> + <td class="text-center"> + <span class="js_price_total font-weight-bold" style="white-space: nowrap;" + t-att-data-product-id="product.id" + t-esc="product.price" + t-options='{ + "widget": "monetary", + "display_currency": to_currency + }'/> + </td> + </tr> + <tr t-if="product.optional_product_ids" class="o_select_options"><td colspan="4"><h4>Available Options:</h4></td></tr> + <t t-call="sale.optional_product_items" /> + </tbody> + </table> + </template> + + <template id="optional_product_items"> + <t t-foreach="product.optional_product_ids" t-as="product"> + <t t-if="len(product.get_filtered_variants(reference_product)) > 0"> + <tr class="js_product"> + <td class="td-img"> + <input type="hidden" class="product_template_id" t-att-value="product.id"/> + <input type="hidden" class="product_id" t-attf-name="optional-product-#{product.id}" t-att-value="product.product_variant_id.id if len(product.product_variant_ids) == 1 else '0'"/> + <input type="hidden" class="product_display_name" t-attf-name="optional-productname-#{product.id}" t-att-value="product.product_variant_id.display_name if len(product.product_variant_ids) == 1 else ''"/> + <span t-field="product.image_small" t-options='{"widget": "image"}' class="variant_image"/> + </td> + <td class='td-product_name'> + <div class="float-left"> + <strong class="product-name" t-field="product.name"/> + <div class="text-muted small" t-field="product.description_sale"/> + </div> + </td> + <td class="td-qty"> + <div class="float-right"> + <t t-call="sale.variants"/> + </div> + </td> + <td class="text-center td-price"> + <span class="text-danger oe_default_price oe_optional" + t-field="product.lst_price" + t-options='{ + "widget": "monetary", + "from_currency": product.currency_id, + "display_currency": to_currency + }'/> + <span class="oe_price" style="white-space: nowrap;" + t-field="product.price" + t-options='{ + "widget": "monetary", + "display_currency": to_currency + }'/> + <span class="js_raw_price d-none" t-esc="product.price" /> + <p class="css_not_available_msg bg-danger" style="padding: 5px;">Option not available</p> + + <input type="hidden" class="js_optional_same_quantity" t-attf-name="optional-add-#{product.id}" value="0"/> + <a role="button" href="#" class="js_add"><i class="fa fa-shopping-cart add-optionnal-item"></i></a> + <span class="js_remove d-none"> + <span class="optional_product_quantity"><span class="js_item add_qty">1 </span><span class="js_items d-none">5 </span> <span t-field="product.uom_id.sudo().name"/><br/></span> + <a role="button" href="#" class="js_remove"><i class="fa fa-trash-o remove-optionnal-item"></i></a> + </span> + </td> + </tr> + </t> + </t> + </template> + + + + <template id="variants"> + <t t-set="attribute_value_ids" t-value="get_attribute_value_ids(product, reference_product, pricelist)"/> + <ul t-attf-class="list-unstyled js_add_cart_variants #{ul_class}" t-att-data-attribute_value_ids="json.dumps(attribute_value_ids)"> + <t t-foreach="product.attribute_line_ids.sorted(key=lambda x: x.attribute_id.sequence)" t-as="variant_id"> + <li t-if="len(variant_id.product_value_ids) > 1"> + + <strong t-field="variant_id.attribute_id.name"/> + + <t t-if="variant_id.attribute_id.type == 'select'"> + <select t-attf-class="form-control #{'js_variant_change' if variant_id.attribute_id.create_variant else ''}" t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"> + <t t-foreach="variant_id.product_value_ids" t-as="value_id"> + <option t-att-value="value_id.id"> + <span t-field="value_id.name"/> + <span t-if="value_id.price_extra"> + <t t-esc="value_id.price_extra > 0 and '+' or ''"/><span t-field="value_id.price_extra" class="variant_price_extra" style="white-space: nowrap;" t-options='{ + "widget": "monetary", + "from_currency": product.currency_id, + "display_currency": to_currency or website.currency_id + }'/> + </span> + </option> + </t> + </select> + </t> + + <t t-if="variant_id.attribute_id.type == 'radio'"> + <ul class="list-unstyled"> + <t t-set="inc" t-value="0"/> + <t t-foreach="variant_id.product_value_ids" t-as="value_id"> + <li class="form-group js_attribute_value" style="margin: 0;"> + <label class="col-form-label" style="margin: 0 20px;"> + <input type="radio" t-att-class="'js_variant_change' if variant_id.attribute_id.create_variant else None" t-att-checked="'checked' if not inc else None" t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)" t-att-value="value_id.id" style="vertical-align: top; margin-right: 10px;"/> + <span t-field="value_id.name"/> + <span class="badge badge-pill" t-if="value_id.price_extra"> + <t t-esc="value_id.price_extra > 0 and '+' or ''"/><span t-field="value_id.price_extra" class="variant_price_extra" style="white-space: nowrap;" t-options='{ + "widget": "monetary", + "from_currency": product.currency_id, + "display_currency": to_currency or website.currency_id + }'/> + </span> + </label> + </li> + <t t-set="inc" t-value="inc+1"/> + </t> + </ul> + </t> + + <t t-if="variant_id.attribute_id.type == 'color'"> + <ul class="list-inline"> + <t t-set="inc" t-value="0"/> + <li t-foreach="variant_id.product_value_ids" t-as="value_id" class="list-inline-item"> + <label t-attf-style="background-color:#{value_id.html_color or value_id.name}" + t-attf-class="css_attribute_color #{'active' if not inc else ''}"> + <input type="radio" t-att-class="'js_variant_change' if variant_id.attribute_id.create_variant else None" + t-att-checked="'checked' if not inc else None" + t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)" + t-att-value="value_id.id" + t-att-title="value_id.name"/> + </label> + <t t-set="inc" t-value="inc+1"/> + </li> + </ul> + </t> + + </li> + </t> + </ul> + </template> +</odoo>
\ No newline at end of file diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index 21876534f7e..3dc714bb33c 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -72,6 +72,24 @@ </field> </record> + <record id="product_template_form_view" model="ir.ui.view"> + <field name="name">product.template.product.website.form</field> + <field name="model">product.template</field> + <field name="inherit_id" ref="product.product_template_form_view"/> + <field name="arch" type="xml"> + <xpath expr="//page[@name='sales']" position="attributes"> + <attribute name="invisible">0</attribute> + </xpath> + <xpath expr="//group[@name='sale']" position="after"> + <group name="options" groups="product.group_product_variant"> + <group string="Options"> + <field name="optional_product_ids" widget="many2many_tags" options="{'color_field': 'color'}" domain="[('id', '!=', active_id)]" /> + </group> + </group> + </xpath> + </field> + </record> + <menuitem id="product_menu_catalog" name="Products" action="product_template_action" parent="sale_menu_root" sequence="4" groups="sales_team.group_sale_salesman"/> <menuitem id="menu_product" name="Product Variants" parent="product_menu_catalog" sequence="2" groups="base.group_no_one" active="False"/> <menuitem action="product_template_action" id="menu_product_template_action" parent="product_menu_catalog" sequence="1" active="False"/> @@ -272,6 +290,7 @@ <field name="validity_date" attrs="{'invisible': [('state', 'in', ['sale', 'done'])]}"/> <field name="confirmation_date" attrs="{'invisible': [('state', 'in', ['draft', 'sent', 'cancel'])]}"/> <field name="pricelist_id" groups="product.group_sale_pricelist"/> + <field name="pricelist_id_id" invisible="1" class="js_sale_order_pricelist_id" /> <field name="currency_id" invisible="1"/> <field name="payment_term_id" options="{'no_create': True}"/> </group> @@ -377,6 +396,7 @@ > <control> <create string="Add a product"/> + <create string="Configure a product" context="{'open_product_configurator': 'true'}" groups="product.group_product_variant"/> <create string="Add a section" context="{'default_display_type': 'line_section'}"/> <create string="Add a note" context="{'default_display_type': 'line_note'}"/> </control> @@ -1001,7 +1021,7 @@ <field name="inherit_id" ref="product.product_template_form_view"/> <field name="arch" type="xml"> <xpath expr="//group[@name='email_template_and_project']" position="before"> - <group string="Invoicing" name="invoicing"> + <group string="Invoicing" name="invoicing" invisible="1"> <field name="invoice_policy" widget="radio"/> <field name="service_type" widget="radio" invisible="True"/> <field name="hide_expense_policy" invisible="1"/> diff --git a/addons/sale/wizard/__init__.py b/addons/sale/wizard/__init__.py index 09f069f85ed..145cce89c1e 100644 --- a/addons/sale/wizard/__init__.py +++ b/addons/sale/wizard/__init__.py @@ -3,3 +3,4 @@ from . import payment_acquirer_onboarding_wizard from . import sale_make_invoice_advance +from . import sale_product_configurator
\ No newline at end of file diff --git a/addons/sale/wizard/sale_product_configurator.py b/addons/sale/wizard/sale_product_configurator.py new file mode 100644 index 00000000000..6324b8d5ee6 --- /dev/null +++ b/addons/sale/wizard/sale_product_configurator.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields + + +class SaleProductConfigurator(models.TransientModel): + _name = 'sale.product.configurator' + + product_template_id = fields.Many2one( + 'product.template', string="Product", + required=True, domain=['|', ('attribute_line_ids', '!=', False), ('optional_product_ids', '!=', False)]) diff --git a/addons/sale/wizard/sale_product_configurator_views.xml b/addons/sale/wizard/sale_product_configurator_views.xml new file mode 100644 index 00000000000..9b29c3f9183 --- /dev/null +++ b/addons/sale/wizard/sale_product_configurator_views.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="sale_product_configurator_view_form" model="ir.ui.view"> + <field name="name">sale.product.configurator.view.form</field> + <field name="model">sale.product.configurator</field> + <field name="arch" type="xml"> + <form js_class="product_configurator_form"> + <group> + <field name="product_template_id" class="oe_product_configurator_product_template_id" /> + </group> + <footer> + <button string="Add" class="btn-primary o_sale_product_configurator_add disabled"/> + <button string="Cancel" class="btn-secondary" special="cancel"/> + </footer> + </form> + </field> + </record> +</odoo>
\ No newline at end of file diff --git a/addons/sale_management/__manifest__.py b/addons/sale_management/__manifest__.py index 98f77d20956..30c6a991371 100644 --- a/addons/sale_management/__manifest__.py +++ b/addons/sale_management/__manifest__.py @@ -39,6 +39,8 @@ The Dashboard for the Sales Manager will include 'depends': ['sale', 'digest'], 'data': [ 'security/sale_management_security.xml', + 'views/assets.xml', + 'data/sale_order_template_data.xml', 'views/sale_portal_templates.xml', 'views/sale_order_template_views.xml', 'security/ir.model.access.csv', diff --git a/addons/sale_management/static/tests/product_configurator_test_ui.js b/addons/sale_management/static/tests/product_configurator_test_ui.js new file mode 100644 index 00000000000..7ae3a7917b0 --- /dev/null +++ b/addons/sale_management/static/tests/product_configurator_test_ui.js @@ -0,0 +1,66 @@ +odoo.define('sale.product_configurator_tour', function(require) { + "use strict"; + + var tour = require('web_tour.tour'); + + tour.register('sale_product_configurator_tour', { + url: "/web", + test: true, + }, [{ + trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"], .oe_menu_toggler[data-menu-xmlid="sale.sale_menu_root"]' + }, { + trigger: ".o_list_button_add", + extra_trigger: ".o_sale_order" + }, { + trigger: "a:contains('Configure a product')" + }, { + trigger: '.o_product_configurator .o_input_dropdown input', + run: 'click' + }, { + trigger: 'li a:contains("Customizable Desk")', + in_modal: false, + extra_trigger: 'ul.ui-autocomplete', + run: 'click' + }, { + trigger: '.configurator_container span:contains("Aluminium")', + run: 'click' + }, { + trigger: ".o_sale_product_configurator_add", + run: 'click' + }, { + trigger: 'span:contains("Aluminium")', + extra_trigger: '.oe_optional_products_modal', + run: 'click' + }, { + trigger: '.js_product:not(.in_cart) .js_add', + extra_trigger: '.oe_optional_products_modal', + run: 'click' + }, { + trigger: '.js_product:not(.in_cart) .js_add', // not a mistake, this adds the option's option + extra_trigger: '.oe_optional_products_modal', + run: 'click' + }, { + trigger: '.a-submit:not(.js_goto_shop)', + extra_trigger: '.oe_optional_products_modal', + run: 'click' + }, + // check that 3 products were added to the SO + { + trigger: 'td.o_data_cell:contains("[FURN_0098] Customizable Desk (White, Aluminium)")', + extra_trigger: 'div[name="order_line"]', + in_modal: false, + run: function(){} + }, { + trigger: 'td.o_data_cell:contains("[E-COM13] Conference Chair (Aluminium)")', + extra_trigger: 'div[name="order_line"]', + in_modal: false, + run: function(){} + }, { + trigger: 'td.o_data_cell:contains("Chair floor protection")', + extra_trigger: 'div[name="order_line"]', + in_modal: false, + run: function(){} + }]); + + }); +
\ No newline at end of file diff --git a/addons/sale_management/views/assets.xml b/addons/sale_management/views/assets.xml new file mode 100644 index 00000000000..a55eeb971ca --- /dev/null +++ b/addons/sale_management/views/assets.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <template id="assets_backend_inherit_sale_management" inherit_id="web.assets_backend" name="Sale management backend assets"> + <xpath expr="script[last()]" position="after"> + <script type="text/javascript" src="/sale_management/static/tests/product_configurator_test_ui.js"/> + </xpath> + </template> +</odoo>
\ No newline at end of file diff --git a/addons/sale_management/views/sale_management_views.xml b/addons/sale_management/views/sale_management_views.xml index 0d5615a2481..c46f8f9fb36 100644 --- a/addons/sale_management/views/sale_management_views.xml +++ b/addons/sale_management/views/sale_management_views.xml @@ -108,6 +108,15 @@ <xpath expr="//group[@name='description']" position="attributes"> <attribute name="invisible">0</attribute> </xpath> + <xpath expr="//group[@name='sale']" position="attributes"> + <attribute name="invisible">0</attribute> + </xpath> + <xpath expr="//group[@name='invoicing']" position="attributes"> + <attribute name="invisible">0</attribute> + </xpath> + <xpath expr="//group[@name='description']" position="attributes"> + <attribute name="invisible">0</attribute> + </xpath> </field> </record> diff --git a/addons/sale_stock/__manifest__.py b/addons/sale_stock/__manifest__.py index 73b46286776..b78b72d927c 100644 --- a/addons/sale_stock/__manifest__.py +++ b/addons/sale_stock/__manifest__.py @@ -19,7 +19,7 @@ Preferences * Incoterms: International Commercial terms """, - 'depends': ['sale_management', 'stock_account'], + 'depends': ['sale', 'stock_account'], 'data': [ 'security/sale_stock_security.xml', 'security/ir.model.access.csv', diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py index ab749b59927..6ac44761c11 100644 --- a/addons/sale_stock/models/sale_order.py +++ b/addons/sale_stock/models/sale_order.py @@ -262,8 +262,8 @@ class SaleOrderLine(models.Model): if float_compare(product.virtual_available, product_qty, precision_digits=precision) == -1: is_available = self._check_routing() if not is_available: - message = _('You plan to sell %s %s but you only have %s %s available in %s warehouse.') % \ - (self.product_uom_qty, self.product_uom.name, product.virtual_available, product.uom_id.name, self.order_id.warehouse_id.name) + message = _('You plan to sell %s %s of %s but you only have %s %s available in %s warehouse.') % \ + (self.product_uom_qty, self.product_uom.name, self.product_id.name, product.virtual_available, product.uom_id.name, self.order_id.warehouse_id.name) # We check if some products are available in other warehouses. if float_compare(product.virtual_available, self.product_id.virtual_available, precision_digits=precision) == -1: message += _('\nThere are %s %s available across all warehouses.\n\n') % \ diff --git a/addons/stock/security/ir.model.access.csv b/addons/stock/security/ir.model.access.csv index 5496ee0b24c..99ac4a55f32 100644 --- a/addons/stock/security/ir.model.access.csv +++ b/addons/stock/security/ir.model.access.csv @@ -73,5 +73,6 @@ access_stock_forecast_manager,report.stock.forecast.manager,model_report_stock_f access_stock_scrap_user,stock.scrap.user,model_stock_scrap,stock.group_stock_user,1,1,1,0 access_product_attribute_manager,product.attribute manager,product.model_product_attribute,stock.group_stock_manager,1,1,1,1 access_product_attribute_value_manager,product.attribute manager value,product.model_product_attribute_value,stock.group_stock_manager,1,1,1,1 -access_product_attribute_price_manager,product.attribute manager price,product.model_product_attribute_price,stock.group_stock_manager,1,1,1,1 +access_product_product_attribute_manager,product.product.attribute manager value,product.model_product_product_attribute_value,stock.group_stock_manager,1,1,1,1 +access_product_attribute_filter_line_manager,product.attribute manager filter line,product.model_product_attribute_filter_line,stock.group_stock_manager,1,1,1,1 access_product_attribute_line_manager,product.attribute manager line,product.model_product_attribute_line,stock.group_stock_manager,1,1,1,1 diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js index 2ea40a2602b..37d2f22ef55 100644 --- a/addons/web/static/src/js/fields/abstract_field.js +++ b/addons/web/static/src/js/fields/abstract_field.js @@ -496,6 +496,7 @@ var AbstractField = Widget.extend({ viewType: this.viewType, doNotSetDirty: options && options.doNotSetDirty, notifyChange: !options || options.notifyChange !== false, + allowWarning: options && options.allowWarning, onSuccess: def.resolve.bind(def), onFailure: def.reject.bind(def), }); diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 7a428923608..3f64c0249cb 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -1461,7 +1461,7 @@ var FieldOne2Many = FieldX2Many.extend({ // we don't want interference with the components upstream. ev.stopPropagation(); - if (this.editable) { + if (this.editable || data.forceEditable) { if (!this.activeActions.create) { if (data.onFail) { data.onFail(); @@ -1471,10 +1471,16 @@ var FieldOne2Many = FieldX2Many.extend({ this.trigger_up('edited_list', { id: this.value.id }); this._setValue({ operation: 'CREATE', - position: this.editable, + position: this.editable || data.forceEditable, context: data.context, + }, { + allowWarning: data.allowWarning }).always(function () { self.creatingRecord = false; + }).done(function(){ + if(data.onSuccess){ + data.onSuccess(); + } }); } } else { diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index ac7187c3874..84e900021e8 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -1344,6 +1344,7 @@ var BasicModel = AbstractModel.extend({ parentID: list.id, position: position, viewType: list.viewType, + allowWarning: options && options.allowWarning }; return this._makeDefaultRecord(list.model, params).then(function (id) { list._changes.push({operation: 'ADD', id: id, position: position, isNew: true}); @@ -1395,7 +1396,7 @@ var BasicModel = AbstractModel.extend({ for (var fieldName in changes) { field = record.fields[fieldName]; if (field.type === 'one2many' || field.type === 'many2many') { - defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], options.viewType)); + defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], options.viewType, options.allowWarning)); } else if (field.type === 'many2one' || field.type === 'reference') { defs.push(this._applyX2OneChange(record, fieldName, changes[fieldName])); } else { @@ -1724,7 +1725,7 @@ var BasicModel = AbstractModel.extend({ * main viewType from the record * @returns {Deferred} */ - _applyX2ManyChange: function (record, fieldName, command, viewType) { + _applyX2ManyChange: function (record, fieldName, command, viewType, allowWarning) { if (command.operation === 'TRIGGER_ONCHANGE') { // the purpose of this operation is to trigger an onchange RPC, so // there is no need to apply any change on the record (the changes @@ -1819,6 +1820,7 @@ var BasicModel = AbstractModel.extend({ var options = { context: command.context, position: command.position, + allowWarning: allowWarning }; def = this._addX2ManyDefaultRecord(list, options).then(function (id) { if (command.position === 'bottom' && list.orderedResIDs && list.orderedResIDs.length >= list.limit) { diff --git a/addons/web/static/src/js/views/field_manager_mixin.js b/addons/web/static/src/js/views/field_manager_mixin.js index cf67b9dd18d..343730cc7a4 100644 --- a/addons/web/static/src/js/views/field_manager_mixin.js +++ b/addons/web/static/src/js/views/field_manager_mixin.js @@ -46,7 +46,7 @@ var FieldManagerMixin = { */ _applyChanges: function (dataPointID, changes, event) { var self = this; - var options = _.pick(event.data, 'context', 'doNotSetDirty', 'notifyChange', 'viewType'); + var options = _.pick(event.data, 'context', 'doNotSetDirty', 'notifyChange', 'viewType', 'allowWarning'); return this.model.notifyChanges(dataPointID, changes, options) .then(function (result) { if (event.data.force_save) { diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index 33224a0fec2..841a8e7d4d8 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -61,7 +61,7 @@ ListRenderer.include({ } _.each(child.children, function (child) { - if (child.tag !== 'create') { + if (child.tag !== 'create' || child.attrs.invisible) { return; } diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index 10027f17655..3f57e1d85b4 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -12,6 +12,7 @@ from odoo.addons.payment.controllers.portal import PaymentProcessing from odoo.addons.website.controllers.main import QueryURL from odoo.exceptions import ValidationError from odoo.addons.website.controllers.main import Website +from odoo.addons.sale.controllers.product_configurator import ProductConfiguratorController from odoo.addons.website_form.controllers.main import WebsiteForm from odoo.osv import expression @@ -130,7 +131,7 @@ class Website(Website): return views -class WebsiteSale(http.Controller): +class WebsiteSale(ProductConfiguratorController): def _get_compute_currency_and_context(self): pricelist_context = dict(request.env.context) @@ -147,31 +148,6 @@ class WebsiteSale(http.Controller): return compute_currency, pricelist_context, pricelist - def get_attribute_value_ids(self, product): - """ list of selectable attributes of a product - - :return: list of product variant description - (variant id, [visible attribute ids], variant price, variant sale price) - """ - # product attributes with at least two choices - quantity = product._context.get('quantity') or 1 - product = product.with_context(quantity=quantity) - - visible_attrs_ids = product.attribute_line_ids.filtered(lambda l: len(l.value_ids) > 1).mapped('attribute_id').ids - to_currency = request.website.get_current_pricelist().currency_id - attribute_value_ids = [] - for variant in product.product_variant_ids: - if to_currency != product.currency_id: - price = variant.currency_id._convert( - variant.website_public_price, to_currency, - request.env.user.company_id, fields.Date.today() - ) / quantity - else: - price = variant.website_public_price / quantity - visible_attribute_ids = [v.id for v in variant.attribute_value_ids if v.attribute_id.id in visible_attrs_ids] - attribute_value_ids.append([variant.id, visible_attribute_ids, variant.website_price / quantity, price]) - return attribute_value_ids - def _get_search_order(self, post): # OrderBy will be parsed in orm and so no direct sql injection # id is added to be sure that order is a unique sort key @@ -344,7 +320,8 @@ class WebsiteSale(http.Controller): 'categories': categs, 'main_object': product, 'product': product, - 'get_attribute_value_ids': self.get_attribute_value_ids, + 'optional_product_ids': [p.with_context({'active_id': p.id}) for p in product.optional_product_ids], + 'get_attribute_value_ids': self._get_attribute_value_ids, } return request.render("website_sale.product", values) @@ -1005,11 +982,6 @@ class WebsiteSale(http.Controller): ret = self.order_2_return_dict(order) return ret - @http.route(['/shop/get_unit_price'], type='json', auth="public", methods=['POST'], website=True) - def get_unit_price(self, product_ids, add_qty, **kw): - products = request.env['product.product'].with_context({'quantity': add_qty}).browse(product_ids) - return {product.id: product.website_price / add_qty for product in products} - # ------------------------------------------------------ # Edit # ------------------------------------------------------ @@ -1097,3 +1069,49 @@ class WebsiteSale(http.Controller): states=[(st.id, st.name, st.code) for st in country.get_website_sale_states(mode=mode)], phone_code=country.phone_code ) + + @http.route(['/shop/cart/update_option'], type='http', auth="public", methods=['POST'], website=True, multilang=False) + def cart_options_update_json(self, product_id, add_qty=1, set_qty=0, goto_shop=None, lang=None, **kw): + if lang: + request.website = request.website.with_context(lang=lang) + + order = request.website.sale_get_order(force_create=1) + optional_product_ids = [] + for k, v in kw.items(): + if "optional-product-" in k and int(kw.get(k.replace("product", "add"))): + optional_product_ids.append(int(v)) + + attributes = self._filter_attributes(**kw) + + value = {} + if add_qty or set_qty: + value = order._cart_update( + product_id=int(product_id), + add_qty=add_qty, + set_qty=set_qty, + attributes=attributes, + optional_product_ids=optional_product_ids + ) + + # options have all time the same quantity + for option_id in optional_product_ids: + order._cart_update( + product_id=option_id, + set_qty=value.get('quantity'), + attributes=attributes, + linked_line_id=value.get('line_id') + ) + + return str(order.cart_quantity) + + @http.route(['/product_configurator/show_optional_products_website'], type='json', auth="public", methods=['POST'], website=True) + def show_optional_products_website(self, product_id, **kw): + return self._show_optional_products(product_id, request.website.get_current_pricelist(), True, **kw) + + @http.route(['/product_configurator/optional_product_items_website'], type='json', auth="public", methods=['POST'], website=True) + def optional_product_items_website(self, product_id, **kw): + return self._optional_product_items(product_id, request.website.get_current_pricelist(), **kw) + + @http.route(['/product_configurator/get_unit_price_website'], type='json', auth="public", methods=['POST'], website=True) + def get_unit_price_website(self, product_ids, add_qty, **kw): + return self._get_unit_price(product_ids, add_qty, request.website.get_current_pricelist()) diff --git a/addons/website_sale/data/demo.xml b/addons/website_sale/data/demo.xml index c296df7a7ef..d054dc9f68a 100644 --- a/addons/website_sale/data/demo.xml +++ b/addons/website_sale/data/demo.xml @@ -5,11 +5,9 @@ <field name="salesteam_id" ref="sales_team.salesteam_website_sales"/> </record> - <record id="product.product_attribute_2" model="product.attribute"> - <field name="type">color</field> - </record> - <record id="product.product_attribute_3" model="product.attribute"> - <field name="type">select</field> + <record id="sale.product_product_1_product_template" model="product.template"> + <field name="website_sequence">100</field> + <field name="website_published" eval="True"/> </record> <record id="product.product_product_24" model="product.product"> @@ -223,12 +221,6 @@ <record id="product.product_delivery_02_product_template" model="product.template"> <field name="public_categ_ids" eval="[(6,0,[ref('public_category_desks')])]"/> </record> - <record id="product.product_attribute_value_3" model="product.attribute.value"> - <field name="html_color">#FFFFFF</field> - </record> - <record id="product.product_attribute_value_4" model="product.attribute.value"> - <field name="html_color">#000000</field> - </record> <record model="product.pricelist" id="product.list0"> <field name="selectable" eval="True" /> @@ -582,4 +574,49 @@ <!-- action_confirm for confirmation date --> <function model="sale.order" name="action_confirm" eval="[[ref('website_sale_order_14')]]"/> + <function model="sale.order" name="_set_demo_create_date"> + <value eval="[ref('website_sale_order_1'), ref('website_sale_order_2'), ref('website_sale_order_3'), ref('website_sale_order_4'), + ref('website_sale_order_5'), ref('website_sale_order_6'), ref('website_sale_order_7')]"/> + <value eval="(datetime.now()-timedelta(days=8)).strftime('%Y-%m-%d %H:%M:%S')"/> + </function> + + <!-- Moved from deleted module "website_sale_option" --> + <record id="product_product_1" model="product.product"> + <field name="name">Warranty</field> + <field name="default_code">SERV_125889</field> + <field name="list_price">20.0</field> + <field name="website_sequence">100</field> + <field name="website_published" eval="True"/> + <field name="type">service</field> + <field name="uom_id" ref="uom.product_uom_unit"/> + <field name="uom_po_id" ref="uom.product_uom_unit"/> + <field name="description_sale">Warranty, issued to the purchaser of an article by its manufacturer, promising to repair or replace it if necessary within a specified period of time.</field> + <field name="attribute_value_ids" eval="[(6,0,[ref('product.product_attribute_value_5')])]"/> + <field name="categ_id" ref="product.product_category_3"/> + </record> + <record id="product_attribute_line_1" model="product.attribute.line"> + <field name="product_tmpl_id" ref="product_product_1_product_template"/> + <field name="attribute_id" ref="product.product_attribute_3"/> + <field name="value_ids" eval="[(6,0,[ref('product.product_attribute_value_5'), ref('product.product_attribute_value_6')])]"/> + </record> + <record id="product_product_1_product_template" model="product.template"> + <field name="attribute_line_ids" eval="[(6,0,[ref('product_attribute_line_1')])]"/> + <field name="public_categ_ids" eval="[(6,0,[ref('website_sale.services')])]"/> + </record> + <record id="product.product_product_4_product_template" model="product.template"> + <field name="optional_product_ids" eval="[(6,0,[ref('product.product_product_11_product_template'), ref('product_product_1_product_template')])]"/> + </record> + + <!-- Handle automatically created product.product.attribute.value --> + <function model="ir.model.data" name="_update_xmlids"> + <value model="base" eval="[{ + 'xml_id': 'website_sale.product_product_attribute_value_1', + 'record': obj().env.ref('website_sale.product_attribute_line_1').product_value_ids[1], + 'noupdate': True, + }]"/> + </function> + + <record id="product_product_attribute_value_1" model="product.product.attribute.value"> + <field name="price_extra">18.00</field> + </record> </odoo> diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py index b4674d62ca2..44ae604e17c 100644 --- a/addons/website_sale/models/product.py +++ b/addons/website_sale/models/product.py @@ -217,20 +217,6 @@ class Product(models.Model): return self.product_tmpl_id.website_publish_button() -class ProductAttribute(models.Model): - _inherit = "product.attribute" - - type = fields.Selection([('radio', 'Radio'), ('select', 'Select'), ('color', 'Color')], default='radio') - - -class ProductAttributeValue(models.Model): - _inherit = "product.attribute.value" - - html_color = fields.Char(string='HTML Color Index', oldname='color', help="Here you can set a " - "specific HTML color index (e.g. #ff0000) to display the color on the website if the " - "attibute type is 'Color'.") - - class ProductImage(models.Model): _name = 'product.image' _description = 'Product Image' diff --git a/addons/website_sale/models/res_config_settings.py b/addons/website_sale/models/res_config_settings.py index bae4d3faf6c..44a41472cbc 100644 --- a/addons/website_sale/models/res_config_settings.py +++ b/addons/website_sale/models/res_config_settings.py @@ -27,7 +27,6 @@ class ResConfigSettings(models.TransientModel): group_delivery_invoice_address = fields.Boolean(string="Shipping Address", implied_group='sale.group_delivery_invoice_address', group='base.group_portal,base.group_user,base.group_public') - module_website_sale_options = fields.Boolean("Optional Products") module_website_sale_digital = fields.Boolean("Digital Content") module_website_sale_wishlist = fields.Boolean("Wishlists") module_website_sale_comparison = fields.Boolean("Product Comparison Tool") diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index 476f0405e2b..3d7397a7079 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -72,7 +72,22 @@ class SaleOrder(models.Model): domain = [('order_id', '=', self.id), ('product_id', '=', product_id)] if line_id: domain += [('id', '=', line_id)] - return self.env['sale.order.line'].sudo().search(domain) + + lines = self.env['sale.order.line'].sudo().search(domain) + + if line_id: + return lines + linked_line_id = kwargs.get('linked_line_id', False) + optional_product_ids = set(kwargs.get('optional_product_ids', [])) + + lines = lines.filtered(lambda line: line.linked_line_id.id == linked_line_id) + if optional_product_ids: + # only match the lines with the same chosen optional products on the existing lines + lines = lines.filtered(lambda line: optional_product_ids == set(line.mapped('option_line_ids.product_id.id'))) + else: + lines = lines.filtered(lambda line: not line.option_line_ids) + + return lines @api.multi def _website_product_id_change(self, order_id, product_id, qty=0): @@ -116,7 +131,7 @@ class SaleOrder(models.Model): untracked_attributes = [] for k, v in attributes.items(): # attribute should be like 'attribute-48-1' where 48 is the product_id, 1 is the attribute_id and v is the attribute value - attribute_value = self.env['product.attribute.value'].sudo().browse(int(v)) + attribute_value = self.env['product.product.attribute.value'].sudo().browse(int(v)) if attribute_value and not attribute_value.attribute_id.create_variant: untracked_attributes.append(attribute_value.name) if untracked_attributes: @@ -198,13 +213,34 @@ class SaleOrder(models.Model): order_line.write(values) - return {'line_id': order_line.id, 'quantity': quantity} + # link a product to the sales order + if kwargs.get('linked_line_id'): + linked_line = SaleOrderLineSudo.browse(kwargs['linked_line_id']) + order_line.write({ + 'linked_line_id': linked_line.id, + 'name': order_line.name + "\n" + _("Option for:") + ' ' + linked_line.product_id.display_name, + }) + linked_line.write({"name": linked_line.name + "\n" + _("Option:") + ' ' + order_line.product_id.display_name}) + + option_lines = self.order_line.filtered(lambda l: l.linked_line_id.id == order_line.id) + for option_line_id in option_lines: + self._cart_update(option_line_id.product_id.id, option_line_id.id, add_qty, set_qty, **kwargs) + + return {'line_id': order_line.id, 'quantity': quantity, 'option_ids': list(set(option_lines.ids))} def _cart_accessories(self): """ Suggest accessories based on 'Accessory Products' of products in cart """ for order in self: accessory_products = order.website_order_line.mapped('product_id.accessory_product_ids').filtered(lambda product: product.website_published) - accessory_products -= order.website_order_line.mapped('product_id') + products = order.website_order_line.mapped('product_id') + accessory_products -= products + + for product in products: + for product_template in accessory_products.mapped('product_tmpl_id'): + template_accessory_products = accessory_products.filtered(lambda accessory_product: accessory_product.product_tmpl_id == product_template) + # remove from the accessories the ones that are not available for the selected product + accessory_products -= template_accessory_products - product_template.get_filtered_variants(product) + return random.sample(accessory_products, len(accessory_products)) @api.multi @@ -240,6 +276,9 @@ class SaleOrderLine(models.Model): name_short = fields.Char(compute="_compute_name_short") + linked_line_id = fields.Many2one('sale.order.line', string='Linked Order Line', domain="[('order_id', '!=', order_id)]", ondelete='cascade') + option_line_ids = fields.One2many('sale.order.line', 'linked_line_id', string='Options Linked') + @api.multi @api.depends('product_id.display_name') def _compute_name_short(self): diff --git a/addons/website_sale/security/ir.model.access.csv b/addons/website_sale/security/ir.model.access.csv index 9ff7391fc97..8c0f6569c8e 100644 --- a/addons/website_sale/security/ir.model.access.csv +++ b/addons/website_sale/security/ir.model.access.csv @@ -10,7 +10,8 @@ access_product_style,product.style.public,website_sale.model_product_style,,1,0, access_product_supplierinfo,product.supplierinfo.public,product.model_product_supplierinfo,,1,0,0,0 access_product_attribute_public,product.attribute public,product.model_product_attribute,,1,0,0,0 access_product_attribute_value_public,product.attribute value public,product.model_product_attribute_value,,1,0,0,0 -access_product_attribute_price_public,product.attribute price public,product.model_product_attribute_price,,1,0,0,0 +access_product_product_attribute,product.product.attribute value public,product.model_product_product_attribute_value,,1,0,0,0 +access_product_attribute_filter_line,product.attribute filter line public,product.model_product_attribute_filter_line,,1,0,0,0 access_product_attribute_line_public,product.attribute line public,product.model_product_attribute_line,,1,0,0,0 access_fiscal_position_public,fiscal position public,account.model_account_fiscal_position,base.group_portal,1,0,0,0 access_payment_term,payment term public,account.model_account_payment_term,base.group_portal,1,0,0,0 diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js index 37fe4372e94..64a353de923 100644 --- a/addons/website_sale/static/src/js/website_sale.js +++ b/addons/website_sale/static/src/js/website_sale.js @@ -124,6 +124,7 @@ odoo.define('website_sale.website_sale', function (require) { 'use strict'; var utils = require('web.utils'); +var ProductConfiguratorUtils = require('sale.product_configurator_utils'); var core = require('web.core'); var config = require('web.config'); var sAnimations = require('website.content.snippets.animation'); @@ -134,18 +135,18 @@ var _t = core._t; sAnimations.registry.WebsiteSale = sAnimations.Class.extend({ selector: '.oe_website_sale', read_events: { - 'change input[name="add_qty"]': '_onChangeAddQuantity', + 'change form .js_product:first input[name="add_qty"]': '_onChangeAddQuantity', 'mouseup .js_publish': '_onMouseupPublish', 'touchend .js_publish': '_onMouseupPublish', 'change .oe_cart input.js_quantity[data-product-id]': '_onChangeCartQuantity', 'click .oe_cart a.js_add_suggested_products': '_onClickSuggestedProduct', - 'click a.js_add_cart_json': '_onClickAddCartJSON', + 'click a.js_add_cart_json, button.js_add_cart_json': '_onClickAddCartJSON', 'click .a-submit': '_onClickSubmit', 'change form.js_attributes input, form.js_attributes select': '_onChangeAttribute', 'mouseup form.js_add_cart_json label': '_onMouseupAddCartLabel', 'touchend form.js_add_cart_json label': '_onMouseupAddCartLabel', 'change .css_attribute_color input': '_onChangeColorAttribute', - 'change input.js_variant_change, select.js_variant_change, input.js_product_change, [data-attribute_value_ids]': '_onChangeVariant', + 'change form .js_main_product input.js_variant_change, form .js_main_product select.js_variant_change, form .js_main_product input.js_product_change, form .js_main_product [data-attribute_value_ids]': '_onChangeVariant', 'click .show_coupon': '_onClickShowCoupon', 'submit .o_website_sale_search': '_onSubmitSaleSearch', 'change select[name="country_id"]': '_onChangeCountry', @@ -328,49 +329,6 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend({ } }); }, - /** - * @private - */ - _priceToStr: function (price) { - var l10n = _t.database.parameters; - var precision = 2; - - if ($(".decimal_precision").length) { - precision = parseInt($(".decimal_precision").last().data('precision')); - } - var formatted = _.str.sprintf('%.' + precision + 'f', price).split('.'); - formatted[0] = utils.insert_thousand_seps(formatted[0]); - return formatted.join(l10n.decimal_point); - }, - /** - * @private - */ - _updateProductImage: function ($productContainer, product_id) { - var $img; - if ($('#o-carousel-product').length) { - $img = $productContainer.find('img.js_variant_img'); - $img.attr("src", "/web/image/product.product/" + product_id + "/image"); - $img.parent().attr('data-oe-model', 'product.product').attr('data-oe-id', product_id) - .data('oe-model', 'product.product').data('oe-id', product_id); - - var $thumbnail = $productContainer.find('img.js_variant_img_small'); - if ($thumbnail.length !== 0) { // if only one, thumbnails are not displayed - $thumbnail.attr("src", "/web/image/product.product/" + product_id + "/image/90x90"); - $('.carousel').carousel(0); - } - } - else { - $img = $productContainer.find('span[data-oe-model^="product."][data-oe-type="image"] img:first, img.product_detail_img'); - $img.attr("src", "/web/image/product.product/" + product_id + "/image"); - $img.parent().attr('data-oe-model', 'product.product').attr('data-oe-id', product_id) - .data('oe-model', 'product.product').data('oe-id', product_id); - } - // reset zooming constructs - $img.filter('[data-zoom-image]').attr('data-zoom-image', $img.attr('src')); - if ($img.data('zoomOdoo') !== undefined) { - $img.data('zoomOdoo').isReady = false; - } - }, //-------------------------------------------------------------------------- // Handlers @@ -380,31 +338,8 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend({ * @private * @param {Event} ev */ - _onChangeAddQuantity: function (ev) { - var productIDs = []; - var $addVariant = $(ev.currentTarget).closest(".js_product").find(".js_add_cart_variants"); - var qty = $(ev.currentTarget).closest('form').find('input[name="add_qty"]').val(); - var attributeValueIDs = $addVariant.data("attribute_value_ids"); - _.each(attributeValueIDs, function (entry) { - productIDs.push(entry[0]); - }); - - if ($("#product_detail").length) { - // display the reduction from the pricelist in function of the quantity - this._rpc({ - route: '/shop/get_unit_price', - params: { - product_ids: productIDs, - add_qty: parseInt(qty), - }, - }).then(function (data) { - var current = attributeValueIDs; - for (var j = 0 ; j < current.length ; j++) { - current[j][2] = data[current[j][0]]; - } - $addVariant.trigger('change'); - }); - } + _onChangeAddQuantity: function (ev, isWebsite, pricelist, no_stock_check) { + ProductConfiguratorUtils.onChangeAddQuantity(ev, true, pricelist, no_stock_check); }, /** * @private @@ -444,27 +379,17 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend({ * @private * @param {Event} ev */ - _onClickAddCartJSON: function (ev) { // hack to add and remove from cart with json - ev.preventDefault(); - var $link = $(ev.currentTarget); - var $input = $link.closest('.input-group').find("input"); - var product_id = +$input.closest('*:has(input[name="product_id"])').find('input[name="product_id"]').val(); - var min = parseFloat($input.data("min") || 0); - var max = parseFloat($input.data("max") || Infinity); - var quantity = ($link.has(".fa-minus").length ? -1 : 1) + parseFloat($input.val() || 0, 10); - var new_qty = quantity > min ? (quantity < max ? quantity : max) : min; - // if they are more of one input for this product (eg: option modal) - $('input[name="'+$input.attr("name")+'"]').add($input).filter(function () { - var $prod = $(this).closest('*:has(input[name="product_id"])'); - return !$prod.length || +$prod.find('input[name="product_id"]').val() === product_id; - }).val(new_qty).change(); - return false; + _onClickAddCartJSON: function (ev) { + ProductConfiguratorUtils.onClickAddCartJSON(ev); }, /** * @private * @param {Event} ev */ - _onClickSubmit: function (ev) { + _onClickSubmit: function (ev, forceSubmit) { + if ($(ev.currentTarget).is('#add_to_cart, #products_grid .a-submit') && !forceSubmit) { + return; + } var $aSubmit = $(ev.currentTarget); if (!ev.isDefaultPrevented() && !$aSubmit.is(".disabled")) { ev.preventDefault(); @@ -522,70 +447,7 @@ sAnimations.registry.WebsiteSale = sAnimations.Class.extend({ * @param {Event} ev */ _onChangeVariant: function (ev) { - var $parent = $(ev.target).closest('.js_product'); - var $ul = $parent.find('.js_add_cart_variants'); - var $product_id = $parent.find('.product_id').first(); - var $price = $parent.find(".oe_price:first .oe_currency_value"); - var $default_price = $parent.find(".oe_default_price:first .oe_currency_value"); - var $optional_price = $parent.find(".oe_optional:first .oe_currency_value"); - var variant_ids = $ul.data("attribute_value_ids"); - var values = []; - var unchanged_values = $parent.find('div.oe_unchanged_value_ids').data('unchanged_value_ids') || []; - - _.each($parent.find('input.js_variant_change:checked, select.js_variant_change'), function (el) { - values.push(+$(el).val()); - }); - values = values.concat(unchanged_values); - var list_variant_id = parseInt($parent.find('input.js_product_change:checked').val()); - - $parent.find("label").removeClass("text-muted css_not_available"); - - var product_id = false; - for (var k in variant_ids) { - if (_.isEmpty(_.difference(variant_ids[k][1], values)) || variant_ids[k][0] === list_variant_id) { - $price.html(this._priceToStr(variant_ids[k][2])); - $default_price.html(this._priceToStr(variant_ids[k][3])); - if (variant_ids[k][3]-variant_ids[k][2]>0.01) { - $default_price.closest('.oe_website_sale').addClass("discount"); - $optional_price.closest('.oe_optional').show().css('text-decoration', 'line-through'); - $default_price.parent().removeClass('d-none'); - } else { - $optional_price.closest('.oe_optional').hide(); - $default_price.parent().addClass('d-none'); - } - product_id = variant_ids[k][0]; - this._updateProductImage($(ev.currentTarget).closest('tr.js_product, .oe_website_sale'), product_id); - break; - } - } - - _.each($parent.find("input.js_variant_change:radio, select.js_variant_change"), function (elem) { - var $input = $(elem); - var id = +$input.val(); - var values = [id]; - - _.each($parent.find("ul:not(:has(input.js_variant_change[value='" + id + "'])) input.js_variant_change:checked, select.js_variant_change"), function (elem) { - values.push(+$(elem).val()); - }); - - for (var k in variant_ids) { - if (!_.difference(values, variant_ids[k][1]).length) { - return; - } - } - $input.closest("label").addClass("css_not_available"); - $input.find("option[value='" + id + "']").addClass("css_not_available"); - }); - - if (product_id) { - $parent.removeClass("css_not_available"); - $product_id.val(product_id); - $parent.find("#add_to_cart").removeClass("disabled"); - } else { - $parent.addClass("css_not_available"); - $product_id.val(0); - $parent.find("#add_to_cart").addClass("disabled"); - } + ProductConfiguratorUtils.onChangeVariant(ev, false); }, /** * @private diff --git a/addons/website_sale/static/src/js/website_sale_options.js b/addons/website_sale/static/src/js/website_sale_options.js new file mode 100644 index 00000000000..cb9e93c204b --- /dev/null +++ b/addons/website_sale/static/src/js/website_sale_options.js @@ -0,0 +1,88 @@ +odoo.define('website_sale_options.website_sale', function (require) { + 'use strict'; + + var OptionalProductsModal = require('sale.OptionalProductsModal'); + var weContext = require('web_editor.context'); + var sAnimations = require('website.content.snippets.animation'); + require('website_sale.website_sale'); + + var product_name_map = {}; + var optional_products_map = {}; + + sAnimations.registry.WebsiteSaleOptions = sAnimations.Class.extend({ + selector: '.oe_website_sale', + read_events: { + 'click #add_to_cart, #products_grid .product_price .a-submit': '_onClickAdd', + }, + + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + + this._handleAdd = _.debounce(this._handleAdd.bind(this), 200, true); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _handleAdd: function ($form) { + var self = this; + var quantity = parseFloat($form.find('input[name="add_qty"]').val() || 1); + var product_id = parseInt($form.find('input[type="hidden"][name="product_id"], input[type="radio"][name="product_id"]:checked').first().val(),10); + + var modal = new OptionalProductsModal(quantity, product_id, $form, null, true); + + modal.on('options_empty', null, function () { + $form.trigger('submit', [true]); + }); + + modal.on('back', null, function () { + self._onModalSubmit($form); + }); + + modal.on('confirm', null, function () { + self._onModalSubmit($form, true); + }); + + modal.on('modal_ready', null, function ($modal) { + $modal.on('hidden.bs.modal', function () { + $form.removeClass('css_options'); // possibly reactivate opacity (see above) + $(this).remove(); + }); + }); + + modal.appendTo($form); + }, + + _onModalSubmit: function($form, go_to_shop){ + $form.ajaxSubmit({ + url: '/shop/cart/update_option', + data: {lang: weContext.get().lang}, + success: function (quantity) { + if (go_to_shop) { + window.location.pathname = window.location.pathname.replace(/shop([\/?].*)?$/, "shop/cart"); + } + var $q = $(".my_cart_quantity"); + $q.parent().parent().removeClass("d-none", !quantity); + $q.html(quantity).hide().fadeIn(600); + } + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + _onClickAdd: function (ev) { + ev.preventDefault(); + this._handleAdd($(ev.currentTarget).closest('form')); + }, + }); + }); +
\ No newline at end of file diff --git a/addons/website_sale/static/src/js/website_sale_tour_buy.js b/addons/website_sale/static/src/js/website_sale_tour_buy.js index c2ffef9b81e..bf674dc7a47 100644 --- a/addons/website_sale/static/src/js/website_sale_tour_buy.js +++ b/addons/website_sale/static/src/js/website_sale_tour_buy.js @@ -39,6 +39,10 @@ tour.register('shop_buy_product', { trigger: '#product_detail form[action^="/shop/cart/update"] .btn-primary', }, { + content: "click in modal on 'Proceed to checkout' button", + trigger: 'a:contains("Proceed to Checkout")', + }, + { content: "add suggested", extra_trigger: '#wrap:not(:has(#cart_products:contains("[A8767] Storage Box")))', trigger: '.oe_cart:has(tr:contains("Steel")) a:contains("Add to Cart")', diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js b/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js new file mode 100644 index 00000000000..11e7ed573b2 --- /dev/null +++ b/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js @@ -0,0 +1,216 @@ +odoo.define('website_sale.tour_shop_customize', function (require) { + 'use strict'; + + var tour = require("web_tour.tour"); + var base = require("web_editor.base"); + + tour.register('shop_customize', { + test: true, + url: '/shop', + wait_for: base.ready() + }, + [ + { + content: "open customize menu", + trigger: '#customize-menu > a', + }, + { + content: "click on 'Product Attribute's Filters'", + trigger: "#customize-menu a:contains(Product Attribute's Filters)", + }, + { + content: "select product attribute memory Steel", + extra_trigger: 'body:not(:has(#customize-menu:visible .dropdown-menu:visible))', + trigger: 'form.js_attributes label:contains(Steel) input:not(:checked)', + }, + { + content: "check the selection", + trigger: 'form.js_attributes label:contains(Steel) input:checked', + run: function () {}, // it's a check + }, + { + content: "select product", + extra_trigger: 'body:not(:has(.oe_website_sale .oe_product_cart:eq(2)))', + trigger: '.oe_product_cart a:contains("Customizable Desk")', + }, + { + content: "open customize menu", + trigger: '#customize-menu', + extra_trigger: "#product_detail", + run: function() { + // ENABLE VARIANT GROUP + $('body').addClass('notReady'); + var ajax = odoo.__DEBUG__.services['web.ajax']; + var get_group_payload = { + model: 'ir.model.data', + method: 'xmlid_to_res_id', + args: ['product.group_product_variant', false], + kwargs: {} + }; + ajax.jsonpRpc('/web/dataset/call_kw', 'call', get_group_payload).then(function(group_id) { + ajax.jsonpRpc('/web/dataset/call_kw', 'call', { + model: 'res.groups', + method: 'write', + args: [group_id, {'users': [[4, 1]]}], + kwargs: {} + }).then(function() { + location.reload(true); + }); + }); + }, + }, + { + content: "open customize menu", + trigger: '#customize-menu > a', + extra_trigger: 'body:not(.notReady)', + }, + { + content: "check page loaded after enable variant group", + trigger: '#customize-menu a:contains(List View of Variants)', + run: function () {}, // it's a check + }, + { + content: "click on 'List View of Variants'", + trigger: "#customize-menu a:contains(List View of Variants)", + }, + { + content: "check page loaded after list of variant customization enabled", + trigger: '.js_product_change', + run: function () {}, // it's a check + }, + { + content: "check price is 750 and set quantity to 2", + trigger: ".js_product:first input.quantity:propValue(1)", + extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)", + run: "text 2", + }, + { + content: "verify pricelist based on quantity has effect", + trigger: ".product_price .oe_price .oe_currency_value:containsExact(600.00)", + run: function () {}, // it's a check + }, + { + content: "check pricelit has been applied and switch to Aluminium variant", + trigger: ".js_product label:contains('Aluminium')", + }, + { + content: "verify that price has changed when changing variant", + trigger: ".product_price .oe_price .oe_currency_value:not(:containsExact(600.00))", + run: function () {}, // it's a check + }, + { + content: "open customize menu", + trigger: '#customize-menu > a', + }, + { + content: "remove 'List View of Variants'", + trigger: "#customize-menu a:contains(List View of Variants):has(input:checked)", + }, + { + content: "check page loaded after list of variant customization disabled", + trigger: ".js_product:not(:has(.js_product_change))", + run: function () {}, // it's a check + }, + { + content: "check price is 750 and set quantity to 2", + trigger: ".js_product:first input.quantity:propValue(1)", + extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)", + run: "text 2", + }, + { + content: "verify pricelist based on quantity has effect", + trigger: ".product_price .oe_price .oe_currency_value:containsExact(600.00)", + run: function () {}, // it's a check + }, + { + content: "switch to Aluminium variant", + trigger: ".js_product label:contains('Aluminium')", + }, + { + content: "verify that price has changed when changing variant", + trigger: ".product_price .oe_price .oe_currency_value:not(:containsExact(600.00))", + run: function () {}, // it's a check + }, + { + content: "switch back to Steel variant", + trigger: ".js_product label:contains('Steel')", + }, + { + content: "click on 'Add to Cart' button", + trigger: "a:contains(Add to Cart)", + }, + { + content: "price is lowered by pricelist and not multiplied by quantity", + trigger: "#product_confirmation .oe_price .oe_currency_value:containsExact(600.00)", + extra_trigger: "#product_confirmation input.quantity:propValue(2)", + run: function () {}, // it's a check + }, + { + content: "set quantity to 1", + trigger: "#product_confirmation .js_add_cart_json .fa-minus", + }, + { + content: "check that product page has been updated", + trigger: ".js_product:first input.quantity:propValue(1)", + extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)", + run: function () {}, // it's a check + }, + { + content: "check that add to cart modal has been updated", + trigger: "#product_confirmation .oe_price .oe_currency_value:containsExact(750.00)", + extra_trigger: "#product_confirmation input.quantity:propValue(1)", + run: function () {}, // it's a check + }, + { + content: "add an optional Warranty", + trigger: ".js_product:contains(Warranty) a:contains(Add to Cart)", + }, + { + content: "click in modal on 'Proceed to checkout' button", + extra_trigger: 'body:has(.js_product:contains(Warranty) a:contains(Add to Cart):hidden)', + trigger: '.modal-footer a:contains("Proceed to Checkout")', + }, + { + content: "check quantity", + trigger: '.my_cart_quantity:containsExact(2),.o_extra_menu_items .fa-plus', + run: function () {}, // it's a check + }, + { + content: "check optional product", + trigger: '.optional_product', + run: function () {}, // it's a check + }, + { + content: "remove large cabinet from cart", + trigger: '#cart_products a.js_add_cart_json:first', + }, + { + content: "check optional product is removed", + trigger: '#wrap:not(:has(.optional_product))', + run: function () {}, // it's a check + }, + { + content: "click on shop", + trigger: "a:contains(Continue Shopping)", + extra_trigger: 'body:not(:has(#products_grid_before .js_attributes))', + }, + { + content: "open customize menu bis", + extra_trigger: '#products_grid_before .js_attributes', + trigger: '#customize-menu > a', + }, + { + content: "remove 'Product Attribute's Filters'", + trigger: "#customize-menu a:contains(Product Attribute's Filters):has(input:checked)", + }, + { + content: "finish", + extra_trigger: 'body:not(:has(#products_grid_before .js_attributes))', + trigger: '#wrap:not(:has(li:has(.my_cart_quantity):visible))', + run: function () {}, // it's a check + }, + ] + ); + + }); +
\ No newline at end of file diff --git a/addons/website_sale/static/src/scss/website_sale.scss b/addons/website_sale/static/src/scss/website_sale.scss index bd928b6f974..f2eed17f3b6 100644 --- a/addons/website_sale/static/src/scss/website_sale.scss +++ b/addons/website_sale/static/src/scss/website_sale.scss @@ -29,66 +29,10 @@ margin-left: 1.5em; } - .td-product_name, h1[itemprop="name"], .oe_product_cart form h5 { word-wrap: break-word; } - - .td-product_name { - min-width: 140px; - } - - .td-img { - width: 100px; - } - - .td-qty { - width: 130px; - a.input-group-addon { - background-color: transparent; - border: 0px; - } - } - .td-action { - width: 30px; - } - - .td-price, - .td-price-total { - width: 100px; - } - - @include media-breakpoint-down(sm) { - .td-img, - .td-price-total { - display: none; - } - - .td-qty { - width: 60px; - } - - .td-price { - width: 80px; - } - } - - @media (max-width: 476px) { - .td-qty { - width: 60px; - } - - #modal_optional_products table thead, - .oe_cart table thead { - display: none; - } - - #modal_optional_products table td.td-img, - .oe_cart table td.td-img { - display: none; - } - } } /* ---- Default Styles ---- */ @@ -483,67 +427,6 @@ div#payment_method { } /* product detail */ -.css_attribute_color { - display: inline-block; - border: 1px solid #999999; - text-align: center; - - input { - margin: 8px; - height: 13px; - opacity: 0; - } - - &.active { - border: 3px ridge #66ee66; - } - - &.active input { - margin: 6px; - } -} - -.css_not_available_msg { - display: none; -} - -.css_not_available.js_product { - > *:nth-child(4) > * { - display: none; - } - - .css_quantity, - .product_price { - display: none; - } - - .css_not_available_msg { - display: block; - } -} - -.css_quantity { - max-width: 125px; - - input[name="add_qty"] { - text-align: center; - } -} - -option.css_not_available { - color: #ccc; -} - -label.css_not_available { - opacity: 0.6; -} - -label.css_attribute_color.css_not_available { - opacity: 1; - background-image: url("/website_sale/static/src/img/redcross.png"); - background-size: cover; -} - .product_detail_img { margin-left: auto; margin-right: auto; @@ -551,14 +434,6 @@ label.css_attribute_color.css_not_available { max-height: 500px; } -.oe_default_price { - display: none; -} - -.discount .oe_default_price { - display: block; -} - .oe_website_sale input.js_quantity { min-width: 48px; text-align: center; diff --git a/addons/website_sale_options/static/src/scss/website_sale.scss b/addons/website_sale/static/src/scss/website_sale_options.scss index 7c129d35b4f..200c9eadd6a 100644 --- a/addons/website_sale_options/static/src/scss/website_sale.scss +++ b/addons/website_sale/static/src/scss/website_sale_options.scss @@ -16,9 +16,4 @@ div#modal_optional_products table tr td { div#modal_optional_products .td-qty { display: none; } -} - -.oe_striked_price { - text-decoration: line-through; - white-space: nowrap; -} +}
\ No newline at end of file diff --git a/addons/website_sale_options/tests/test_customize.py b/addons/website_sale/tests/test_customize.py index 5a397dbba54..e387c068a0b 100644 --- a/addons/website_sale_options/tests/test_customize.py +++ b/addons/website_sale/tests/test_customize.py @@ -6,8 +6,4 @@ import odoo.tests class TestUi(odoo.tests.HttpCase): def test_01_admin_shop_customize_tour(self): - self.env.ref('product.group_product_variant').write({ - 'users': [(4, self.env.ref('base.user_admin').id)] - }) - self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('shop_customize')", "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_customize.ready", login="admin") diff --git a/addons/website_sale/views/product_views.xml b/addons/website_sale/views/product_views.xml index 13c77ff81c0..9ebf0340b9b 100644 --- a/addons/website_sale/views/product_views.xml +++ b/addons/website_sale/views/product_views.xml @@ -28,35 +28,6 @@ </field> </record> - <record id="product_attribute_line_form" model="ir.ui.view"> - <field name="name">product.attribute.line.form.inherit</field> - <field name="model">product.attribute.line</field> - <field name="inherit_id" ref="product.product_attribute_line_form"/> - <field name="priority" eval="8"/> - <field name="arch" type="xml"> - <xpath expr="//field[@name='value_ids']/tree/field[@name='name']" position="after"> - <field name="html_color"/> - </xpath> - </field> - </record> - - <record id="product_attribute_view_form" model="ir.ui.view"> - <field name="name">product.attribute.form.inherit</field> - <field name="model">product.attribute</field> - <field name="inherit_id" ref="product.product_attribute_view_form"/> - <field name="priority" eval="8"/> - <field name="arch" type="xml"> - <xpath expr="//group[@name='main_fields']" position="after"> - <group> - <field name="type"/> - </group> - </xpath> - <xpath expr="//field[@name='value_ids']/tree/field[@name='name']" position="after"> - <field name="html_color"/> - </xpath> - </field> - </record> - <record model="ir.ui.view" id="product_pricelist_view"> <field name="name">product.pricelist.website.form</field> <field name="model">product.pricelist</field> @@ -154,39 +125,6 @@ </field> </record> - <!-- Product attribute --> - - <record id="product_attribute_value_view_tree_inherit_website_sale" model="ir.ui.view"> - <field name="name">product.attribute.value.view.tree.inherit.website.sale</field> - <field name="model">product.attribute.value</field> - <field name="inherit_id" ref="product.product_attribute_value_view_tree"/> - <field name="arch" type="xml"> - <field name="name" position="after"> - <field name="html_color"/> - </field> - </field> - </record> - <record id="variants_tree_view" model="ir.ui.view"> - <field name="name">variants.template.tree</field> - <field name="model">product.attribute.value</field> - <field name="inherit_id" ref="product.variants_tree_view"/> - <field name="arch" type="xml"> - <field name="name" position="after"> - <field name="html_color"/> - </field> - </field> - </record> - <record id="attribute_tree_view" model="ir.ui.view"> - <field name="name">product.attribute.tree.type</field> - <field name="model">product.attribute</field> - <field name="inherit_id" ref="product.attribute_tree_view"></field> - <field name="arch" type="xml"> - <field name="name" position="after"> - <field name="type"/> - </field> - </field> - </record> - <!-- Product Public Categories --> <record id="product_public_category_form_view" model="ir.ui.view"> <field name="name">product.public.category.form</field> diff --git a/addons/website_sale/views/res_config_settings_views.xml b/addons/website_sale/views/res_config_settings_views.xml index f0dda9eef90..04410eb05d0 100644 --- a/addons/website_sale/views/res_config_settings_views.xml +++ b/addons/website_sale/views/res_config_settings_views.xml @@ -59,17 +59,6 @@ </div> </div> </div> - <div class="col-12 col-lg-6 o_setting_box" id="sale_option_setting" title="Select optional products from the configuration form of your main products (in Sales tab)."> - <div class="o_setting_left_pane"> - <field name="module_website_sale_options"/> - </div> - <div class="o_setting_right_pane"> - <label for="module_website_sale_options"/> - <div class="text-muted"> - Suggest optional products when adding to cart - </div> - </div> - </div> <div class="col-12 col-lg-6 o_setting_box" id="product_availability_setting"> <div class="o_setting_left_pane"> <field name="module_website_sale_stock"/> diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 83fb7f4fef9..68052b253d6 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -14,14 +14,21 @@ <link rel="stylesheet" type="text/scss" href="/website_sale/static/src/scss/website_sale.scss" /> <link rel="stylesheet" type="text/scss" href="/website_sale/static/src/scss/website_mail.scss" /> <link rel="stylesheet" type="text/scss" href="/website_sale/static/src/scss/website_sale_frontend.scss"/> + <link rel="stylesheet" type="text/scss" href="/sale/static/src/scss/sale_portal.scss"/> + <link rel="stylesheet" type="text/scss" href="/sale/static/src/scss/product_configurator.scss"/> + <link rel="stylesheet" type="text/scss" href="/website_sale/static/src/scss/website_sale_options.scss"/> </xpath> <xpath expr="script[last()]" position="after"> + <script type="text/javascript" src="/sale/static/src/js/product_configurator_utils.js"></script> + <script type="text/javascript" src="/sale/static/src/js/product_configurator_modal.js"></script> <script type="text/javascript" src="/website_sale/static/src/js/website_sale.js"></script> <script type="text/javascript" src="/website_sale/static/src/js/website_sale_utils.js"></script> <script type="text/javascript" src="/website_sale/static/src/js/website_sale_payment.js"></script> <script type="text/javascript" src="/website_sale/static/src/js/website_sale_validate.js"></script> <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_buy.js"></script> <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tracking.js"></script> + <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_customize.js"></script> + <script type="text/javascript" src="/website_sale/static/src/js/website_sale_options.js"></script> </xpath> </template> @@ -149,6 +156,12 @@ </a> </t> </xpath> + <xpath expr="//div[hasclass('product_price')]/t[1]" position="attributes"> + <attribute name="t-if">len(product.product_variant_ids) == 1 and not len(product.optional_product_ids) >= 1</attribute> + </xpath> + <xpath expr="//div[hasclass('product_price')]/t[2]" position="attributes"> + <attribute name="t-if">len(product.product_variant_ids) > 1 or len(product.optional_product_ids) >= 1</attribute> + </xpath> </template> <template id="pricelist_list" name="Pricelists Dropdown"> @@ -470,10 +483,10 @@ <span itemprop="url" style="display:none;" t-esc="'%sshop/product/%s' % (request.httprequest.url_root, slug(product))"/> <form action="/shop/cart/update" method="POST"> <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" /> - <div class="js_product" t-if="product.product_variant_ids"> + <div class="js_product js_main_product" t-if="product.product_variant_ids"> <t t-placeholder="select"> <input type="hidden" class="product_id" name="product_id" t-att-value="product.product_variant_id.id if len(product.product_variant_ids) == 1 else '0'" /> - <t t-call="website_sale.variants"> + <t t-call="sale.variants"> <t t-set="ul_class" t-value="'flex-column'" /> </t> </t> @@ -602,13 +615,13 @@ "display_currency": product.currency_id, }'/> </h4> - <h4 class="d-none oe_not_available bg-warning">Product not available</h4> + <h4 class="d-none oe_not_available bg-warning">Option not available</h4> </div> </template> <template id="product_variants" inherit_id="website_sale.product" active="False" customize_show="True" name="List View of Variants"> <xpath expr="//t[@t-placeholder='select']" position="replace"> - <t t-set="attribute_value_ids" t-value="get_attribute_value_ids(product)"/> + <t t-set="attribute_value_ids" t-value="get_attribute_value_ids(product, reference_product, pricelist)"/> <ul class="d-none js_add_cart_variants" t-att-data-attribute_value_ids="json.dumps(attribute_value_ids)"/> <input type="hidden" t-if="len(product.product_variant_ids) == 1" class="product_id" name="product_id" t-att-value="product.product_variant_id.id"/> <t t-if="len(product.product_variant_ids) > 1"> @@ -626,75 +639,6 @@ </xpath> </template> - <template id="variants"> - <t t-set="attribute_value_ids" t-value="get_attribute_value_ids(product)"/> - <ul t-attf-class="list-unstyled js_add_cart_variants #{ul_class}" t-att-data-attribute_value_ids="json.dumps(attribute_value_ids)"> - <t t-foreach="product.attribute_line_ids.sorted(key=lambda x: x.attribute_id.sequence)" t-as="variant_id"> - <li t-if="len(variant_id.value_ids) > 1"> - - <strong t-field="variant_id.attribute_id.name"/> - - <t t-if="variant_id.attribute_id.type == 'select'"> - <select t-attf-class="form-control #{'js_variant_change' if variant_id.attribute_id.create_variant else ''}" t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"> - <t t-foreach="variant_id.value_ids" t-as="value_id"> - <option t-att-value="value_id.id"> - <span t-field="value_id.name"/> - <span t-if="value_id.price_extra"> - <t t-esc="value_id.price_extra > 0 and '+' or ''"/><span t-field="value_id.price_extra" style="white-space: nowrap;" t-options='{ - "widget": "monetary", - "from_currency": product.currency_id, - "display_currency": website.currency_id - }'/> - </span> - </option> - </t> - </select> - </t> - - <t t-if="variant_id.attribute_id.type == 'radio'"> - <ul class="list-unstyled"> - <t t-set="inc" t-value="0"/> - <t t-foreach="variant_id.value_ids" t-as="value_id"> - <li class="form-group js_attribute_value" style="margin: 0;"> - <label class="col-form-label" style="margin: 0 20px;"> - <input type="radio" t-att-class="'js_variant_change' if variant_id.attribute_id.create_variant else None" t-att-checked="'checked' if not inc else None" t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)" t-att-value="value_id.id" style="vertical-align: top; margin-right: 10px;"/> - <span t-field="value_id.name"/> - <span class="badge badge-pill" t-if="value_id.price_extra"> - <t t-esc="value_id.price_extra > 0 and '+' or ''"/><span t-field="value_id.price_extra" style="white-space: nowrap;" t-options='{ - "widget": "monetary", - "from_currency": product.currency_id, - "display_currency": website.currency_id - }'/> - </span> - </label> - </li> - <t t-set="inc" t-value="inc+1"/> - </t> - </ul> - </t> - - <t t-if="variant_id.attribute_id.type == 'color'"> - <ul class="list-inline"> - <t t-set="inc" t-value="0"/> - <li t-foreach="variant_id.value_ids" t-as="value_id" class="list-inline-item"> - <label t-attf-style="background-color:#{value_id.html_color or value_id.name}" - t-attf-class="css_attribute_color #{'active' if not inc else ''}"> - <input type="radio" t-att-class="'js_variant_change' if variant_id.attribute_id.create_variant else None" - t-att-checked="'checked' if not inc else None" - t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)" - t-att-value="value_id.id" - t-att-title="value_id.name"/> - </label> - <t t-set="inc" t-value="inc+1"/> - </li> - </ul> - </t> - - </li> - </t> - </ul> - </template> - <template id="wizard_checkout" name="Wizard Checkout"> <t t-set="website_sale_order" t-value="website.sale_get_order()"/> @@ -840,7 +784,7 @@ </thead> <tbody> <t t-foreach="website_sale_order.website_order_line" t-as="line"> - <tr> + <tr t-att-class="'optional_product info' if line.linked_line_id else None"> <td colspan="2" t-if="not line.product_id.product_tmpl_id" class='td-img'></td> <td align="center" t-if="line.product_id.product_tmpl_id" class='td-img'> <span t-field="line.product_id.image_small" t-options="{'widget': 'image', 'class': 'rounded'}" /> @@ -856,7 +800,7 @@ </t> </td> <td class="text-center td-qty"> - <div class="css_quantity input-group oe_website_spinner"> + <div t-if="not line.linked_line_id" class="css_quantity input-group oe_website_spinner"> <div class="input-group-prepend"> <a t-attf-href="#" class="btn btn-link js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one"> <i class="fa fa-minus"></i> @@ -869,6 +813,9 @@ </a> </div> </div> + <t t-if="line.linked_line_id"> + <span class="js_quantity text-muted" t-att-data-line-id="line.id" t-att-data-product-id="line.product_id.id" t-esc="int(line.product_uom_qty)"/> + </t> </td> <td class="text-center td-price" name="price"> <t t-if="(compute_currency(line.product_id.lst_price) - line.price_reduce ) > 0.01 and website.get_current_pricelist().discount_policy=='without_discount'"> diff --git a/addons/website_sale_comparison/views/website_sale_comparison_view.xml b/addons/website_sale_comparison/views/website_sale_comparison_view.xml index a49c73e61c7..0237d0cfbab 100644 --- a/addons/website_sale_comparison/views/website_sale_comparison_view.xml +++ b/addons/website_sale_comparison/views/website_sale_comparison_view.xml @@ -38,7 +38,7 @@ <record id="product_attribute_view_form" model="ir.ui.view"> <field name="name">product.attribute.form.inherit</field> <field name="model">product.attribute</field> - <field name="inherit_id" ref="website_sale.product_attribute_view_form"/> + <field name="inherit_id" ref="product.product_attribute_view_form"/> <field name="priority" eval="8"/> <field name="arch" type="xml"> <field name='name' position='after'> diff --git a/addons/website_sale_options/__init__.py b/addons/website_sale_options/__init__.py deleted file mode 100644 index 7d34c7c054a..00000000000 --- a/addons/website_sale_options/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from . import controllers -from . import models diff --git a/addons/website_sale_options/__manifest__.py b/addons/website_sale_options/__manifest__.py deleted file mode 100644 index bb7c76099e0..00000000000 --- a/addons/website_sale_options/__manifest__.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -{ - 'name': 'Optional Products', - 'category': 'Website', - 'summary': 'Suggest optional products when adding to cart', - 'version': '1.0', - 'description': """ -Suggest optional products when shoppers add products to their cart (e.g. for computers: warranty, OS software, extra components). -Optional products are defined in the product form. - """, - 'depends': ['website_sale'], - 'data': [ - 'views/product_views.xml', - 'views/website_sale_options_templates.xml', - ], - 'demo': [ - 'data/product_demo.xml', - ], - 'qweb': ['static/src/xml/*.xml'], - 'installable': True, -} diff --git a/addons/website_sale_options/controllers/__init__.py b/addons/website_sale_options/controllers/__init__.py deleted file mode 100644 index 5d4b25db9c0..00000000000 --- a/addons/website_sale_options/controllers/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from . import main diff --git a/addons/website_sale_options/controllers/main.py b/addons/website_sale_options/controllers/main.py deleted file mode 100644 index c4d5f761258..00000000000 --- a/addons/website_sale_options/controllers/main.py +++ /dev/null @@ -1,85 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import fields, http -from odoo.http import request -from odoo.addons.website_sale.controllers.main import WebsiteSale - - -class WebsiteSaleOptions(WebsiteSale): - - @http.route(['/shop/product/<model("product.template"):product>'], type='http', auth="public", website=True) - def product(self, product, category='', search='', **kwargs): - r = super(WebsiteSaleOptions, self).product(product, category, search, **kwargs) - r.qcontext['optional_product_ids'] = [p.with_context({'active_id': p.id}) for p in product.optional_product_ids] - return r - - @http.route(['/shop/cart/update_option'], type='http', auth="public", methods=['POST'], website=True, multilang=False) - def cart_options_update_json(self, product_id, add_qty=1, set_qty=0, goto_shop=None, lang=None, **kw): - if lang: - request.website = request.website.with_context(lang=lang) - - order = request.website.sale_get_order(force_create=1) - product = request.env['product.product'].browse(int(product_id)) - - option_ids = product.optional_product_ids.mapped('product_variant_ids').ids - optional_product_ids = [] - for k, v in kw.items(): - if "optional-product-" in k and int(kw.get(k.replace("product", "add"))) and int(v) in option_ids: - optional_product_ids.append(int(v)) - - attributes = self._filter_attributes(**kw) - - value = {} - if add_qty or set_qty: - value = order._cart_update( - product_id=int(product_id), - add_qty=add_qty, - set_qty=set_qty, - attributes=attributes, - optional_product_ids=optional_product_ids - ) - - # options have all time the same quantity - for option_id in optional_product_ids: - order._cart_update( - product_id=option_id, - set_qty=value.get('quantity'), - attributes=attributes, - linked_line_id=value.get('line_id') - ) - - return str(order.cart_quantity) - - @http.route(['/shop/modal'], type='json', auth="public", methods=['POST'], website=True) - def modal(self, product_id, **kw): - pricelist = request.website.get_current_pricelist() - product_context = dict(request.context) - quantity = kw['kwargs']['context']['quantity'] - if not product_context.get('pricelist'): - product_context['pricelist'] = pricelist.id - # fetch quantity from custom context - product_context.update(kw.get('kwargs', {}).get('context', {})) - - from_currency = request.env.user.company_id.currency_id - to_currency = pricelist.currency_id - company = request.env['res.company'].browse(request.env.context.get('company_id')) or request.env['res.users']._get_company() - date = request.env.context.get('date') or fields.Date.today() - compute_currency = lambda price: from_currency._convert( - price, to_currency, company, date) - product = request.env['product.product'].with_context(product_context).browse(int(product_id)) - - main_product_attr_ids = self.get_attribute_value_ids(product) - for variant in main_product_attr_ids: - if variant[0] == product.id: - # We indeed need a list of lists (even with only 1 element) - main_product_attr_ids = [variant] - break - - return request.env['ir.ui.view'].render_template("website_sale_options.modal", { - 'product': product, - 'quantity': quantity, - 'compute_currency': compute_currency, - 'get_attribute_value_ids': self.get_attribute_value_ids, - 'main_product_attr_ids': main_product_attr_ids, - }) diff --git a/addons/website_sale_options/data/product_demo.xml b/addons/website_sale_options/data/product_demo.xml deleted file mode 100644 index 75820c74454..00000000000 --- a/addons/website_sale_options/data/product_demo.xml +++ /dev/null @@ -1,38 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<odoo> - <data noupdate="1"> - <record id="product_product_1" model="product.product"> - <field name="name">Warranty</field> - <field name="default_code">SERV_125889</field> - <field name="list_price">20.0</field> - <field name="website_sequence">100</field> - <field name="website_published" eval="True"/> - <field name="type">service</field> - <field name="uom_id" ref="uom.product_uom_unit"/> - <field name="uom_po_id" ref="uom.product_uom_unit"/> - <field name="description_sale">Warranty, issued to the purchaser of an article by its manufacturer, promising to repair or replace it if necessary within a specified period of time.</field> - <field name="attribute_value_ids" eval="[(6,0,[ref('product.product_attribute_value_5')])]"/> - <field name="categ_id" ref="product.product_category_3"/> - </record> - <record id="product_attribute_line_1" model="product.attribute.line"> - <field name="product_tmpl_id" ref="product_product_1_product_template"/> - <field name="attribute_id" ref="product.product_attribute_3"/> - <field name="value_ids" eval="[(6,0,[ref('product.product_attribute_value_5'), ref('product.product_attribute_value_6')])]"/> - </record> - <record id="product_product_1_product_template" model="product.template"> - <field name="attribute_line_ids" eval="[(6,0,[ref('product_attribute_line_1')])]"/> - <field name="public_categ_ids" eval="[(6,0,[ref('website_sale.services')])]"/> - </record> - <record id="product.product_product_4_product_template" model="product.template"> - <field name="optional_product_ids" eval="[(6,0,[ref('product_product_1_product_template')])]"/> - </record> - <record id="product_attribute_price_1" model="product.attribute.price"> - <field name="product_tmpl_id" ref="product_product_1_product_template"/> - <field name="value_id" ref="product.product_attribute_value_6"/> - <field name="price_extra">18.00</field> - </record> - - <!-- --> - - </data> -</odoo> diff --git a/addons/website_sale_options/models/__init__.py b/addons/website_sale_options/models/__init__.py deleted file mode 100644 index ff797314513..00000000000 --- a/addons/website_sale_options/models/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from . import product -from . import sale_order diff --git a/addons/website_sale_options/models/sale_order.py b/addons/website_sale_options/models/sale_order.py deleted file mode 100644 index c5d105fbeb7..00000000000 --- a/addons/website_sale_options/models/sale_order.py +++ /dev/null @@ -1,55 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models, _ - - -class SaleOrderLine(models.Model): - _inherit = "sale.order.line" - - linked_line_id = fields.Many2one('sale.order.line', string='Linked Order Line', domain="[('order_id', '!=', order_id)]", ondelete='cascade') - option_line_ids = fields.One2many('sale.order.line', 'linked_line_id', string='Options Linked') - - -class SaleOrder(models.Model): - _inherit = "sale.order" - - @api.multi - def _cart_find_product_line(self, product_id=None, line_id=None, **kwargs): - self.ensure_one() - lines = super(SaleOrder, self)._cart_find_product_line(product_id, line_id) - if line_id: - return lines - linked_line_id = kwargs.get('linked_line_id', False) - optional_product_ids = set(kwargs.get('optional_product_ids', [])) - - lines = lines.filtered(lambda line: line.linked_line_id.id == linked_line_id) - if optional_product_ids: - # only match the lines with the same chosen optional products on the existing lines - lines = lines.filtered(lambda line: optional_product_ids == set(line.mapped('option_line_ids.product_id.id'))) - else: - lines = lines.filtered(lambda line: not line.option_line_ids) - return lines - - @api.multi - def _cart_update(self, product_id=None, line_id=None, add_qty=0, set_qty=0, **kwargs): - self.ensure_one() - """ Add or set product quantity, add_qty can be negative """ - value = super(SaleOrder, self)._cart_update(product_id, line_id, add_qty, set_qty, **kwargs) - SaleOrderLineSudo = self.env['sale.order.line'].sudo() - line = SaleOrderLineSudo.browse(value.get('line_id')) - # link a product to the sales order - if kwargs.get('linked_line_id'): - linked_line = SaleOrderLineSudo.browse(kwargs['linked_line_id']) - line.write({ - 'linked_line_id': linked_line.id, - 'name': line.name + "\n" + _("Option for:") + ' ' + linked_line.product_id.display_name, - }) - linked_line.write({"name": linked_line.name + "\n" + _("Option:") + ' ' + line.product_id.display_name}) - - option_lines = self.order_line.filtered(lambda l: l.linked_line_id.id == line.id) - for option_line_id in option_lines: - super(SaleOrder, self)._cart_update(option_line_id.product_id.id, option_line_id.id, add_qty, set_qty, **kwargs) - - value['option_ids'] = list(set(option_lines.ids)) - return value diff --git a/addons/website_sale_options/static/description/icon.png b/addons/website_sale_options/static/description/icon.png Binary files differdeleted file mode 100644 index 4e8642ae3e8..00000000000 --- a/addons/website_sale_options/static/description/icon.png +++ /dev/null diff --git a/addons/website_sale_options/static/description/icon.svg b/addons/website_sale_options/static/description/icon.svg deleted file mode 100644 index 9d6a24868d2..00000000000 --- a/addons/website_sale_options/static/description/icon.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="100%" x2="0%" y1="0%" y2="100%"><stop offset="0%" stop-color="#CDC484"/><stop offset="100%" stop-color="#B5AA59"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path fill="url(#c)" d="M0 0H70V70H0z"/><path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/><path fill="#393939" d="M3.986 70C.584 70 0 68 0 65V31.978L6 26l8-5h32l18 6-13 10v14L38.632 70H3.986z" opacity=".324"/><path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/><path fill="#000" d="M18 33L6.016 29.586c-1.344-.39-1.413-1.25-.207-2.576l7.28-8.007c.607-.669 1.582-.82 2.925-.454L25 21h19l9.35-2.4c1.1-.4 1.959-.26 2.576.419l7.078 7.786c1.33 1.463 1.327 2.383-.01 2.758L51 33v20c0 .667-.333 1-1 1H19c-.667 0-1-.333-1-1V33zm7.367-10l-6.414 10h31L43.54 23H25.367zm.323 13.954v1.895H23.73v1.896h1.959v8.529h1.958v-8.53h1.959V38.85h-1.959v-1.895H25.69zm7.834 0v7.581h-1.958v1.896h1.958v2.843h1.959V46.43h1.959v-1.896h-1.96v-7.581h-1.958zm8.814 0v5.686H40.38v1.895h1.96v4.739h1.958v-4.739h1.958V42.64h-1.958v-5.686h-1.959z" opacity=".3"/><path fill="#FFF" d="M18 31L6.016 27.586c-1.344-.39-1.413-1.25-.207-2.576l7.28-8.007c.607-.669 1.582-.82 2.925-.454L25 20h19l9.35-3.4c1.1-.4 1.959-.26 2.576.419l7.078 7.786c1.33 1.463 1.327 2.383-.01 2.758L51 31v20c0 .667-.333 1-1 1H19c-.667 0-1-.333-1-1V31zm7.367-10l-6.414 10h31L43.54 21H25.367zm.323 13.954v1.895H23.73v1.896h1.959v8.529h1.958v-8.53h1.959V36.85h-1.959v-1.895H25.69zm7.834 0v7.581h-1.958v1.896h1.958v2.843h1.959V44.43h1.959v-1.896h-1.96v-7.581h-1.958zm8.814 0v5.686H40.38v1.895h1.96v4.739h1.958v-4.739h1.958V40.64h-1.958v-5.686h-1.959z"/></g></g></svg>
\ No newline at end of file diff --git a/addons/website_sale_options/static/src/js/website_sale.test.js b/addons/website_sale_options/static/src/js/website_sale.test.js deleted file mode 100644 index b982178afc3..00000000000 --- a/addons/website_sale_options/static/src/js/website_sale.test.js +++ /dev/null @@ -1,197 +0,0 @@ -odoo.define('website_sale.test', function (require) { -'use strict'; - -require("website_sale.tour"); -var tour = require("web_tour.tour"); -var base = require("web_editor.base"); -var ajax = require('web.ajax'); - -var steps = tour.tours.shop_buy_product.steps; -for (var k=0; k<steps.length; k++) { - if (steps[k].content === "click on add to cart") { - steps.splice(k+1, 0, { - content: "click in modal on 'Proceed to checkout' button", - trigger: 'a:contains("Proceed to Checkout")', - }); - break; - } -} - -tour.register('shop_customize', { - test: true, - url: '/shop', - wait_for: base.ready() -}, - [ - { - content: "open customize menu", - trigger: '#customize-menu > a', - }, - { - content: "click on 'Product Attribute's Filters'", - trigger: "#customize-menu a:contains(Product Attribute's Filters)", - }, - { - content: "select product attribute memory Steel", - extra_trigger: 'body:not(:has(#customize-menu:visible .dropdown-menu:visible))', - trigger: 'form.js_attributes label:contains(Steel) input:not(:checked)', - }, - { - content: "check the selection", - trigger: 'form.js_attributes label:contains(Steel) input:checked', - run: function () {}, // it's a check - }, - { - content: "select product", - extra_trigger: 'body:not(:has(.oe_website_sale .oe_product_cart:eq(2)))', - trigger: '.oe_product_cart a:contains("Customizable Desk")', - }, - { - content: "open customize menu", - trigger: '#customize-menu > a', - extra_trigger: "#product_detail", - }, - { - content: "click on 'List View of Variants'", - trigger: "#customize-menu a:contains(List View of Variants)", - }, - { - content: "check page loaded after list of variant customization enabled", - trigger: '.js_product_change', - run: function () {}, // it's a check - }, - { - content: "check price is 750 and set quantity to 2", - trigger: ".js_product:first input.quantity:propValue(1)", - extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)", - run: "text 2", - }, - { - content: "verify pricelist based on quantity has effect", - trigger: ".product_price .oe_price .oe_currency_value:containsExact(600.00)", - run: function () {}, // it's a check - }, - { - content: "check pricelit has been applied and switch to Aluminium variant", - trigger: ".js_product label:contains('Aluminium')", - }, - { - content: "verify that price has changed when changing variant", - trigger: ".product_price .oe_price .oe_currency_value:not(:containsExact(600.00))", - run: function () {}, // it's a check - }, - { - content: "open customize menu", - trigger: '#customize-menu > a', - }, - { - content: "remove 'List View of Variants'", - trigger: "#customize-menu a:contains(List View of Variants):has(input:checked)", - }, - { - content: "check page loaded after list of variant customization disabled", - trigger: ".js_product:not(:has(.js_product_change))", - run: function () {}, // it's a check - }, - { - content: "check price is 750 and set quantity to 2", - trigger: ".js_product:first input.quantity:propValue(1)", - extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)", - run: "text 2", - }, - { - content: "verify pricelist based on quantity has effect", - trigger: ".product_price .oe_price .oe_currency_value:containsExact(600.00)", - run: function () {}, // it's a check - }, - { - content: "switch to Aluminium variant", - trigger: ".js_product label:contains('Aluminium')", - }, - { - content: "verify that price has changed when changing variant", - trigger: ".product_price .oe_price .oe_currency_value:not(:containsExact(600.00))", - run: function () {}, // it's a check - }, - { - content: "switch back to Steel variant", - trigger: ".js_product label:contains('Steel')", - }, - { - content: "click on 'Add to Cart' button", - trigger: "a:contains(Add to Cart)", - }, - { - content: "price is lowered by pricelist and not multiplied by quantity", - trigger: "#product_confirmation .oe_price .oe_currency_value:containsExact(600.00)", - extra_trigger: "#product_confirmation input.quantity:propValue(2)", - run: function () {}, // it's a check - }, - { - content: "set quantity to 1", - trigger: "#product_confirmation .js_add_cart_json .fa-minus", - }, - { - content: "check that product page has been updated", - trigger: ".js_product:first input.quantity:propValue(1)", - extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)", - run: function () {}, // it's a check - }, - { - content: "check that add to cart modal has been updated", - trigger: "#product_confirmation .oe_price .oe_currency_value:containsExact(750.00)", - extra_trigger: "#product_confirmation input.quantity:propValue(1)", - run: function () {}, // it's a check - }, - { - content: "add an optional Warranty", - trigger: ".js_product:contains(Warranty) a:contains(Add to Cart)", - }, - { - content: "click in modal on 'Proceed to checkout' button", - extra_trigger: 'body:has(.js_product:contains(Warranty) a:contains(Add to Cart):hidden)', - trigger: '.modal-footer a:contains("Proceed to Checkout")', - }, - { - content: "check quantity", - trigger: '.my_cart_quantity:containsExact(2),.o_extra_menu_items .fa-plus', - run: function () {}, // it's a check - }, - { - content: "check optional product", - trigger: '.optional_product', - run: function () {}, // it's a check - }, - { - content: "remove large cabinet from cart", - trigger: '#cart_products a.js_add_cart_json:first', - }, - { - content: "check optional product is removed", - trigger: '#wrap:not(:has(.optional_product))', - run: function () {}, // it's a check - }, - { - content: "click on shop", - trigger: "a:contains(Continue Shopping)", - extra_trigger: 'body:not(:has(#products_grid_before .js_attributes))', - }, - { - content: "open customize menu bis", - extra_trigger: '#products_grid_before .js_attributes', - trigger: '#customize-menu > a', - }, - { - content: "remove 'Product Attribute's Filters'", - trigger: "#customize-menu a:contains(Product Attribute's Filters):has(input:checked)", - }, - { - content: "finish", - extra_trigger: 'body:not(:has(#products_grid_before .js_attributes))', - trigger: '#wrap:not(:has(li:has(.my_cart_quantity):visible))', - run: function () {}, // it's a check - }, - ] -); - -}); diff --git a/addons/website_sale_options/tests/__init__.py b/addons/website_sale_options/tests/__init__.py deleted file mode 100644 index 38533d75c78..00000000000 --- a/addons/website_sale_options/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import test_customize
\ No newline at end of file diff --git a/addons/website_sale_options/views/product_views.xml b/addons/website_sale_options/views/product_views.xml deleted file mode 100644 index c75b2d215e3..00000000000 --- a/addons/website_sale_options/views/product_views.xml +++ /dev/null @@ -1,15 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<odoo> - - <record id="product_template_form_view" model="ir.ui.view"> - <field name="name">product.template.product.website.form</field> - <field name="model">product.template</field> - <field name="inherit_id" ref="website_sale.product_template_form_view"/> - <field name="arch" type="xml"> - <field name="accessory_product_ids" position="after"> - <field name="optional_product_ids" widget="many2many_tags" options="{'color_field': 'color'}"/> - </field> - </field> - </record> - -</odoo> diff --git a/addons/website_sale_stock/__manifest__.py b/addons/website_sale_stock/__manifest__.py index 6eb8f199bec..758886bfd29 100644 --- a/addons/website_sale_stock/__manifest__.py +++ b/addons/website_sale_stock/__manifest__.py @@ -2,14 +2,19 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. { - 'name': 'Website Sale Stock - Website Delivery Information', - 'category': 'Technical Settings', - 'description': "", + 'name': 'Product Availability', + 'category': 'Website', + 'summary': 'Manage product inventory & availability', + 'description': """ +Manage the inventory of your products and display their availability status in your eCommerce store. +In case of stockout, you can decide to block further sales or to keep selling. +A default behavior can be selected in the Website settings. +Then it can be made specific at the product level. + """, 'depends': [ 'website_sale', 'sale_stock', ], - 'auto_install': True, 'data': [ 'views/product_template_views.xml', 'views/res_config_settings_views.xml', @@ -19,4 +24,5 @@ 'demo': [ 'data/website_sale_stock_demo.xml', ], + 'auto_install': True, } diff --git a/addons/website_sale_stock/controllers/main.py b/addons/website_sale_stock/controllers/main.py index 587cac2aece..333baa08f83 100644 --- a/addons/website_sale_stock/controllers/main.py +++ b/addons/website_sale_stock/controllers/main.py @@ -8,8 +8,8 @@ from odoo.tools.pycompat import izip class WebsiteSale(WebsiteSale): - def get_attribute_value_ids(self, product): - res = super(WebsiteSale, self).get_attribute_value_ids(product) + def _get_attribute_value_ids(self, product, reference_product=None, pricelist=None): + res = super(WebsiteSale, self)._get_attribute_value_ids(product, reference_product, pricelist) variant_ids = [r[0] for r in res] # recordsets conserve the order for r, variant in izip(res, request.env['product.product'].sudo().browse(variant_ids)): diff --git a/addons/website_sale_stock_options/static/description/icon.png b/addons/website_sale_stock/static/description/icon.png Binary files differindex c772bcebbbd..c772bcebbbd 100644 --- a/addons/website_sale_stock_options/static/description/icon.png +++ b/addons/website_sale_stock/static/description/icon.png diff --git a/addons/website_sale_stock_options/static/description/icon.svg b/addons/website_sale_stock/static/description/icon.svg index b519bd02d02..b519bd02d02 100644 --- a/addons/website_sale_stock_options/static/description/icon.svg +++ b/addons/website_sale_stock/static/description/icon.svg diff --git a/addons/website_sale_stock/static/src/js/website_sale_stock.js b/addons/website_sale_stock/static/src/js/website_sale_stock.js index 134d8c45ad7..d57e09c8c9c 100644 --- a/addons/website_sale_stock/static/src/js/website_sale_stock.js +++ b/addons/website_sale_stock/static/src/js/website_sale_stock.js @@ -17,8 +17,8 @@ $('.oe_website_sale').each(function() { var oe_website_sale = this; // For options products - $(oe_website_sale).on('change', 'input[name="add_qty"]', function() { - $(oe_website_sale).find('ul[data-attribute_value_ids]').trigger('change'); + $(oe_website_sale).on('change', 'input[name="add_qty"]', function(ev, no_stock_check) { + $(oe_website_sale).find('ul[data-attribute_value_ids]').trigger('change', [no_stock_check]); }); $(oe_website_sale).find('input[name="add_qty"]').trigger('change'); @@ -36,7 +36,11 @@ $('.oe_website_sale').each(function() { /* Renders a specific message concerning the stock of the product and its variants on the product website page. */ - $(oe_website_sale).on('change', 'ul[data-attribute_value_ids]', function(event) { + $(oe_website_sale).on('change', 'ul[data-attribute_value_ids]', function(event, no_stock_check) { + if(no_stock_check){ + return; + } + var $ul = $(event.target).closest('.js_add_cart_variants'); var $parent = $ul.closest('.js_product'); var variant_ids = $ul.data("attribute_value_ids"); @@ -44,13 +48,14 @@ $('.oe_website_sale').each(function() { $parent.find('input.js_variant_change:checked, select.js_variant_change').each(function() { values.push(+$(this).val()); }); + $parent.find('#add_to_cart').removeClass('disabled out_of_stock'); var list_variant_id = parseInt($parent.find('input.js_product_change:checked').val()); var qty = $parent.find('input[name="add_qty"]').val(); for (var k in variant_ids) { if (_.isEmpty(_.difference(variant_ids[k][1], values)) || variant_ids[k][0] === list_variant_id) { // clone so permanent object is not modified - var info = _.clone(variant_ids[k][4]); + var info = _.clone(variant_ids[k][6]); if(_.contains(['always', 'threshold'], info['inventory_availability'])) { info['virtual_available'] -= parseInt(info['cart_qty']); if (info['virtual_available'] < 0) { @@ -58,10 +63,14 @@ $('.oe_website_sale').each(function() { } // Handle case when manually write in input if(qty > info['virtual_available']) { - $parent.find('input[name="add_qty"]').val(info['virtual_available'] || 1); + var $input_add_qty = $parent.find('input[name="add_qty"]'); + $input_add_qty.val(info['virtual_available'] || 1); + // trigger change to update the price but add context to avoid coming back here + // and start infinite loop + $input_add_qty.trigger('change', [true, "", true]); } if(qty > info['virtual_available'] || info['virtual_available'] < 1 || qty < 1) { - $parent.find('#add_to_cart').addClass('disabled'); + $parent.find('#add_to_cart').addClass('disabled out_of_stock'); } // For options products: if qty not available disable add to cart on change variant $parent.find('.js_add').toggleClass('disabled btn', info['virtual_available'] < 1); diff --git a/addons/website_sale_stock/views/website_sale_stock_templates.xml b/addons/website_sale_stock/views/website_sale_stock_templates.xml index 95f75c2ff30..ad7fcff72ce 100644 --- a/addons/website_sale_stock/views/website_sale_stock_templates.xml +++ b/addons/website_sale_stock/views/website_sale_stock_templates.xml @@ -57,5 +57,15 @@ </xpath> </template> + <template id="website_sale_stock_modal" inherit_id="sale.product_configurator_configure_optional_products" name="Stocks Modal"> + <xpath expr="//input[@type='text'][hasclass('quantity')]" position="attributes"> + <attribute name='t-att-data-max'>max(product.sudo().virtual_available - product.cart_qty, 1) if handle_stock and product.type == "product" and product.inventory_availability in ["always", "threshold"] else None</attribute> + </xpath> + <xpath expr="//div[hasclass('css_quantity')]" position="after"> + <t t-if="handle_stock"> + <div class='availability_messages'/> + </t> + </xpath> + </template> </odoo> diff --git a/addons/website_sale_stock_options/__init__.py b/addons/website_sale_stock_options/__init__.py deleted file mode 100644 index 67dee8c60db..00000000000 --- a/addons/website_sale_stock_options/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. diff --git a/addons/website_sale_stock_options/__manifest__.py b/addons/website_sale_stock_options/__manifest__.py deleted file mode 100644 index 6ddbbadbfc3..00000000000 --- a/addons/website_sale_stock_options/__manifest__.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -{ - 'name': 'Product Availability', - 'category': 'Website', - 'summary': 'Manage product inventory & availability', - 'description': """ -Manage the inventory of your products and display their availability status in your eCommerce store. -In case of stockout, you can decide to block further sales or to keep selling. -A default behavior can be selected in the Website settings. -Then it can be made specific at the product level. - """, - 'depends': [ - 'website_sale_stock', - 'website_sale_options', - ], - 'data': [ - 'views/website_sale_stock_templates.xml', - ], - 'auto_install': True, -} diff --git a/addons/website_sale_stock_options/views/website_sale_stock_templates.xml b/addons/website_sale_stock_options/views/website_sale_stock_templates.xml deleted file mode 100644 index d5709c8556c..00000000000 --- a/addons/website_sale_stock_options/views/website_sale_stock_templates.xml +++ /dev/null @@ -1,13 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<odoo> - <!-- Shopping Cart Lines --> - <template id="website_sale_stock_modal" inherit_id="website_sale_options.modal" name="Stocks Modal"> - <xpath expr="//input[@type='text'][hasclass('quantity')]" position="attributes"> - <attribute name='t-att-data-max'>max(product.sudo().virtual_available - product.cart_qty, 1) if product.type == "product" and product.inventory_availability in ["always", "threshold"] else None</attribute> - </xpath> - <xpath expr="//div[hasclass('css_quantity')]" position="after"> - <div class='availability_messages'/> - </xpath> - </template> - -</odoo> |
