# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import re from odoo import api, fields, models, tools, _ from odoo.exceptions import ValidationError from odoo.osv import expression import odoo.addons.decimal_precision as dp class ProductCategory(models.Model): _name = "product.category" _description = "Product Category" _parent_name = "parent_id" _parent_store = True _parent_order = 'name' _order = 'parent_left' name = fields.Char('Name', index=True, required=True, translate=True) parent_id = fields.Many2one('product.category', 'Parent Category', index=True, ondelete='cascade') child_id = fields.One2many('product.category', 'parent_id', 'Child Categories') type = fields.Selection([ ('view', 'View'), ('normal', 'Normal')], 'Category Type', default='normal', help="A category of the view type is a virtual category that can be used as the parent of another category to create a hierarchical structure.") parent_left = fields.Integer('Left Parent', index=1) parent_right = fields.Integer('Right Parent', index=1) product_count = fields.Integer( '# Products', compute='_compute_product_count', help="The number of products under this category (Does not consider the children categories)") def _compute_product_count(self): read_group_res = self.env['product.template'].read_group([('categ_id', 'child_of', self.ids)], ['categ_id'], ['categ_id']) group_data = dict((data['categ_id'][0], data['categ_id_count']) for data in read_group_res) for categ in self: product_count = 0 for sub_categ_id in categ.search([('id', 'child_of', categ.id)]).ids: product_count += group_data.get(sub_categ_id, 0) categ.product_count = product_count @api.constrains('parent_id') def _check_category_recursion(self): if not self._check_recursion(): raise ValidationError(_('Error ! You cannot create recursive categories.')) return True @api.multi def name_get(self): def get_names(cat): """ Return the list [cat.name, cat.parent_id.name, ...] """ res = [] while cat: res.append(cat.name) cat = cat.parent_id return res return [(cat.id, " / ".join(reversed(get_names(cat)))) for cat in self] @api.model def name_search(self, name, args=None, operator='ilike', limit=100): if not args: args = [] if name: # Be sure name_search is symetric to name_get category_names = name.split(' / ') parents = list(category_names) child = parents.pop() domain = [('name', operator, child)] if parents: names_ids = self.name_search(' / '.join(parents), args=args, operator='ilike', limit=limit) category_ids = [name_id[0] for name_id in names_ids] if operator in expression.NEGATIVE_TERM_OPERATORS: categories = self.search([('id', 'not in', category_ids)]) domain = expression.OR([[('parent_id', 'in', categories.ids)], domain]) else: domain = expression.AND([[('parent_id', 'in', category_ids)], domain]) for i in range(1, len(category_names)): domain = [[('name', operator, ' / '.join(category_names[-1 - i:]))], domain] if operator in expression.NEGATIVE_TERM_OPERATORS: domain = expression.AND(domain) else: domain = expression.OR(domain) categories = self.search(expression.AND([domain, args]), limit=limit) else: categories = self.search(args, limit=limit) return categories.name_get() class ProductPriceHistory(models.Model): """ Keep track of the ``product.template`` standard prices as they are changed. """ _name = 'product.price.history' _rec_name = 'datetime' _order = 'datetime desc' def _get_default_company_id(self): return self._context.get('force_company', self.env.user.company_id.id) company_id = fields.Many2one('res.company', string='Company', default=_get_default_company_id, required=True) product_id = fields.Many2one('product.product', 'Product', ondelete='cascade', required=True) datetime = fields.Datetime('Date', default=fields.Datetime.now) cost = fields.Float('Cost', digits=dp.get_precision('Product Price')) class ProductProduct(models.Model): _name = "product.product" _description = "Product" _inherits = {'product.template': 'product_tmpl_id'} _inherit = ['mail.thread'] _order = 'default_code, name, id' price = fields.Float( 'Price', compute='_compute_product_price', digits=dp.get_precision('Product Price'), inverse='_set_product_price') price_extra = fields.Float( 'Variant Price Extra', compute='_compute_product_price_extra', digits=dp.get_precision('Product Price'), help="This is the sum of the extra price of all attributes") lst_price = fields.Float( 'Sale Price', compute='_compute_product_lst_price', digits=dp.get_precision('Product Price'), inverse='_set_product_lst_price', help="The sale price is managed from the product template. Click on the 'Variant Prices' button to set the extra attribute prices.") default_code = fields.Char('Internal Reference', index=True) code = fields.Char('Internal 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, oldname='ean13', help="International Article Number used for product identification.") attribute_value_ids = fields.Many2many( 'product.attribute.value', string='Attributes', ondelete='restrict') # image: all image fields are base64 encoded and PIL-supported image_variant = fields.Binary( "Variant Image", attachment=True, help="This field holds the image used as image for the product variant, limited to 1024x1024px.") image = fields.Binary( "Big-sized image", compute='_compute_images', inverse='_set_image', help="Image of the product variant (Big-sized image of product template if false). It is automatically " "resized as a 1024x1024px image, with aspect ratio preserved.") image_small = fields.Binary( "Small-sized image", compute='_compute_images', inverse='_set_image_small', help="Image of the product variant (Small-sized image of product template if false).") image_medium = fields.Binary( "Medium-sized image", compute='_compute_images', inverse='_set_image_medium', help="Image of the product variant (Medium-sized image of product template if false).") standard_price = fields.Float( 'Cost', company_dependent=True, digits=dp.get_precision('Product Price'), groups="base.group_user", help="Cost of the product template used for standard stock valuation in accounting and used as a base price on purchase orders. " "Expressed in the default unit of measure of the product.") volume = fields.Float('Volume', help="The volume in m3.") weight = fields.Float( 'Weight', digits=dp.get_precision('Stock Weight'), help="The weight of the contents in Kg, not including any packaging, etc.") pricelist_item_ids = fields.Many2many( 'product.pricelist.item', 'Pricelist Items', compute='_get_pricelist_items') _sql_constraints = [ ('barcode_uniq', 'unique(barcode)', _("A barcode can only be assigned to one product !")), ] def _compute_product_price(self): prices = {} pricelist_id_or_name = self._context.get('pricelist') if pricelist_id_or_name: pricelist = None partner = self._context.get('partner', False) quantity = self._context.get('quantity', 1.0) # Support context pricelists specified as display_name or ID for compatibility if isinstance(pricelist_id_or_name, basestring): pricelist_name_search = self.env['product.pricelist'].name_search(pricelist_id_or_name, operator='=', limit=1) if pricelist_name_search: pricelist = self.env['product.pricelist'].browse([pricelist_name_search[0][0]]) elif isinstance(pricelist_id_or_name, (int, long)): 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 product in self: product.price = prices.get(product.id, 0.0) def _set_product_price(self): for product in self: if self._context.get('uom'): value = self.env['product.uom'].browse(self._context['uom'])._compute_price(product.price, product.uom_id) else: value = product.price value -= product.price_extra product.write({'list_price': value}) def _set_product_lst_price(self): for product in self: if self._context.get('uom'): value = self.env['product.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('attribute_value_ids.price_ids.price_extra', 'attribute_value_ids.price_ids.product_tmpl_id') def _compute_product_price_extra(self): # TDE FIXME: do a real multi and optimize a bit ? for product in self: price_extra = 0.0 for attribute_price in product.mapped('attribute_value_ids.price_ids'): if attribute_price.product_tmpl_id == product.product_tmpl_id: price_extra += attribute_price.price_extra product.price_extra = price_extra @api.depends('list_price', 'price_extra') def _compute_product_lst_price(self): to_uom = None if 'uom' in self._context: to_uom = self.env['product.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.one def _compute_product_code(self): for supplier_info in self.seller_ids: if supplier_info.name.id == self._context.get('partner_id'): self.code = supplier_info.product_code or self.default_code break else: self.code = self.default_code @api.one def _compute_partner_ref(self): for supplier_info in self.seller_ids: if supplier_info.name.id == self._context.get('partner_id'): product_name = supplier_info.product_name or self.default_code break else: product_name = self.name self.partner_ref = '%s%s' % (self.code and '[%s] ' % self.code or '', product_name) @api.one @api.depends('image_variant', 'product_tmpl_id.image') def _compute_images(self): if self._context.get('bin_size'): self.image_medium = self.image_variant self.image_small = self.image_variant self.image = self.image_variant else: resized_images = tools.image_get_resized_images(self.image_variant, return_big=True, avoid_resize_medium=True) self.image_medium = resized_images['image_medium'] self.image_small = resized_images['image_small'] self.image = resized_images['image'] if not self.image_medium: self.image_medium = self.product_tmpl_id.image_medium if not self.image_small: self.image_small = self.product_tmpl_id.image_small if not self.image: self.image = self.product_tmpl_id.image @api.one def _set_image(self): self._set_image_value(self.image) @api.one def _set_image_medium(self): self._set_image_value(self.image_medium) @api.one def _set_image_small(self): self._set_image_value(self.image_small) @api.one def _set_image_value(self, value): image = tools.image_resize_image_big(value) if self.product_tmpl_id.image: self.image_variant = image else: self.product_tmpl_id.image = image @api.one def _get_pricelist_items(self): self.pricelist_item_ids = self.env['product.pricelist.item'].search([ '|', ('product_id', '=', self.id), ('product_tmpl_id', '=', self.product_tmpl_id.id)]).ids @api.constrains('attribute_value_ids') def _check_attribute_value_ids(self): for product in self: attributes = self.env['product.attribute'] for value in product.attribute_value_ids: if value.attribute_id in attributes: raise ValidationError(_('Error! It is not allowed to choose more than one value for a given attribute.')) if value.attribute_id.create_variant: attributes |= value.attribute_id return True @api.onchange('uom_id', '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.model def create(self, vals): product = super(ProductProduct, self.with_context(create_product_product=True)).create(vals) # When a unique variant is created from tmpl then the standard price is set by _set_standard_price if not (self.env.context.get('create_from_tmpl') and len(product.product_tmpl_id.product_variant_ids) == 1): product._set_standard_price(vals.get('standard_price') or 0.0) return product @api.multi def write(self, values): ''' Store the standard price change in order to be able to retrieve the cost of a product for a given date''' res = super(ProductProduct, self).write(values) if 'standard_price' in values: self._set_standard_price(values['standard_price']) return res @api.multi def unlink(self): unlink_products = self.env['product.product'] unlink_templates = self.env['product.template'] for product in self: # 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)]) if not other_products: 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() return res @api.multi def copy(self, default=None): # TDE FIXME: clean context / variant brol if default is None: default = {} if self._context.get('variant'): # if we copy a variant or create one, we keep the same template default['product_tmpl_id'] = self.product_tmpl_id.id elif 'name' not in default: default['name'] = self.name return super(ProductProduct, self).copy(default=default) @api.model def search(self, args, offset=0, limit=None, order=None, count=False): # TDE FIXME: strange if self._context.get('search_default_categ_id'): args.append((('categ_id', 'child_of', self._context['search_default_categ_id']))) return super(ProductProduct, self).search(args, offset=offset, limit=limit, order=order, count=count) @api.multi def name_get(self): # TDE: this could be cleaned a bit I think def _name_get(d): name = d.get('name', '') code = self._context.get('display_default_code', True) and d.get('default_code', False) or False if code: name = '[%s] %s' % (code,name) return (d['id'], 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 = [] # 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") result = [] for product in self.sudo(): # display only the attributes with multiple possible values on the template variable_attributes = product.attribute_line_ids.filtered(lambda l: len(l.value_ids) > 1).mapped('attribute_id') variant = product.attribute_value_ids._variant_name(variable_attributes) name = variant and "%s (%s)" % (product.name, variant) or product.name sellers = [] if partner_ids: sellers = [x for x in product.seller_ids if (x.name.id in partner_ids) and (x.product_id == product)] if not sellers: sellers = [x for x in product.seller_ids if (x.name.id in partner_ids) and not x.product_id] if sellers: for s in sellers: seller_variant = s.product_name and ( variant and "%s (%s)" % (s.product_name, variant) or s.product_name ) or False mydict = { 'id': product.id, 'name': seller_variant or name, 'default_code': s.product_code or product.default_code, } temp = _name_get(mydict) if temp not in result: result.append(temp) else: mydict = { 'id': product.id, 'name': name, 'default_code': product.default_code, } result.append(_name_get(mydict)) return result @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): if not args: args = [] if name: positive_operators = ['=', 'ilike', '=ilike', 'like', '=like'] products = self.env['product.product'] if operator in positive_operators: products = self.search([('default_code', '=', name)] + args, limit=limit) if not products: products = self.search([('barcode', '=', name)] + args, limit=limit) if not products 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 products = self.search(args + [('default_code', operator, name)], limit=limit) if not limit or len(products) < limit: # we may underrun the limit because of dupes in the results, that's fine limit2 = (limit - len(products)) if limit else False products += self.search(args + [('name', operator, name), ('id', 'not in', products.ids)], limit=limit2) elif not products and operator in expression.NEGATIVE_TERM_OPERATORS: domain = expression.OR([ ['&', ('default_code', operator, name), ('name', operator, name)], ['&', ('default_code', '=', False), ('name', operator, name)], ]) domain = expression.AND([args, domain]) products = self.search(domain, limit=limit) if not products and operator in positive_operators: ptrn = re.compile('(\[(.*?)\])') res = ptrn.search(name) if res: products = self.search([('default_code', '=', res.group(2))] + args, limit=limit) # still no results, partner in context: search on supplier info as last hope to find something if not products and self._context.get('partner_id'): suppliers = self.env['product.supplierinfo'].search([ ('name', '=', self._context.get('partner_id')), '|', ('product_code', operator, name), ('product_name', operator, name)]) if suppliers: products = self.search([('product_tmpl_id.seller_ids', 'in', suppliers.ids)], limit=limit) else: products = self.search(args, limit=limit) return products.name_get() @api.model def view_header_get(self, view_id, view_type): res = super(ProductProduct, self).view_header_get(view_id, view_type) if self._context.get('categ_id'): return _('Products: ') + self.env['product.category'].browse(self._context['categ_id']).name return res @api.multi 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'} @api.multi def _select_seller(self, partner_id=False, quantity=0.0, date=None, uom_id=False): self.ensure_one() if date is None: date = fields.Date.today() res = self.env['product.supplierinfo'] for seller in self.seller_ids: # 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.name not in [partner_id, partner_id.parent_id]: continue if quantity_uom_seller < seller.min_qty: continue if seller.product_id and seller.product_id != self: continue res |= seller break return res @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']) products = 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 products = 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 product in products: prices[product.id] = product[price_type] or 0.0 if price_type == 'list_price': prices[product.id] += product.price_extra if uom: prices[product.id] = product.uom_id._compute_price(prices[product.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[product.id] = product.currency_id.compute(prices[product.id], currency) return prices # compatibility to remove after v10 - DEPRECATED @api.multi def price_get(self, ptype='list_price'): return self.price_compute(ptype) @api.multi def _set_standard_price(self, value): ''' Store the standard price change in order to be able to retrieve the cost of a product for a given date''' PriceHistory = self.env['product.price.history'] for product in self: PriceHistory.create({ 'product_id': product.id, 'cost': value, 'company_id': self._context.get('force_company', self.env.user.company_id.id), }) @api.multi def get_history_price(self, company_id, date=None): history = self.env['product.price.history'].search([ ('company_id', '=', company_id), ('product_id', 'in', self.ids), ('datetime', '<=', date or fields.Datetime.now())], order='datetime desc,id desc', limit=1) return history.cost or 0.0 def _need_procurement(self): # When sale/product is installed alone, there is no need to create procurements. Only # sale_stock and sale_service need procurements return False class ProductPackaging(models.Model): _name = "product.packaging" _description = "Packaging" _order = 'sequence' name = fields.Char('Packaging Type', required=True) sequence = fields.Integer('Sequence', default=1, help="The first in the sequence is the default one.") product_tmpl_id = fields.Many2one('product.template', string='Product') qty = fields.Float('Quantity per Package', help="The total number of products you can have per pallet or box.") class SuppliferInfo(models.Model): _name = "product.supplierinfo" _description = "Information about a product vendor" _order = 'sequence, min_qty desc, price' name = fields.Many2one( 'res.partner', 'Vendor', domain=[('supplier', '=', True)], ondelete='cascade', required=True, help="Vendor of this product") product_name = fields.Char( 'Vendor Product Name', help="This vendor's product name will be used when printing a request for quotation. Keep empty to use the internal one.") product_code = fields.Char( 'Vendor Product Code', help="This vendor's product code will be used when printing a request for quotation. Keep empty to use the internal one.") sequence = fields.Integer( 'Sequence', default=1, help="Assigns the priority to the list of product vendor.") product_uom = fields.Many2one( 'product.uom', 'Vendor Unit of Measure', readonly="1", related='product_tmpl_id.uom_po_id', help="This comes from the product form.") min_qty = fields.Float( 'Minimal Quantity', default=0.0, required=True, help="The minimal quantity to purchase from this vendor, expressed in the vendor Product Unit of Measure if not any, in the default unit of measure of the product otherwise.") price = fields.Float( 'Price', default=0.0, digits=dp.get_precision('Product Price'), required=True, help="The price to purchase a product") company_id = fields.Many2one( 'res.company', 'Company', default=lambda self: self.env.user.company_id.id, index=1) currency_id = fields.Many2one( 'res.currency', 'Currency', default=lambda self: self.env.user.company_id.currency_id.id, required=True) date_start = fields.Date('Start Date', help="Start date for this vendor price") date_end = fields.Date('End Date', help="End date for this vendor price") product_id = fields.Many2one( 'product.product', 'Product Variant', help="When this field is filled in, the vendor data will only apply to the variant.") product_tmpl_id = fields.Many2one( 'product.template', 'Product Template', index=True, ondelete='cascade', oldname='product_id') delay = fields.Integer( 'Delivery Lead Time', default=1, required=True, help="Lead time in days between the confirmation of the purchase order and the receipt of the products in your warehouse. Used by the scheduler for automatic computation of the purchase order planning.")