1# -*- coding: utf-8 -*- 2# Part of Odoo. See LICENSE file for full copyright and licensing details. 3 4import logging 5import re 6 7from odoo import api, fields, models, tools, _ 8from odoo.exceptions import UserError, ValidationError 9from odoo.osv import expression 10 11 12from odoo.tools import float_compare 13 14_logger = logging.getLogger(__name__) 15 16 17 18class ProductCategory(models.Model): 19 _name = "product.category" 20 _description = "Product Category" 21 _parent_name = "parent_id" 22 _parent_store = True 23 _rec_name = 'complete_name' 24 _order = 'complete_name' 25 26 name = fields.Char('Name', index=True, required=True) 27 complete_name = fields.Char( 28 'Complete Name', compute='_compute_complete_name', 29 store=True) 30 parent_id = fields.Many2one('product.category', 'Parent Category', index=True, ondelete='cascade') 31 parent_path = fields.Char(index=True) 32 child_id = fields.One2many('product.category', 'parent_id', 'Child Categories') 33 product_count = fields.Integer( 34 '# Products', compute='_compute_product_count', 35 help="The number of products under this category (Does not consider the children categories)") 36 37 @api.depends('name', 'parent_id.complete_name') 38 def _compute_complete_name(self): 39 for category in self: 40 if category.parent_id: 41 category.complete_name = '%s / %s' % (category.parent_id.complete_name, category.name) 42 else: 43 category.complete_name = category.name 44 45 def _compute_product_count(self): 46 read_group_res = self.env['product.template'].read_group([('categ_id', 'child_of', self.ids)], ['categ_id'], ['categ_id']) 47 group_data = dict((data['categ_id'][0], data['categ_id_count']) for data in read_group_res) 48 for categ in self: 49 product_count = 0 50 for sub_categ_id in categ.search([('id', 'child_of', categ.ids)]).ids: 51 product_count += group_data.get(sub_categ_id, 0) 52 categ.product_count = product_count 53 54 @api.constrains('parent_id') 55 def _check_category_recursion(self): 56 if not self._check_recursion(): 57 raise ValidationError(_('You cannot create recursive categories.')) 58 return True 59 60 @api.model 61 def name_create(self, name): 62 return self.create({'name': name}).name_get()[0] 63 64 def unlink(self): 65 main_category = self.env.ref('product.product_category_all') 66 if main_category in self: 67 raise UserError(_("You cannot delete this product category, it is the default generic category.")) 68 return super().unlink() 69 70 71class ProductProduct(models.Model): 72 _name = "product.product" 73 _description = "Product" 74 _inherits = {'product.template': 'product_tmpl_id'} 75 _inherit = ['mail.thread', 'mail.activity.mixin'] 76 _order = 'default_code, name, id' 77 78 # price: total price, context dependent (partner, pricelist, quantity) 79 price = fields.Float( 80 'Price', compute='_compute_product_price', 81 digits='Product Price', inverse='_set_product_price') 82 # price_extra: catalog extra value only, sum of variant extra attributes 83 price_extra = fields.Float( 84 'Variant Price Extra', compute='_compute_product_price_extra', 85 digits='Product Price', 86 help="This is the sum of the extra price of all attributes") 87 # lst_price: catalog value + extra, context dependent (uom) 88 lst_price = fields.Float( 89 'Public Price', compute='_compute_product_lst_price', 90 digits='Product Price', inverse='_set_product_lst_price', 91 help="The sale price is managed from the product template. Click on the 'Configure Variants' button to set the extra attribute prices.") 92 93 default_code = fields.Char('Internal Reference', index=True) 94 code = fields.Char('Reference', compute='_compute_product_code') 95 partner_ref = fields.Char('Customer Ref', compute='_compute_partner_ref') 96 97 active = fields.Boolean( 98 'Active', default=True, 99 help="If unchecked, it will allow you to hide the product without removing it.") 100 product_tmpl_id = fields.Many2one( 101 'product.template', 'Product Template', 102 auto_join=True, index=True, ondelete="cascade", required=True) 103 barcode = fields.Char( 104 'Barcode', copy=False, 105 help="International Article Number used for product identification.") 106 product_template_attribute_value_ids = fields.Many2many('product.template.attribute.value', relation='product_variant_combination', string="Attribute Values", ondelete='restrict') 107 combination_indices = fields.Char(compute='_compute_combination_indices', store=True, index=True) 108 is_product_variant = fields.Boolean(compute='_compute_is_product_variant') 109 110 standard_price = fields.Float( 111 'Cost', company_dependent=True, 112 digits='Product Price', 113 groups="base.group_user", 114 help="""In Standard Price & AVCO: value of the product (automatically computed in AVCO). 115 In FIFO: value of the last unit that left the stock (automatically computed). 116 Used to value the product when the purchase cost is not known (e.g. inventory adjustment). 117 Used to compute margins on sale orders.""") 118 volume = fields.Float('Volume', digits='Volume') 119 weight = fields.Float('Weight', digits='Stock Weight') 120 121 pricelist_item_count = fields.Integer("Number of price rules", compute="_compute_variant_item_count") 122 123 packaging_ids = fields.One2many( 124 'product.packaging', 'product_id', 'Product Packages', 125 help="Gives the different ways to package the same product.") 126 127 # all image fields are base64 encoded and PIL-supported 128 129 # all image_variant fields are technical and should not be displayed to the user 130 image_variant_1920 = fields.Image("Variant Image", max_width=1920, max_height=1920) 131 132 # resized fields stored (as attachment) for performance 133 image_variant_1024 = fields.Image("Variant Image 1024", related="image_variant_1920", max_width=1024, max_height=1024, store=True) 134 image_variant_512 = fields.Image("Variant Image 512", related="image_variant_1920", max_width=512, max_height=512, store=True) 135 image_variant_256 = fields.Image("Variant Image 256", related="image_variant_1920", max_width=256, max_height=256, store=True) 136 image_variant_128 = fields.Image("Variant Image 128", related="image_variant_1920", max_width=128, max_height=128, store=True) 137 can_image_variant_1024_be_zoomed = fields.Boolean("Can Variant Image 1024 be zoomed", compute='_compute_can_image_variant_1024_be_zoomed', store=True) 138 139 # Computed fields that are used to create a fallback to the template if 140 # necessary, it's recommended to display those fields to the user. 141 image_1920 = fields.Image("Image", compute='_compute_image_1920', inverse='_set_image_1920') 142 image_1024 = fields.Image("Image 1024", compute='_compute_image_1024') 143 image_512 = fields.Image("Image 512", compute='_compute_image_512') 144 image_256 = fields.Image("Image 256", compute='_compute_image_256') 145 image_128 = fields.Image("Image 128", compute='_compute_image_128') 146 can_image_1024_be_zoomed = fields.Boolean("Can Image 1024 be zoomed", compute='_compute_can_image_1024_be_zoomed') 147 148 @api.depends('image_variant_1920', 'image_variant_1024') 149 def _compute_can_image_variant_1024_be_zoomed(self): 150 for record in self: 151 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) 152 153 def _compute_image_1920(self): 154 """Get the image from the template if no image is set on the variant.""" 155 for record in self: 156 record.image_1920 = record.image_variant_1920 or record.product_tmpl_id.image_1920 157 158 def _set_image_1920(self): 159 for record in self: 160 if ( 161 # We are trying to remove an image even though it is already 162 # not set, remove it from the template instead. 163 not record.image_1920 and not record.image_variant_1920 or 164 # We are trying to add an image, but the template image is 165 # not set, write on the template instead. 166 record.image_1920 and not record.product_tmpl_id.image_1920 or 167 # There is only one variant, always write on the template. 168 self.search_count([ 169 ('product_tmpl_id', '=', record.product_tmpl_id.id), 170 ('active', '=', True), 171 ]) <= 1 172 ): 173 record.image_variant_1920 = False 174 record.product_tmpl_id.image_1920 = record.image_1920 175 else: 176 record.image_variant_1920 = record.image_1920 177 178 def _compute_image_1024(self): 179 """Get the image from the template if no image is set on the variant.""" 180 for record in self: 181 record.image_1024 = record.image_variant_1024 or record.product_tmpl_id.image_1024 182 183 def _compute_image_512(self): 184 """Get the image from the template if no image is set on the variant.""" 185 for record in self: 186 record.image_512 = record.image_variant_512 or record.product_tmpl_id.image_512 187 188 def _compute_image_256(self): 189 """Get the image from the template if no image is set on the variant.""" 190 for record in self: 191 record.image_256 = record.image_variant_256 or record.product_tmpl_id.image_256 192 193 def _compute_image_128(self): 194 """Get the image from the template if no image is set on the variant.""" 195 for record in self: 196 record.image_128 = record.image_variant_128 or record.product_tmpl_id.image_128 197 198 def _compute_can_image_1024_be_zoomed(self): 199 """Get the image from the template if no image is set on the variant.""" 200 for record in self: 201 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 202 203 def init(self): 204 """Ensure there is at most one active variant for each combination. 205 206 There could be no variant for a combination if using dynamic attributes. 207 """ 208 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" 209 % self._table) 210 211 _sql_constraints = [ 212 ('barcode_uniq', 'unique(barcode)', "A barcode can only be assigned to one product !"), 213 ] 214 215 def _get_invoice_policy(self): 216 return False 217 218 @api.depends('product_template_attribute_value_ids') 219 def _compute_combination_indices(self): 220 for product in self: 221 product.combination_indices = product.product_template_attribute_value_ids._ids2str() 222 223 def _compute_is_product_variant(self): 224 self.is_product_variant = True 225 226 @api.depends_context('pricelist', 'partner', 'quantity', 'uom', 'date', 'no_variant_attributes_price_extra') 227 def _compute_product_price(self): 228 prices = {} 229 pricelist_id_or_name = self._context.get('pricelist') 230 if pricelist_id_or_name: 231 pricelist = None 232 partner = self.env.context.get('partner', False) 233 quantity = self.env.context.get('quantity', 1.0) 234 235 # Support context pricelists specified as list, display_name or ID for compatibility 236 if isinstance(pricelist_id_or_name, list): 237 pricelist_id_or_name = pricelist_id_or_name[0] 238 if isinstance(pricelist_id_or_name, str): 239 pricelist_name_search = self.env['product.pricelist'].name_search(pricelist_id_or_name, operator='=', limit=1) 240 if pricelist_name_search: 241 pricelist = self.env['product.pricelist'].browse([pricelist_name_search[0][0]]) 242 elif isinstance(pricelist_id_or_name, int): 243 pricelist = self.env['product.pricelist'].browse(pricelist_id_or_name) 244 245 if pricelist: 246 quantities = [quantity] * len(self) 247 partners = [partner] * len(self) 248 prices = pricelist.get_products_price(self, quantities, partners) 249 250 for product in self: 251 product.price = prices.get(product.id, 0.0) 252 253 def _set_product_price(self): 254 for product in self: 255 if self._context.get('uom'): 256 value = self.env['uom.uom'].browse(self._context['uom'])._compute_price(product.price, product.uom_id) 257 else: 258 value = product.price 259 value -= product.price_extra 260 product.write({'list_price': value}) 261 262 def _set_product_lst_price(self): 263 for product in self: 264 if self._context.get('uom'): 265 value = self.env['uom.uom'].browse(self._context['uom'])._compute_price(product.lst_price, product.uom_id) 266 else: 267 value = product.lst_price 268 value -= product.price_extra 269 product.write({'list_price': value}) 270 271 def _compute_product_price_extra(self): 272 for product in self: 273 product.price_extra = sum(product.product_template_attribute_value_ids.mapped('price_extra')) 274 275 @api.depends('list_price', 'price_extra') 276 @api.depends_context('uom') 277 def _compute_product_lst_price(self): 278 to_uom = None 279 if 'uom' in self._context: 280 to_uom = self.env['uom.uom'].browse(self._context['uom']) 281 282 for product in self: 283 if to_uom: 284 list_price = product.uom_id._compute_price(product.list_price, to_uom) 285 else: 286 list_price = product.list_price 287 product.lst_price = list_price + product.price_extra 288 289 @api.depends_context('partner_id') 290 def _compute_product_code(self): 291 for product in self: 292 for supplier_info in product.seller_ids: 293 if supplier_info.name.id == product._context.get('partner_id'): 294 product.code = supplier_info.product_code or product.default_code 295 break 296 else: 297 product.code = product.default_code 298 299 @api.depends_context('partner_id') 300 def _compute_partner_ref(self): 301 for product in self: 302 for supplier_info in product.seller_ids: 303 if supplier_info.name.id == product._context.get('partner_id'): 304 product_name = supplier_info.product_name or product.default_code or product.name 305 product.partner_ref = '%s%s' % (product.code and '[%s] ' % product.code or '', product_name) 306 break 307 else: 308 product.partner_ref = product.display_name 309 310 def _compute_variant_item_count(self): 311 for product in self: 312 domain = ['|', 313 '&', ('product_tmpl_id', '=', product.product_tmpl_id.id), ('applied_on', '=', '1_product'), 314 '&', ('product_id', '=', product.id), ('applied_on', '=', '0_product_variant')] 315 product.pricelist_item_count = self.env['product.pricelist.item'].search_count(domain) 316 317 @api.onchange('uom_id') 318 def _onchange_uom_id(self): 319 if self.uom_id: 320 self.uom_po_id = self.uom_id.id 321 322 @api.onchange('uom_po_id') 323 def _onchange_uom(self): 324 if self.uom_id and self.uom_po_id and self.uom_id.category_id != self.uom_po_id.category_id: 325 self.uom_po_id = self.uom_id 326 327 @api.model_create_multi 328 def create(self, vals_list): 329 products = super(ProductProduct, self.with_context(create_product_product=True)).create(vals_list) 330 # `_get_variant_id_for_combination` depends on existing variants 331 self.clear_caches() 332 return products 333 334 def write(self, values): 335 res = super(ProductProduct, self).write(values) 336 if 'product_template_attribute_value_ids' in values: 337 # `_get_variant_id_for_combination` depends on `product_template_attribute_value_ids` 338 self.clear_caches() 339 if 'active' in values: 340 # prefetched o2m have to be reloaded (because of active_test) 341 # (eg. product.template: product_variant_ids) 342 self.flush() 343 self.invalidate_cache() 344 # `_get_first_possible_variant_id` depends on variants active state 345 self.clear_caches() 346 return res 347 348 def unlink(self): 349 unlink_products = self.env['product.product'] 350 unlink_templates = self.env['product.template'] 351 for product in self: 352 # If there is an image set on the variant and no image set on the 353 # template, move the image to the template. 354 if product.image_variant_1920 and not product.product_tmpl_id.image_1920: 355 product.product_tmpl_id.image_1920 = product.image_variant_1920 356 # Check if product still exists, in case it has been unlinked by unlinking its template 357 if not product.exists(): 358 continue 359 # Check if the product is last product of this template... 360 other_products = self.search([('product_tmpl_id', '=', product.product_tmpl_id.id), ('id', '!=', product.id)]) 361 # ... and do not delete product template if it's configured to be created "on demand" 362 if not other_products and not product.product_tmpl_id.has_dynamic_attributes(): 363 unlink_templates |= product.product_tmpl_id 364 unlink_products |= product 365 res = super(ProductProduct, unlink_products).unlink() 366 # delete templates after calling super, as deleting template could lead to deleting 367 # products due to ondelete='cascade' 368 unlink_templates.unlink() 369 # `_get_variant_id_for_combination` depends on existing variants 370 self.clear_caches() 371 return res 372 373 def _filter_to_unlink(self, check_access=True): 374 return self 375 376 def _unlink_or_archive(self, check_access=True): 377 """Unlink or archive products. 378 Try in batch as much as possible because it is much faster. 379 Use dichotomy when an exception occurs. 380 """ 381 382 # Avoid access errors in case the products is shared amongst companies 383 # but the underlying objects are not. If unlink fails because of an 384 # AccessError (e.g. while recomputing fields), the 'write' call will 385 # fail as well for the same reason since the field has been set to 386 # recompute. 387 if check_access: 388 self.check_access_rights('unlink') 389 self.check_access_rule('unlink') 390 self.check_access_rights('write') 391 self.check_access_rule('write') 392 self = self.sudo() 393 to_unlink = self._filter_to_unlink() 394 to_archive = self - to_unlink 395 to_archive.write({'active': False}) 396 self = to_unlink 397 398 try: 399 with self.env.cr.savepoint(), tools.mute_logger('odoo.sql_db'): 400 self.unlink() 401 except Exception: 402 # We catch all kind of exceptions to be sure that the operation 403 # doesn't fail. 404 if len(self) > 1: 405 self[:len(self) // 2]._unlink_or_archive(check_access=False) 406 self[len(self) // 2:]._unlink_or_archive(check_access=False) 407 else: 408 if self.active: 409 # Note: this can still fail if something is preventing 410 # from archiving. 411 # This is the case from existing stock reordering rules. 412 self.write({'active': False}) 413 414 @api.returns('self', lambda value: value.id) 415 def copy(self, default=None): 416 """Variants are generated depending on the configuration of attributes 417 and values on the template, so copying them does not make sense. 418 419 For convenience the template is copied instead and its first variant is 420 returned. 421 """ 422 return self.product_tmpl_id.copy(default=default).product_variant_id 423 424 @api.model 425 def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): 426 # TDE FIXME: strange 427 if self._context.get('search_default_categ_id'): 428 args.append((('categ_id', 'child_of', self._context['search_default_categ_id']))) 429 return super(ProductProduct, self)._search(args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid) 430 431 @api.depends_context('display_default_code') 432 def _compute_display_name(self): 433 # `display_name` is calling `name_get()`` which is overidden on product 434 # to depend on `display_default_code` 435 return super()._compute_display_name() 436 437 def name_get(self): 438 # TDE: this could be cleaned a bit I think 439 440 def _name_get(d): 441 name = d.get('name', '') 442 code = self._context.get('display_default_code', True) and d.get('default_code', False) or False 443 if code: 444 name = '[%s] %s' % (code,name) 445 return (d['id'], name) 446 447 partner_id = self._context.get('partner_id') 448 if partner_id: 449 partner_ids = [partner_id, self.env['res.partner'].browse(partner_id).commercial_partner_id.id] 450 else: 451 partner_ids = [] 452 company_id = self.env.context.get('company_id') 453 454 # all user don't have access to seller and partner 455 # check access and use superuser 456 self.check_access_rights("read") 457 self.check_access_rule("read") 458 459 result = [] 460 461 # Prefetch the fields used by the `name_get`, so `browse` doesn't fetch other fields 462 # Use `load=False` to not call `name_get` for the `product_tmpl_id` 463 self.sudo().read(['name', 'default_code', 'product_tmpl_id'], load=False) 464 465 product_template_ids = self.sudo().mapped('product_tmpl_id').ids 466 467 if partner_ids: 468 supplier_info = self.env['product.supplierinfo'].sudo().search([ 469 ('product_tmpl_id', 'in', product_template_ids), 470 ('name', 'in', partner_ids), 471 ]) 472 # Prefetch the fields used by the `name_get`, so `browse` doesn't fetch other fields 473 # Use `load=False` to not call `name_get` for the `product_tmpl_id` and `product_id` 474 supplier_info.sudo().read(['product_tmpl_id', 'product_id', 'product_name', 'product_code'], load=False) 475 supplier_info_by_template = {} 476 for r in supplier_info: 477 supplier_info_by_template.setdefault(r.product_tmpl_id, []).append(r) 478 for product in self.sudo(): 479 variant = product.product_template_attribute_value_ids._get_combination_name() 480 481 name = variant and "%s (%s)" % (product.name, variant) or product.name 482 sellers = [] 483 if partner_ids: 484 product_supplier_info = supplier_info_by_template.get(product.product_tmpl_id, []) 485 sellers = [x for x in product_supplier_info if x.product_id and x.product_id == product] 486 if not sellers: 487 sellers = [x for x in product_supplier_info if not x.product_id] 488 # Filter out sellers based on the company. This is done afterwards for a better 489 # code readability. At this point, only a few sellers should remain, so it should 490 # not be a performance issue. 491 if company_id: 492 sellers = [x for x in sellers if x.company_id.id in [company_id, False]] 493 if sellers: 494 for s in sellers: 495 seller_variant = s.product_name and ( 496 variant and "%s (%s)" % (s.product_name, variant) or s.product_name 497 ) or False 498 mydict = { 499 'id': product.id, 500 'name': seller_variant or name, 501 'default_code': s.product_code or product.default_code, 502 } 503 temp = _name_get(mydict) 504 if temp not in result: 505 result.append(temp) 506 else: 507 mydict = { 508 'id': product.id, 509 'name': name, 510 'default_code': product.default_code, 511 } 512 result.append(_name_get(mydict)) 513 return result 514 515 @api.model 516 def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): 517 if not args: 518 args = [] 519 if name: 520 positive_operators = ['=', 'ilike', '=ilike', 'like', '=like'] 521 product_ids = [] 522 if operator in positive_operators: 523 product_ids = list(self._search([('default_code', '=', name)] + args, limit=limit, access_rights_uid=name_get_uid)) 524 if not product_ids: 525 product_ids = list(self._search([('barcode', '=', name)] + args, limit=limit, access_rights_uid=name_get_uid)) 526 if not product_ids and operator not in expression.NEGATIVE_TERM_OPERATORS: 527 # Do not merge the 2 next lines into one single search, SQL search performance would be abysmal 528 # on a database with thousands of matching products, due to the huge merge+unique needed for the 529 # OR operator (and given the fact that the 'name' lookup results come from the ir.translation table 530 # Performing a quick memory merge of ids in Python will give much better performance 531 product_ids = list(self._search(args + [('default_code', operator, name)], limit=limit)) 532 if not limit or len(product_ids) < limit: 533 # we may underrun the limit because of dupes in the results, that's fine 534 limit2 = (limit - len(product_ids)) if limit else False 535 product2_ids = self._search(args + [('name', operator, name), ('id', 'not in', product_ids)], limit=limit2, access_rights_uid=name_get_uid) 536 product_ids.extend(product2_ids) 537 elif not product_ids and operator in expression.NEGATIVE_TERM_OPERATORS: 538 domain = expression.OR([ 539 ['&', ('default_code', operator, name), ('name', operator, name)], 540 ['&', ('default_code', '=', False), ('name', operator, name)], 541 ]) 542 domain = expression.AND([args, domain]) 543 product_ids = list(self._search(domain, limit=limit, access_rights_uid=name_get_uid)) 544 if not product_ids and operator in positive_operators: 545 ptrn = re.compile('(\[(.*?)\])') 546 res = ptrn.search(name) 547 if res: 548 product_ids = list(self._search([('default_code', '=', res.group(2))] + args, limit=limit, access_rights_uid=name_get_uid)) 549 # still no results, partner in context: search on supplier info as last hope to find something 550 if not product_ids and self._context.get('partner_id'): 551 suppliers_ids = self.env['product.supplierinfo']._search([ 552 ('name', '=', self._context.get('partner_id')), 553 '|', 554 ('product_code', operator, name), 555 ('product_name', operator, name)], access_rights_uid=name_get_uid) 556 if suppliers_ids: 557 product_ids = self._search([('product_tmpl_id.seller_ids', 'in', suppliers_ids)], limit=limit, access_rights_uid=name_get_uid) 558 else: 559 product_ids = self._search(args, limit=limit, access_rights_uid=name_get_uid) 560 return product_ids 561 562 @api.model 563 def view_header_get(self, view_id, view_type): 564 if self._context.get('categ_id'): 565 return _( 566 'Products: %(category)s', 567 category=self.env['product.category'].browse(self.env.context['categ_id']).name, 568 ) 569 return super().view_header_get(view_id, view_type) 570 571 def open_pricelist_rules(self): 572 self.ensure_one() 573 domain = ['|', 574 '&', ('product_tmpl_id', '=', self.product_tmpl_id.id), ('applied_on', '=', '1_product'), 575 '&', ('product_id', '=', self.id), ('applied_on', '=', '0_product_variant')] 576 return { 577 'name': _('Price Rules'), 578 'view_mode': 'tree,form', 579 'views': [(self.env.ref('product.product_pricelist_item_tree_view_from_product').id, 'tree'), (False, 'form')], 580 'res_model': 'product.pricelist.item', 581 'type': 'ir.actions.act_window', 582 'target': 'current', 583 'domain': domain, 584 'context': { 585 'default_product_id': self.id, 586 'default_applied_on': '0_product_variant', 587 } 588 } 589 590 def open_product_template(self): 591 """ Utility method used to add an "Open Template" button in product views """ 592 self.ensure_one() 593 return {'type': 'ir.actions.act_window', 594 'res_model': 'product.template', 595 'view_mode': 'form', 596 'res_id': self.product_tmpl_id.id, 597 'target': 'new'} 598 599 def _prepare_sellers(self, params=False): 600 return self.seller_ids.filtered(lambda s: s.name.active).sorted(lambda s: (s.sequence, -s.min_qty, s.price, s.id)) 601 602 def _select_seller(self, partner_id=False, quantity=0.0, date=None, uom_id=False, params=False): 603 self.ensure_one() 604 if date is None: 605 date = fields.Date.context_today(self) 606 precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') 607 608 res = self.env['product.supplierinfo'] 609 sellers = self._prepare_sellers(params) 610 sellers = sellers.filtered(lambda s: not s.company_id or s.company_id.id == self.env.company.id) 611 for seller in sellers: 612 # Set quantity in UoM of seller 613 quantity_uom_seller = quantity 614 if quantity_uom_seller and uom_id and uom_id != seller.product_uom: 615 quantity_uom_seller = uom_id._compute_quantity(quantity_uom_seller, seller.product_uom) 616 617 if seller.date_start and seller.date_start > date: 618 continue 619 if seller.date_end and seller.date_end < date: 620 continue 621 if partner_id and seller.name not in [partner_id, partner_id.parent_id]: 622 continue 623 if float_compare(quantity_uom_seller, seller.min_qty, precision_digits=precision) == -1: 624 continue 625 if seller.product_id and seller.product_id != self: 626 continue 627 if not res or res.name == seller.name: 628 res |= seller 629 return res.sorted('price')[:1] 630 631 def price_compute(self, price_type, uom=False, currency=False, company=None): 632 # TDE FIXME: delegate to template or not ? fields are reencoded here ... 633 # compatibility about context keys used a bit everywhere in the code 634 if not uom and self._context.get('uom'): 635 uom = self.env['uom.uom'].browse(self._context['uom']) 636 if not currency and self._context.get('currency'): 637 currency = self.env['res.currency'].browse(self._context['currency']) 638 639 products = self 640 if price_type == 'standard_price': 641 # standard_price field can only be seen by users in base.group_user 642 # Thus, in order to compute the sale price from the cost for users not in this group 643 # We fetch the standard price as the superuser 644 products = self.with_company(company or self.env.company).sudo() 645 646 prices = dict.fromkeys(self.ids, 0.0) 647 for product in products: 648 prices[product.id] = product[price_type] or 0.0 649 if price_type == 'list_price': 650 prices[product.id] += product.price_extra 651 # we need to add the price from the attributes that do not generate variants 652 # (see field product.attribute create_variant) 653 if self._context.get('no_variant_attributes_price_extra'): 654 # we have a list of price_extra that comes from the attribute values, we need to sum all that 655 prices[product.id] += sum(self._context.get('no_variant_attributes_price_extra')) 656 657 if uom: 658 prices[product.id] = product.uom_id._compute_price(prices[product.id], uom) 659 660 # Convert from current user company currency to asked one 661 # This is right cause a field cannot be in more than one currency 662 if currency: 663 prices[product.id] = product.currency_id._convert( 664 prices[product.id], currency, product.company_id, fields.Date.today()) 665 666 return prices 667 668 @api.model 669 def get_empty_list_help(self, help): 670 self = self.with_context( 671 empty_list_help_document_name=_("product"), 672 ) 673 return super(ProductProduct, self).get_empty_list_help(help) 674 675 def get_product_multiline_description_sale(self): 676 """ Compute a multiline description of this product, in the context of sales 677 (do not use for purchases or other display reasons that don't intend to use "description_sale"). 678 It will often be used as the default description of a sale order line referencing this product. 679 """ 680 name = self.display_name 681 if self.description_sale: 682 name += '\n' + self.description_sale 683 684 return name 685 686 def _is_variant_possible(self, parent_combination=None): 687 """Return whether the variant is possible based on its own combination, 688 and optionally a parent combination. 689 690 See `_is_combination_possible` for more information. 691 692 :param parent_combination: combination from which `self` is an 693 optional or accessory product. 694 :type parent_combination: recordset `product.template.attribute.value` 695 696 :return: ẁhether the variant is possible based on its own combination 697 :rtype: bool 698 """ 699 self.ensure_one() 700 return self.product_tmpl_id._is_combination_possible(self.product_template_attribute_value_ids, parent_combination=parent_combination, ignore_no_variant=True) 701 702 def toggle_active(self): 703 """ Archiving related product.template if there is not any more active product.product 704 (and vice versa, unarchiving the related product template if there is now an active product.product) """ 705 result = super().toggle_active() 706 # We deactivate product templates which are active with no active variants. 707 tmpl_to_deactivate = self.filtered(lambda product: (product.product_tmpl_id.active 708 and not product.product_tmpl_id.product_variant_ids)).mapped('product_tmpl_id') 709 # We activate product templates which are inactive with active variants. 710 tmpl_to_activate = self.filtered(lambda product: (not product.product_tmpl_id.active 711 and product.product_tmpl_id.product_variant_ids)).mapped('product_tmpl_id') 712 (tmpl_to_deactivate + tmpl_to_activate).toggle_active() 713 return result 714 715 716class ProductPackaging(models.Model): 717 _name = "product.packaging" 718 _description = "Product Packaging" 719 _order = 'sequence' 720 _check_company_auto = True 721 722 name = fields.Char('Package Type', required=True) 723 sequence = fields.Integer('Sequence', default=1, help="The first in the sequence is the default one.") 724 product_id = fields.Many2one('product.product', string='Product', check_company=True) 725 qty = fields.Float('Contained Quantity', help="Quantity of products contained in the packaging.") 726 barcode = fields.Char('Barcode', copy=False, help="Barcode used for packaging identification. Scan this packaging barcode from a transfer in the Barcode app to move all the contained units") 727 product_uom_id = fields.Many2one('uom.uom', related='product_id.uom_id', readonly=True) 728 company_id = fields.Many2one('res.company', 'Company', index=True) 729 730 731class SupplierInfo(models.Model): 732 _name = "product.supplierinfo" 733 _description = "Supplier Pricelist" 734 _order = 'sequence, min_qty DESC, price, id' 735 736 name = fields.Many2one( 737 'res.partner', 'Vendor', 738 ondelete='cascade', required=True, 739 help="Vendor of this product", check_company=True) 740 product_name = fields.Char( 741 'Vendor Product Name', 742 help="This vendor's product name will be used when printing a request for quotation. Keep empty to use the internal one.") 743 product_code = fields.Char( 744 'Vendor Product Code', 745 help="This vendor's product code will be used when printing a request for quotation. Keep empty to use the internal one.") 746 sequence = fields.Integer( 747 'Sequence', default=1, help="Assigns the priority to the list of product vendor.") 748 product_uom = fields.Many2one( 749 'uom.uom', 'Unit of Measure', 750 related='product_tmpl_id.uom_po_id', 751 help="This comes from the product form.") 752 min_qty = fields.Float( 753 'Quantity', default=0.0, required=True, digits="Product Unit Of Measure", 754 help="The quantity to purchase from this vendor to benefit from the price, expressed in the vendor Product Unit of Measure if not any, in the default unit of measure of the product otherwise.") 755 price = fields.Float( 756 'Price', default=0.0, digits='Product Price', 757 required=True, help="The price to purchase a product") 758 company_id = fields.Many2one( 759 'res.company', 'Company', 760 default=lambda self: self.env.company.id, index=1) 761 currency_id = fields.Many2one( 762 'res.currency', 'Currency', 763 default=lambda self: self.env.company.currency_id.id, 764 required=True) 765 date_start = fields.Date('Start Date', help="Start date for this vendor price") 766 date_end = fields.Date('End Date', help="End date for this vendor price") 767 product_id = fields.Many2one( 768 'product.product', 'Product Variant', check_company=True, 769 help="If not set, the vendor price will apply to all variants of this product.") 770 product_tmpl_id = fields.Many2one( 771 'product.template', 'Product Template', check_company=True, 772 index=True, ondelete='cascade') 773 product_variant_count = fields.Integer('Variant Count', related='product_tmpl_id.product_variant_count') 774 delay = fields.Integer( 775 'Delivery Lead Time', default=1, required=True, 776 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.") 777 778 @api.model 779 def get_import_templates(self): 780 return [{ 781 'label': _('Import Template for Vendor Pricelists'), 782 'template': '/product/static/xls/product_supplierinfo.xls' 783 }] 784