diff options
Diffstat (limited to 'addons/sale/models/product_template.py')
| -rw-r--r-- | addons/sale/models/product_template.py | 294 |
1 files changed, 294 insertions, 0 deletions
diff --git a/addons/sale/models/product_template.py b/addons/sale/models/product_template.py new file mode 100644 index 00000000..25c6aaa1 --- /dev/null +++ b/addons/sale/models/product_template.py @@ -0,0 +1,294 @@ +# -*- 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.exceptions import ValidationError +from odoo.tools.float_utils import float_round + +_logger = logging.getLogger(__name__) + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + def _default_visible_expense_policy(self): + return self.user_has_groups('analytic.group_analytic_accounting') + + service_type = fields.Selection([('manual', 'Manually set quantities on order')], string='Track Service', + help="Manually set quantities on order: Invoice based on the manually entered quantity, without creating an analytic account.\n" + "Timesheets on contract: Invoice based on the tracked hours on the related timesheet.\n" + "Create a task and track hours: Create a task on the sales order validation and track the work hours.", + default='manual') + sale_line_warn = fields.Selection(WARNING_MESSAGE, 'Sales Order Line', help=WARNING_HELP, required=True, default="no-message") + sale_line_warn_msg = fields.Text('Message for Sales Order Line') + expense_policy = fields.Selection( + [('no', 'No'), ('cost', 'At cost'), ('sales_price', 'Sales price')], + string='Re-Invoice Expenses', + default='no', + help="Expenses and vendor bills can be re-invoiced to a customer." + "With this option, a validated expense can be re-invoice to a customer at its cost or sales price.") + visible_expense_policy = fields.Boolean("Re-Invoice Policy visible", compute='_compute_visible_expense_policy', default=lambda self: self._default_visible_expense_policy()) + sales_count = fields.Float(compute='_compute_sales_count', string='Sold') + visible_qty_configurator = fields.Boolean("Quantity visible in configurator", compute='_compute_visible_qty_configurator') + invoice_policy = fields.Selection([ + ('order', 'Ordered quantities'), + ('delivery', 'Delivered quantities')], string='Invoicing Policy', + help='Ordered Quantity: Invoice quantities ordered by the customer.\n' + 'Delivered Quantity: Invoice quantities delivered to the customer.', + default='order') + + def _compute_visible_qty_configurator(self): + for product_template in self: + product_template.visible_qty_configurator = True + + @api.depends('name') + def _compute_visible_expense_policy(self): + visibility = self.user_has_groups('analytic.group_analytic_accounting') + for product_template in self: + product_template.visible_expense_policy = visibility + + + @api.onchange('sale_ok') + def _change_sale_ok(self): + if not self.sale_ok: + self.expense_policy = 'no' + + @api.depends('product_variant_ids.sales_count') + def _compute_sales_count(self): + for product in self: + product.sales_count = float_round(sum([p.sales_count for p in product.with_context(active_test=False).product_variant_ids]), precision_rounding=product.uom_id.rounding) + + + @api.constrains('company_id') + def _check_sale_product_company(self): + """Ensure the product is not being restricted to a single company while + having been sold in another one in the past, as this could cause issues.""" + target_company = self.company_id + if target_company: # don't prevent writing `False`, should always work + product_data = self.env['product.product'].sudo().with_context(active_test=False).search_read([('product_tmpl_id', 'in', self.ids)], fields=['id']) + product_ids = list(map(lambda p: p['id'], product_data)) + so_lines = self.env['sale.order.line'].sudo().search_read([('product_id', 'in', product_ids), ('company_id', '!=', target_company.id)], fields=['id', 'product_id']) + used_products = list(map(lambda sol: sol['product_id'][1], so_lines)) + if so_lines: + raise ValidationError(_('The following products cannot be restricted to the company' + ' %s because they have already been used in quotations or ' + 'sales orders in another company:\n%s\n' + 'You can archive these products and recreate them ' + 'with your company restriction instead, or leave them as ' + 'shared product.') % (target_company.name, ', '.join(used_products))) + + def action_view_sales(self): + action = self.env["ir.actions.actions"]._for_xml_id("sale.report_all_channels_sales_action") + action['domain'] = [('product_tmpl_id', 'in', self.ids)] + action['context'] = { + 'pivot_measures': ['product_uom_qty'], + 'active_id': self._context.get('active_id'), + 'active_model': 'sale.report', + 'search_default_Sales': 1, + 'time_ranges': {'field': 'date', 'range': 'last_365_days'} + } + return action + + 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): + """ Force values to stay consistent with integrity constraints """ + res = super(ProductTemplate, self)._onchange_type() + if self.type == 'consu': + if not self.invoice_policy: + self.invoice_policy = 'order' + self.service_type = 'manual' + return res + + @api.model + def get_import_templates(self): + res = super(ProductTemplate, self).get_import_templates() + if self.env.context.get('sale_multi_pricelist_product_template'): + if self.user_has_groups('product.group_sale_pricelist'): + return [{ + 'label': _('Import Template for Products'), + 'template': '/product/static/xls/product_template.xls' + }] + return res + + def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=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. + + :param only_template: boolean, if set to True, get the info for the + template only: ignore combination and don't try to find variant + + :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() + # get the name before the change of context to benefit from prefetch + display_name = self.display_name + + display_image = True + 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 and not only_template: + combination = product_template._get_first_possible_combination(parent_combination) + + if only_template: + product = product_template.env['product.product'] + elif 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=tuple(no_variant_attributes_price_extra) + ) + list_price = product.price_compute('list_price')[product.id] + price = product.price if pricelist else list_price + display_image = bool(product.image_1920) + display_name = product.display_name + 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_image = bool(product_template.image_1920) + + combination_name = combination._get_combination_name() + if combination_name: + display_name = "%s (%s)" % (display_name, combination_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._get_current_company(pricelist=pricelist), + 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, + 'display_image': display_image, + 'price': price, + 'list_price': list_price, + 'has_discounted_price': has_discounted_price, + } + + 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. + + :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: + # for performance: avoid calling `_get_possible_combinations` + return False + return next(self._get_possible_combinations(parent_combination), False) is not False + + def _get_current_company_fallback(self, **kwargs): + """Override: if a pricelist is given, fallback to the company of the + pricelist if it is set, otherwise use the one from parent method.""" + res = super(ProductTemplate, self)._get_current_company_fallback(**kwargs) + pricelist = kwargs.get('pricelist') + return pricelist and pricelist.company_id or res |
