455 lines
22 KiB
Python
455 lines
22 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
|
|
|
|
import itertools
|
|
import psycopg2
|
|
|
|
from flectra.addons import decimal_precision as dp
|
|
|
|
from flectra import api, fields, models, tools, _
|
|
from flectra.exceptions import ValidationError, RedirectWarning, except_orm
|
|
from flectra.tools import pycompat
|
|
|
|
|
|
class ProductTemplate(models.Model):
|
|
_name = "product.template"
|
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
|
_description = "Product Template"
|
|
_order = "name"
|
|
|
|
def _get_default_category_id(self):
|
|
if self._context.get('categ_id') or self._context.get('default_categ_id'):
|
|
return self._context.get('categ_id') or self._context.get('default_categ_id')
|
|
category = self.env.ref('product.product_category_all', raise_if_not_found=False)
|
|
if not category:
|
|
category = self.env['product.category'].search([], limit=1)
|
|
if category:
|
|
return category.id
|
|
else:
|
|
err_msg = _('You must define at least one product category in order to be able to create products.')
|
|
redir_msg = _('Go to Internal Categories')
|
|
raise RedirectWarning(err_msg, self.env.ref('product.product_category_action_form').id, redir_msg)
|
|
|
|
def _get_default_uom_id(self):
|
|
return self.env["product.uom"].search([], limit=1, order='id').id
|
|
|
|
name = fields.Char('Name', index=True, required=True, translate=True)
|
|
sequence = fields.Integer('Sequence', default=1, help='Gives the sequence order when displaying a product list')
|
|
description = fields.Text(
|
|
'Description', translate=True,
|
|
help="A precise description of the Product, used only for internal information purposes.")
|
|
description_purchase = fields.Text(
|
|
'Purchase Description', translate=True,
|
|
help="A description of the Product that you want to communicate to your vendors. "
|
|
"This description will be copied to every Purchase Order, Receipt and Vendor Bill/Credit Note.")
|
|
description_sale = fields.Text(
|
|
'Sale Description', translate=True,
|
|
help="A description of the Product that you want to communicate to your customers. "
|
|
"This description will be copied to every Sales Order, Delivery Order and Customer Invoice/Credit Note")
|
|
type = fields.Selection([
|
|
('consu', _('Consumable')),
|
|
('service', _('Service'))], string='Product Type', default='consu', required=True,
|
|
help='A stockable product is a product for which you manage stock. The "Inventory" app has to be installed.\n'
|
|
'A consumable product, on the other hand, is a product for which stock is not managed.\n'
|
|
'A service is a non-material product you provide.\n'
|
|
'A digital content is a non-material product you sell online. The files attached to the products are the one that are sold on '
|
|
'the e-commerce such as e-books, music, pictures,... The "Digital Product" module has to be installed.')
|
|
rental = fields.Boolean('Can be Rent')
|
|
categ_id = fields.Many2one(
|
|
'product.category', 'Internal Category',
|
|
change_default=True, default=_get_default_category_id,
|
|
required=True, help="Select category for the current product")
|
|
|
|
currency_id = fields.Many2one(
|
|
'res.currency', 'Currency', compute='_compute_currency_id')
|
|
|
|
# price fields
|
|
price = fields.Float(
|
|
'Price', compute='_compute_template_price', inverse='_set_template_price',
|
|
digits=dp.get_precision('Product Price'))
|
|
list_price = fields.Float(
|
|
'Sales Price', default=1.0,
|
|
digits=dp.get_precision('Product Price'),
|
|
help="Base price to compute the customer price. Sometimes called the catalog price.")
|
|
lst_price = fields.Float(
|
|
'Public Price', related='list_price',
|
|
digits=dp.get_precision('Product Price'))
|
|
standard_price = fields.Float(
|
|
'Cost', compute='_compute_standard_price',
|
|
inverse='_set_standard_price', search='_search_standard_price',
|
|
digits=dp.get_precision('Product Price'), groups="base.group_user",
|
|
help = "Cost used for stock valuation in standard price and as a first price to set in average/fifo. "
|
|
"Also used as a base price for pricelists. "
|
|
"Expressed in the default unit of measure of the product. ")
|
|
|
|
volume = fields.Float(
|
|
'Volume', compute='_compute_volume', inverse='_set_volume',
|
|
help="The volume in m3.", store=True)
|
|
weight = fields.Float(
|
|
'Weight', compute='_compute_weight', digits=dp.get_precision('Stock Weight'),
|
|
inverse='_set_weight', store=True,
|
|
help="The weight of the contents in Kg, not including any packaging, etc.")
|
|
|
|
sale_ok = fields.Boolean(
|
|
'Can be Sold', default=True,
|
|
help="Specify if the product can be selected in a sales order line.")
|
|
purchase_ok = fields.Boolean('Can be Purchased', default=True)
|
|
pricelist_id = fields.Many2one(
|
|
'product.pricelist', 'Pricelist', store=False,
|
|
help='Technical field. Used for searching on pricelists, not stored in database.')
|
|
uom_id = fields.Many2one(
|
|
'product.uom', 'Unit of Measure',
|
|
default=_get_default_uom_id, required=True,
|
|
help="Default Unit of Measure used for all stock operation.")
|
|
uom_po_id = fields.Many2one(
|
|
'product.uom', 'Purchase Unit of Measure',
|
|
default=_get_default_uom_id, required=True,
|
|
help="Default Unit of Measure used for purchase orders. It must be in the same category than the default unit of measure.")
|
|
company_id = fields.Many2one(
|
|
'res.company', 'Company',
|
|
default=lambda self: self.env['res.company']._company_default_get('product.template'), index=1)
|
|
packaging_ids = fields.One2many(
|
|
'product.packaging', string="Product Packages", compute="_compute_packaging_ids", inverse="_set_packaging_ids",
|
|
help="Gives the different ways to package the same product.")
|
|
seller_ids = fields.One2many('product.supplierinfo', 'product_tmpl_id', 'Vendors')
|
|
variant_seller_ids = fields.One2many('product.supplierinfo', 'product_tmpl_id')
|
|
|
|
active = fields.Boolean('Active', default=True, help="If unchecked, it will allow you to hide the product without removing it.")
|
|
color = fields.Integer('Color Index')
|
|
|
|
is_product_variant = fields.Boolean(string='Is a product variant', compute='_compute_is_product_variant')
|
|
attribute_line_ids = fields.One2many('product.attribute.line', 'product_tmpl_id', 'Product Attributes')
|
|
product_variant_ids = fields.One2many('product.product', 'product_tmpl_id', 'Products', required=True)
|
|
# performance: product_variant_id provides prefetching on the first product variant only
|
|
product_variant_id = fields.Many2one('product.product', 'Product', compute='_compute_product_variant_id')
|
|
|
|
product_variant_count = fields.Integer(
|
|
'# Product Variants', compute='_compute_product_variant_count')
|
|
|
|
# related to display product product information if is_product_variant
|
|
barcode = fields.Char('Barcode', oldname='ean13', related='product_variant_ids.barcode')
|
|
default_code = fields.Char(
|
|
'Internal Reference', compute='_compute_default_code',
|
|
inverse='_set_default_code', store=True)
|
|
|
|
item_ids = fields.One2many('product.pricelist.item', 'product_tmpl_id', 'Pricelist Items')
|
|
|
|
# image: all image fields are base64 encoded and PIL-supported
|
|
image = fields.Binary(
|
|
"Image", attachment=True,
|
|
help="This field holds the image used as image for the product, limited to 1024x1024px.")
|
|
image_medium = fields.Binary(
|
|
"Medium-sized image", attachment=True,
|
|
help="Medium-sized image of the product. It is automatically "
|
|
"resized as a 128x128px image, with aspect ratio preserved, "
|
|
"only when the image exceeds one of those sizes. Use this field in form views or some kanban views.")
|
|
image_small = fields.Binary(
|
|
"Small-sized image", attachment=True,
|
|
help="Small-sized image of the product. It is automatically "
|
|
"resized as a 64x64px image, with aspect ratio preserved. "
|
|
"Use this field anywhere a small image is required.")
|
|
|
|
@api.depends('product_variant_ids')
|
|
def _compute_product_variant_id(self):
|
|
for p in self:
|
|
p.product_variant_id = p.product_variant_ids[:1].id
|
|
|
|
@api.multi
|
|
def _compute_currency_id(self):
|
|
try:
|
|
main_company = self.sudo().env.ref('base.main_company')
|
|
except ValueError:
|
|
main_company = self.env['res.company'].sudo().search([], limit=1, order="id")
|
|
for template in self:
|
|
template.currency_id = template.company_id.sudo().currency_id.id or main_company.currency_id.id
|
|
|
|
@api.multi
|
|
def _compute_template_price(self):
|
|
prices = {}
|
|
pricelist_id_or_name = self._context.get('pricelist')
|
|
if pricelist_id_or_name:
|
|
pricelist = None
|
|
partner = self._context.get('partner')
|
|
quantity = self._context.get('quantity', 1.0)
|
|
|
|
# Support context pricelists specified as display_name or ID for compatibility
|
|
if isinstance(pricelist_id_or_name, pycompat.string_types):
|
|
pricelist_data = self.env['product.pricelist'].name_search(pricelist_id_or_name, operator='=', limit=1)
|
|
if pricelist_data:
|
|
pricelist = self.env['product.pricelist'].browse(pricelist_data[0][0])
|
|
elif isinstance(pricelist_id_or_name, pycompat.integer_types):
|
|
pricelist = self.env['product.pricelist'].browse(pricelist_id_or_name)
|
|
|
|
if pricelist:
|
|
quantities = [quantity] * len(self)
|
|
partners = [partner] * len(self)
|
|
prices = pricelist.get_products_price(self, quantities, partners)
|
|
|
|
for template in self:
|
|
template.price = prices.get(template.id, 0.0)
|
|
|
|
@api.multi
|
|
def _set_template_price(self):
|
|
if self._context.get('uom'):
|
|
for template in self:
|
|
value = self.env['product.uom'].browse(self._context['uom'])._compute_price(template.price, template.uom_id)
|
|
template.write({'list_price': value})
|
|
else:
|
|
self.write({'list_price': self.price})
|
|
|
|
@api.depends('product_variant_ids', 'product_variant_ids.standard_price')
|
|
def _compute_standard_price(self):
|
|
unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1)
|
|
for template in unique_variants:
|
|
template.standard_price = template.product_variant_ids.standard_price
|
|
for template in (self - unique_variants):
|
|
template.standard_price = 0.0
|
|
|
|
@api.one
|
|
def _set_standard_price(self):
|
|
if len(self.product_variant_ids) == 1:
|
|
self.product_variant_ids.standard_price = self.standard_price
|
|
|
|
def _search_standard_price(self, operator, value):
|
|
products = self.env['product.product'].search([('standard_price', operator, value)], limit=None)
|
|
return [('id', 'in', products.mapped('product_tmpl_id').ids)]
|
|
|
|
@api.depends('product_variant_ids', 'product_variant_ids.volume')
|
|
def _compute_volume(self):
|
|
unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1)
|
|
for template in unique_variants:
|
|
template.volume = template.product_variant_ids.volume
|
|
for template in (self - unique_variants):
|
|
template.volume = 0.0
|
|
|
|
@api.one
|
|
def _set_volume(self):
|
|
if len(self.product_variant_ids) == 1:
|
|
self.product_variant_ids.volume = self.volume
|
|
|
|
@api.depends('product_variant_ids', 'product_variant_ids.weight')
|
|
def _compute_weight(self):
|
|
unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1)
|
|
for template in unique_variants:
|
|
template.weight = template.product_variant_ids.weight
|
|
for template in (self - unique_variants):
|
|
template.weight = 0.0
|
|
|
|
def _compute_is_product_variant(self):
|
|
for template in self:
|
|
if template._name == 'product.template':
|
|
template.is_product_variant = False
|
|
else:
|
|
template.is_product_variant = True
|
|
|
|
@api.one
|
|
def _set_weight(self):
|
|
if len(self.product_variant_ids) == 1:
|
|
self.product_variant_ids.weight = self.weight
|
|
|
|
@api.one
|
|
@api.depends('product_variant_ids.product_tmpl_id')
|
|
def _compute_product_variant_count(self):
|
|
# do not pollute variants to be prefetched when counting variants
|
|
self.product_variant_count = len(self.with_prefetch().product_variant_ids)
|
|
|
|
@api.depends('product_variant_ids', 'product_variant_ids.default_code')
|
|
def _compute_default_code(self):
|
|
unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1)
|
|
for template in unique_variants:
|
|
template.default_code = template.product_variant_ids.default_code
|
|
for template in (self - unique_variants):
|
|
template.default_code = ''
|
|
|
|
@api.one
|
|
def _set_default_code(self):
|
|
if len(self.product_variant_ids) == 1:
|
|
self.product_variant_ids.default_code = self.default_code
|
|
|
|
@api.depends('product_variant_ids', 'product_variant_ids.packaging_ids')
|
|
def _compute_packaging_ids(self):
|
|
for p in self:
|
|
if len(p.product_variant_ids) == 1:
|
|
p.packaging_ids = p.product_variant_ids.packaging_ids
|
|
|
|
def _set_packaging_ids(self):
|
|
for p in self:
|
|
if len(p.product_variant_ids) == 1:
|
|
p.product_variant_ids.packaging_ids = p.packaging_ids
|
|
|
|
@api.constrains('uom_id', 'uom_po_id')
|
|
def _check_uom(self):
|
|
if any(template.uom_id and template.uom_po_id and template.uom_id.category_id != template.uom_po_id.category_id for template in self):
|
|
raise ValidationError(_('Error: The default Unit of Measure and the purchase Unit of Measure must be in the same category.'))
|
|
return True
|
|
|
|
@api.onchange('uom_id')
|
|
def _onchange_uom_id(self):
|
|
if self.uom_id:
|
|
self.uom_po_id = self.uom_id.id
|
|
|
|
@api.model
|
|
def create(self, vals):
|
|
''' Store the initial standard price in order to be able to retrieve the cost of a product template for a given date'''
|
|
# TDE FIXME: context brol
|
|
tools.image_resize_images(vals)
|
|
template = super(ProductTemplate, self).create(vals)
|
|
if "create_product_product" not in self._context:
|
|
template.with_context(create_from_tmpl=True).create_variant_ids()
|
|
|
|
# This is needed to set given values to first variant after creation
|
|
related_vals = {}
|
|
if vals.get('barcode'):
|
|
related_vals['barcode'] = vals['barcode']
|
|
if vals.get('default_code'):
|
|
related_vals['default_code'] = vals['default_code']
|
|
if vals.get('standard_price'):
|
|
related_vals['standard_price'] = vals['standard_price']
|
|
if vals.get('volume'):
|
|
related_vals['volume'] = vals['volume']
|
|
if vals.get('weight'):
|
|
related_vals['weight'] = vals['weight']
|
|
if related_vals:
|
|
template.write(related_vals)
|
|
return template
|
|
|
|
@api.multi
|
|
def write(self, vals):
|
|
tools.image_resize_images(vals)
|
|
res = super(ProductTemplate, self).write(vals)
|
|
if 'attribute_line_ids' in vals or vals.get('active'):
|
|
self.create_variant_ids()
|
|
if 'active' in vals and not vals.get('active'):
|
|
self.with_context(active_test=False).mapped('product_variant_ids').write({'active': vals.get('active')})
|
|
return res
|
|
|
|
@api.multi
|
|
def copy(self, default=None):
|
|
# TDE FIXME: should probably be copy_data
|
|
self.ensure_one()
|
|
if default is None:
|
|
default = {}
|
|
if 'name' not in default:
|
|
default['name'] = _("%s (copy)") % self.name
|
|
return super(ProductTemplate, self).copy(default=default)
|
|
|
|
@api.multi
|
|
def name_get(self):
|
|
return [(template.id, '%s%s' % (template.default_code and '[%s] ' % template.default_code or '', template.name))
|
|
for template in self]
|
|
|
|
@api.model
|
|
def name_search(self, name='', args=None, operator='ilike', limit=100):
|
|
# Only use the product.product heuristics if there is a search term and the domain
|
|
# does not specify a match on `product.template` IDs.
|
|
if not name or any(term[0] == 'id' for term in (args or [])):
|
|
return super(ProductTemplate, self).name_search(name=name, args=args, operator=operator, limit=limit)
|
|
|
|
Product = self.env['product.product']
|
|
templates = self.browse([])
|
|
while True:
|
|
domain = templates and [('product_tmpl_id', 'not in', templates.ids)] or []
|
|
args = args if args is not None else []
|
|
products_ns = Product.name_search(name, args+domain, operator=operator)
|
|
products = Product.browse([x[0] for x in products_ns])
|
|
templates |= products.mapped('product_tmpl_id')
|
|
if (not products) or (limit and (len(templates) > limit)):
|
|
break
|
|
|
|
# re-apply product.template order + name_get
|
|
return super(ProductTemplate, self).name_search(
|
|
'', args=[('id', 'in', list(set(templates.ids)))],
|
|
operator='ilike', limit=limit)
|
|
|
|
@api.multi
|
|
def price_compute(self, price_type, uom=False, currency=False, company=False):
|
|
# TDE FIXME: delegate to template or not ? fields are reencoded here ...
|
|
# compatibility about context keys used a bit everywhere in the code
|
|
if not uom and self._context.get('uom'):
|
|
uom = self.env['product.uom'].browse(self._context['uom'])
|
|
if not currency and self._context.get('currency'):
|
|
currency = self.env['res.currency'].browse(self._context['currency'])
|
|
|
|
templates = self
|
|
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
|
|
templates = self.with_context(force_company=company and company.id or self._context.get('force_company', self.env.user.company_id.id)).sudo()
|
|
|
|
prices = dict.fromkeys(self.ids, 0.0)
|
|
for template in templates:
|
|
prices[template.id] = template[price_type] or 0.0
|
|
|
|
if uom:
|
|
prices[template.id] = template.uom_id._compute_price(prices[template.id], 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:
|
|
prices[template.id] = template.currency_id.compute(prices[template.id], currency)
|
|
|
|
return prices
|
|
|
|
# compatibility to remove after v10 - DEPRECATED
|
|
@api.model
|
|
def _price_get(self, products, ptype='list_price'):
|
|
return products.price_compute(ptype)
|
|
|
|
@api.multi
|
|
def create_variant_ids(self):
|
|
Product = self.env["product.product"]
|
|
AttributeValues = self.env['product.attribute.value']
|
|
for tmpl_id in self.with_context(active_test=False):
|
|
# adding an attribute with only one value should not recreate product
|
|
# write this attribute on every product to make sure we don't lose them
|
|
variant_alone = tmpl_id.attribute_line_ids.filtered(lambda line: line.attribute_id.create_variant and len(line.value_ids) == 1).mapped('value_ids')
|
|
for value_id in variant_alone:
|
|
updated_products = tmpl_id.product_variant_ids.filtered(lambda product: value_id.attribute_id not in product.mapped('attribute_value_ids.attribute_id'))
|
|
updated_products.write({'attribute_value_ids': [(4, value_id.id)]})
|
|
|
|
# iterator of n-uple of product.attribute.value *ids*
|
|
variant_matrix = [
|
|
AttributeValues.browse(value_ids)
|
|
for value_ids in itertools.product(*(line.value_ids.ids for line in tmpl_id.attribute_line_ids if line.value_ids[:1].attribute_id.create_variant))
|
|
]
|
|
|
|
# get the value (id) sets of existing variants
|
|
existing_variants = {frozenset(variant.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant).ids) for variant in tmpl_id.product_variant_ids}
|
|
# -> for each value set, create a recordset of values to create a
|
|
# variant for if the value set isn't already a variant
|
|
to_create_variants = [
|
|
value_ids
|
|
for value_ids in variant_matrix
|
|
if set(value_ids.ids) not in existing_variants
|
|
]
|
|
|
|
# check product
|
|
variants_to_activate = self.env['product.product']
|
|
variants_to_unlink = self.env['product.product']
|
|
for product_id in tmpl_id.product_variant_ids:
|
|
if not product_id.active and product_id.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant) in variant_matrix:
|
|
variants_to_activate |= product_id
|
|
elif product_id.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant) not in variant_matrix:
|
|
variants_to_unlink |= product_id
|
|
if variants_to_activate:
|
|
variants_to_activate.write({'active': True})
|
|
|
|
# create new product
|
|
for variant_ids in to_create_variants:
|
|
new_variant = Product.create({
|
|
'product_tmpl_id': tmpl_id.id,
|
|
'attribute_value_ids': [(6, 0, variant_ids.ids)]
|
|
})
|
|
|
|
# unlink or inactive product
|
|
for variant in variants_to_unlink:
|
|
try:
|
|
with self._cr.savepoint(), tools.mute_logger('flectra.sql_db'):
|
|
variant.unlink()
|
|
# We catch all kind of exception to be sure that the operation doesn't fail.
|
|
except (psycopg2.Error, except_orm):
|
|
variant.write({'active': False})
|
|
pass
|
|
return True
|