330 lines
16 KiB
Python
330 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from collections import defaultdict
|
|
|
|
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'
|
|
|
|
service_type = fields.Selection(
|
|
[('manual', 'Manually set quantities on order')], string='Track Service',
|
|
compute='_compute_service_type', store=True, readonly=False, precompute=True,
|
|
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.")
|
|
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',
|
|
compute='_compute_expense_policy', store=True, readonly=False,
|
|
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')
|
|
sales_count = fields.Float(compute='_compute_sales_count', string='Sold', digits='Product Unit of Measure')
|
|
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',
|
|
compute='_compute_invoice_policy', store=True, readonly=False, precompute=True,
|
|
help='Ordered Quantity: Invoice quantities ordered by the customer.\n'
|
|
'Delivered Quantity: Invoice quantities delivered to the customer.')
|
|
|
|
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.depends('sale_ok')
|
|
def _compute_expense_policy(self):
|
|
self.filtered(lambda t: not t.sale_ok).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."""
|
|
products_by_company = defaultdict(lambda: self.env['product.template'])
|
|
for product in self:
|
|
if not product.product_variant_ids or not product.company_id:
|
|
# No need to check if the product has just being created (`product_variant_ids` is
|
|
# still empty) or if we're writing `False` on its company (should always work.)
|
|
continue
|
|
products_by_company[product.company_id] |= product
|
|
|
|
for target_company, products in products_by_company.items():
|
|
subquery_products = self.env['product.product'].sudo().with_context(active_test=False)._search([('product_tmpl_id', 'in', products.ids)])
|
|
so_lines = self.env['sale.order.line'].sudo().search_read(
|
|
[('product_id', 'in', subquery_products), ('company_id', '!=', target_company.id)],
|
|
fields=['id', 'product_id']
|
|
)
|
|
if so_lines:
|
|
used_products = [sol['product_id'][1] for sol in so_lines]
|
|
raise ValidationError(_('The following products cannot be restricted to the company'
|
|
' %(company)s because they have already been used in quotations or '
|
|
'sales orders in another company:\n%(used_products)s\n'
|
|
'You can archive these products and recreate them '
|
|
'with your company restriction instead, or leave them as '
|
|
'shared product.', company=target_company.name, used_products=', '.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,
|
|
'search_default_filter_order_date': 1,
|
|
}
|
|
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: 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(product_template_attribute_value_ids)
|
|
|
|
return self._create_product_variant(combination, log_warning=True).id or 0
|
|
|
|
@api.onchange('type')
|
|
def _onchange_type(self):
|
|
res = super(ProductTemplate, self)._onchange_type()
|
|
if self._origin and self.sales_count > 0:
|
|
res['warning'] = {
|
|
'title': _("Warning"),
|
|
'message': _("You cannot change the product's type because it is already used in sales orders.")
|
|
}
|
|
return res
|
|
|
|
@api.depends('type')
|
|
def _compute_service_type(self):
|
|
self.filtered(lambda t: t.type == 'consu' or not t.service_type).service_type = 'manual'
|
|
|
|
@api.depends('type')
|
|
def _compute_invoice_policy(self):
|
|
self.filtered(lambda t: t.type == 'consu' or not t.invoice_policy).invoice_policy = 'order'
|
|
|
|
@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)
|
|
product_template = self
|
|
|
|
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]
|
|
if pricelist:
|
|
price = pricelist._get_product_price(product, quantity)
|
|
else:
|
|
price = list_price
|
|
display_image = bool(product.image_128)
|
|
display_name = product.display_name
|
|
price_extra = (product.price_extra or 0.0) + (sum(no_variant_attributes_price_extra) or 0.0)
|
|
else:
|
|
current_attributes_price_extra = [v.price_extra or 0.0 for v in combination]
|
|
product_template = product_template.with_context(current_attributes_price_extra=current_attributes_price_extra)
|
|
price_extra = sum(current_attributes_price_extra)
|
|
list_price = product_template.price_compute('list_price')[product_template.id]
|
|
if pricelist:
|
|
price = pricelist._get_product_price(product_template, quantity)
|
|
else:
|
|
price = list_price
|
|
display_image = bool(product_template.image_128)
|
|
|
|
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_extra = product_template.currency_id._convert(
|
|
price_extra, 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,
|
|
'price_extra': price_extra,
|
|
'has_discounted_price': has_discounted_price,
|
|
}
|
|
|
|
def _can_be_added_to_cart(self):
|
|
"""
|
|
Pre-check to `_is_add_to_cart_possible` to know if product can be sold.
|
|
"""
|
|
return self.sale_ok
|
|
|
|
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 or not self._can_be_added_to_cart():
|
|
# 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
|