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