330 lines
15 KiB
Python
330 lines
15 KiB
Python
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||
|
|
||
|
from collections import defaultdict
|
||
|
|
||
|
from odoo import _, api, fields, models
|
||
|
from odoo.exceptions import ValidationError
|
||
|
from odoo.tools import float_round, format_list
|
||
|
|
||
|
from odoo.addons.base.models.res_partner import WARNING_HELP, WARNING_MESSAGE
|
||
|
|
||
|
|
||
|
class ProductTemplate(models.Model):
|
||
|
_inherit = 'product.template'
|
||
|
_check_company_auto = True
|
||
|
|
||
|
service_type = fields.Selection(
|
||
|
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, string="Sales Order Line",
|
||
|
help=WARNING_HELP, required=True, default="no-message")
|
||
|
sale_line_warn_msg = fields.Text(string="Message for Sales Order Line")
|
||
|
expense_policy = fields.Selection(
|
||
|
selection=[
|
||
|
('no', "No"),
|
||
|
('cost', "At cost"),
|
||
|
('sales_price', "Sales price"),
|
||
|
],
|
||
|
string="Re-Invoice Costs", default='no',
|
||
|
compute='_compute_expense_policy', store=True, readonly=False,
|
||
|
help="Validated expenses, vendor bills, or stock pickings (set up to track costs) can be invoiced to the customer at either cost or sales price.")
|
||
|
visible_expense_policy = fields.Boolean(
|
||
|
string="Re-Invoice Policy visible", compute='_compute_visible_expense_policy')
|
||
|
sales_count = fields.Float(
|
||
|
string="Sold", compute='_compute_sales_count', digits='Product Unit of Measure')
|
||
|
invoice_policy = fields.Selection(
|
||
|
selection=[
|
||
|
('order', "Ordered quantities"),
|
||
|
('delivery', "Delivered quantities"),
|
||
|
],
|
||
|
string="Invoicing Policy",
|
||
|
compute='_compute_invoice_policy',
|
||
|
precompute=True,
|
||
|
store=True,
|
||
|
readonly=False,
|
||
|
tracking=True,
|
||
|
help="Ordered Quantity: Invoice quantities ordered by the customer.\n"
|
||
|
"Delivered Quantity: Invoice quantities delivered to the customer.")
|
||
|
optional_product_ids = fields.Many2many(
|
||
|
comodel_name='product.template',
|
||
|
relation='product_optional_rel',
|
||
|
column1='src_id',
|
||
|
column2='dest_id',
|
||
|
string="Optional Products",
|
||
|
help="Optional Products are suggested "
|
||
|
"whenever the customer hits *Add to Cart* (cross-sell strategy, "
|
||
|
"e.g. for computers: warranty, software, etc.).",
|
||
|
check_company=True)
|
||
|
|
||
|
@api.depends('invoice_policy', 'sale_ok', 'service_tracking')
|
||
|
def _compute_product_tooltip(self):
|
||
|
super()._compute_product_tooltip()
|
||
|
|
||
|
def _prepare_tooltip(self):
|
||
|
tooltip = super()._prepare_tooltip()
|
||
|
if not self.sale_ok:
|
||
|
return tooltip
|
||
|
|
||
|
invoicing_tooltip = self._prepare_invoicing_tooltip()
|
||
|
|
||
|
tooltip = f'{tooltip} {invoicing_tooltip}' if tooltip else invoicing_tooltip
|
||
|
|
||
|
if self.type == 'service':
|
||
|
additional_tooltip = self._prepare_service_tracking_tooltip()
|
||
|
tooltip = f'{tooltip} {additional_tooltip}' if additional_tooltip else tooltip
|
||
|
|
||
|
return tooltip
|
||
|
|
||
|
def _prepare_invoicing_tooltip(self):
|
||
|
if self.invoice_policy == 'delivery':
|
||
|
return _("Invoice after delivery, based on quantities delivered, not ordered.")
|
||
|
elif self.invoice_policy == 'order':
|
||
|
if self.type == 'consu':
|
||
|
return _("You can invoice goods before they are delivered.")
|
||
|
elif self.type == 'service':
|
||
|
return _("Invoice ordered quantities as soon as this service is sold.")
|
||
|
return ""
|
||
|
|
||
|
def _prepare_service_tracking_tooltip(self):
|
||
|
return ""
|
||
|
|
||
|
@api.depends('sale_ok')
|
||
|
def _compute_service_tracking(self):
|
||
|
super()._compute_service_tracking()
|
||
|
self.filtered(lambda pt: not pt.sale_ok).service_tracking = 'no'
|
||
|
|
||
|
@api.depends('purchase_ok')
|
||
|
def _compute_visible_expense_policy(self):
|
||
|
visibility = self.env.user.has_group('analytic.group_analytic_accounting')
|
||
|
for product_template in self:
|
||
|
product_template.visible_expense_policy = visibility and product_template.purchase_ok
|
||
|
|
||
|
@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_compagny = 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_compagny[product.company_id] |= product
|
||
|
|
||
|
for target_company, products in products_by_compagny.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', 'child_of', 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,
|
||
|
'search_default_group_by_date': 1,
|
||
|
}
|
||
|
return action
|
||
|
|
||
|
@api.onchange('type')
|
||
|
def _onchange_type(self):
|
||
|
res = super()._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'
|
||
|
|
||
|
def _get_backend_root_menu_ids(self):
|
||
|
return super()._get_backend_root_menu_ids() + [self.env.ref('sale.sale_menu_root').id]
|
||
|
|
||
|
@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.env.user.has_group('product.group_product_pricelist'):
|
||
|
return [{
|
||
|
'label': _("Import Template for Products"),
|
||
|
'template': '/product/static/xls/product_template.xls'
|
||
|
}]
|
||
|
return res
|
||
|
|
||
|
@api.model
|
||
|
def _get_incompatible_types(self):
|
||
|
return []
|
||
|
|
||
|
@api.constrains(lambda self: self._get_incompatible_types())
|
||
|
def _check_incompatible_types(self):
|
||
|
incompatible_types = self._get_incompatible_types()
|
||
|
if len(incompatible_types) < 2:
|
||
|
return
|
||
|
fields = self.env['ir.model.fields'].sudo().search_read(
|
||
|
[('model', '=', 'product.template'), ('name', 'in', incompatible_types)],
|
||
|
['name', 'field_description'])
|
||
|
field_descriptions = {v['name']: v['field_description'] for v in fields}
|
||
|
field_list = incompatible_types + ['name']
|
||
|
values = self.read(field_list)
|
||
|
for val in values:
|
||
|
incompatible_fields = [f for f in incompatible_types if val[f]]
|
||
|
if len(incompatible_fields) > 1:
|
||
|
raise ValidationError(_(
|
||
|
"The product (%(product)s) has incompatible values: %(value_list)s",
|
||
|
product=val['name'],
|
||
|
value_list=format_list(self.env, [field_descriptions[v] for v in incompatible_fields]),
|
||
|
))
|
||
|
|
||
|
def get_single_product_variant(self):
|
||
|
""" Method used by the product configurator to check if the product is configurable or not.
|
||
|
|
||
|
We need to open the product configurator if the product:
|
||
|
- is configurable (see has_configurable_attributes)
|
||
|
- has optional products """
|
||
|
res = super().get_single_product_variant()
|
||
|
if res.get('product_id', False):
|
||
|
has_optional_products = False
|
||
|
for optional_product in self.product_variant_id.optional_product_ids:
|
||
|
if optional_product.has_dynamic_attributes() or optional_product._get_possible_variants(
|
||
|
self.product_variant_id.product_template_attribute_value_ids
|
||
|
):
|
||
|
has_optional_products = True
|
||
|
break
|
||
|
res.update({
|
||
|
'has_optional_products': has_optional_products,
|
||
|
'is_combo': self.type == 'combo',
|
||
|
})
|
||
|
if self.sale_line_warn != 'no-message':
|
||
|
res['sale_warning'] = {
|
||
|
'type': self.sale_line_warn,
|
||
|
'title': _("Warning for %s", self.name),
|
||
|
'message': self.sale_line_warn_msg,
|
||
|
}
|
||
|
return res
|
||
|
|
||
|
@api.model
|
||
|
def _get_saleable_tracking_types(self):
|
||
|
"""Return list of salealbe service_tracking types.
|
||
|
|
||
|
:rtype: list
|
||
|
"""
|
||
|
return ['no']
|
||
|
|
||
|
def _get_product_accounts(self):
|
||
|
product_accounts = super()._get_product_accounts()
|
||
|
product_accounts['downpayment'] = self.categ_id.property_account_downpayment_categ_id
|
||
|
return product_accounts
|
||
|
|
||
|
####################################
|
||
|
# Product/combo configurator hooks #
|
||
|
####################################
|
||
|
|
||
|
@api.model
|
||
|
def _get_configurator_display_price(
|
||
|
self, product_or_template, quantity, date, currency, pricelist, **kwargs
|
||
|
):
|
||
|
""" Return the specified product's display price, to be used by the product and combo
|
||
|
configurators.
|
||
|
|
||
|
This is a hook meant to customize the display price computation in overriding modules.
|
||
|
|
||
|
:param product.product|product.template product_or_template: The product for which to get
|
||
|
the price.
|
||
|
:param int quantity: The quantity of the product.
|
||
|
:param datetime date: The date to use to compute the price.
|
||
|
:param res.currency currency: The currency to use to compute the price.
|
||
|
:param product.pricelist pricelist: The pricelist to use to compute the price.
|
||
|
:param dict kwargs: Locally unused data passed to `_get_configurator_price`.
|
||
|
:rtype: tuple(float, int or False)
|
||
|
:return: The specified product's display price (and the applied pricelist rule)
|
||
|
"""
|
||
|
return self._get_configurator_price(
|
||
|
product_or_template, quantity, date, currency, pricelist, **kwargs
|
||
|
)
|
||
|
|
||
|
@api.model
|
||
|
def _get_configurator_price(
|
||
|
self, product_or_template, quantity, date, currency, pricelist, **kwargs
|
||
|
):
|
||
|
""" Return the specified product's price, to be used by the product and combo configurators.
|
||
|
|
||
|
This is a hook meant to customize the price computation in overriding modules.
|
||
|
|
||
|
This hook has been extracted from `_get_configurator_display_price` because the price
|
||
|
computation can be overridden in 2 ways:
|
||
|
|
||
|
- Either by transforming super's price (e.g. in `website_sale`, we apply taxes to the
|
||
|
price),
|
||
|
- Or by computing a different price (e.g. in `sale_subscription`, we ignore super when
|
||
|
computing subscription prices).
|
||
|
In some cases, the order of the overrides matters, which is why we need 2 separate methods
|
||
|
(e.g. in `website_sale_subscription`, we must compute the subscription price before applying
|
||
|
taxes).
|
||
|
|
||
|
:param product.product|product.template product_or_template: The product for which to get
|
||
|
the price.
|
||
|
:param int quantity: The quantity of the product.
|
||
|
:param datetime date: The date to use to compute the price.
|
||
|
:param res.currency currency: The currency to use to compute the price.
|
||
|
:param product.pricelist pricelist: The pricelist to use to compute the price.
|
||
|
:param dict kwargs: Locally unused data passed to `_get_product_price`.
|
||
|
:rtype: tuple(float, int or False)
|
||
|
:return: The specified product's price (and the applied pricelist rule)
|
||
|
"""
|
||
|
return pricelist._get_product_price_rule(
|
||
|
product_or_template, quantity=quantity, currency=currency, date=date, **kwargs
|
||
|
)
|
||
|
|
||
|
@api.model
|
||
|
def _get_additional_configurator_data(
|
||
|
self, product_or_template, date, currency, pricelist, **kwargs
|
||
|
):
|
||
|
""" Return additional data about the specified product, to be used by the product and combo
|
||
|
configurators.
|
||
|
|
||
|
This is a hook meant to append module-specific data in overriding modules.
|
||
|
|
||
|
:param product.product|product.template product_or_template: The product for which to get
|
||
|
additional data.
|
||
|
:param datetime date: The date to use to compute prices.
|
||
|
:param res.currency currency: The currency to use to compute prices.
|
||
|
:param product.pricelist pricelist: The pricelist to use to compute prices.
|
||
|
:param dict kwargs: Locally unused data passed to overrides.
|
||
|
:rtype: dict
|
||
|
:return: A dict containing additional data about the specified product.
|
||
|
"""
|
||
|
return {}
|