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
|