[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
This commit is contained in:
Aurélien Warnon
2018-09-25 18:39:32 +02:00
parent fd07990b85
commit 856c2e9008
84 changed files with 2893 additions and 1151 deletions
-5
View File
@@ -942,11 +942,6 @@ file_filter = addons/website_sale_management/i18n/<lang>.po
source_file = addons/website_sale_management/i18n/website_sale_management.pot
source_lang = en
[odoo-12.website_sale_options]
file_filter = addons/website_sale_options/i18n/<lang>.po
source_file = addons/website_sale_options/i18n/website_sale_options.pot
source_lang = en
[odoo-12.website_sale_stock]
file_filter = addons/website_sale_stock/i18n/<lang>.po
source_file = addons/website_sale_stock/i18n/website_sale_stock.pot
+2 -3
View File
@@ -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({
+24 -14
View File
@@ -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>
+11 -8
View File
@@ -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([
+99 -48
View File
@@ -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):
@@ -105,6 +98,64 @@ class ProductAttributeLine(models.Model):
raise ValidationError(_('You cannot use this attribute with the following value.'))
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
+53
View File
@@ -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(
+1 -1
View File
@@ -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",
+4 -2
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
10 access_product_price_history_employee prices.history employee model_product_price_history base.group_user 1 0 0 0
11 access_product_attribute product.attribute model_product_attribute base.group_user 1 0 0 0
12 access_product_attribute_value product.attribute value model_product_attribute_value base.group_user 1 0 0 0
13 access_product_attribute_price access_product_product_attribute product.attribute price product.product.attribute value model_product_attribute_price model_product_product_attribute_value base.group_user 1 0 0 0
14 access_product_attribute_filter_line product.attribute filter line model_product_attribute_filter_line base.group_user 1 0 0 0
15 access_product_attribute_line product.attribute line model_product_attribute_line base.group_user 1 0 0 0
16 access_product_category_manager product.category.manager model_product_category base.group_system 1 1 1 1
17 access_product_template_manager product.template.manager model_product_template base.group_system 1 1 1 1
23 access_product_price_history_manager prices.history manager model_product_price_history base.group_system 1 1 1 1
24 access_product_attribute_manager product.attribute manager model_product_attribute base.group_system 1 1 1 1
25 access_product_attribute_value_manager product.attribute value manager model_product_attribute_value base.group_system 1 1 1 1
26 access_product_attribute_price_manager access_product_product_attribute_manager product.attribute price manager product.product.attribute value manager model_product_attribute_price model_product_product_attribute_value base.group_system 1 1 1 1
27 access_product_attribute_filter_line_manager product.attribute filter line manager model_product_attribute_filter_line base.group_system 1 1 1 1
28 access_product_attribute_line_manager product.attribute line manager model_product_attribute_line base.group_system 1 1 1 1
+5 -1
View File
@@ -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
@@ -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))
+101 -77
View File
@@ -1,96 +1,120 @@
<?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="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="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"/>
</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="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>
<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>
</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"/>
</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="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="product_attribute_line_form" model="ir.ui.view">
<field name="name">product.attribute.line.form</field>
<field name="model">product.attribute.line</field>
+1 -1
View File
@@ -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', '&lt;=', 1)]}"
groups="product.group_product_variant"/>
+4 -1
View File
@@ -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
}
+1
View File
@@ -3,3 +3,4 @@
from . import onboarding
from . import portal
from . import product_configurator
@@ -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
+80
View File
@@ -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>
+36
View File
@@ -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")
+12 -1
View File
@@ -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,
+2 -1
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
41 access_product_product_sale_manager product.product salemanager model_product_product sales_team.group_sale_manager 1 1 1 1
42 access_product_attribute_sale_manager product.attribute manager product.model_product_attribute sales_team.group_sale_manager 1 1 1 1
43 access_product_attribute_value_sale_manager product.attribute manager value product.model_product_attribute_value sales_team.group_sale_manager 1 1 1 1
44 access_product_attribute_price_sale_manager access_product_product_attribute_sale_manager product.attribute manager price product.product.attribute manager value product.model_product_attribute_price product.model_product_product_attribute_value sales_team.group_sale_manager 1 1 1 1
45 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
46 access_product_attribute_line_sale_manager product.attribute manager line product.model_product_attribute_line sales_team.group_sale_manager 1 1 1 1
47 access_account_tax_sale_manager account.tax sale manager account.model_account_tax sales_team.group_sale_salesman 1 0 0 0
48 access_account_journal_sale_manager account.journal sale manager account.model_account_journal sales_team.group_sale_salesman 1 0 0 0
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -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;
});
@@ -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
}
});
}
});
});
@@ -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;
});
@@ -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
};
});
@@ -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;
}
@@ -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();
});
});
});
+10
View File
@@ -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>
@@ -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>
@@ -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">
@@ -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) &lt; 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>
+21 -1
View File
@@ -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"/>
+1
View File
@@ -3,3 +3,4 @@
from . import payment_acquirer_onboarding_wizard
from . import sale_make_invoice_advance
from . import sale_product_configurator
@@ -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)])
@@ -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>
+2
View File
@@ -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',
@@ -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(){}
}]);
});
+8
View File
@@ -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>
@@ -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>
+1 -1
View File
@@ -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',
+2 -2
View File
@@ -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') % \
+2 -1
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
73 access_stock_scrap_user stock.scrap.user model_stock_scrap stock.group_stock_user 1 1 1 0
74 access_product_attribute_manager product.attribute manager product.model_product_attribute stock.group_stock_manager 1 1 1 1
75 access_product_attribute_value_manager product.attribute manager value product.model_product_attribute_value stock.group_stock_manager 1 1 1 1
76 access_product_attribute_price_manager access_product_product_attribute_manager product.attribute manager price product.product.attribute manager value product.model_product_attribute_price product.model_product_product_attribute_value stock.group_stock_manager 1 1 1 1
77 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
78 access_product_attribute_line_manager product.attribute manager line product.model_product_attribute_line stock.group_stock_manager 1 1 1 1
@@ -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),
});
@@ -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 {
@@ -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) {
@@ -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) {
@@ -61,7 +61,7 @@ ListRenderer.include({
}
_.each(child.children, function (child) {
if (child.tag !== 'create') {
if (child.tag !== 'create' || child.attrs.invisible) {
return;
}
+50 -32
View File
@@ -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())
+48 -11
View File
@@ -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>
-14
View File
@@ -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'
@@ -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")
+43 -4
View File
@@ -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):
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
10 access_product_supplierinfo product.supplierinfo.public product.model_product_supplierinfo 1 0 0 0
11 access_product_attribute_public product.attribute public product.model_product_attribute 1 0 0 0
12 access_product_attribute_value_public product.attribute value public product.model_product_attribute_value 1 0 0 0
13 access_product_attribute_price_public access_product_product_attribute product.attribute price public product.product.attribute value public product.model_product_attribute_price product.model_product_product_attribute_value 1 0 0 0
14 access_product_attribute_filter_line product.attribute filter line public product.model_product_attribute_filter_line 1 0 0 0
15 access_product_attribute_line_public product.attribute line public product.model_product_attribute_line 1 0 0 0
16 access_fiscal_position_public fiscal position public account.model_account_fiscal_position base.group_portal 1 0 0 0
17 access_payment_term payment term public account.model_account_payment_term base.group_portal 1 0 0 0
+13 -151
View File
@@ -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
@@ -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'));
},
});
});
@@ -38,6 +38,10 @@ tour.register('shop_buy_product', {
extra_trigger: 'label:contains(Steel) input:propChecked',
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")))',
@@ -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
},
]
);
});
@@ -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;
@@ -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;
}
}
@@ -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")
@@ -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>
@@ -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"/>
+22 -75
View File
@@ -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) &gt;= 1</attribute>
</xpath>
<xpath expr="//div[hasclass('product_price')]/t[2]" position="attributes">
<attribute name="t-if">len(product.product_variant_ids) &gt; 1 or len(product.optional_product_ids) &gt;= 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) &gt; 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 ) &gt; 0.01 and website.get_current_pricelist().discount_policy=='without_discount'">
@@ -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'>
-5
View File
@@ -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
@@ -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,
}
@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
@@ -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,
})
@@ -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>
@@ -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
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

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

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -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
},
]
);
});
@@ -1 +0,0 @@
from . import test_customize
@@ -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>
+10 -4
View File
@@ -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,
}
@@ -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)):

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -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);
@@ -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>
@@ -1,2 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
@@ -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,
}
@@ -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>