801 lines
38 KiB
Python
801 lines
38 KiB
Python
# -*- coding: utf-8 -*-
|
||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||
|
||
import re
|
||
from collections import defaultdict
|
||
from operator import itemgetter
|
||
|
||
from odoo import api, fields, models, tools, _
|
||
from odoo.exceptions import ValidationError
|
||
from odoo.osv import expression
|
||
from odoo.tools import float_compare, groupby
|
||
from odoo.tools.misc import unique
|
||
|
||
|
||
class ProductProduct(models.Model):
|
||
_name = "product.product"
|
||
_description = "Product Variant"
|
||
_inherits = {'product.template': 'product_tmpl_id'}
|
||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||
_order = 'priority desc, default_code, name, id'
|
||
|
||
# price_extra: catalog extra value only, sum of variant extra attributes
|
||
price_extra = fields.Float(
|
||
'Variant Price Extra', compute='_compute_product_price_extra',
|
||
digits='Product Price',
|
||
help="This is the sum of the extra price of all attributes")
|
||
# lst_price: catalog value + extra, context dependent (uom)
|
||
lst_price = fields.Float(
|
||
'Sales Price', compute='_compute_product_lst_price',
|
||
digits='Product Price', inverse='_set_product_lst_price',
|
||
help="The sale price is managed from the product template. Click on the 'Configure Variants' button to set the extra attribute prices.")
|
||
|
||
default_code = fields.Char('Internal Reference', index=True)
|
||
code = fields.Char('Reference', compute='_compute_product_code')
|
||
partner_ref = fields.Char('Customer Ref', compute='_compute_partner_ref')
|
||
|
||
active = fields.Boolean(
|
||
'Active', default=True,
|
||
help="If unchecked, it will allow you to hide the product without removing it.")
|
||
product_tmpl_id = fields.Many2one(
|
||
'product.template', 'Product Template',
|
||
auto_join=True, index=True, ondelete="cascade", required=True)
|
||
barcode = fields.Char(
|
||
'Barcode', copy=False, index='btree_not_null',
|
||
help="International Article Number used for product identification.")
|
||
product_template_attribute_value_ids = fields.Many2many('product.template.attribute.value', relation='product_variant_combination', string="Attribute Values", ondelete='restrict')
|
||
product_template_variant_value_ids = fields.Many2many('product.template.attribute.value', relation='product_variant_combination',
|
||
domain=[('attribute_line_id.value_count', '>', 1)], string="Variant Values", ondelete='restrict')
|
||
combination_indices = fields.Char(compute='_compute_combination_indices', store=True, index=True)
|
||
is_product_variant = fields.Boolean(compute='_compute_is_product_variant')
|
||
|
||
standard_price = fields.Float(
|
||
'Cost', company_dependent=True,
|
||
digits='Product Price',
|
||
groups="base.group_user",
|
||
help="""Value of the product (automatically computed in AVCO).
|
||
Used to value the product when the purchase cost is not known (e.g. inventory adjustment).
|
||
Used to compute margins on sale orders.""")
|
||
volume = fields.Float('Volume', digits='Volume')
|
||
weight = fields.Float('Weight', digits='Stock Weight')
|
||
|
||
pricelist_item_count = fields.Integer("Number of price rules", compute="_compute_variant_item_count")
|
||
|
||
product_document_ids = fields.One2many(
|
||
string="Documents",
|
||
comodel_name='product.document',
|
||
inverse_name='res_id',
|
||
domain=lambda self: [('res_model', '=', self._name)])
|
||
product_document_count = fields.Integer(
|
||
string="Documents Count", compute='_compute_product_document_count')
|
||
|
||
packaging_ids = fields.One2many(
|
||
'product.packaging', 'product_id', 'Product Packages',
|
||
help="Gives the different ways to package the same product.")
|
||
|
||
additional_product_tag_ids = fields.Many2many(
|
||
string="Additional Product Tags",
|
||
comodel_name='product.tag',
|
||
relation='product_tag_product_product_rel',
|
||
domain="[('id', 'not in', product_tag_ids)]",
|
||
)
|
||
all_product_tag_ids = fields.Many2many('product.tag', compute='_compute_all_product_tag_ids', search='_search_all_product_tag_ids')
|
||
|
||
# all image fields are base64 encoded and PIL-supported
|
||
|
||
# all image_variant fields are technical and should not be displayed to the user
|
||
image_variant_1920 = fields.Image("Variant Image", max_width=1920, max_height=1920)
|
||
|
||
# resized fields stored (as attachment) for performance
|
||
image_variant_1024 = fields.Image("Variant Image 1024", related="image_variant_1920", max_width=1024, max_height=1024, store=True)
|
||
image_variant_512 = fields.Image("Variant Image 512", related="image_variant_1920", max_width=512, max_height=512, store=True)
|
||
image_variant_256 = fields.Image("Variant Image 256", related="image_variant_1920", max_width=256, max_height=256, store=True)
|
||
image_variant_128 = fields.Image("Variant Image 128", related="image_variant_1920", max_width=128, max_height=128, store=True)
|
||
can_image_variant_1024_be_zoomed = fields.Boolean("Can Variant Image 1024 be zoomed", compute='_compute_can_image_variant_1024_be_zoomed', store=True)
|
||
|
||
# Computed fields that are used to create a fallback to the template if
|
||
# necessary, it's recommended to display those fields to the user.
|
||
image_1920 = fields.Image("Image", compute='_compute_image_1920', inverse='_set_image_1920')
|
||
image_1024 = fields.Image("Image 1024", compute='_compute_image_1024')
|
||
image_512 = fields.Image("Image 512", compute='_compute_image_512')
|
||
image_256 = fields.Image("Image 256", compute='_compute_image_256')
|
||
image_128 = fields.Image("Image 128", compute='_compute_image_128')
|
||
can_image_1024_be_zoomed = fields.Boolean("Can Image 1024 be zoomed", compute='_compute_can_image_1024_be_zoomed')
|
||
write_date = fields.Datetime(compute='_compute_write_date', store=True)
|
||
|
||
@api.depends('image_variant_1920', 'image_variant_1024')
|
||
def _compute_can_image_variant_1024_be_zoomed(self):
|
||
for record in self:
|
||
record.can_image_variant_1024_be_zoomed = record.image_variant_1920 and tools.is_image_size_above(record.image_variant_1920, record.image_variant_1024)
|
||
|
||
def _set_template_field(self, template_field, variant_field):
|
||
for record in self:
|
||
if (
|
||
# We are trying to remove a field from the variant even though it is already
|
||
# not set on the variant, remove it from the template instead.
|
||
(not record[template_field] and not record[variant_field])
|
||
# We are trying to add a field to the variant, but the template field is
|
||
# not set, write on the template instead.
|
||
or (record[template_field] and not record.product_tmpl_id[template_field])
|
||
# There is only one variant, always write on the template.
|
||
or self.search_count([
|
||
('product_tmpl_id', '=', record.product_tmpl_id.id),
|
||
('active', '=', True),
|
||
]) <= 1
|
||
):
|
||
record[variant_field] = False
|
||
record.product_tmpl_id[template_field] = record[template_field]
|
||
else:
|
||
record[variant_field] = record[template_field]
|
||
|
||
@api.depends("product_tmpl_id.write_date")
|
||
def _compute_write_date(self):
|
||
"""
|
||
First, the purpose of this computation is to update a product's
|
||
write_date whenever its template's write_date is updated. Indeed,
|
||
when a template's image is modified, updating its products'
|
||
write_date will invalidate the browser's cache for the products'
|
||
image, which may be the same as the template's. This guarantees UI
|
||
consistency.
|
||
|
||
Second, the field 'write_date' is automatically updated by the
|
||
framework when the product is modified. The recomputation of the
|
||
field supplements that behavior to keep the product's write_date
|
||
up-to-date with its template's write_date.
|
||
|
||
Third, the framework normally prevents us from updating write_date
|
||
because it is a "magic" field. However, the assignment inside the
|
||
compute method is not subject to this restriction. It therefore
|
||
works as intended :-)
|
||
"""
|
||
for record in self:
|
||
record.write_date = max(record.write_date or self.env.cr.now(), record.product_tmpl_id.write_date)
|
||
|
||
def _compute_image_1920(self):
|
||
"""Get the image from the template if no image is set on the variant."""
|
||
for record in self:
|
||
record.image_1920 = record.image_variant_1920 or record.product_tmpl_id.image_1920
|
||
|
||
def _set_image_1920(self):
|
||
return self._set_template_field('image_1920', 'image_variant_1920')
|
||
|
||
def _compute_image_1024(self):
|
||
"""Get the image from the template if no image is set on the variant."""
|
||
for record in self:
|
||
record.image_1024 = record.image_variant_1024 or record.product_tmpl_id.image_1024
|
||
|
||
def _compute_image_512(self):
|
||
"""Get the image from the template if no image is set on the variant."""
|
||
for record in self:
|
||
record.image_512 = record.image_variant_512 or record.product_tmpl_id.image_512
|
||
|
||
def _compute_image_256(self):
|
||
"""Get the image from the template if no image is set on the variant."""
|
||
for record in self:
|
||
record.image_256 = record.image_variant_256 or record.product_tmpl_id.image_256
|
||
|
||
def _compute_image_128(self):
|
||
"""Get the image from the template if no image is set on the variant."""
|
||
for record in self:
|
||
record.image_128 = record.image_variant_128 or record.product_tmpl_id.image_128
|
||
|
||
def _compute_can_image_1024_be_zoomed(self):
|
||
"""Get the image from the template if no image is set on the variant."""
|
||
for record in self:
|
||
record.can_image_1024_be_zoomed = record.can_image_variant_1024_be_zoomed if record.image_variant_1920 else record.product_tmpl_id.can_image_1024_be_zoomed
|
||
|
||
def _get_placeholder_filename(self, field):
|
||
image_fields = ['image_%s' % size for size in [1920, 1024, 512, 256, 128]]
|
||
if field in image_fields:
|
||
return 'product/static/img/placeholder_thumbnail.png'
|
||
return super()._get_placeholder_filename(field)
|
||
|
||
def init(self):
|
||
"""Ensure there is at most one active variant for each combination.
|
||
|
||
There could be no variant for a combination if using dynamic attributes.
|
||
"""
|
||
self.env.cr.execute("CREATE UNIQUE INDEX IF NOT EXISTS product_product_combination_unique ON %s (product_tmpl_id, combination_indices) WHERE active is true"
|
||
% self._table)
|
||
|
||
def _get_barcodes_by_company(self):
|
||
return [
|
||
(company_id, [p.barcode for p in products if p.barcode])
|
||
for company_id, products in groupby(self, lambda p: p.company_id.id)
|
||
]
|
||
|
||
def _get_barcode_search_domain(self, barcodes_within_company, company_id):
|
||
domain = [('barcode', 'in', barcodes_within_company)]
|
||
if company_id:
|
||
domain.append(('company_id', 'in', (False, company_id)))
|
||
return domain
|
||
|
||
def _check_duplicated_product_barcodes(self, barcodes_within_company, company_id):
|
||
domain = self._get_barcode_search_domain(barcodes_within_company, company_id)
|
||
products_by_barcode = self.sudo().read_group(domain, ['barcode', 'id:array_agg'], ['barcode'])
|
||
|
||
duplicates_as_str = "\n".join(
|
||
_(
|
||
"- Barcode \"%s\" already assigned to product(s): %s",
|
||
record['barcode'], ", ".join(p.display_name for p in self.search([('id', 'in', record['id'])]))
|
||
)
|
||
for record in products_by_barcode if len(record['id']) > 1
|
||
)
|
||
if duplicates_as_str.strip():
|
||
duplicates_as_str += _(
|
||
"\n\nNote: products that you don't have access to will not be shown above."
|
||
)
|
||
raise ValidationError(_("Barcode(s) already assigned:\n\n%s", duplicates_as_str))
|
||
|
||
def _check_duplicated_packaging_barcodes(self, barcodes_within_company, company_id):
|
||
packaging_domain = self._get_barcode_search_domain(barcodes_within_company, company_id)
|
||
if self.env['product.packaging'].sudo().search(packaging_domain, order="id", limit=1):
|
||
raise ValidationError(_("A packaging already uses the barcode"))
|
||
|
||
@api.constrains('barcode')
|
||
def _check_barcode_uniqueness(self):
|
||
""" With GS1 nomenclature, products and packagings use the same pattern. Therefore, we need
|
||
to ensure the uniqueness between products' barcodes and packagings' ones"""
|
||
# Barcodes should only be unique within a company
|
||
for company_id, barcodes_within_company in self._get_barcodes_by_company():
|
||
self._check_duplicated_product_barcodes(barcodes_within_company, company_id)
|
||
self._check_duplicated_packaging_barcodes(barcodes_within_company, company_id)
|
||
|
||
def _get_invoice_policy(self):
|
||
return False
|
||
|
||
@api.depends('product_template_attribute_value_ids')
|
||
def _compute_combination_indices(self):
|
||
for product in self:
|
||
product.combination_indices = product.product_template_attribute_value_ids._ids2str()
|
||
|
||
def _compute_is_product_variant(self):
|
||
self.is_product_variant = True
|
||
|
||
@api.onchange('lst_price')
|
||
def _set_product_lst_price(self):
|
||
for product in self:
|
||
if self._context.get('uom'):
|
||
value = self.env['uom.uom'].browse(self._context['uom'])._compute_price(product.lst_price, product.uom_id)
|
||
else:
|
||
value = product.lst_price
|
||
value -= product.price_extra
|
||
product.write({'list_price': value})
|
||
|
||
@api.depends("product_template_attribute_value_ids.price_extra")
|
||
def _compute_product_price_extra(self):
|
||
for product in self:
|
||
product.price_extra = sum(product.product_template_attribute_value_ids.mapped('price_extra'))
|
||
|
||
@api.depends('list_price', 'price_extra')
|
||
@api.depends_context('uom')
|
||
def _compute_product_lst_price(self):
|
||
to_uom = None
|
||
if 'uom' in self._context:
|
||
to_uom = self.env['uom.uom'].browse(self._context['uom'])
|
||
|
||
for product in self:
|
||
if to_uom:
|
||
list_price = product.uom_id._compute_price(product.list_price, to_uom)
|
||
else:
|
||
list_price = product.list_price
|
||
product.lst_price = list_price + product.price_extra
|
||
|
||
@api.depends_context('partner_id')
|
||
def _compute_product_code(self):
|
||
for product in self:
|
||
product.code = product.default_code
|
||
if self.env['ir.model.access'].check('product.supplierinfo', 'read', False):
|
||
for supplier_info in product.seller_ids:
|
||
if supplier_info.partner_id.id == product._context.get('partner_id'):
|
||
product.code = supplier_info.product_code or product.default_code
|
||
break
|
||
|
||
@api.depends_context('partner_id')
|
||
def _compute_partner_ref(self):
|
||
for product in self:
|
||
for supplier_info in product.seller_ids:
|
||
if supplier_info.partner_id.id == product._context.get('partner_id'):
|
||
product_name = supplier_info.product_name or product.default_code or product.name
|
||
product.partner_ref = '%s%s' % (product.code and '[%s] ' % product.code or '', product_name)
|
||
break
|
||
else:
|
||
product.partner_ref = product.display_name
|
||
|
||
def _compute_variant_item_count(self):
|
||
for product in self:
|
||
domain = ['|',
|
||
'&', ('product_tmpl_id', '=', product.product_tmpl_id.id), ('applied_on', '=', '1_product'),
|
||
'&', ('product_id', '=', product.id), ('applied_on', '=', '0_product_variant')]
|
||
product.pricelist_item_count = self.env['product.pricelist.item'].search_count(domain)
|
||
|
||
def _compute_product_document_count(self):
|
||
for product in self:
|
||
product.product_document_count = product.env['product.document'].search_count([
|
||
('res_model', '=', 'product.product'),
|
||
('res_id', '=', product.id),
|
||
])
|
||
|
||
@api.depends('product_tag_ids', 'additional_product_tag_ids')
|
||
def _compute_all_product_tag_ids(self):
|
||
for product in self:
|
||
product.all_product_tag_ids = product.product_tag_ids | product.additional_product_tag_ids
|
||
|
||
def _search_all_product_tag_ids(self, operator, operand):
|
||
if operator in expression.NEGATIVE_TERM_OPERATORS:
|
||
return [('product_tag_ids', operator, operand), ('additional_product_tag_ids', operator, operand)]
|
||
return ['|', ('product_tag_ids', operator, operand), ('additional_product_tag_ids', operator, operand)]
|
||
|
||
@api.onchange('uom_id')
|
||
def _onchange_uom_id(self):
|
||
if self.uom_id:
|
||
self.uom_po_id = self.uom_id.id
|
||
|
||
@api.onchange('uom_po_id')
|
||
def _onchange_uom(self):
|
||
if self.uom_id and self.uom_po_id and self.uom_id.category_id != self.uom_po_id.category_id:
|
||
self.uom_po_id = self.uom_id
|
||
|
||
@api.onchange('default_code')
|
||
def _onchange_default_code(self):
|
||
if not self.default_code:
|
||
return
|
||
|
||
domain = [('default_code', '=', self.default_code)]
|
||
if self.id.origin:
|
||
domain.append(('id', '!=', self.id.origin))
|
||
|
||
if self.env['product.product'].search(domain, limit=1):
|
||
return {'warning': {
|
||
'title': _("Note:"),
|
||
'message': _("The Internal Reference '%s' already exists.", self.default_code),
|
||
}}
|
||
|
||
@api.model_create_multi
|
||
def create(self, vals_list):
|
||
for vals in vals_list:
|
||
self.product_tmpl_id._sanitize_vals(vals)
|
||
products = super(ProductProduct, self.with_context(create_product_product=False)).create(vals_list)
|
||
# `_get_variant_id_for_combination` depends on existing variants
|
||
self.env.registry.clear_cache()
|
||
return products
|
||
|
||
def write(self, values):
|
||
self.product_tmpl_id._sanitize_vals(values)
|
||
res = super(ProductProduct, self).write(values)
|
||
if 'product_template_attribute_value_ids' in values:
|
||
# `_get_variant_id_for_combination` depends on `product_template_attribute_value_ids`
|
||
self.env.registry.clear_cache()
|
||
elif 'active' in values:
|
||
# `_get_first_possible_variant_id` depends on variants active state
|
||
self.env.registry.clear_cache()
|
||
return res
|
||
|
||
def unlink(self):
|
||
unlink_products = self.env['product.product']
|
||
unlink_templates = self.env['product.template']
|
||
for product in self:
|
||
# If there is an image set on the variant and no image set on the
|
||
# template, move the image to the template.
|
||
if product.image_variant_1920 and not product.product_tmpl_id.image_1920:
|
||
product.product_tmpl_id.image_1920 = product.image_variant_1920
|
||
# Check if product still exists, in case it has been unlinked by unlinking its template
|
||
if not product.exists():
|
||
continue
|
||
# Check if the product is last product of this template...
|
||
other_products = self.search([('product_tmpl_id', '=', product.product_tmpl_id.id), ('id', '!=', product.id)])
|
||
# ... and do not delete product template if it's configured to be created "on demand"
|
||
if not other_products and not product.product_tmpl_id.has_dynamic_attributes():
|
||
unlink_templates |= product.product_tmpl_id
|
||
unlink_products |= product
|
||
res = super(ProductProduct, unlink_products).unlink()
|
||
# delete templates after calling super, as deleting template could lead to deleting
|
||
# products due to ondelete='cascade'
|
||
unlink_templates.unlink()
|
||
# `_get_variant_id_for_combination` depends on existing variants
|
||
self.env.registry.clear_cache()
|
||
return res
|
||
|
||
def _filter_to_unlink(self, check_access=True):
|
||
return self
|
||
|
||
def _unlink_or_archive(self, check_access=True):
|
||
"""Unlink or archive products.
|
||
Try in batch as much as possible because it is much faster.
|
||
Use dichotomy when an exception occurs.
|
||
"""
|
||
|
||
# Avoid access errors in case the products is shared amongst companies
|
||
# but the underlying objects are not. If unlink fails because of an
|
||
# AccessError (e.g. while recomputing fields), the 'write' call will
|
||
# fail as well for the same reason since the field has been set to
|
||
# recompute.
|
||
if check_access:
|
||
self.check_access_rights('unlink')
|
||
self.check_access_rule('unlink')
|
||
self.check_access_rights('write')
|
||
self.check_access_rule('write')
|
||
self = self.sudo()
|
||
to_unlink = self._filter_to_unlink()
|
||
to_archive = self - to_unlink
|
||
to_archive.write({'active': False})
|
||
self = to_unlink
|
||
|
||
try:
|
||
with self.env.cr.savepoint(), tools.mute_logger('odoo.sql_db'):
|
||
self.unlink()
|
||
except Exception:
|
||
# We catch all kind of exceptions to be sure that the operation
|
||
# doesn't fail.
|
||
if len(self) > 1:
|
||
self[:len(self) // 2]._unlink_or_archive(check_access=False)
|
||
self[len(self) // 2:]._unlink_or_archive(check_access=False)
|
||
else:
|
||
if self.active:
|
||
# Note: this can still fail if something is preventing
|
||
# from archiving.
|
||
# This is the case from existing stock reordering rules.
|
||
self.write({'active': False})
|
||
|
||
@api.returns('self', lambda value: value.id)
|
||
def copy(self, default=None):
|
||
"""Variants are generated depending on the configuration of attributes
|
||
and values on the template, so copying them does not make sense.
|
||
|
||
For convenience the template is copied instead and its first variant is
|
||
returned.
|
||
"""
|
||
# copy variant is disabled in https://github.com/odoo/odoo/pull/38303
|
||
# this returns the first possible combination of variant to make it
|
||
# works for now, need to be fixed to return product_variant_id if it's
|
||
# possible in the future
|
||
template = self.product_tmpl_id.copy(default=default)
|
||
return template.product_variant_id or template._create_first_product_variant()
|
||
|
||
@api.model
|
||
def _search(self, domain, offset=0, limit=None, order=None, access_rights_uid=None):
|
||
# TDE FIXME: strange
|
||
if self._context.get('search_default_categ_id'):
|
||
domain = domain.copy()
|
||
domain.append((('categ_id', 'child_of', self._context['search_default_categ_id'])))
|
||
return super()._search(domain, offset, limit, order, access_rights_uid)
|
||
|
||
@api.depends('name', 'default_code', 'product_tmpl_id')
|
||
@api.depends_context('display_default_code', 'seller_id', 'company_id', 'partner_id')
|
||
def _compute_display_name(self):
|
||
|
||
def get_display_name(name, code):
|
||
if self._context.get('display_default_code', True) and code:
|
||
return f'[{code}] {name}'
|
||
return name
|
||
|
||
partner_id = self._context.get('partner_id')
|
||
if partner_id:
|
||
partner_ids = [partner_id, self.env['res.partner'].browse(partner_id).commercial_partner_id.id]
|
||
else:
|
||
partner_ids = []
|
||
company_id = self.env.context.get('company_id')
|
||
|
||
# all user don't have access to seller and partner
|
||
# check access and use superuser
|
||
self.check_access_rights("read")
|
||
self.check_access_rule("read")
|
||
|
||
product_template_ids = self.sudo().product_tmpl_id.ids
|
||
|
||
if partner_ids:
|
||
# prefetch the fields used by the `display_name`
|
||
supplier_info = self.env['product.supplierinfo'].sudo().search_fetch(
|
||
[('product_tmpl_id', 'in', product_template_ids), ('partner_id', 'in', partner_ids)],
|
||
['product_tmpl_id', 'product_id', 'company_id', 'product_name', 'product_code'],
|
||
)
|
||
supplier_info_by_template = {}
|
||
for r in supplier_info:
|
||
supplier_info_by_template.setdefault(r.product_tmpl_id, []).append(r)
|
||
|
||
for product in self.sudo():
|
||
variant = product.product_template_attribute_value_ids._get_combination_name()
|
||
|
||
name = variant and "%s (%s)" % (product.name, variant) or product.name
|
||
sellers = self.env['product.supplierinfo'].sudo().browse(self.env.context.get('seller_id')) or []
|
||
if not sellers and partner_ids:
|
||
product_supplier_info = supplier_info_by_template.get(product.product_tmpl_id, [])
|
||
sellers = [x for x in product_supplier_info if x.product_id and x.product_id == product]
|
||
if not sellers:
|
||
sellers = [x for x in product_supplier_info if not x.product_id]
|
||
# Filter out sellers based on the company. This is done afterwards for a better
|
||
# code readability. At this point, only a few sellers should remain, so it should
|
||
# not be a performance issue.
|
||
if company_id:
|
||
sellers = [x for x in sellers if x.company_id.id in [company_id, False]]
|
||
if sellers:
|
||
temp = []
|
||
for s in sellers:
|
||
seller_variant = s.product_name and (
|
||
variant and "%s (%s)" % (s.product_name, variant) or s.product_name
|
||
) or False
|
||
temp.append(get_display_name(seller_variant or name, s.product_code or product.default_code))
|
||
|
||
# => Feature drop here, one record can only have one display_name now, instead separate with `,`
|
||
# Remove this comment
|
||
product.display_name = ", ".join(unique(temp))
|
||
else:
|
||
product.display_name = get_display_name(name, product.default_code)
|
||
|
||
@api.model
|
||
def _name_search(self, name, domain=None, operator='ilike', limit=None, order=None):
|
||
domain = domain or []
|
||
if name:
|
||
positive_operators = ['=', 'ilike', '=ilike', 'like', '=like']
|
||
product_ids = []
|
||
if operator in positive_operators:
|
||
product_ids = list(self._search([('default_code', '=', name)] + domain, limit=limit, order=order))
|
||
if not product_ids:
|
||
product_ids = list(self._search([('barcode', '=', name)] + domain, limit=limit, order=order))
|
||
if not product_ids and operator not in expression.NEGATIVE_TERM_OPERATORS:
|
||
# Do not merge the 2 next lines into one single search, SQL search performance would be abysmal
|
||
# on a database with thousands of matching products, due to the huge merge+unique needed for the
|
||
# OR operator (and given the fact that the 'name' lookup results come from the ir.translation table
|
||
# Performing a quick memory merge of ids in Python will give much better performance
|
||
product_ids = list(self._search(domain + [('default_code', operator, name)], limit=limit, order=order))
|
||
if not limit or len(product_ids) < limit:
|
||
# we may underrun the limit because of dupes in the results, that's fine
|
||
limit2 = (limit - len(product_ids)) if limit else False
|
||
product2_ids = self._search(domain + [('name', operator, name), ('id', 'not in', product_ids)], limit=limit2, order=order)
|
||
product_ids.extend(product2_ids)
|
||
elif not product_ids and operator in expression.NEGATIVE_TERM_OPERATORS:
|
||
domain2 = expression.OR([
|
||
['&', ('default_code', operator, name), ('name', operator, name)],
|
||
['&', ('default_code', '=', False), ('name', operator, name)],
|
||
])
|
||
domain2 = expression.AND([domain, domain2])
|
||
product_ids = list(self._search(domain2, limit=limit, order=order))
|
||
if not product_ids and operator in positive_operators:
|
||
ptrn = re.compile('(\[(.*?)\])')
|
||
res = ptrn.search(name)
|
||
if res:
|
||
product_ids = list(self._search([('default_code', '=', res.group(2))] + domain, limit=limit, order=order))
|
||
# still no results, partner in context: search on supplier info as last hope to find something
|
||
if not product_ids and self._context.get('partner_id'):
|
||
suppliers_ids = self.env['product.supplierinfo']._search([
|
||
('partner_id', '=', self._context.get('partner_id')),
|
||
'|',
|
||
('product_code', operator, name),
|
||
('product_name', operator, name)])
|
||
if suppliers_ids:
|
||
product_ids = self._search([('product_tmpl_id.seller_ids', 'in', suppliers_ids)], limit=limit, order=order)
|
||
else:
|
||
product_ids = self._search(domain, limit=limit, order=order)
|
||
return product_ids
|
||
|
||
@api.model
|
||
def view_header_get(self, view_id, view_type):
|
||
if self._context.get('categ_id'):
|
||
return _(
|
||
'Products: %(category)s',
|
||
category=self.env['product.category'].browse(self.env.context['categ_id']).name,
|
||
)
|
||
return super().view_header_get(view_id, view_type)
|
||
|
||
#=== ACTION METHODS ===#
|
||
|
||
def action_open_label_layout(self):
|
||
action = self.env['ir.actions.act_window']._for_xml_id('product.action_open_label_layout')
|
||
action['context'] = {'default_product_ids': self.ids}
|
||
return action
|
||
|
||
def open_pricelist_rules(self):
|
||
self.ensure_one()
|
||
domain = ['|',
|
||
'&', ('product_tmpl_id', '=', self.product_tmpl_id.id), ('applied_on', '=', '1_product'),
|
||
'&', ('product_id', '=', self.id), ('applied_on', '=', '0_product_variant')]
|
||
return {
|
||
'name': _('Price Rules'),
|
||
'view_mode': 'tree,form',
|
||
'views': [(self.env.ref('product.product_pricelist_item_tree_view_from_product').id, 'tree'), (False, 'form')],
|
||
'res_model': 'product.pricelist.item',
|
||
'type': 'ir.actions.act_window',
|
||
'target': 'current',
|
||
'domain': domain,
|
||
'context': {
|
||
'default_product_id': self.id,
|
||
'default_applied_on': '0_product_variant',
|
||
'search_default_visible': True,
|
||
}
|
||
}
|
||
|
||
def open_product_template(self):
|
||
""" Utility method used to add an "Open Template" button in product views """
|
||
self.ensure_one()
|
||
return {
|
||
'type': 'ir.actions.act_window',
|
||
'res_model': 'product.template',
|
||
'view_mode': 'form',
|
||
'res_id': self.product_tmpl_id.id,
|
||
'target': 'new'
|
||
}
|
||
|
||
def action_open_documents(self):
|
||
res = self.product_tmpl_id.action_open_documents()
|
||
res['context'].update({
|
||
'default_res_model': self._name,
|
||
'default_res_id': self.id,
|
||
'search_default_context_variant': True,
|
||
})
|
||
return res
|
||
|
||
#=== BUSINESS METHODS ===#
|
||
|
||
def _prepare_sellers(self, params=False):
|
||
return self.seller_ids.filtered(lambda s: s.partner_id.active).sorted(lambda s: (s.sequence, -s.min_qty, s.price, s.id))
|
||
|
||
def _get_filtered_sellers(self, partner_id=False, quantity=0.0, date=None, uom_id=False, params=False):
|
||
self.ensure_one()
|
||
if date is None:
|
||
date = fields.Date.context_today(self)
|
||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||
|
||
sellers_filtered = self._prepare_sellers(params)
|
||
sellers_filtered = sellers_filtered.filtered(lambda s: not s.company_id or s.company_id.id == self.env.company.id)
|
||
sellers = self.env['product.supplierinfo']
|
||
for seller in sellers_filtered:
|
||
# Set quantity in UoM of seller
|
||
quantity_uom_seller = quantity
|
||
if quantity_uom_seller and uom_id and uom_id != seller.product_uom:
|
||
quantity_uom_seller = uom_id._compute_quantity(quantity_uom_seller, seller.product_uom)
|
||
|
||
if seller.date_start and seller.date_start > date:
|
||
continue
|
||
if seller.date_end and seller.date_end < date:
|
||
continue
|
||
if partner_id and seller.partner_id not in [partner_id, partner_id.parent_id]:
|
||
continue
|
||
if quantity is not None and float_compare(quantity_uom_seller, seller.min_qty, precision_digits=precision) == -1:
|
||
continue
|
||
if seller.product_id and seller.product_id != self:
|
||
continue
|
||
sellers |= seller
|
||
return sellers
|
||
|
||
def _select_seller(self, partner_id=False, quantity=0.0, date=None, uom_id=False, ordered_by='price_discounted', params=False):
|
||
# Always sort by discounted price but another field can take the primacy through the `ordered_by` param.
|
||
sort_key = itemgetter('price_discounted', 'sequence', 'id')
|
||
if ordered_by != 'price_discounted':
|
||
sort_key = itemgetter(ordered_by, 'price_discounted', 'sequence', 'id')
|
||
|
||
sellers = self._get_filtered_sellers(partner_id=partner_id, quantity=quantity, date=date, uom_id=uom_id, params=params)
|
||
res = self.env['product.supplierinfo']
|
||
for seller in sellers:
|
||
if not res or res.partner_id == seller.partner_id:
|
||
res |= seller
|
||
return res and res.sorted(sort_key)[:1]
|
||
|
||
def _get_product_price_context(self, combination):
|
||
self.ensure_one()
|
||
res = {}
|
||
|
||
# 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.product_tmpl_id == self.product_tmpl_id
|
||
and ptav not in self.product_template_attribute_value_ids
|
||
)
|
||
]
|
||
if no_variant_attributes_price_extra:
|
||
res['no_variant_attributes_price_extra'] = tuple(no_variant_attributes_price_extra)
|
||
|
||
return res
|
||
|
||
def _get_attributes_extra_price(self):
|
||
self.ensure_one()
|
||
|
||
return self.price_extra + sum(
|
||
self.env.context.get('no_variant_attributes_price_extra', []))
|
||
|
||
def _price_compute(self, price_type, uom=None, currency=None, company=None, date=False):
|
||
company = company or self.env.company
|
||
date = date or fields.Date.context_today(self)
|
||
|
||
self = self.with_company(company)
|
||
if price_type == 'standard_price':
|
||
# standard_price field can only be seen by users in base.group_user
|
||
# Thus, in order to compute the sale price from the cost for users not in this group
|
||
# We fetch the standard price as the superuser
|
||
self = self.sudo()
|
||
|
||
prices = dict.fromkeys(self.ids, 0.0)
|
||
for product in self:
|
||
price = product[price_type] or 0.0
|
||
price_currency = product.currency_id
|
||
if price_type == 'standard_price':
|
||
price_currency = product.cost_currency_id
|
||
elif price_type == 'list_price':
|
||
price += product._get_attributes_extra_price()
|
||
|
||
if uom:
|
||
price = product.uom_id._compute_price(price, uom)
|
||
|
||
# Convert from current user company currency to asked one
|
||
# This is right cause a field cannot be in more than one currency
|
||
if currency:
|
||
price = price_currency._convert(price, currency, company, date)
|
||
|
||
prices[product.id] = price
|
||
|
||
return prices
|
||
|
||
@api.model
|
||
def get_empty_list_help(self, help_message):
|
||
self = self.with_context(
|
||
empty_list_help_document_name=_("product"),
|
||
)
|
||
return super(ProductProduct, self).get_empty_list_help(help_message)
|
||
|
||
def get_product_multiline_description_sale(self):
|
||
""" Compute a multiline description of this product, in the context of sales
|
||
(do not use for purchases or other display reasons that don't intend to use "description_sale").
|
||
It will often be used as the default description of a sale order line referencing this product.
|
||
"""
|
||
name = self.display_name
|
||
if self.description_sale:
|
||
name += '\n' + self.description_sale
|
||
|
||
return name
|
||
|
||
def _is_variant_possible(self, parent_combination=None):
|
||
"""Return whether the variant is possible based on its own combination,
|
||
and optionally a parent combination.
|
||
|
||
See `_is_combination_possible` for more information.
|
||
|
||
:param parent_combination: combination from which `self` is an
|
||
optional or accessory product.
|
||
:type parent_combination: recordset `product.template.attribute.value`
|
||
|
||
:return: ẁhether the variant is possible based on its own combination
|
||
:rtype: bool
|
||
"""
|
||
self.ensure_one()
|
||
return self.product_tmpl_id._is_combination_possible(self.product_template_attribute_value_ids, parent_combination=parent_combination, ignore_no_variant=True)
|
||
|
||
def toggle_active(self):
|
||
""" Archiving related product.template if there is not any more active product.product
|
||
(and vice versa, unarchiving the related product template if there is now an active product.product) """
|
||
result = super().toggle_active()
|
||
# We deactivate product templates which are active with no active variants.
|
||
tmpl_to_deactivate = self.filtered(lambda product: (product.product_tmpl_id.active
|
||
and not product.product_tmpl_id.product_variant_ids)).mapped('product_tmpl_id')
|
||
# We activate product templates which are inactive with active variants.
|
||
tmpl_to_activate = self.filtered(lambda product: (not product.product_tmpl_id.active
|
||
and product.product_tmpl_id.product_variant_ids)).mapped('product_tmpl_id')
|
||
(tmpl_to_deactivate + tmpl_to_activate).toggle_active()
|
||
return result
|
||
|
||
def get_contextual_price(self):
|
||
return self._get_contextual_price()
|
||
|
||
def _get_contextual_price(self):
|
||
self.ensure_one()
|
||
return self.product_tmpl_id._get_contextual_price(self)
|
||
|
||
def _get_contextual_discount(self):
|
||
self.ensure_one()
|
||
|
||
pricelist = self.product_tmpl_id._get_contextual_pricelist()
|
||
if not pricelist:
|
||
# No pricelist = no discount
|
||
return 0.0
|
||
|
||
lst_price = self.currency_id._convert(
|
||
self.lst_price,
|
||
pricelist.currency_id,
|
||
self.env.company,
|
||
fields.Datetime.now(),
|
||
)
|
||
if lst_price:
|
||
return (lst_price - self._get_contextual_price()) / lst_price
|
||
return 0.0
|