diff --git a/addons/product/models/__init__.py b/addons/product/models/__init__.py index fe28ba21bf8..7c0bca91e03 100644 --- a/addons/product/models/__init__.py +++ b/addons/product/models/__init__.py @@ -3,16 +3,18 @@ # flake8: noqa: F401 -from . import decimal_precision - # don't try to be a good boy and sort imports alphabetically. # `product.template` should be initialised before `product.product` from . import product_template -from . import product +from . import product_product +from . import decimal_precision from . import product_attribute +from . import product_category +from . import product_packaging from . import product_pricelist from . import product_pricelist_item +from . import product_supplierinfo from . import product_tag from . import res_company from . import res_config_settings diff --git a/addons/product/models/product_category.py b/addons/product/models/product_category.py new file mode 100644 index 00000000000..5f4e160e49a --- /dev/null +++ b/addons/product/models/product_category.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError + + +class ProductCategory(models.Model): + _name = "product.category" + _description = "Product Category" + _parent_name = "parent_id" + _parent_store = True + _rec_name = 'complete_name' + _order = 'complete_name' + + name = fields.Char('Name', index='trigram', required=True) + complete_name = fields.Char( + 'Complete Name', compute='_compute_complete_name', recursive=True, + store=True) + parent_id = fields.Many2one('product.category', 'Parent Category', index=True, ondelete='cascade') + parent_path = fields.Char(index=True, unaccent=False) + child_id = fields.One2many('product.category', 'parent_id', 'Child Categories') + product_count = fields.Integer( + '# Products', compute='_compute_product_count', + help="The number of products under this category (Does not consider the children categories)") + + @api.depends('name', 'parent_id.complete_name') + def _compute_complete_name(self): + for category in self: + if category.parent_id: + category.complete_name = '%s / %s' % (category.parent_id.complete_name, category.name) + else: + category.complete_name = category.name + + 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.ids)]).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(_('You cannot create recursive categories.')) + + @api.model + def name_create(self, name): + return self.create({'name': name}).name_get()[0] + + def name_get(self): + if not self.env.context.get('hierarchical_naming', True): + return [(record.id, record.name) for record in self] + return super().name_get() + + @api.ondelete(at_uninstall=False) + def _unlink_except_default_category(self): + main_category = self.env.ref('product.product_category_all') + if main_category in self: + raise UserError(_("You cannot delete this product category, it is the default generic category.")) diff --git a/addons/product/models/product_packaging.py b/addons/product/models/product_packaging.py new file mode 100644 index 00000000000..6b409558e7f --- /dev/null +++ b/addons/product/models/product_packaging.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError +from odoo.osv import expression + + +from odoo.tools import float_compare, float_round + + +class ProductPackaging(models.Model): + _name = "product.packaging" + _description = "Product Packaging" + _order = 'product_id, sequence, id' + _check_company_auto = True + + name = fields.Char('Product Packaging', required=True) + sequence = fields.Integer('Sequence', default=1, help="The first in the sequence is the default one.") + product_id = fields.Many2one('product.product', string='Product', check_company=True) + qty = fields.Float('Contained Quantity', default=1, digits='Product Unit of Measure', help="Quantity of products contained in the packaging.") + 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") + product_uom_id = fields.Many2one('uom.uom', related='product_id.uom_id', readonly=True) + company_id = fields.Many2one('res.company', 'Company', index=True) + + _sql_constraints = [ + ('positive_qty', 'CHECK(qty > 0)', 'Contained Quantity should be positive.'), + ('barcode_uniq', 'unique(barcode)', 'A barcode can only be assigned to one packaging.'), + ] + + @api.constrains('barcode') + def _check_barcode_uniqueness(self): + """ With GS1 nomenclature, products and packagins use the same pattern. Therefore, we need + to ensure the uniqueness between products' barcodes and packagings' ones""" + condition = expression.OR([[('barcode', '=', b)] for b in self.mapped('barcode') if b]) + if self.env['product.product'].search(condition, limit=1): + raise ValidationError(_("A product already uses the barcode")) + + def _check_qty(self, product_qty, uom_id, rounding_method="HALF-UP"): + """Check if product_qty in given uom is a multiple of the packaging qty. + If not, rounding the product_qty to closest multiple of the packaging qty + according to the rounding_method "UP", "HALF-UP or "DOWN". + """ + self.ensure_one() + default_uom = self.product_id.uom_id + packaging_qty = default_uom._compute_quantity(self.qty, uom_id) + # We do not use the modulo operator to check if qty is a mltiple of q. Indeed the quantity + # per package might be a float, leading to incorrect results. For example: + # 8 % 1.6 = 1.5999999999999996 + # 5.4 % 1.8 = 2.220446049250313e-16 + if ( + product_qty + and packaging_qty + and float_compare( + product_qty / packaging_qty, + float_round(product_qty / packaging_qty, precision_rounding=1.0), + precision_rounding=default_uom.rounding + ) + != 0 + ): + return float_round( + product_qty / packaging_qty, precision_rounding=1.0, rounding_method=rounding_method + ) * packaging_qty + return product_qty + + def _find_suitable_product_packaging(self, product_qty, uom_id): + """ try find in `self` if a packaging's qty in given uom is a divisor of + the given product_qty. If so, return the one with greatest divisor. + """ + packagings = self.sorted(lambda p: p.qty, reverse=True) + for packaging in packagings: + new_qty = packaging._check_qty(product_qty, uom_id) + if new_qty == product_qty: + return packaging + return self.env['product.packaging'] diff --git a/addons/product/models/product.py b/addons/product/models/product_product.py similarity index 77% rename from addons/product/models/product.py rename to addons/product/models/product_product.py index 5055c1589a6..c908686539c 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product_product.py @@ -4,68 +4,9 @@ import re from odoo import api, fields, models, tools, _ -from odoo.exceptions import UserError, ValidationError +from odoo.exceptions import ValidationError from odoo.osv import expression - - -from odoo.tools import float_compare, float_round - - -class ProductCategory(models.Model): - _name = "product.category" - _description = "Product Category" - _parent_name = "parent_id" - _parent_store = True - _rec_name = 'complete_name' - _order = 'complete_name' - - name = fields.Char('Name', index='trigram', required=True) - complete_name = fields.Char( - 'Complete Name', compute='_compute_complete_name', recursive=True, - store=True) - parent_id = fields.Many2one('product.category', 'Parent Category', index=True, ondelete='cascade') - parent_path = fields.Char(index=True, unaccent=False) - child_id = fields.One2many('product.category', 'parent_id', 'Child Categories') - product_count = fields.Integer( - '# Products', compute='_compute_product_count', - help="The number of products under this category (Does not consider the children categories)") - - @api.depends('name', 'parent_id.complete_name') - def _compute_complete_name(self): - for category in self: - if category.parent_id: - category.complete_name = '%s / %s' % (category.parent_id.complete_name, category.name) - else: - category.complete_name = category.name - - 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.ids)]).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(_('You cannot create recursive categories.')) - - @api.model - def name_create(self, name): - return self.create({'name': name}).name_get()[0] - - def name_get(self): - if not self.env.context.get('hierarchical_naming', True): - return [(record.id, record.name) for record in self] - return super().name_get() - - @api.ondelete(at_uninstall=False) - def _unlink_except_default_category(self): - main_category = self.env.ref('product.product_category_all') - if main_category in self: - raise UserError(_("You cannot delete this product category, it is the default generic category.")) +from odoo.tools import float_compare class ProductProduct(models.Model): @@ -745,147 +686,3 @@ class ProductProduct(models.Model): uom = self.env['uom.uom'].browse(self.env.context.get('uom')) date = self.env.context.get('date') return pricelist._get_product_price(self, quantity, uom=uom, date=date) - - -class ProductPackaging(models.Model): - _name = "product.packaging" - _description = "Product Packaging" - _order = 'product_id, sequence, id' - _check_company_auto = True - - name = fields.Char('Product Packaging', required=True) - sequence = fields.Integer('Sequence', default=1, help="The first in the sequence is the default one.") - product_id = fields.Many2one('product.product', string='Product', check_company=True) - qty = fields.Float('Contained Quantity', default=1, digits='Product Unit of Measure', help="Quantity of products contained in the packaging.") - 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") - product_uom_id = fields.Many2one('uom.uom', related='product_id.uom_id', readonly=True) - company_id = fields.Many2one('res.company', 'Company', index=True) - - _sql_constraints = [ - ('positive_qty', 'CHECK(qty > 0)', 'Contained Quantity should be positive.'), - ('barcode_uniq', 'unique(barcode)', 'A barcode can only be assigned to one packaging.'), - ] - - @api.constrains('barcode') - def _check_barcode_uniqueness(self): - """ With GS1 nomenclature, products and packagins use the same pattern. Therefore, we need - to ensure the uniqueness between products' barcodes and packagings' ones""" - condition = expression.OR([[('barcode', '=', b)] for b in self.mapped('barcode') if b]) - if self.env['product.product'].search(condition, limit=1): - raise ValidationError(_("A product already uses the barcode")) - - def _check_qty(self, product_qty, uom_id, rounding_method="HALF-UP"): - """Check if product_qty in given uom is a multiple of the packaging qty. - If not, rounding the product_qty to closest multiple of the packaging qty - according to the rounding_method "UP", "HALF-UP or "DOWN". - """ - self.ensure_one() - default_uom = self.product_id.uom_id - packaging_qty = default_uom._compute_quantity(self.qty, uom_id) - # We do not use the modulo operator to check if qty is a mltiple of q. Indeed the quantity - # per package might be a float, leading to incorrect results. For example: - # 8 % 1.6 = 1.5999999999999996 - # 5.4 % 1.8 = 2.220446049250313e-16 - if ( - product_qty - and packaging_qty - and float_compare( - product_qty / packaging_qty, - float_round(product_qty / packaging_qty, precision_rounding=1.0), - precision_rounding=default_uom.rounding - ) - != 0 - ): - return float_round( - product_qty / packaging_qty, precision_rounding=1.0, rounding_method=rounding_method - ) * packaging_qty - return product_qty - - def _find_suitable_product_packaging(self, product_qty, uom_id): - """ try find in `self` if a packaging's qty in given uom is a divisor of - the given product_qty. If so, return the one with greatest divisor. - """ - packagings = self.sorted(lambda p: p.qty, reverse=True) - for packaging in packagings: - new_qty = packaging._check_qty(product_qty, uom_id) - if new_qty == product_qty: - return packaging - return self.env['product.packaging'] - - -class SupplierInfo(models.Model): - _name = "product.supplierinfo" - _description = "Supplier Pricelist" - _order = 'sequence, min_qty DESC, price, id' - _rec_name = 'partner_id' - - def _default_product_id(self): - product_id = self.env.get('default_product_id') - if not product_id: - model, active_id = [self.env.context.get(k) for k in ['model', 'active_id']] - if model == 'product.product' and active_id: - product_id = self.env[model].browse(active_id).exists() - return product_id - - def _domain_product_id(self): - domain = "product_tmpl_id and [('product_tmpl_id', '=', product_tmpl_id)] or []" - if self.env.context.get('base_model_name') == 'product.template': - domain = "[('product_tmpl_id', '=', parent.id)]" - elif self.env.context.get('base_model_name') == 'product.product': - domain = "[('product_tmpl_id', '=', parent.product_tmpl_id)]" - return domain - - partner_id = fields.Many2one( - 'res.partner', 'Vendor', - ondelete='cascade', required=True, - help="Vendor of this product", check_company=True) - 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( - 'uom.uom', 'Unit of Measure', - related='product_tmpl_id.uom_po_id', - help="This comes from the product form.") - min_qty = fields.Float( - 'Quantity', default=0.0, required=True, digits="Product Unit Of Measure", - 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.") - price = fields.Float( - 'Price', default=0.0, digits='Product Price', - required=True, help="The price to purchase a product") - company_id = fields.Many2one( - 'res.company', 'Company', - default=lambda self: self.env.company.id, index=1) - currency_id = fields.Many2one( - 'res.currency', 'Currency', - default=lambda self: self.env.company.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', check_company=True, - domain=_domain_product_id, default=_default_product_id, - help="If not set, the vendor price will apply to all variants of this product.") - product_tmpl_id = fields.Many2one( - 'product.template', 'Product Template', check_company=True, - index=True, ondelete='cascade') - product_variant_count = fields.Integer('Variant Count', related='product_tmpl_id.product_variant_count') - 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.") - - @api.onchange('product_tmpl_id') - def _onchange_product_tmpl_id(self): - if self.product_id and self.product_tmpl_id and self.product_id not in self.product_tmpl_id.product_variant_ids: - self.product_id = False - - @api.model - def get_import_templates(self): - return [{ - 'label': _('Import Template for Vendor Pricelists'), - 'template': '/product/static/xls/product_supplierinfo.xls' - }] diff --git a/addons/product/models/product_supplierinfo.py b/addons/product/models/product_supplierinfo.py new file mode 100644 index 00000000000..f15f352cd1e --- /dev/null +++ b/addons/product/models/product_supplierinfo.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ + + +class SupplierInfo(models.Model): + _name = "product.supplierinfo" + _description = "Supplier Pricelist" + _order = 'sequence, min_qty DESC, price, id' + _rec_name = 'partner_id' + + def _default_product_id(self): + product_id = self.env.get('default_product_id') + if not product_id: + model, active_id = [self.env.context.get(k) for k in ['model', 'active_id']] + if model == 'product.product' and active_id: + product_id = self.env[model].browse(active_id).exists() + return product_id + + def _domain_product_id(self): + domain = "product_tmpl_id and [('product_tmpl_id', '=', product_tmpl_id)] or []" + if self.env.context.get('base_model_name') == 'product.template': + domain = "[('product_tmpl_id', '=', parent.id)]" + elif self.env.context.get('base_model_name') == 'product.product': + domain = "[('product_tmpl_id', '=', parent.product_tmpl_id)]" + return domain + + partner_id = fields.Many2one( + 'res.partner', 'Vendor', + ondelete='cascade', required=True, + help="Vendor of this product", check_company=True) + 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( + 'uom.uom', 'Unit of Measure', + related='product_tmpl_id.uom_po_id', + help="This comes from the product form.") + min_qty = fields.Float( + 'Quantity', default=0.0, required=True, digits="Product Unit Of Measure", + 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.") + price = fields.Float( + 'Price', default=0.0, digits='Product Price', + required=True, help="The price to purchase a product") + company_id = fields.Many2one( + 'res.company', 'Company', + default=lambda self: self.env.company.id, index=1) + currency_id = fields.Many2one( + 'res.currency', 'Currency', + default=lambda self: self.env.company.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', check_company=True, + domain=_domain_product_id, default=_default_product_id, + help="If not set, the vendor price will apply to all variants of this product.") + product_tmpl_id = fields.Many2one( + 'product.template', 'Product Template', check_company=True, + index=True, ondelete='cascade') + product_variant_count = fields.Integer('Variant Count', related='product_tmpl_id.product_variant_count') + 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.") + + @api.onchange('product_tmpl_id') + def _onchange_product_tmpl_id(self): + if self.product_id and self.product_tmpl_id and self.product_id not in self.product_tmpl_id.product_variant_ids: + self.product_id = False + + @api.model + def get_import_templates(self): + return [{ + 'label': _('Import Template for Vendor Pricelists'), + 'template': '/product/static/xls/product_supplierinfo.xls' + }]