mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-27 13:52:00 +02:00
Initial commit: Sale packages
This commit is contained in:
commit
14e3d26998
6469 changed files with 2479670 additions and 0 deletions
329
odoo-bringout-oca-ocb-sale/sale/models/product_template.py
Normal file
329
odoo-bringout-oca-ocb-sale/sale/models/product_template.py
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
# -*- 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue