- Purchase Order
+
+ Request for Quotation
+
+
+ Purchase Order
+
diff --git a/addons/purchase_requisition/models/stock.py b/addons/purchase_requisition/models/stock.py
index b41b674fb4a..fd42537b8a1 100644
--- a/addons/purchase_requisition/models/stock.py
+++ b/addons/purchase_requisition/models/stock.py
@@ -48,7 +48,7 @@ class StockMove(models.Model):
def _get_upstream_documents_and_responsibles(self, visited):
if self.requisition_line_ids:
- return [(requisition_line.requisition_id, requisition_line.requisition_id.user_id, visited) for requisition_line in self.requisition_line_ids if requisition_line.state not in ('done', 'cancel')]
+ return [(requisition_line.requisition_id, requisition_line.requisition_id.user_id, visited) for requisition_line in self.requisition_line_ids if requisition_line.requisition_id.state not in ('done', 'cancel')]
else:
return super(StockMove, self)._get_upstream_documents_and_responsibles(visited)
diff --git a/addons/sale/controllers/portal.py b/addons/sale/controllers/portal.py
index 117a0598c08..88084288b0a 100644
--- a/addons/sale/controllers/portal.py
+++ b/addons/sale/controllers/portal.py
@@ -168,6 +168,8 @@ class CustomerPortal(CustomerPortal):
'partner_id': order_sudo.partner_id.id,
'report_type': 'html',
}
+ if order_sudo.company_id:
+ values['res_company'] = order_sudo.company_id
if order_sudo.has_to_be_paid():
domain = expression.AND([
diff --git a/addons/sale/controllers/product_configurator.py b/addons/sale/controllers/product_configurator.py
index 9a9d397b4dd..5a5ae1b26d9 100644
--- a/addons/sale/controllers/product_configurator.py
+++ b/addons/sale/controllers/product_configurator.py
@@ -8,146 +8,125 @@ 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):
+ add_qty = int(kw.get('add_qty', 1))
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 deprecated, get it from the pricelist or product directly
'to_currency': to_currency,
'pricelist': pricelist,
+ 'add_qty': add_qty,
+ # get_attribute_exclusions deprecated, use product method
'get_attribute_exclusions': self._get_attribute_exclusions
})
@http.route(['/product_configurator/show_optional_products'], type='json', auth="user", methods=['POST'])
def show_optional_products(self, product_id, variant_values, pricelist_id, **kw):
- return self._show_optional_products(product_id, variant_values, self._get_pricelist(pricelist_id), False, **kw)
+ pricelist = self._get_pricelist(pricelist_id)
+ return self._show_optional_products(product_id, variant_values, pricelist, 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)
+ pricelist = self._get_pricelist(pricelist_id)
+ return self._optional_product_items(product_id, pricelist, **kw)
@http.route(['/product_configurator/get_combination_info'], type='json', auth="user", methods=['POST'])
def get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist_id, **kw):
- return self._get_combination_info(product_template_id, product_id, combination, add_qty, self._get_pricelist(pricelist_id))
+ combination = request.env['product.template.attribute.value'].browse(combination)
+ pricelist = self._get_pricelist(pricelist_id)
+ return request.env['product.template'].browse(int(product_template_id))._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
+
+ @http.route(['/product_configurator/create_product_variant'], type='json', auth="user", methods=['POST'])
+ def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs):
+ return request.env['product.template'].browse(int(product_template_id)).create_product_variant(product_template_attribute_value_ids)
def _optional_product_items(self, product_id, pricelist, **kw):
+ add_qty = int(kw.get('add_qty', 1))
product = request.env['product.product'].with_context(self._get_product_context(pricelist, **kw)).browse(int(product_id))
to_currency = product.currency_id
+
if pricelist:
to_currency = pricelist.currency_id
+ parent_combination = product.product_template_attribute_value_ids
+ if product.env.context.get('no_variant_attribute_values'):
+ # Add "no_variant" attribute values' exclusions
+ # They are kept in the context since they are not linked to this product variant
+ parent_combination |= product.env.context.get('no_variant_attribute_values')
+
return request.env['ir.ui.view'].render_template("sale.optional_product_items", {
+ # product deprecated, it's not used in the view
'product': product,
+ # reference_product deprecated, use parent_combination instead
'reference_product': product,
+ 'parent_combination': parent_combination,
'pricelist': pricelist,
+ # to_currency deprecated, get from pricelist or product
'to_currency': to_currency,
+ # get_attribute_exclusions deprecated, use product method
'get_attribute_exclusions': self._get_attribute_exclusions,
+ 'add_qty': add_qty,
})
def _show_optional_products(self, product_id, variant_values, pricelist, handle_stock, **kw):
- 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
+ product = request.env['product.product'].with_context(self._get_product_context(pricelist, **kw)).browse(int(product_id))
+ combination = request.env['product.template.attribute.value'].browse(variant_values)
+ has_optional_products = product.optional_product_ids.filtered(lambda p: p._is_add_to_cart_possible(combination))
+
+ if not has_optional_products:
+ return False
+
+ add_qty = int(kw.get('add_qty', 1))
+ to_currency = (pricelist or 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))
- no_variant_attribute_values = request.env['product.template.attribute.value'].browse(variant_values).filtered(
+ def compute_currency(price):
+ return product.currency_id._convert(price, to_currency, company, date)
+
+ no_variant_attribute_values = combination.filtered(
lambda product_template_attribute_value: product_template_attribute_value.attribute_id.create_variant == 'no_variant'
)
if no_variant_attribute_values:
product = product.with_context(no_variant_attribute_values=no_variant_attribute_values)
- has_optional_products = False
- for optional_product in product.optional_product_ids:
- if optional_product.has_dynamic_attributes() or 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,
- 'variant_values': variant_values,
- 'pricelist': pricelist,
- 'compute_currency': compute_currency,
- 'to_currency': to_currency,
- 'handle_stock': handle_stock,
- 'get_attribute_exclusions': self._get_attribute_exclusions,
- })
+ return request.env['ir.ui.view'].render_template("sale.optional_products_modal", {
+ 'product': product,
+ 'combination': combination,
+ 'add_qty': add_qty,
+ # reference_product deprecated, use combination instead
+ 'reference_product': product,
+ 'variant_values': variant_values,
+ 'pricelist': pricelist,
+ # compute_currency deprecated, get from pricelist or product
+ 'compute_currency': compute_currency,
+ # to_currency deprecated, get from pricelist or product
+ 'to_currency': to_currency,
+ 'handle_stock': handle_stock,
+ # get_attribute_exclusions deprecated, use product method
+ 'get_attribute_exclusions': self._get_attribute_exclusions,
+ })
def _get_attribute_exclusions(self, product, reference_product=None):
- """ list of attribute exclusions of a product
-
- Args:
- - product (product.template): The base product template
- - reference_product (product.product): The reference product from which 'product' is an optional or accessory product
-
- :return: dict of exclusions
- exclusions.exclusions: exclusions within this product
- exclusions.parent_exclusions: exclusions coming from the reference_product
- """
-
- product_attribute_values = request.env['product.template.attribute.value'].search([
- ('product_tmpl_id', '=', product.id),
- ('product_attribute_value_id', 'in', product.attribute_line_ids.mapped('value_ids').ids),
- ])
-
- # array of all the excluded value_ids of all the filter lines for this product
- mapped_exclusions = {
- product_attribute_value.id: [
- value_id
- for filter_line in product_attribute_value.exclude_for.filtered(
- lambda filter_line: filter_line.product_tmpl_id == product
- ) for value_id in filter_line.value_ids.ids
- ]
- for product_attribute_value in product_attribute_values
- }
-
- parent_exclusions = []
+ """deprecated, use product method"""
+ parent_combination = request.env['product.template.attribute.value']
if reference_product:
- parent_attribute_value_ids = reference_product.product_template_attribute_value_ids
- if parent_attribute_value_ids and reference_product._context.get('no_variant_attribute_values'):
+ parent_combination |= reference_product.product_template_attribute_value_ids
+ if reference_product.env.context.get('no_variant_attribute_values'):
# Add "no_variant" attribute values' exclusions
# They are kept in the context since they are not linked to this product variant
- parent_attribute_value_ids |= reference_product._context.get('no_variant_attribute_values')
-
- parent_exclusions = [
- value_id
- for filter_line in parent_attribute_value_ids.mapped('exclude_for').filtered(
- lambda filter_line: filter_line.product_tmpl_id == product
- ) for value_id in filter_line.value_ids.ids]
-
- # Query all archived products for this template
- archived_combinations = request.env['product.product'].search(
- [('product_tmpl_id', '=', product.id), ('active', '=', False)])
-
- if archived_combinations:
- # Old archived variants could have a different set of attributes and are not relevant here
- # -> filter them out
- attribute_ids = product_attribute_values.mapped('attribute_id')
- archived_combinations = archived_combinations.filtered(
- lambda product: all(
- attribute_id in product.mapped('product_template_attribute_value_ids.attribute_id')
- for attribute_id in attribute_ids
- )
- )
-
- return {
- 'exclusions': mapped_exclusions,
- 'parent_exclusions': parent_exclusions,
- 'archived_combinations': [archived_combination.product_template_attribute_value_ids.ids
- for archived_combination in archived_combinations]
- }
+ parent_combination |= reference_product.env.context.get('no_variant_attribute_values')
+ return product._get_attribute_exclusions(parent_combination)
def _get_product_context(self, pricelist=None, **kw):
+ """deprecated, can be removed in master"""
product_context = dict(request.context)
if pricelist:
if not product_context.get('pricelist'):
@@ -157,64 +136,9 @@ class ProductConfiguratorController(http.Controller):
return product_context
def _get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist, **kw):
- product_template_attribute_values = request.env['product.template.attribute.value'].browse(combination)
- filtered_product_template_attribute_values = product_template_attribute_values.filtered(
- lambda product_attribute_value: product_attribute_value.attribute_id.create_variant != 'no_variant'
- )
- context = {
- 'quantity': add_qty,
- 'pricelist': pricelist.id if pricelist else None,
- 'current_attributes_price_extra': [product_attribute_value.price_extra or 0.0 for product_attribute_value in product_template_attribute_values]
- }
+ """deprecated, use product method"""
+ combination = request.env['product.template.attribute.value'].browse(combination)
+ return request.env['product.template'].browse(product_template_id)._get_combination_info(combination, product_id, add_qty, pricelist)
- product_template = request.env['product.template'].with_context(context).browse(product_template_id)
-
- Product = request.env['product.product'].with_context(context)
- if product_id and not combination:
- product = Product.browse(product_id)
- else:
- products = Product.search([
- ('product_tmpl_id', '=', product_template_id)
- ])
- product = products.filtered(
- lambda product: all(product_attribute_value in product.product_template_attribute_value_ids
- for product_attribute_value in filtered_product_template_attribute_values)
- )
-
- product_id = None
- list_price = product_template.price_compute('list_price')[product_template.id]
- price = product_template.price
- if(product):
- product = product.with_context(
- no_variant_attributes_price_extra=[product_attribute_value.price_extra or 0.0
- for product_attribute_value in product_template_attribute_values.filtered(
- lambda product_attribute_value: product_attribute_value.attribute_id.create_variant == 'no_variant'
- )
- ]
- )
- product_id = product.id
- list_price = product.price_compute('list_price')[product.id]
- price = product.price
-
- display_name = [product_template.name]
- if filtered_product_template_attribute_values:
- display_name.append(' (')
- display_name.append(', '.join(filtered_product_template_attribute_values.mapped('name')))
- display_name.append(')')
-
- if pricelist and pricelist.currency_id != product_template.currency_id:
- list_price = product_template.currency_id._convert(
- list_price, pricelist.currency_id,
- request.env.user.company_id, fields.Date.today()
- )
-
- return {
- 'product_id': product_id,
- 'product_template_id': product_template.id,
- 'display_name': ''.join(display_name),
- 'price': price,
- 'list_price': list_price
- }
-
- def _get_pricelist(self, pricelist_id):
- return request.env['product.pricelist'].browse(int(pricelist_id)) if pricelist_id and pricelist_id != '0' else None
+ def _get_pricelist(self, pricelist_id, pricelist_fallback=False):
+ return request.env['product.pricelist'].browse(int(pricelist_id or 0))
diff --git a/addons/sale/data/sale_demo.xml b/addons/sale/data/sale_demo.xml
index 9f7106b6e71..0cd6ee4f40b 100644
--- a/addons/sale/data/sale_demo.xml
+++ b/addons/sale/data/sale_demo.xml
@@ -750,6 +750,7 @@ Thanks!
Custom
True
+
3
diff --git a/addons/sale/models/analytic.py b/addons/sale/models/analytic.py
index 9bac794a098..8fb938582a0 100644
--- a/addons/sale/models/analytic.py
+++ b/addons/sale/models/analytic.py
@@ -39,7 +39,7 @@ class AccountAnalyticLine(models.Model):
self.ensure_one()
if self.product_id.expense_policy == 'sales_price':
return self.product_id.with_context(
- partner=order.partner_id.id,
+ partner=order.partner_id,
date_order=order.date_order,
pricelist=order.pricelist_id.id,
uom=self.product_uom_id.id
diff --git a/addons/sale/models/product_product.py b/addons/sale/models/product_product.py
index 9d54d991b86..a489b03b952 100644
--- a/addons/sale/models/product_product.py
+++ b/addons/sale/models/product_product.py
@@ -56,6 +56,14 @@ class ProductProduct(models.Model):
def _get_invoice_policy(self):
return self.invoice_policy
+ @api.multi
+ def _get_combination_info_variant(self, add_qty=1, pricelist=False, parent_combination=False):
+ """Return the variant info based on its combination.
+ See `_get_combination_info` for more information.
+ """
+ self.ensure_one()
+ return self.product_tmpl_id._get_combination_info(self.product_template_attribute_value_ids, self.id, add_qty, pricelist, parent_combination)
+
class ProductAttribute(models.Model):
_inherit = "product.attribute"
diff --git a/addons/sale/models/product_template.py b/addons/sale/models/product_template.py
index f7f21afc455..4da13305464 100644
--- a/addons/sale/models/product_template.py
+++ b/addons/sale/models/product_template.py
@@ -1,10 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import json
+import logging
+
from odoo import api, fields, models, _
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
from odoo.tools.float_utils import float_round
-import json
+
+_logger = logging.getLogger(__name__)
class ProductTemplate(models.Model):
@@ -58,21 +62,88 @@ class ProductTemplate(models.Model):
}
return action
- def create_product_variant(self, product_template_attribute_value_ids):
+ @api.multi
+ def _create_product_variant(self, combination, log_warning=False):
+ """ Create if necessary and possible and return the product variant
+ matching the given combination for this template.
+
+ It is possible to create only if the template has dynamic attributes
+ and the combination itself is possible.
+
+ :param combination: the combination for which to get or create variant.
+ The combination must contain all necessary attributes, including
+ those of type no_variant. Indeed even though those attributes won't
+ be included in the variant if newly created, they are needed when
+ checking if the combination is possible.
+ :type combination: recordset of `product.template.attribute.value`
+
+ :param log_warning: whether a warning should be logged on fail
+ :type log_warning: bool
+
+ :return: the product variant matching the combination or none
+ :rtype: recordset of `product.product`
+ """
self.ensure_one()
- attribute_value_ids = \
- self.env['product.template.attribute.value'] \
- .browse(json.loads(product_template_attribute_value_ids)) \
- .mapped('product_attribute_value_id') \
- .filtered(lambda attribute_value_id: attribute_value_id.attribute_id.create_variant != 'no_variant')
+ Product = self.env['product.product']
- product_variant = self.env['product.product'].create({
+ product_variant = self._get_variant_for_combination(combination)
+ if product_variant:
+ return product_variant
+
+ if not self.has_dynamic_attributes():
+ if log_warning:
+ _logger.warning('The user #%s tried to create a variant for the non-dynamic product %s.' % (self.env.user.id, self.id))
+ return Product
+
+ if not self._is_combination_possible(combination):
+ if log_warning:
+ _logger.warning('The user #%s tried to create an invalid variant for the product %s.' % (self.env.user.id, self.id))
+ return Product
+
+ attribute_values = combination.mapped('product_attribute_value_id')._without_no_variant_attributes()
+
+ return Product.sudo().create({
'product_tmpl_id': self.id,
- 'attribute_value_ids': [(6, 0, attribute_value_ids.ids)]
+ 'attribute_value_ids': [(6, 0, attribute_values.ids)]
})
- return product_variant.id
+ @api.multi
+ def create_product_variant(self, product_template_attribute_value_ids):
+ """ Create if necessary and possible and return the id of the product
+ variant matching the given combination for this template.
+
+ Note AWA: Known "exploit" issues with this method:
+
+ - This method could be used by an unauthenticated user to generate a
+ lot of useless variants. Unfortunately, after discussing the
+ matter with ODO, there's no easy and user-friendly way to block
+ that behavior.
+
+ We would have to use captcha/server actions to clean/... that
+ are all not user-friendly/overkill mechanisms.
+
+ - This method could be used to try to guess what product variant ids
+ are created in the system and what product template ids are
+ configured as "dynamic", but that does not seem like a big deal.
+
+ The error messages are identical on purpose to avoid giving too much
+ information to a potential attacker:
+ - returning 0 when failing
+ - returning the variant id whether it already existed or not
+
+ :param product_template_attribute_value_ids: the combination for which
+ to get or create variant
+ :type product_template_attribute_value_ids: json encoded list of id
+ of `product.template.attribute.value`
+
+ :return: id of the product variant matching the combination or 0
+ :rtype: int
+ """
+ combination = self.env['product.template.attribute.value'] \
+ .browse(json.loads(product_template_attribute_value_ids))
+
+ return self._create_product_variant(combination, log_warning=True).id or 0
@api.onchange('type')
def _onchange_type(self):
@@ -96,3 +167,132 @@ class ProductTemplate(models.Model):
'template': '/sale/static/xls/product_pricelist_several.xls'
}]
return res
+
+ @api.multi
+ def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False):
+ """ Return info about a given combination.
+
+ Note: this method does not take into account whether the combination is
+ actually possible.
+
+ :param combination: recordset of `product.template.attribute.value`
+
+ :param product_id: id of a `product.product`. If no `combination`
+ is set, the method will try to load the variant `product_id` if
+ it exists instead of finding a variant based on the combination.
+
+ If there is no combination, that means we definitely want a
+ variant and not something that will have no_variant set.
+
+ :param add_qty: float with the quantity for which to get the info,
+ indeed some pricelist rules might depend on it.
+
+ :param pricelist: `product.pricelist` the pricelist to use
+ (can be none, eg. from SO if no partner and no pricelist selected)
+
+ :param parent_combination: if no combination and no product_id are
+ given, it will try to find the first possible combination, taking
+ into account parent_combination (if set) for the exclusion rules.
+
+ :return: dict with product/combination info:
+
+ - product_id: the variant id matching the combination (if it exists)
+
+ - product_template_id: the current template id
+
+ - display_name: the name of the combination
+
+ - price: the computed price of the combination, take the catalog
+ price if no pricelist is given
+
+ - list_price: the catalog price of the combination, but this is
+ not the "real" list_price, it has price_extra included (so
+ it's actually more closely related to `lst_price`), and it
+ is converted to the pricelist currency (if given)
+
+ - has_discounted_price: True if the pricelist discount policy says
+ the price does not include the discount and there is actually a
+ discount applied (price < list_price), else False
+ """
+ self.ensure_one()
+
+ quantity = self.env.context.get('quantity', add_qty)
+ context = dict(self.env.context, quantity=quantity, pricelist=pricelist.id if pricelist else False)
+ product_template = self.with_context(context)
+
+ combination = combination or product_template.env['product.template.attribute.value']
+
+ if not product_id and not combination:
+ combination = product_template._get_first_possible_combination(parent_combination)
+
+ if product_id and not combination:
+ product = product_template.env['product.product'].browse(product_id)
+ else:
+ product = product_template._get_variant_for_combination(combination)
+
+ if product:
+ # We need to add the price_extra for the attributes that are not
+ # in the variant, typically those of type no_variant, but it is
+ # possible that a no_variant attribute is still in a variant if
+ # the type of the attribute has been changed after creation.
+ no_variant_attributes_price_extra = [
+ ptav.price_extra for ptav in combination.filtered(
+ lambda ptav:
+ ptav.price_extra and
+ ptav not in product.product_template_attribute_value_ids
+ )
+ ]
+ if no_variant_attributes_price_extra:
+ product = product.with_context(
+ no_variant_attributes_price_extra=no_variant_attributes_price_extra
+ )
+ list_price = product.price_compute('list_price')[product.id]
+ price = product.price if pricelist else list_price
+ else:
+ product_template = product_template.with_context(current_attributes_price_extra=[v.price_extra or 0.0 for v in combination])
+ list_price = product_template.price_compute('list_price')[product_template.id]
+ price = product_template.price if pricelist else list_price
+
+ display_name = product_template.name
+
+ filtered_combination = combination._without_no_variant_attributes()
+ if filtered_combination:
+ display_name = '%s (%s)' % (display_name, ', '.join(filtered_combination.mapped('name')))
+
+ if pricelist and pricelist.currency_id != product_template.currency_id:
+ list_price = product_template.currency_id._convert(
+ list_price, pricelist.currency_id, product_template.company_id,
+ fields.Date.today()
+ )
+
+ price_without_discount = list_price if pricelist and pricelist.discount_policy == 'without_discount' else price
+ has_discounted_price = (pricelist or product_template).currency_id.compare_amounts(price_without_discount, price) == 1
+
+ return {
+ 'product_id': product.id,
+ 'product_template_id': product_template.id,
+ 'display_name': display_name,
+ 'price': price,
+ 'list_price': list_price,
+ 'has_discounted_price': has_discounted_price,
+ }
+
+ @api.multi
+ def _is_add_to_cart_possible(self, parent_combination=None):
+ """
+ It's possible to add to cart (potentially after configuration) if
+ there is at least one possible combination, or if there is no
+ `product.template.attribute.line` at all.
+
+ :param parent_combination: the combination from which `self` is an
+ optional or accessory product.
+ :type parent_combination: recordset `product.template.attribute.value`
+
+ :return: True if it's possible to add to cart, else False
+ :rtype: bool
+ """
+ self.ensure_one()
+ if not self.active:
+ return False
+ combination = self._get_first_possible_combination(parent_combination)
+ return True if combination else self._is_combination_possible(combination, parent_combination)
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index bc4343e5b7d..5ab5355ac24 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -381,11 +381,13 @@ class SaleOrder(models.Model):
if self.env.context.get('mail_activity_automation_skip'):
return super(SaleOrder, self)._write(values)
- res = super(SaleOrder, self)._write(values)
if 'invoice_status' in values:
- self.activity_unlink(['sale.mail_act_sale_upsell'])
if values['invoice_status'] == 'upselling':
- for order in self.filtered('user_id'):
+ filtered_self = self.search([('id', 'in', self.ids),
+ ('user_id', '!=', False),
+ ('invoice_status', '!=', 'upselling')])
+ filtered_self.activity_unlink(['sale.mail_act_sale_upsell'])
+ for order in filtered_self:
order.activity_schedule(
'sale.mail_act_sale_upsell',
user_id=order.user_id.id,
@@ -393,7 +395,7 @@ class SaleOrder(models.Model):
order._name, order.id, order.name,
order.partner_id._name, order.partner_id.id, order.partner_id.display_name))
- return res
+ return super(SaleOrder, self)._write(values)
@api.multi
def copy_data(self, default=None):
@@ -649,9 +651,10 @@ class SaleOrder(models.Model):
self.force_quotation_send()
# create an analytic account if at least an expense product
- if any([expense_policy != 'no' for expense_policy in self.order_line.mapped('product_id.expense_policy')]):
- if not self.analytic_account_id:
- self._create_analytic_account()
+ for order in self:
+ if any([expense_policy != 'no' for expense_policy in order.order_line.mapped('product_id.expense_policy')]):
+ if not order.analytic_account_id:
+ order._create_analytic_account()
return True
@@ -1110,9 +1113,16 @@ class SaleOrderLine(models.Model):
product_uom_qty = fields.Float(string='Ordered Quantity', digits=dp.get_precision('Product Unit of Measure'), required=True, default=1.0)
product_uom = fields.Many2one('uom.uom', string='Unit of Measure')
product_custom_attribute_value_ids = fields.One2many('product.attribute.custom.value', 'sale_order_line_id', string='User entered custom product attribute values')
+
# M2M holding the values of product.attribute with create_variant field set to 'no_variant'
# It allows keeping track of the extra_price associated to those attribute values and add them to the SO line description
+ # Note: If the attributes are changed on the template, some or all records
+ # in `product_no_variant_attribute_value_ids` will be removed here, even
+ # from existing or locked sales order lines. Thus this field can only be
+ # relied on initially, but it cannot be used to recompute anything later
+ # because the result might be different then.
product_no_variant_attribute_value_ids = fields.Many2many('product.template.attribute.value', string='Product attribute values that do not create variants')
+
# Non-stored related field to allow portal user to see the image of the product he has ordered
product_image = fields.Binary('Product Image', related="product_id.image", store=False, readonly=False)
@@ -1362,11 +1372,20 @@ class SaleOrderLine(models.Model):
# TO DO: move me in master/saas-16 on sale.order
# awa: don't know if it's still the case since we need the "product_no_variant_attribute_value_ids" field now
# to be able to compute the full price
- if self.product_no_variant_attribute_value_ids:
- product = product.with_context(no_variant_attributes_price_extra=[
- no_variant_attribute_value.price_extra or 0
- for no_variant_attribute_value in self.product_no_variant_attribute_value_ids
- ])
+
+ # it is possible that a no_variant attribute is still in a variant if
+ # the type of the attribute has been changed after creation.
+ no_variant_attributes_price_extra = [
+ ptav.price_extra for ptav in self.product_no_variant_attribute_value_ids.filtered(
+ lambda ptav:
+ ptav.price_extra and
+ ptav not in product.product_template_attribute_value_ids
+ )
+ ]
+ if no_variant_attributes_price_extra:
+ product = product.with_context(
+ no_variant_attributes_price_extra=no_variant_attributes_price_extra
+ )
if self.order_id.pricelist_id.discount_policy == 'with_discount':
return product.with_context(pricelist=self.order_id.pricelist_id.id).price
@@ -1387,6 +1406,16 @@ class SaleOrderLine(models.Model):
if not self.product_id:
return {'domain': {'product_uom': []}}
+ # remove the is_custom values that don't belong to this template
+ for pacv in self.product_custom_attribute_value_ids:
+ if pacv.attribute_value_id not in self.product_id.product_tmpl_id._get_valid_product_attribute_values():
+ self.product_custom_attribute_value_ids -= pacv
+
+ # remove the no_variant attributes that don't belong to this template
+ for ptav in self.product_no_variant_attribute_value_ids:
+ if ptav.product_attribute_value_id not in self.product_id.product_tmpl_id._get_valid_product_attribute_values():
+ self.product_no_variant_attribute_value_ids -= ptav
+
vals = {}
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):
@@ -1395,7 +1424,7 @@ class SaleOrderLine(models.Model):
product = self.product_id.with_context(
lang=self.order_id.partner_id.lang,
- partner=self.order_id.partner_id.id,
+ partner=self.order_id.partner_id,
quantity=vals.get('product_uom_qty') or self.product_uom_qty,
date=self.order_id.date_order,
pricelist=self.order_id.pricelist_id.id,
@@ -1419,20 +1448,6 @@ class SaleOrderLine(models.Model):
name = self.get_sale_order_line_multiline_description_sale(product)
- if self.product_custom_attribute_value_ids or self.product_no_variant_attribute_value_ids:
- name += '\n'
-
- if self.product_custom_attribute_value_ids:
- for product_custom_attribute_value in self.product_custom_attribute_value_ids:
- if product_custom_attribute_value.custom_value and product_custom_attribute_value.custom_value.strip():
- name += '\n' + product_custom_attribute_value.attribute_value_id.name + ': ' + product_custom_attribute_value.custom_value.strip()
-
- if self.product_no_variant_attribute_value_ids:
- for no_variant_attribute_value in self.product_no_variant_attribute_value_ids.filtered(
- lambda product_attribute_value: not product_attribute_value.is_custom
- ):
- name += '\n' + no_variant_attribute_value.attribute_id.name + ': ' + no_variant_attribute_value.name
-
vals.update(name=name)
self._compute_tax_id()
@@ -1451,7 +1466,7 @@ class SaleOrderLine(models.Model):
if self.order_id.pricelist_id and self.order_id.partner_id:
product = self.product_id.with_context(
lang=self.order_id.partner_id.lang,
- partner=self.order_id.partner_id.id,
+ partner=self.order_id.partner_id,
quantity=self.product_uom_qty,
date=self.order_id.date_order,
pricelist=self.order_id.pricelist_id.id,
@@ -1547,7 +1562,7 @@ class SaleOrderLine(models.Model):
self.discount = 0.0
product = self.product_id.with_context(
lang=self.order_id.partner_id.lang,
- partner=self.order_id.partner_id.id,
+ partner=self.order_id.partner_id,
quantity=self.product_uom_qty,
date=self.order_id.date_order,
pricelist=self.order_id.pricelist_id.id,
@@ -1581,4 +1596,38 @@ class SaleOrderLine(models.Model):
BUT in event_sale we need to know specifically the sales order line as well as the product to generate the name:
the product is not sufficient because we also need to know the event_id and the event_ticket_id (both which belong to the sale order line).
"""
- return product.get_product_multiline_description_sale()
+ return product.get_product_multiline_description_sale() + self._get_sale_order_line_multiline_description_variants()
+
+ def _get_sale_order_line_multiline_description_variants(self):
+ """When using no_variant attributes or is_custom values, the product
+ itself is not sufficient to create the description: we need to add
+ information about those special attributes and values.
+
+ See note about `product_no_variant_attribute_value_ids` above the field
+ definition: this method is not reliable to recompute the description at
+ a later time, it should only be used initially.
+
+ :return: the description related to special variant attributes/values
+ :rtype: string
+ """
+ if not self.product_custom_attribute_value_ids and not self.product_no_variant_attribute_value_ids:
+ return ""
+
+ name = "\n"
+
+ product_attribute_with_is_custom = self.product_custom_attribute_value_ids.mapped('attribute_value_id.attribute_id')
+
+ # display the no_variant attributes, except those that are also
+ # displayed by a custom (avoid duplicate)
+ for no_variant_attribute_value in self.product_no_variant_attribute_value_ids.filtered(
+ lambda ptav: ptav.attribute_id not in product_attribute_with_is_custom
+ ):
+ name += "\n" + no_variant_attribute_value.attribute_id.name + ': ' + no_variant_attribute_value.name
+
+ # display the is_custom values
+ for pacv in self.product_custom_attribute_value_ids:
+ name += "\n" + pacv.attribute_value_id.attribute_id.name + \
+ ': ' + pacv.attribute_value_id.name + \
+ ': ' + (pacv.custom_value or '').strip()
+
+ return name
diff --git a/addons/sale/static/src/js/product_configurator_controller.js b/addons/sale/static/src/js/product_configurator_controller.js
index f7019fa3e04..a2b59cf835d 100644
--- a/addons/sale/static/src/js/product_configurator_controller.js
+++ b/addons/sale/static/src/js/product_configurator_controller.js
@@ -49,21 +49,27 @@ var ProductConfiguratorFormController = FormController.extend({
* @override
*/
_onFieldChanged: function (event) {
+ this._super.apply(this, arguments);
+
var self = this;
+ var product_id = event.data.changes.product_template_id.id;
+
+ // check to prevent traceback when emptying the field
+ if (!product_id) {
+ return;
+ }
this.$el.parents('.modal').find('.o_sale_product_configurator_add').removeClass('disabled');
this._rpc({
route: '/product_configurator/configure',
params: {
- product_id: event.data.changes.product_template_id.id,
+ product_id: product_id,
pricelist_id: this.renderer.pricelistId
}
}).then(function (configurator) {
self.renderer.renderConfigurator(configurator);
});
-
- this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
@@ -95,7 +101,7 @@ var ProductConfiguratorFormController = FormController.extend({
false
);
- productReady.done(function (productId){
+ productReady.done(function (productId) {
$modal.find(productSelector.join(', ')).val(productId);
var variantValues = self
diff --git a/addons/sale/static/src/js/product_configurator_mixin.js b/addons/sale/static/src/js/product_configurator_mixin.js
index aa1066a0c24..458dd5a580c 100644
--- a/addons/sale/static/src/js/product_configurator_mixin.js
+++ b/addons/sale/static/src/js/product_configurator_mixin.js
@@ -13,6 +13,7 @@ var ProductConfiguratorMixin = {
'click button.js_add_cart_json': 'onClickAddCartJSON',
'change [data-attribute_exclusions]': 'onChangeVariant'
},
+ isSelectedVariantAllowed: true,
//--------------------------------------------------------------------------
// Public
@@ -22,7 +23,6 @@ var ProductConfiguratorMixin = {
* 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 ?
- *
*/
computePriceTotal: function () {
if ($('.js_price_total').length){
@@ -56,8 +56,10 @@ var ProductConfiguratorMixin = {
$component = $(ev.currentTarget).closest('form');
} else if ($(ev.currentTarget).closest('.oe_optional_products_modal').length > 0){
$component = $(ev.currentTarget).closest('.oe_optional_products_modal');
- } else {
+ } else if ($(ev.currentTarget).closest('.o_product_configurator').length > 0) {
$component = $(ev.currentTarget).closest('.o_product_configurator');
+ } else {
+ $component = $(ev.currentTarget);
}
var qty = $component.find('input[name="add_qty"]').val();
@@ -68,10 +70,10 @@ var ProductConfiguratorMixin = {
ajax.jsonRpc(this._getUri('/product_configurator/get_combination_info'), 'call', {
product_template_id: parseInt($parent.find('.product_template_id').val()),
- product_id: parseInt($parent.find('.product_id').val()),
+ product_id: this._getProductId($parent),
combination: combination,
add_qty: parseInt(qty),
- pricelist_id: this.pricelistId
+ pricelist_id: this.pricelistId || false,
}).then(function (combinationData) {
self._onChangeCombination(ev, $parent, combinationData);
});
@@ -291,23 +293,28 @@ var ProductConfiguratorMixin = {
*/
selectOrCreateProduct: function ($container, productId, productTemplateId, useAjax) {
var self = this;
+ productId = parseInt(productId);
+ productTemplateId = parseInt(productTemplateId);
var productReady = $.Deferred();
- if (productId && productId !== '0'){
+ if (productId) {
productReady.resolve(productId);
} else {
var params = {
- model: 'product.template',
- method: 'create_product_variant',
- args: [
- productTemplateId,
- JSON.stringify(self.getSelectedVariantValues($container))
- ]
+ product_template_id: productTemplateId,
+ product_template_attribute_value_ids:
+ JSON.stringify(self.getSelectedVariantValues($container)),
};
+ // Note about 12.0 compatibility: this route will not exist if
+ // updating the code but not restarting the server. (404)
+ // We don't handle that compatibility because the previous code was
+ // not working either: it was making an RPC that failed with any
+ // non-admin user anyway. To use this feature, restart the server.
+ var route = '/product_configurator/create_product_variant';
if (useAjax) {
- productReady = ajax.jsonRpc('/web/dataset/call', 'call', params);
+ productReady = ajax.jsonRpc(route, 'call', params);
} else {
- productReady = this._rpc(params);
+ productReady = this._rpc({route: route, params: params});
}
}
@@ -335,6 +342,23 @@ var ProductConfiguratorMixin = {
* @param {Array} combination the selected combination of product attribute values
*/
_checkExclusions: function ($parent, combination) {
+
+ function areCombinationsEqual(c1, c2) {
+ return c1.length === c2.length && _.every(c1, function (ptav) {
+ return c2.indexOf(ptav) > -1;
+ });
+ }
+
+ function isCombinationInList(c1, list) {
+ return _.some(list, function (c2) {
+ return areCombinationsEqual(c1, c2);
+ });
+ }
+
+ function isPtavInCombination(ptav, combination) {
+ return combination.indexOf(ptav) > -1;
+ }
+
var self = this;
var combinationData = $parent
.find('ul[data-attribute_exclusions]')
@@ -343,43 +367,62 @@ var ProductConfiguratorMixin = {
$parent.find('option, input, label').removeClass('css_not_available');
var disable = false;
+
+ // compatibility 12.0
+ var filteredCombination = combination;
+ if (combinationData.no_variant_product_template_attribute_value_ids !== undefined) {
+ var no_variants = combinationData.no_variant_product_template_attribute_value_ids;
+ filteredCombination = _.filter(combination, function (ptav) {
+ return !isPtavInCombination(ptav, no_variants);
+ });
+ }
+
+ // exclusion rules: array of ptav
+ // for each of them, contains array with the other ptav they exclude
if (combinationData.exclusions) {
- _.each(combination, function (combinationValue){
- if (combinationData.exclusions &&
- combinationData.exclusions.hasOwnProperty(combinationValue)){
- // check that the selected combination is in the exclusions
- _.each(combinationData.exclusions[combinationValue], function (exclusion) {
- if (!disable && combination.indexOf(exclusion) > -1) {
+ // browse all the currently selected attributes
+ _.each(combination, function (current_ptav) {
+ if (combinationData.exclusions.hasOwnProperty(current_ptav)) {
+ // for each exclusion of the current attribute:
+ _.each(combinationData.exclusions[current_ptav], function (excluded_ptav) {
+ // disable if it excludes any other attribute already in the combination
+ if (isPtavInCombination(excluded_ptav, combination)) {
disable = true;
}
- self._disableInput($parent, exclusion);
+ // disable the excluded input (even when not already selected)
+ // to give a visual feedback before click
+ self._disableInput($parent, excluded_ptav);
});
}
});
}
- if (combinationData.parent_exclusions){
- _.each(combinationData.parent_exclusions, function (exclusion){
- if (!disable && combination.indexOf(exclusion) > -1) {
- disable = true;
- }
- self._disableInput($parent, exclusion);
- });
+ // parent exclusions (tell which attributes are excluded from parent)
+ _.each(combinationData.parent_exclusions, function (ptav) {
+ if (isPtavInCombination(ptav, combination)) {
+ disable = true;
+ }
+ // disable the excluded input (even when not already selected)
+ // to give a visual feedback before click
+ self._disableInput($parent, ptav);
+ });
+
+ // archived variants
+ if (isCombinationInList(filteredCombination, combinationData.archived_combinations)) {
+ disable = true;
}
- if (combinationData.archived_combinations){
- _.each(combinationData.archived_combinations, function (archived_combination){
- if (disable) {
- return;
- }
-
- disable = _.every(archived_combination, function (attribute_value){
- return combination.indexOf(attribute_value) > -1;
- });
- });
+ // if not using dynamic attributes, exclude variants that are deleted
+ if (filteredCombination.length && // compatibility 12.0 list view of variants
+ combinationData.has_dynamic_attributes === false &&
+ combinationData.existing_combinations !== undefined &&
+ !isCombinationInList(filteredCombination, combinationData.existing_combinations)
+ ) {
+ disable = true;
}
+ this.isSelectedVariantAllowed = !disable;
$parent.toggleClass('css_not_available', disable);
$parent.find("#add_to_cart").toggleClass('disabled', disable);
$parent
@@ -388,6 +431,15 @@ var ProductConfiguratorMixin = {
.toggleClass('disabled', disable);
},
+ /**
+ * Extracted to a method to be extendable by other modules
+ *
+ * @param {$.Element} $parent
+ */
+ _getProductId: function ($parent) {
+ return parseInt($parent.find('.product_id').val());
+ },
+
/**
* Will disable the input/option that refers to the passed attributeValueId.
* This is used for showing the user that some combinations are not available.
@@ -418,7 +470,11 @@ var ProductConfiguratorMixin = {
var $optional_price = $parent.find(".oe_optional:first .oe_currency_value");
$price.html(self._priceToStr(combination.price));
$default_price.html(self._priceToStr(combination.list_price));
- if (combination.list_price - combination.price >= 0.01) {
+
+ // compatibility_check to remove in master
+ // needed for fix in 12.0 in the case of git pull and no server restart
+ var compatibility_check = combination.list_price - combination.price >= 0.01;
+ if (combination.has_discounted_price !== undefined ? combination.has_discounted_price : compatibility_check) {
$default_price
.closest('.oe_website_sale')
.addClass("discount");
@@ -428,6 +484,9 @@ var ProductConfiguratorMixin = {
.css('text-decoration', 'line-through');
$default_price.parent().removeClass('d-none');
} else {
+ $default_price
+ .closest('.oe_website_sale')
+ .removeClass("discount");
$optional_price.closest('.oe_optional').addClass('d-none');
$default_price.parent().addClass('d-none');
}
@@ -438,11 +497,16 @@ var ProductConfiguratorMixin = {
'.o_product_configurator'
];
- self._updateProductImage(
- $parent.closest(rootComponentSelectors.join(', ')),
- combination.product_id,
- combination.product_template_id
- );
+ // update images only when changing product
+ if (combination.product_id !== this.last_product_id) {
+ this.last_product_id = combination.product_id;
+ self._updateProductImage(
+ $parent.closest(rootComponentSelectors.join(', ')),
+ combination.product_id,
+ combination.product_template_id,
+ combination.carousel
+ );
+ }
$parent
.find('.product_id')
@@ -492,47 +556,20 @@ var ProductConfiguratorMixin = {
* @param {integer} productTemplateId
*/
_updateProductImage: function ($productContainer, productId, productTemplateId) {
- var $img;
var model = productId ? 'product.product' : 'product.template';
var modelId = productId || productTemplateId;
- var imageSrc = '/web/image?model={0}&id={1}&field=image'
+ var imageSrc = '/web/image/{0}/{1}/image'
.replace("{0}", model)
.replace("{1}", modelId);
- if ($productContainer.find('#o-carousel-product').length) {
- $img = $productContainer.find('img.js_variant_img');
- $img.attr("src", imageSrc);
- $img.parent().attr('data-oe-model', model).attr('data-oe-id', modelId)
- .data('oe-model', model).data('oe-id', modelId);
+ var imagesSelectors = [
+ 'span[data-oe-model^="product."][data-oe-type="image"] img:first',
+ 'img.product_detail_img',
+ 'span.variant_image img'
+ ];
- 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/{0}/{1}/image/90x90"
- .replace('{0}', model)
- .replace('{1}', modelId));
- $('.carousel').carousel(0);
- }
- }
- else {
- var imagesSelectors = [
- 'span[data-oe-model^="product."][data-oe-type="image"] img:first',
- 'img.product_detail_img',
- 'span.variant_image img'
- ];
-
- $img = $productContainer.find(imagesSelectors.join(', '));
- $img.attr('src', imageSrc);
- $img.parent()
- .attr('data-oe-model', model)
- .attr('data-oe-id', modelId)
- .data('oe-model', model)
- .data('oe-id', modelId);
- }
- // 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;
- }
+ var $img = $productContainer.find(imagesSelectors.join(', '));
+ $img.attr('src', imageSrc);
},
/**
@@ -553,11 +590,13 @@ var ProductConfiguratorMixin = {
* Website behavior is slightly different from backend so we append
* "_website" to URLs to lead to a different route
*
+ * TODO this should be overriden in website_sale instead.
+ *
* @private
* @param {string} uri The uri to adapt
*/
_getUri: function (uri) {
- if (this.isWebsite){
+ if (this.isWebsite) {
return uri + '_website';
} else {
return uri;
diff --git a/addons/sale/static/src/js/product_configurator_modal.js b/addons/sale/static/src/js/product_configurator_modal.js
index 1cebc683162..630774b8da5 100644
--- a/addons/sale/static/src/js/product_configurator_modal.js
+++ b/addons/sale/static/src/js/product_configurator_modal.js
@@ -47,7 +47,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
init: function (parent, params) {
var self = this;
- this._super(parent, {
+ var options = _.extend({
size: 'large',
buttons: [{
text: params.okButtonText,
@@ -57,8 +57,10 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
text: params.cancelButtonText,
click: this._onCancelButtonClick
}],
- title: params.title
- });
+ technical: !params.isWebsite,
+ }, params || {});
+
+ this._super(parent, options);
this.rootProduct = params.rootProduct;
this.container = parent;
@@ -70,10 +72,6 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
delete this.events['change [data-attribute_exclusions]'];
delete this.events['click button.js_add_cart_json'];
}
-
- this._opened.then(function () {
- self.triggerVariantChange(self.$el);
- });
},
/**
* @override
@@ -85,7 +83,8 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
var getModalContent = ajax.jsonRpc(uri, 'call', {
product_id: self.rootProduct.product_id,
variant_values: self.rootProduct.variant_values,
- pricelist_id: self.pricelistId,
+ pricelist_id: self.pricelistId || false,
+ add_qty: self.rootProduct.quantity,
kwargs: {
context: _.extend({
'quantity': self.rootProduct.quantity
@@ -124,6 +123,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
self.$modal.attr('open', true);
self.$modal.removeAttr("aria-hidden");
self.$modal.modal().appendTo(self.container);
+ self.$modal.focus();
self._opened.resolve();
}
});
@@ -138,10 +138,18 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
*
* @override
*/
- start: function (){
- this._super.apply(this, arguments);
+ start: function () {
+ var def = this._super.apply(this, arguments);
+ var self = this;
this.$el.find('input[name="add_qty"]').val(this.rootProduct.quantity);
+
+ return def.then(function () {
+ // This has to be triggered to compute the "out of stock" feature
+ self._opened.then(function () {
+ self.triggerVariantChange(self.$el);
+ });
+ });
},
//--------------------------------------------------------------------------
@@ -357,7 +365,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
ajax.jsonRpc(self._getUri("/product_configurator/optional_product_items"), 'call', {
'product_id': productId,
- 'pricelist_id': self.pricelistId
+ 'pricelist_id': self.pricelistId || false,
}).then(function (addedItem) {
var $addedItem = $(addedItem);
$modal.find('tr:last').after($addedItem);
@@ -408,7 +416,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
this._removeOptionOption($modal, productTemplateId);
- $('tr:last').after($parent);
+ $modal.find('tr:last').after($parent);
},
/**
diff --git a/addons/sale/static/src/js/tour.js b/addons/sale/static/src/js/tour.js
index f14c7d9f88b..8b82c091cee 100644
--- a/addons/sale/static/src/js/tour.js
+++ b/addons/sale/static/src/js/tour.js
@@ -46,7 +46,7 @@ tour.register('sale_tour', {
content: _t("Select a product, or create a new one on the fly."),
position: "right",
run: function (actions) {
- actions.text("DESK0001", this.$anchor.find("input"));
+ actions.text("Chair", this.$anchor.find("input"));
},
}, {
trigger: ".ui-menu-item > a",
@@ -58,12 +58,12 @@ tour.register('sale_tour', {
$('.modal-dialog:has(div.o_dialog_warning) footer.modal-footer .btn-primary').trigger('click');
}
},
- id: "quotation_product_selected",
}, {
trigger: ".o_form_button_save",
extra_trigger: ".o_sale_order",
content: _t("Once your quotation is ready, you can save, print or send it by email."),
position: "right",
+ id: "form_button_save_clicked"
}, {
trigger: ".o_sale_print",
extra_trigger: ".o_sale_order.o_form_readonly",
diff --git a/addons/sale/static/src/scss/product_configurator.scss b/addons/sale/static/src/scss/product_configurator.scss
index ca3b9f63af2..8cf022dad50 100644
--- a/addons/sale/static/src/scss/product_configurator.scss
+++ b/addons/sale/static/src/scss/product_configurator.scss
@@ -39,6 +39,13 @@
.css_not_available_msg {
display: block;
}
+
+ .js_add,
+ .oe_price,
+ .oe_default_price,
+ .oe_optional {
+ display: none;
+ }
}
.css_quantity {
@@ -113,12 +120,7 @@ label.css_attribute_color.css_not_available {
}
.o_total_row {
- h5 {
- padding-top: 0.4rem;
- }
- .js_price_total {
- font-size: 1.5rem;
- }
+ font-size: 1.2rem;
}
}
@@ -140,10 +142,6 @@ label.css_attribute_color.css_not_available {
background-image: none;
}
- .remove-optionnal-item, .add-optionnal-item {
- padding-left: 5px;
- }
-
.td-product_name {
word-wrap: break-word;
}
diff --git a/addons/sale/tests/__init__.py b/addons/sale/tests/__init__.py
index e6be9852a3b..48bee457a5a 100644
--- a/addons/sale/tests/__init__.py
+++ b/addons/sale/tests/__init__.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import test_sale_product_attribute_value_config
from . import test_sale_to_invoice
from . import test_sale_order
from . import test_sale_pricelist
diff --git a/addons/sale/tests/test_sale_product_attribute_value_config.py b/addons/sale/tests/test_sale_product_attribute_value_config.py
new file mode 100644
index 00000000000..dd36a916778
--- /dev/null
+++ b/addons/sale/tests/test_sale_product_attribute_value_config.py
@@ -0,0 +1,389 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields
+from odoo.addons.product.tests.test_product_attribute_value_config import TestProductAttributeValueSetup
+from odoo.tests import tagged
+
+
+class TestSaleProductAttributeValueSetup(TestProductAttributeValueSetup):
+ def _setup_currency(self, currency_ratio=2):
+ """Get or create a currency. This makes the test non-reliant on demo.
+
+ With an easy currency rate, for a simple 2 ratio in the following tests.
+ """
+ from_currency = self.computer.currency_id
+ self._set_or_create_rate_today(from_currency, rate=1)
+
+ to_currency = self._get_or_create_currency("my currency", "C")
+ self._set_or_create_rate_today(to_currency, currency_ratio)
+
+ return to_currency
+
+ def _set_or_create_rate_today(self, currency, rate):
+ """Get or create a currency rate for today. This makes the test
+ non-reliant on demo data."""
+ name = fields.Date.today()
+ currency_id = currency.id
+ company_id = self.env.user.company_id.id
+
+ CurrencyRate = self.env['res.currency.rate']
+
+ currency_rate = CurrencyRate.search([
+ ('company_id', '=', company_id),
+ ('currency_id', '=', currency_id),
+ ('name', '=', name),
+ ])
+
+ if currency_rate:
+ currency_rate.rate = rate
+ else:
+ CurrencyRate.create({
+ 'company_id': company_id,
+ 'currency_id': currency_id,
+ 'name': name,
+ 'rate': rate,
+ })
+
+ def _get_or_create_currency(self, name, symbol):
+ """Get or create a currency based on name. This makes the test
+ non-reliant on demo data."""
+ currency = self.env['res.currency'].search([('name', '=', name)])
+ return currency or currency.create({
+ 'name': name,
+ 'symbol': symbol,
+ })
+
+
+@tagged('post_install', '-at_install')
+class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
+ def _setup_pricelist(self, currency_ratio=2):
+ to_currency = self._setup_currency(currency_ratio)
+
+ discount = 10
+
+ pricelist = self.env['product.pricelist'].create({
+ 'name': 'test pl',
+ 'currency_id': to_currency.id,
+ 'company_id': self.computer.company_id.id,
+ })
+
+ pricelist_item = self.env['product.pricelist.item'].create({
+ 'min_quantity': 2,
+ 'compute_price': 'percentage',
+ 'percent_price': discount
+ })
+
+ pricelist.item_ids += pricelist_item
+
+ return (pricelist, pricelist_item, currency_ratio, 1 - discount / 100)
+
+ def test_01_is_combination_possible_archived(self):
+ """The goal is to test the possibility of archived combinations.
+ This test could not be put into product module because there was no
+ field which had product_id as required and without cascade on delete.
+ """
+ def do_test(self):
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+ computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
+
+ variant = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ variant2 = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_2)
+
+ # Create a dummy SO to prevent the variant from being deleted by
+ # create_variant_ids() because the variant is a related field that
+ # is required on the SO line
+ so = self.env['sale.order'].create({'partner_id': 1})
+ self.env['sale.order.line'].create({
+ 'order_id': so.id,
+ 'name': "test",
+ 'product_id': variant.id
+ })
+ # additional variant to test correct ignoring when mismatch values
+ self.env['sale.order.line'].create({
+ 'order_id': so.id,
+ 'name': "test",
+ 'product_id': variant2.id
+ })
+
+ variant2.active = False
+ # CASE: 1 not archived, 2 archived
+ self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
+ self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2))
+ # CASE: both archived combination (without no_variant)
+ variant.active = False
+ self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2))
+ self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
+
+ # CASE: not archived (with no_variant)
+ self.computer_hdd_attribute_lines.unlink()
+ self.hdd_attribute.create_variant = 'no_variant'
+ self._add_hdd_attribute_line()
+ self.computer.create_variant_ids()
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+ computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
+
+ self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
+
+ # CASE: archived combination found (with no_variant)
+ variant = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ variant.active = False
+ self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
+
+ # CASE: archived combination has different attributes (including no_variant)
+ self.computer_ssd_attribute_lines.unlink()
+ self.computer.create_variant_ids()
+
+ variant4 = self.computer._get_variant_for_combination(computer_ram_8 + computer_hdd_1)
+ self.env['sale.order.line'].create({
+ 'order_id': so.id,
+ 'name': "test",
+ 'product_id': variant4.id
+ })
+ self.assertTrue(self.computer._is_combination_possible(computer_ram_8 + computer_hdd_1))
+
+ # CASE: archived combination has different attributes (without no_variant)
+ self.computer_hdd_attribute_lines.unlink()
+ self.hdd_attribute.create_variant = 'always'
+ self._add_hdd_attribute_line()
+ self.computer.create_variant_ids()
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+ computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
+
+ variant5 = self.computer._get_variant_for_combination(computer_ram_8 + computer_hdd_1)
+ self.env['sale.order.line'].create({
+ 'order_id': so.id,
+ 'name': "test",
+ 'product_id': variant5.id
+ })
+
+ self.assertTrue(variant4 != variant5)
+
+ self.assertTrue(self.computer._is_combination_possible(computer_ram_8 + computer_hdd_1))
+
+ computer_ssd_256_before = self._get_product_template_attribute_value(self.ssd_256)
+
+ do_test(self)
+
+ # CASE: add back the removed attribute and try everything again
+ # It will be the same attribute but the ptal and ptav will be different!
+ self.computer_ssd_attribute_lines = self.env['product.template.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])],
+ })
+ self.computer.create_variant_ids()
+
+ computer_ssd_256_after = self._get_product_template_attribute_value(self.ssd_256)
+ self.assertTrue(computer_ssd_256_after != computer_ssd_256_before)
+ do_test(self)
+
+ def test_02_get_combination_info(self):
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+
+ # CASE: no pricelist, no currency, with existing combination, with price_extra on attributes
+ combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
+ computer_variant = self.computer._get_variant_for_combination(combination)
+
+ res = self.computer._get_combination_info(combination)
+ self.assertEqual(res['product_template_id'], self.computer.id)
+ self.assertEqual(res['product_id'], computer_variant.id)
+ self.assertEqual(res['display_name'], "Super Computer (256 GB, 8 GB, 1 To)")
+ self.assertEqual(res['price'], 2222)
+ self.assertEqual(res['list_price'], 2222)
+
+ # CASE: no combination, product given
+ res = self.computer._get_combination_info(self.env['product.template.attribute.value'], computer_variant.id)
+ self.assertEqual(res['product_template_id'], self.computer.id)
+ self.assertEqual(res['product_id'], computer_variant.id)
+ # the variant has the same name as the template
+ self.assertEqual(res['display_name'], "Super Computer")
+ self.assertEqual(res['price'], 2222)
+ self.assertEqual(res['list_price'], 2222)
+
+ # CASE: using pricelist, quantity rule
+ pricelist, pricelist_item, currency_ratio, discount_ratio = self._setup_pricelist()
+
+ res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
+ self.assertEqual(res['product_template_id'], self.computer.id)
+ self.assertEqual(res['product_id'], computer_variant.id)
+ self.assertEqual(res['display_name'], "Super Computer (256 GB, 8 GB, 1 To)")
+ self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
+ self.assertEqual(res['list_price'], 2222 * currency_ratio)
+
+ # CASE: no_variant combination, it's another variant now
+
+ self.computer_ssd_attribute_lines.unlink()
+ self.ssd_attribute.create_variant = 'no_variant'
+ self._add_ssd_attribute_line()
+ self.computer.create_variant_ids()
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+ combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
+
+ computer_variant_new = self.computer._get_variant_for_combination(combination)
+ res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
+ self.assertEqual(res['product_template_id'], self.computer.id)
+ self.assertEqual(res['product_id'], computer_variant_new.id)
+ self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To)")
+ self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
+ self.assertEqual(res['list_price'], 2222 * currency_ratio)
+
+ # CASE: dynamic combination, but the variant already exists
+ self.computer_hdd_attribute_lines.unlink()
+ self.hdd_attribute.create_variant = 'dynamic'
+ self._add_hdd_attribute_line()
+ self.computer.create_variant_ids()
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+ combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
+
+ res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
+ self.assertEqual(res['product_template_id'], self.computer.id)
+ self.assertEqual(res['product_id'], computer_variant_new.id)
+ self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To)")
+ self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
+ self.assertEqual(res['list_price'], 2222 * currency_ratio)
+
+ # CASE: dynamic combination, no variant existing
+ self._add_keyboard_attribute()
+ self.computer.create_variant_ids()
+ combination += self._get_product_template_attribute_value(self.keyboard_excluded)
+ res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
+ self.assertEqual(res['product_template_id'], self.computer.id)
+ self.assertEqual(res['product_id'], False)
+ self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To, Excluded)")
+ self.assertEqual(res['price'], (2222 - 5) * currency_ratio * discount_ratio)
+ self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio)
+
+ # CASE: pricelist set value to 0, no variant
+ pricelist_item.percent_price = 100
+ self.computer.invalidate_cache() # need o2m to be refetched
+ res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
+ self.assertEqual(res['product_template_id'], self.computer.id)
+ self.assertEqual(res['product_id'], False)
+ self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To, Excluded)")
+ self.assertEqual(res['price'], 0)
+ self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio)
+
+ def test_03_get_combination_info_discount_policy(self):
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+ combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
+
+ pricelist, pricelist_item, currency_ratio, discount_ratio = self._setup_pricelist()
+
+ pricelist.discount_policy = 'with_discount'
+
+ # CASE: no discount, setting with_discount
+ res = self.computer._get_combination_info(combination, add_qty=1, pricelist=pricelist)
+ self.assertEqual(res['price'], 2222 * currency_ratio)
+ self.assertEqual(res['list_price'], 2222 * currency_ratio)
+ self.assertEqual(res['has_discounted_price'], False)
+
+ # CASE: discount, setting with_discount
+ res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
+ self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
+ self.assertEqual(res['list_price'], 2222 * currency_ratio)
+ self.assertEqual(res['has_discounted_price'], False)
+
+ # CASE: no discount, setting without_discount
+ pricelist.discount_policy = 'without_discount'
+ res = self.computer._get_combination_info(combination, add_qty=1, pricelist=pricelist)
+ self.assertEqual(res['price'], 2222 * currency_ratio)
+ self.assertEqual(res['list_price'], 2222 * currency_ratio)
+ self.assertEqual(res['has_discounted_price'], False)
+
+ # CASE: discount, setting without_discount
+ res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
+ self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
+ self.assertEqual(res['list_price'], 2222 * currency_ratio)
+ self.assertEqual(res['has_discounted_price'], True)
+
+ def test_04_create_product_variant_non_dynamic(self):
+ """The goal of this test is to make sure the create_product_variant does
+ not create variant if the type is not dynamic. It can however return a
+ variant if it already exists."""
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+ self._add_exclude(computer_ram_16, computer_hdd_1)
+
+ # CASE: variant is already created, it should return it
+ combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
+ variant1 = self.computer._get_variant_for_combination(combination)
+ self.assertEqual(self.computer._create_product_variant(combination), variant1)
+
+ # CASE: variant does not exist, but template is non-dynamic, so it
+ # should not create it
+ Product = self.env['product.product']
+ variant1.unlink()
+ self.assertEqual(self.computer._create_product_variant(combination), Product)
+
+ def test_05_create_product_variant_dynamic(self):
+ """The goal of this test is to make sure the create_product_variant does
+ work with dynamic. If the combination is possible, it should create it.
+ If it's not possible, it should not create it."""
+ self.computer_hdd_attribute_lines.unlink()
+ self.computer.create_variant_ids()
+ self.hdd_attribute.create_variant = 'dynamic'
+ self._add_hdd_attribute_line()
+ self.computer.create_variant_ids()
+ self.computer.invalidate_cache()
+
+ computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
+ computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
+ computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
+ self._add_exclude(computer_ram_16, computer_hdd_1)
+
+ # CASE: variant does not exist, but combination is not possible
+ # so it should not create it
+ impossible_combination = computer_ssd_256 + computer_ram_16 + computer_hdd_1
+ Product = self.env['product.product']
+ self.assertEqual(self.computer._create_product_variant(impossible_combination), Product)
+
+ # CASE: the variant does not exist, and the combination is possible, so
+ # it should create it
+ combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
+ variant = self.computer._create_product_variant(combination)
+ self.assertTrue(variant)
+
+ # CASE: the variant already exists, so it should return it
+ self.assertEqual(variant, self.computer._create_product_variant(combination))
+
+ def _add_keyboard_attribute(self):
+ self.keyboard_attribute = self.env['product.attribute'].create({
+ 'name': 'Keyboard',
+ 'sequence': 6,
+ 'create_variant': 'dynamic',
+ })
+ self.keyboard_included = self.env['product.attribute.value'].create({
+ 'name': 'Included',
+ 'attribute_id': self.keyboard_attribute.id,
+ 'sequence': 1,
+ })
+ self.keyboard_excluded = self.env['product.attribute.value'].create({
+ 'name': 'Excluded',
+ 'attribute_id': self.keyboard_attribute.id,
+ 'sequence': 2,
+ })
+ self.computer_keyboard_attribute_lines = self.env['product.template.attribute.line'].create({
+ 'product_tmpl_id': self.computer.id,
+ 'attribute_id': self.keyboard_attribute.id,
+ 'value_ids': [(6, 0, [self.keyboard_included.id, self.keyboard_excluded.id])],
+ })
+ self.computer_keyboard_attribute_lines.product_template_value_ids[0].price_extra = 5
+ self.computer_keyboard_attribute_lines.product_template_value_ids[1].price_extra = -5
diff --git a/addons/sale/views/sale_portal_templates.xml b/addons/sale/views/sale_portal_templates.xml
index 0e8c7378139..7f0338d1b6d 100644
--- a/addons/sale/views/sale_portal_templates.xml
+++ b/addons/sale/views/sale_portal_templates.xml
@@ -455,6 +455,7 @@
|
-
+
% discount
|
diff --git a/addons/sale/views/sale_product_configurator_templates.xml b/addons/sale/views/sale_product_configurator_templates.xml
index bf9df14a54d..6711dd2db8b 100644
--- a/addons/sale/views/sale_product_configurator_templates.xml
+++ b/addons/sale/views/sale_product_configurator_templates.xml
@@ -6,43 +6,64 @@
+
+
+
+
+
+
-
+
-
+
![Product Image]()
+
-
-
-
+
+
+
+
+
+
+
+
-
This combination does not exist.
+
This combination does not exist.
+
+
+
This product has no valid combination.
+
@@ -55,215 +76,237 @@
+
+
+
+
-
+
-
+
+
|
-
-
+
+
|
- |
-
-
-
-
-
+
+
+
+
+
|
- |
- Total: |
-
+ |
+ Total:
+ "widget": "monetary",
+ "display_currency": (pricelist or product).currency_id
+ }'/>
|
Available Options: |
-
+
+
+
+
-
-
-
- |
-
-
-
-
- |
-
-
-
-
-
- |
-
-
-
-
- Option not available
+
+
+
-
-
-
- 1 5
-
-
- |
-
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+ Option not available
+
+
+ Add to cart
+
+ 1 5
+
+
+ |
+
+
-
-
-
-
-
- -
+
+
+
+
+ -
-
+
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
+
+
+
diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml
index 619006e3c28..582c23404bf 100644
--- a/addons/sale/views/sale_views.xml
+++ b/addons/sale/views/sale_views.xml
@@ -6,6 +6,7 @@
name="Sales"
web_icon="sale,static/description/icon.png"
active="False"
+ groups="sales_team.group_sale_salesman,sales_team.group_sale_salesman_all_leads,sales_team.group_sale_manager"
sequence="7"/>
diff --git a/addons/sale_quotation_builder/__manifest__.py b/addons/sale_quotation_builder/__manifest__.py
index ab2a9883bad..eab71371c05 100644
--- a/addons/sale_quotation_builder/__manifest__.py
+++ b/addons/sale_quotation_builder/__manifest__.py
@@ -13,6 +13,7 @@
'data/sale_order_template_data.xml',
'views/sale_portal_templates.xml',
'views/sale_order_template_views.xml',
+ 'views/res_config_settings_views.xml',
'views/sale_order_views.xml',
],
'installable': True,
diff --git a/addons/sale_quotation_builder/views/res_config_settings_views.xml b/addons/sale_quotation_builder/views/res_config_settings_views.xml
new file mode 100644
index 00000000000..0894b145e34
--- /dev/null
+++ b/addons/sale_quotation_builder/views/res_config_settings_views.xml
@@ -0,0 +1,11 @@
+
+
+
+ res.config.settings.view.form.inherit.sale.management.inherit.sale.quotation.builder
+ res.config.settings
+
+
+
+
+
+
diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py
index 430c3cc52d9..4c2ad3262d3 100644
--- a/addons/sale_stock/models/sale_order.py
+++ b/addons/sale_stock/models/sale_order.py
@@ -285,7 +285,14 @@ class SaleOrderLine(models.Model):
@api.onchange('product_uom_qty')
def _onchange_product_uom_qty(self):
- if self.state == 'sale' and self.product_id.type in ['product', 'consu'] and self.product_uom_qty < self._origin.product_uom_qty:
+ # When modifying a one2many, _origin doesn't guarantee that its values will be the ones
+ # in database. Hence, we need to explicitly read them from there.
+ if self._origin:
+ product_uom_qty_origin = self._origin.read(["product_uom_qty"])[0]["product_uom_qty"]
+ else:
+ product_uom_qty_origin = 0
+
+ if self.state == 'sale' and self.product_id.type in ['product', 'consu'] and self.product_uom_qty < product_uom_qty_origin:
# Do not display this warning if the new quantity is below the delivered
# one; the `write` will raise an `UserError` anyway.
if self.product_uom_qty < self.qty_delivered:
diff --git a/addons/sale_stock/static/src/js/tour.js b/addons/sale_stock/static/src/js/tour.js
index 245a25db2b2..d27080305d5 100644
--- a/addons/sale_stock/static/src/js/tour.js
+++ b/addons/sale_stock/static/src/js/tour.js
@@ -4,17 +4,26 @@ odoo.define("sale_stock.tour", function (require) {
var tour = require('web_tour.tour');
require('sale.tour');
- var quotation_product_selected_step_index = _.findIndex(tour.tours.sale_tour.steps, function (step) {
+ var quotation_product_selected_step_index = _.findIndex(tour.tours.sale_product_configurator_tour.steps, function (step) {
return (step.id === "quotation_product_selected");
});
- tour.tours.sale_tour.steps[quotation_product_selected_step_index].run = function (actions) {
+ tour.tours.sale_product_configurator_tour.steps[quotation_product_selected_step_index].run = function (actions) {
actions.auto();
};
- tour.tours.sale_tour.steps.splice(quotation_product_selected_step_index+1, 0, {
- trigger: ".modal-footer .btn-primary",
+ tour.tours.sale_product_configurator_tour.steps.splice(quotation_product_selected_step_index+1, 0, {
+ trigger: ".o_dialog_warning + .modal-footer .btn-primary",
auto: true,
+ run: function (actions) {
+ actions.auto('.o_dialog_warning + .modal-footer .btn-primary');
+ }
+ }, {
+ trigger: ".o_dialog_warning + .modal-footer .btn-primary",
+ auto: true,
+ run: function (actions) {
+ actions.auto('.o_dialog_warning + .modal-footer .btn-primary');
+ }
}, {
trigger: "body:not(:has(.o_dialog_warning))",
auto: true,
@@ -25,4 +34,18 @@ odoo.define("sale_stock.tour", function (require) {
}
},
});
+
+ // Check if sale_management is installed since sale_stock is adding an extra
+ // step to add to SO (not enough inventory)
+ if ('sale.product_configurator_pricelist_tour' in odoo.__DEBUG__.services) {
+ var steps = tour.tours.sale_product_configurator_pricelist_tour.steps;
+ for (var k=0; k div:first-child {
+ padding-left: 10mm;
+ }
}
.article>.page {
> h1, > h2, > h3 {
width: 50%;
}
}
-.article {
- > .address.row {
- padding-top: 95px;
- }
- > .page {
- margin-top:-20px;
+.article > .address.row > div[name="address"] {
+ height: 65mm;
+ background-color: #ffffff;
+ padding-top: 23mm;
+ padding-left: 5mm;
+ padding-bottom: 5mm;
+ left: -20px !important;
+ address {
+ height: 28mm;
}
}
diff --git a/addons/snailmail/views/report_assets.xml b/addons/snailmail/views/report_assets.xml
index 9ede269e291..20c2d2c1369 100644
--- a/addons/snailmail/views/report_assets.xml
+++ b/addons/snailmail/views/report_assets.xml
@@ -3,6 +3,7 @@
+
diff --git a/addons/snailmail_account/__init__.py b/addons/snailmail_account/__init__.py
index 35e7c9600c5..d22fb1f7f36 100644
--- a/addons/snailmail_account/__init__.py
+++ b/addons/snailmail_account/__init__.py
@@ -2,3 +2,4 @@
from . import models
from . import wizard
+from . import tests
diff --git a/addons/snailmail_account/tests/__init__.py b/addons/snailmail_account/tests/__init__.py
new file mode 100644
index 00000000000..b81c3a2d614
--- /dev/null
+++ b/addons/snailmail_account/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_pingen_send
diff --git a/addons/snailmail_account/tests/test_pingen_send.py b/addons/snailmail_account/tests/test_pingen_send.py
new file mode 100644
index 00000000000..ce73f388274
--- /dev/null
+++ b/addons/snailmail_account/tests/test_pingen_send.py
@@ -0,0 +1,96 @@
+
+import requests
+import json
+import base64
+
+from odoo.addons.account.tests.account_test_classes import AccountingTestCase
+from odoo.tests import tagged
+
+@tagged('post_install', '-at_install', '-standard', 'external')
+class TestPingenSend(AccountingTestCase):
+
+ def setUp(self):
+ super(TestPingenSend, self).setUp()
+ self.pingen_url = "https://stage-api.pingen.com/document/upload/token/30fc3947dbea4792eb12548b41ec8117/"
+ self.sample_invoice = self.create_invoice()
+ self.sample_invoice.partner_id.vat = "BE000000000"
+ self.letter = self.env['snailmail.letter'].create({
+ 'partner_id': self.sample_invoice.partner_id.id,
+ 'model': 'account.invoice',
+ 'res_id': self.sample_invoice.id,
+ 'user_id': self.env.user.id,
+ 'company_id': self.sample_invoice.company_id.id,
+ 'report_template': self.env.ref('account.account_invoices').id
+ })
+ self.data = {
+ 'data': json.dumps({
+ 'speed': 1,
+ 'color': 2,
+ 'duplex': 0,
+ 'send': True,
+ })
+ }
+
+ def create_invoice(self):
+ """ Create a sample invoice """
+ currency = self.env.ref('base.EUR')
+ partner_agrolait = self.env.ref("base.res_partner_2")
+ product = self.env.ref("product.product_product_4")
+
+ account_receivable = self.env['account.account'].create({
+ 'code': 'TESTPINGEN1',
+ 'name': 'Test Receivable Account',
+ 'user_type_id': self.env.ref('account.data_account_type_receivable').id,
+ 'reconcile': True
+ })
+ account_income = self.env['account.account'].create({
+ 'code': 'TESTPINGEN2',
+ 'name': 'Test Account',
+ 'user_type_id': self.env.ref('account.data_account_type_direct_costs').id
+ })
+
+ invoice = self.env['account.invoice'].create({
+ 'partner_id': partner_agrolait.id,
+ 'currency_id': currency.id,
+ 'name': 'invoice to client',
+ 'account_id': account_receivable.id,
+ 'type': 'out_invoice',
+ 'date_invoice': '2018-12-11',
+ })
+
+ self.env['account.invoice.line'].create({
+ 'product_id': product.id,
+ 'quantity': 1,
+ 'price_unit': 42,
+ 'invoice_id': invoice.id,
+ 'name': 'something',
+ 'account_id': account_income.id,
+ })
+
+ invoice.action_invoice_open()
+
+ return invoice
+
+ def render_and_send(self, report_name):
+ self.sample_invoice.company_id.external_report_layout_id = self.env.ref('web.' + report_name)
+ self.letter.attachment_id = False
+ attachment_id = self.letter.with_context(force_report_rendering=True)._fetch_attachment()
+
+ files = {
+ 'file': ('pingen_test_%s.pdf' % report_name, base64.b64decode(attachment_id.datas), 'application/pdf'),
+ }
+
+ response = requests.post(self.pingen_url, data=self.data, files=files)
+
+ try:
+ response.raise_for_status()
+ except:
+ return False
+
+ return True
+
+ def test_pingen_send_invoice(self):
+ self.assertTrue(self.render_and_send('external_layout_standard'))
+ self.assertTrue(self.render_and_send('external_layout_background'))
+ self.assertTrue(self.render_and_send('external_layout_boxed'))
+ self.assertTrue(self.render_and_send('external_layout_clean'))
diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py
index b0416c1a2ae..263476c140e 100644
--- a/addons/stock/models/stock_inventory.py
+++ b/addons/stock/models/stock_inventory.py
@@ -425,7 +425,7 @@ class InventoryLine(models.Model):
"""
for line in self:
if line.product_id.type != 'product':
- raise UserError(_("You can only adjust storable products."))
+ raise UserError(_("You can only adjust storable products.") + '\n\n%s -> %s' % (line.product_id.display_name, line.product_id.type))
def _get_move_values(self, qty, location_id, location_dest_id, out):
self.ensure_one()
diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py
index 4c02adf6400..83e979d8de4 100644
--- a/addons/stock/models/stock_move.py
+++ b/addons/stock/models/stock_move.py
@@ -512,7 +512,7 @@ class StockMove(models.Model):
rules = self.env['procurement.group']._search_rule(move.route_ids, move.product_id, warehouse_id, domain)
# Make sure it is not returning the return
- if rules and (not move.origin_returned_move_id or move.origin_returned_move_id.location_dest_id.id != rules.location_dest_id.id):
+ if rules and (not move.origin_returned_move_id or move.origin_returned_move_id.location_dest_id.id != rules.location_id.id):
rules._run_push(move)
def _merge_moves_fields(self):
diff --git a/addons/stock/report/report_location_barcode.xml b/addons/stock/report/report_location_barcode.xml
index 7cf706d0467..a1f81d50aac 100644
--- a/addons/stock/report/report_location_barcode.xml
+++ b/addons/stock/report/report_location_barcode.xml
@@ -10,7 +10,7 @@
-
+
diff --git a/addons/stock/static/src/js/stock_traceability_report_widgets.js b/addons/stock/static/src/js/stock_traceability_report_widgets.js
index 65e9d4d5b2f..7a52ea100c1 100644
--- a/addons/stock/static/src/js/stock_traceability_report_widgets.js
+++ b/addons/stock/static/src/js/stock_traceability_report_widgets.js
@@ -49,11 +49,10 @@ var ReportWidget = Widget.extend({
},
updownStream: function(e) {
var $el = $(e.target).parents('tr');
- var string = "Traceability Report";
this.do_action({
type: "ir.actions.client",
tag: 'stock_report_generic',
- name: _t(string),
+ name: _t("Traceability Report"),
context: {
active_id : $el.data('model_id'),
active_model : $el.data('model'),
diff --git a/addons/stock/wizard/product_replenish.py b/addons/stock/wizard/product_replenish.py
index ced229de126..9b743b5116e 100644
--- a/addons/stock/wizard/product_replenish.py
+++ b/addons/stock/wizard/product_replenish.py
@@ -72,7 +72,7 @@ class ProductReplenish(models.TransientModel):
values = {
'warehouse_id': self.warehouse_id,
'route_ids': self.route_ids,
- 'date_planned': self.date_planned,
+ 'date_planned': self.date_planned or fields.Datetime.now(),
'group_id': replenishment,
}
return values
diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py
index c0d05ebbd0d..3f362f29989 100644
--- a/addons/stock_account/models/stock.py
+++ b/addons/stock_account/models/stock.py
@@ -18,7 +18,7 @@ class StockInventory(models.Model):
'Accounting Date',
help="Date at which the accounting entries will be created"
" in case of automated inventory valuation."
- " If empty, the inventoy date will be used.")
+ " If empty, the inventory date will be used.")
@api.multi
def post_inventory(self):
@@ -306,18 +306,20 @@ class StockMove(models.Model):
vals = {}
price_unit = self._get_price_unit()
value = price_unit * (quantity or valued_quantity)
+ value_to_return = value if quantity is None or not self.value else self.value
vals = {
'price_unit': price_unit,
- 'value': value if quantity is None or not self.value else self.value,
+ 'value': value_to_return,
'remaining_value': value if quantity is None else self.remaining_value + value,
}
vals['remaining_qty'] = valued_quantity if quantity is None else self.remaining_qty + quantity
if self.product_id.cost_method == 'standard':
value = self.product_id.standard_price * (quantity or valued_quantity)
+ value_to_return = value if quantity is None or not self.value else self.value
vals.update({
'price_unit': self.product_id.standard_price,
- 'value': value if quantity is None or not self.value else self.value,
+ 'value': value_to_return,
})
self.write(vals)
elif self._is_out():
@@ -329,8 +331,9 @@ class StockMove(models.Model):
if self.product_id.cost_method in ['standard', 'average']:
curr_rounding = self.company_id.currency_id.rounding
value = -float_round(self.product_id.standard_price * (valued_quantity if quantity is None else quantity), precision_rounding=curr_rounding)
+ value_to_return = value if quantity is None else self.value + value
self.write({
- 'value': value if quantity is None else self.value + value,
+ 'value': value_to_return,
'price_unit': value / valued_quantity,
})
elif self._is_dropshipped() or self._is_dropshipped_returned():
@@ -342,12 +345,14 @@ class StockMove(models.Model):
else:
price_unit = self.product_id.standard_price
value = float_round(self.product_qty * price_unit, precision_rounding=curr_rounding)
+ value_to_return = value if self._is_dropshipped() else -value
# In move have a positive value, out move have a negative value, let's arbitrary say
# dropship are positive.
self.write({
- 'value': value if self._is_dropshipped() else -value,
+ 'value': value_to_return,
'price_unit': price_unit if self._is_dropshipped() else -price_unit,
})
+ return value_to_return
def _action_done(self, cancel_backorder=False):
self.product_price_update_before_done()
diff --git a/addons/stock_picking_batch/wizard/stock_picking_to_batch.py b/addons/stock_picking_batch/wizard/stock_picking_to_batch.py
index ddf02ffc172..4739e39670e 100644
--- a/addons/stock_picking_batch/wizard/stock_picking_to_batch.py
+++ b/addons/stock_picking_batch/wizard/stock_picking_to_batch.py
@@ -8,7 +8,7 @@ class StockPickingToBatch(models.TransientModel):
_name = 'stock.picking.to.batch'
_description = 'Batch Picking Lines'
- batch_id = fields.Many2one('stock.picking.batch', string='Batch Picking', required=True, oldname="wave_id")
+ batch_id = fields.Many2one('stock.picking.batch', string='Batch Picking', oldname="wave_id")
@api.multi
def attach_pickings(self):
diff --git a/addons/stock_picking_batch/wizard/stock_picking_to_batch_views.xml b/addons/stock_picking_batch/wizard/stock_picking_to_batch_views.xml
index da393c44972..b3b7b9a0108 100644
--- a/addons/stock_picking_batch/wizard/stock_picking_to_batch_views.xml
+++ b/addons/stock_picking_batch/wizard/stock_picking_to_batch_views.xml
@@ -8,7 +8,7 @@