[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
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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))
|
||||
@@ -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>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<field name="arch" type="xml">
|
||||
<form string="Product">
|
||||
<header>
|
||||
<button string="Variant Prices" type="action"
|
||||
<button string="Configure Variants" type="action"
|
||||
name="%(product_attribute_value_action)d"
|
||||
attrs="{'invisible': [('product_variant_count', '<=', 1)]}"
|
||||
groups="product.group_product_variant"/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
|
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) < 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>
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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(){}
|
||||
}]);
|
||||
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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') % \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"/>
|
||||
|
||||
@@ -14,14 +14,21 @@
|
||||
<link rel="stylesheet" type="text/scss" href="/website_sale/static/src/scss/website_sale.scss" />
|
||||
<link rel="stylesheet" type="text/scss" href="/website_sale/static/src/scss/website_mail.scss" />
|
||||
<link rel="stylesheet" type="text/scss" href="/website_sale/static/src/scss/website_sale_frontend.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/sale/static/src/scss/sale_portal.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/sale/static/src/scss/product_configurator.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/website_sale/static/src/scss/website_sale_options.scss"/>
|
||||
</xpath>
|
||||
<xpath expr="script[last()]" position="after">
|
||||
<script type="text/javascript" src="/sale/static/src/js/product_configurator_utils.js"></script>
|
||||
<script type="text/javascript" src="/sale/static/src/js/product_configurator_modal.js"></script>
|
||||
<script type="text/javascript" src="/website_sale/static/src/js/website_sale.js"></script>
|
||||
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_utils.js"></script>
|
||||
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_payment.js"></script>
|
||||
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_validate.js"></script>
|
||||
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_buy.js"></script>
|
||||
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_tracking.js"></script>
|
||||
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_customize.js"></script>
|
||||
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_options.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
@@ -149,6 +156,12 @@
|
||||
</a>
|
||||
</t>
|
||||
</xpath>
|
||||
<xpath expr="//div[hasclass('product_price')]/t[1]" position="attributes">
|
||||
<attribute name="t-if">len(product.product_variant_ids) == 1 and not len(product.optional_product_ids) >= 1</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//div[hasclass('product_price')]/t[2]" position="attributes">
|
||||
<attribute name="t-if">len(product.product_variant_ids) > 1 or len(product.optional_product_ids) >= 1</attribute>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="pricelist_list" name="Pricelists Dropdown">
|
||||
@@ -470,10 +483,10 @@
|
||||
<span itemprop="url" style="display:none;" t-esc="'%sshop/product/%s' % (request.httprequest.url_root, slug(product))"/>
|
||||
<form action="/shop/cart/update" method="POST">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" />
|
||||
<div class="js_product" t-if="product.product_variant_ids">
|
||||
<div class="js_product js_main_product" t-if="product.product_variant_ids">
|
||||
<t t-placeholder="select">
|
||||
<input type="hidden" class="product_id" name="product_id" t-att-value="product.product_variant_id.id if len(product.product_variant_ids) == 1 else '0'" />
|
||||
<t t-call="website_sale.variants">
|
||||
<t t-call="sale.variants">
|
||||
<t t-set="ul_class" t-value="'flex-column'" />
|
||||
</t>
|
||||
</t>
|
||||
@@ -602,13 +615,13 @@
|
||||
"display_currency": product.currency_id,
|
||||
}'/>
|
||||
</h4>
|
||||
<h4 class="d-none oe_not_available bg-warning">Product not available</h4>
|
||||
<h4 class="d-none oe_not_available bg-warning">Option not available</h4>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="product_variants" inherit_id="website_sale.product" active="False" customize_show="True" name="List View of Variants">
|
||||
<xpath expr="//t[@t-placeholder='select']" position="replace">
|
||||
<t t-set="attribute_value_ids" t-value="get_attribute_value_ids(product)"/>
|
||||
<t t-set="attribute_value_ids" t-value="get_attribute_value_ids(product, reference_product, pricelist)"/>
|
||||
<ul class="d-none js_add_cart_variants" t-att-data-attribute_value_ids="json.dumps(attribute_value_ids)"/>
|
||||
<input type="hidden" t-if="len(product.product_variant_ids) == 1" class="product_id" name="product_id" t-att-value="product.product_variant_id.id"/>
|
||||
<t t-if="len(product.product_variant_ids) > 1">
|
||||
@@ -626,75 +639,6 @@
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="variants">
|
||||
<t t-set="attribute_value_ids" t-value="get_attribute_value_ids(product)"/>
|
||||
<ul t-attf-class="list-unstyled js_add_cart_variants #{ul_class}" t-att-data-attribute_value_ids="json.dumps(attribute_value_ids)">
|
||||
<t t-foreach="product.attribute_line_ids.sorted(key=lambda x: x.attribute_id.sequence)" t-as="variant_id">
|
||||
<li t-if="len(variant_id.value_ids) > 1">
|
||||
|
||||
<strong t-field="variant_id.attribute_id.name"/>
|
||||
|
||||
<t t-if="variant_id.attribute_id.type == 'select'">
|
||||
<select t-attf-class="form-control #{'js_variant_change' if variant_id.attribute_id.create_variant else ''}" t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)">
|
||||
<t t-foreach="variant_id.value_ids" t-as="value_id">
|
||||
<option t-att-value="value_id.id">
|
||||
<span t-field="value_id.name"/>
|
||||
<span t-if="value_id.price_extra">
|
||||
<t t-esc="value_id.price_extra > 0 and '+' or ''"/><span t-field="value_id.price_extra" style="white-space: nowrap;" t-options='{
|
||||
"widget": "monetary",
|
||||
"from_currency": product.currency_id,
|
||||
"display_currency": website.currency_id
|
||||
}'/>
|
||||
</span>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</t>
|
||||
|
||||
<t t-if="variant_id.attribute_id.type == 'radio'">
|
||||
<ul class="list-unstyled">
|
||||
<t t-set="inc" t-value="0"/>
|
||||
<t t-foreach="variant_id.value_ids" t-as="value_id">
|
||||
<li class="form-group js_attribute_value" style="margin: 0;">
|
||||
<label class="col-form-label" style="margin: 0 20px;">
|
||||
<input type="radio" t-att-class="'js_variant_change' if variant_id.attribute_id.create_variant else None" t-att-checked="'checked' if not inc else None" t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)" t-att-value="value_id.id" style="vertical-align: top; margin-right: 10px;"/>
|
||||
<span t-field="value_id.name"/>
|
||||
<span class="badge badge-pill" t-if="value_id.price_extra">
|
||||
<t t-esc="value_id.price_extra > 0 and '+' or ''"/><span t-field="value_id.price_extra" style="white-space: nowrap;" t-options='{
|
||||
"widget": "monetary",
|
||||
"from_currency": product.currency_id,
|
||||
"display_currency": website.currency_id
|
||||
}'/>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
<t t-set="inc" t-value="inc+1"/>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
|
||||
<t t-if="variant_id.attribute_id.type == 'color'">
|
||||
<ul class="list-inline">
|
||||
<t t-set="inc" t-value="0"/>
|
||||
<li t-foreach="variant_id.value_ids" t-as="value_id" class="list-inline-item">
|
||||
<label t-attf-style="background-color:#{value_id.html_color or value_id.name}"
|
||||
t-attf-class="css_attribute_color #{'active' if not inc else ''}">
|
||||
<input type="radio" t-att-class="'js_variant_change' if variant_id.attribute_id.create_variant else None"
|
||||
t-att-checked="'checked' if not inc else None"
|
||||
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"
|
||||
t-att-value="value_id.id"
|
||||
t-att-title="value_id.name"/>
|
||||
</label>
|
||||
<t t-set="inc" t-value="inc+1"/>
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<template id="wizard_checkout" name="Wizard Checkout">
|
||||
<t t-set="website_sale_order" t-value="website.sale_get_order()"/>
|
||||
|
||||
@@ -840,7 +784,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="website_sale_order.website_order_line" t-as="line">
|
||||
<tr>
|
||||
<tr t-att-class="'optional_product info' if line.linked_line_id else None">
|
||||
<td colspan="2" t-if="not line.product_id.product_tmpl_id" class='td-img'></td>
|
||||
<td align="center" t-if="line.product_id.product_tmpl_id" class='td-img'>
|
||||
<span t-field="line.product_id.image_small" t-options="{'widget': 'image', 'class': 'rounded'}" />
|
||||
@@ -856,7 +800,7 @@
|
||||
</t>
|
||||
</td>
|
||||
<td class="text-center td-qty">
|
||||
<div class="css_quantity input-group oe_website_spinner">
|
||||
<div t-if="not line.linked_line_id" class="css_quantity input-group oe_website_spinner">
|
||||
<div class="input-group-prepend">
|
||||
<a t-attf-href="#" class="btn btn-link js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one">
|
||||
<i class="fa fa-minus"></i>
|
||||
@@ -869,6 +813,9 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<t t-if="line.linked_line_id">
|
||||
<span class="js_quantity text-muted" t-att-data-line-id="line.id" t-att-data-product-id="line.product_id.id" t-esc="int(line.product_uom_qty)"/>
|
||||
</t>
|
||||
</td>
|
||||
<td class="text-center td-price" name="price">
|
||||
<t t-if="(compute_currency(line.product_id.lst_price) - line.price_reduce ) > 0.01 and website.get_current_pricelist().discount_policy=='without_discount'">
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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
|
||||
|
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>
|
||||
@@ -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>
|
||||