# This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. import copy import logging from decimal import Decimal from importlib import import_module import stdnum import stdnum.exceptions from sql import Literal from sql.functions import CharLength from sql.operators import Equal from trytond.i18n import gettext from trytond.model import ( DeactivableMixin, Exclude, Index, Model, ModelSQL, ModelView, UnionMixin, fields, sequence_ordered) from trytond.modules.company.model import ( CompanyMultiValueMixin, CompanyValueMixin) from trytond.pool import Pool from trytond.pyson import Eval, Get, If from trytond.tools import is_full_text, lstrip_wildcard from trytond.transaction import Transaction try: from trytond.tools import barcode except ImportError: barcode = None from .exceptions import InvalidIdentifierCode from .ir import price_decimal __all__ = ['price_digits', 'round_price', 'TemplateFunction'] logger = logging.getLogger(__name__) TYPES = [ ('goods', 'Goods'), ('assets', 'Assets'), ('service', 'Service'), ] COST_PRICE_METHODS = [ ('fixed', 'Fixed'), ('average', 'Average'), ] price_digits = (16, price_decimal) def round_price(value, rounding=None): "Round price using the price digits" if isinstance(value, int): return Decimal(value) return value.quantize( Decimal(1) / 10 ** price_digits[1], rounding=rounding) class Template( DeactivableMixin, ModelSQL, ModelView, CompanyMultiValueMixin): "Product Template" __name__ = "product.template" _order_name = 'rec_name' name = fields.Char( "Name", size=None, required=True, translate=True) code_readonly = fields.Function( fields.Boolean("Code Readonly"), 'get_code_readonly') code = fields.Char( "Code", strip='leading', states={ 'readonly': Eval('code_readonly', False), }) type = fields.Selection(TYPES, "Type", required=True) consumable = fields.Boolean('Consumable', states={ 'invisible': Eval('type', 'goods') != 'goods', }, help="Check to allow stock moves to be assigned " "regardless of stock level.") list_price = fields.MultiValue(fields.Numeric( "List Price", digits=price_digits, states={ 'readonly': ~Eval('context', {}).get('company'), }, help="The standard price the product is sold at.")) list_prices = fields.One2Many( 'product.list_price', 'template', "List Prices") cost_price = fields.Function(fields.Numeric( "Cost Price", digits=price_digits, help="The amount it costs to purchase or make the product, " "or carry out the service."), 'get_cost_price') cost_price_method = fields.MultiValue(fields.Selection( COST_PRICE_METHODS, "Cost Price Method", required=True, help="The method used to calculate the cost price.")) cost_price_methods = fields.One2Many( 'product.cost_price_method', 'template', "Cost Price Methods") default_uom = fields.Many2One( 'product.uom', "Default UoM", required=True, help="The standard Unit of Measure for the product.\n" "Used internally when calculating the stock levels of goods " "and assets.") default_uom_category = fields.Function( fields.Many2One( 'product.uom.category', "Default UoM Category", help="The category of the default Unit of Measure."), 'on_change_with_default_uom_category', searcher='search_default_uom_category') categories = fields.Many2Many( 'product.template-product.category', 'template', 'category', "Categories", help="The categories that the product is in.\n" "Used to group similar products together.") categories_all = fields.Many2Many( 'product.template-product.category.all', 'template', 'category', "Categories", readonly=True) products = fields.One2Many( 'product.product', 'template', "Variants", domain=[ If(~Eval('active'), ('active', '=', False), ()), ], help="The different variants the product comes in.") @classmethod def __setup__(cls): cls.code.search_unaccented = False super().__setup__() t = cls.__table__() cls._sql_indexes.update({ Index(t, (t.code, Index.Similarity())), }) cls._order.insert(0, ('rec_name', 'ASC')) types_cost_method = cls._cost_price_method_domain_per_type() cls.cost_price_method.domain = [ Get(types_cost_method, Eval('type'), []), ] @classmethod def _cost_price_method_domain_per_type(cls): return {'service': [('cost_price_method', '=', 'fixed')]} @classmethod def multivalue_model(cls, field): pool = Pool() if field == 'list_price': return pool.get('product.list_price') elif field == 'cost_price_method': return pool.get('product.cost_price_method') return super(Template, cls).multivalue_model(field) @classmethod def order_code(cls, tables): table, _ = tables[None] if cls.default_code_readonly(): return [CharLength(table.code), table.code] else: return [table.code] @classmethod def order_rec_name(cls, tables): table, _ = tables[None] return cls.order_code(tables) + [table.name] def get_rec_name(self, name): if self.code: return '[' + self.code + '] ' + self.name else: return self.name @classmethod def search_rec_name(cls, name, clause): _, operator, operand, *extra = clause if operator.startswith('!') or operator.startswith('not '): bool_op = 'AND' else: bool_op = 'OR' code_value = operand if operator.endswith('like') and is_full_text(operand): code_value = lstrip_wildcard(operand) return [bool_op, ('name', operator, operand, *extra), ('code', operator, code_value, *extra), ('products.code', operator, code_value, *extra), ('products.identifiers.code', operator, code_value, *extra), ] @staticmethod def default_type(): return 'goods' @staticmethod def default_consumable(): return False def get_cost_price(self, name): if len(self.products) == 1: product, = self.products return product.cost_price @classmethod def default_cost_price_method(cls, **pattern): pool = Pool() Configuration = pool.get('product.configuration') return Configuration(1).get_multivalue( 'default_cost_price_method', **pattern) @classmethod def default_products(cls): transaction = Transaction() if (transaction.user == 0 or not transaction.context.get('default_products', True)): return [] return [{}] @classmethod def default_code_readonly(cls): pool = Pool() Configuration = pool.get('product.configuration') config = Configuration(1) return bool(config.template_sequence) def get_code_readonly(self, name): return self.default_code_readonly() @fields.depends('type', 'cost_price_method') def on_change_type(self): if self.type == 'service': self.cost_price_method = 'fixed' @fields.depends('default_uom') def on_change_with_default_uom_category(self, name=None): return self.default_uom.category if self.default_uom else None @classmethod def search_default_uom_category(cls, name, clause): return [('default_uom.category' + clause[0][len(name):], *clause[1:])] @classmethod def _new_code(cls): pool = Pool() Configuration = pool.get('product.configuration') config = Configuration(1) sequence = config.template_sequence if sequence: return sequence.get() @classmethod def create(cls, vlist): pool = Pool() Product = pool.get('product.product') vlist = [v.copy() for v in vlist] for values in vlist: values.setdefault('products', None) if not values.get('code'): values['code'] = cls._new_code() templates = super(Template, cls).create(vlist) products = sum((t.products for t in templates), ()) Product.sync_code(products) return templates @classmethod def write(cls, *args): pool = Pool() Product = pool.get('product.product') super().write(*args) templates = sum(args[0:None:2], []) products = sum((t.products for t in templates), ()) Product.sync_code(products) @classmethod def copy(cls, templates, default=None): if default is None: default = {} else: default = default.copy() default.setdefault('code', None) return super().copy(templates, default=default) @classmethod def search_global(cls, text): for record, rec_name, icon in super(Template, cls).search_global(text): icon = icon or 'tryton-product' yield record, rec_name, icon class TemplateFunction(fields.Function): def __init__(self, field): super(TemplateFunction, self).__init__( field, 'get_template', searcher='search_template') # Disable on_change as it is managed by on_change_template self.on_change = set() self.on_change_with = set() def __copy__(self): return TemplateFunction(copy.copy(self._field)) def __deepcopy__(self, memo): return TemplateFunction(copy.deepcopy(self._field, memo)) @staticmethod def order(name): @classmethod def order(cls, tables): pool = Pool() Template = pool.get('product.template') product, _ = tables[None] if 'template' not in tables: template = Template.__table__() tables['template'] = { None: (template, product.template == template.id), } return getattr(Template, name).convert_order( name, tables['template'], Template) return order def definition(self, model, language): pool = Pool() Template = pool.get('product.template') definition = super().definition(model, language) definition['searchable'] = self._field.definition( Template, language)['searchable'] return definition class Product( DeactivableMixin, ModelSQL, ModelView, CompanyMultiValueMixin): "Product Variant" __name__ = "product.product" _order_name = 'rec_name' template = fields.Many2One( 'product.template', "Product Template", required=True, ondelete='CASCADE', search_context={'default_products': False}, domain=[ If(Eval('active'), ('active', '=', True), ()), ], help="The product that defines the common properties " "inherited by the variant.") code_readonly = fields.Function(fields.Boolean('Code Readonly'), 'get_code_readonly') prefix_code = fields.Function(fields.Char( "Prefix Code", states={ 'invisible': ~Eval('prefix_code'), }), 'on_change_with_prefix_code') suffix_code = fields.Char( "Suffix Code", strip='trailing', states={ 'readonly': Eval('code_readonly', False), }, help="The unique identifier for the product (aka SKU).") code = fields.Char( "Code", readonly=True, help="A unique identifier for the variant.") identifiers = fields.One2Many( 'product.identifier', 'product', "Identifiers", help="Other identifiers associated with the variant.") cost_price = fields.MultiValue(fields.Numeric( "Cost Price", digits=price_digits, states={ 'readonly': ~Eval('context', {}).get('company'), }, help="The amount it costs to purchase or make the variant, " "or carry out the service.")) cost_prices = fields.One2Many( 'product.cost_price', 'product', "Cost Prices") description = fields.Text("Description", translate=True) list_price_uom = fields.Function(fields.Numeric('List Price', digits=price_digits), 'get_price_uom') cost_price_uom = fields.Function(fields.Numeric('Cost Price', digits=price_digits), 'get_price_uom') @classmethod def __setup__(cls): pool = Pool() Template = pool.get('product.template') if not hasattr(cls, '_no_template_field'): cls._no_template_field = set() cls._no_template_field.update(['products']) cls.suffix_code.search_unaccented = False cls.code.search_unaccented = False super(Product, cls).__setup__() cls.__access__.add('template') cls._order.insert(0, ('rec_name', 'ASC')) t = cls.__table__() cls._sql_constraints = [ ('code_exclude', Exclude(t, (t.code, Equal), where=(t.active == Literal(True)) & (t.code != '')), 'product.msg_product_code_unique'), ] cls._sql_indexes.add( Index(t, (t.code, Index.Similarity()))) for attr in dir(Template): tfield = getattr(Template, attr) if not isinstance(tfield, fields.Field): continue if attr in cls._no_template_field: continue field = getattr(cls, attr, None) if not field or isinstance(field, TemplateFunction): tfield = copy.deepcopy(tfield) if hasattr(tfield, 'field'): tfield.field = None invisible_state = ~Eval('template') if 'invisible' in tfield.states: tfield.states['invisible'] |= invisible_state else: tfield.states['invisible'] = invisible_state setattr(cls, attr, TemplateFunction(tfield)) order_method = getattr(cls, 'order_%s' % attr, None) if (not order_method and not isinstance(tfield, ( fields.Function, fields.One2Many, fields.Many2Many))): order_method = TemplateFunction.order(attr) setattr(cls, 'order_%s' % attr, order_method) if isinstance(tfield, fields.One2Many): getattr(cls, attr).setter = '_set_template_function' @classmethod def __register__(cls, module): table = cls.__table__() table_h = cls.__table_handler__(module) fill_suffix_code = ( table_h.column_exist('code') and not table_h.column_exist('suffix_code')) super().__register__(module) cursor = Transaction().connection.cursor() # Migration from 5.4: split code into prefix/suffix if fill_suffix_code: cursor.execute(*table.update( [table.suffix_code], [table.code])) @classmethod def _set_template_function(cls, products, name, value): # Prevent NotImplementedError for One2Many pass @classmethod def order_suffix_code(cls, tables): table, _ = tables[None] if cls.default_code_readonly(): return [CharLength(table.suffix_code), table.suffix_code] else: return [table.suffix_code] @classmethod def order_code(cls, tables): pool = Pool() Template = pool.get('product.template') table, _ = tables[None] if cls.default_code_readonly() or Template.default_code_readonly(): return [CharLength(table.code), table.code] else: return [table.code] @fields.depends('template', '_parent_template.id') def on_change_template(self): for name, field in self._fields.items(): if isinstance(field, TemplateFunction): if self.template: value = getattr(self.template, name, None) else: value = None setattr(self, name, value) def get_template(self, name): value = getattr(self.template, name) if isinstance(value, Model): field = getattr(self.__class__, name) if field._type == 'reference': return str(value) return value.id elif (isinstance(value, (list, tuple)) and value and isinstance(value[0], Model)): return [r.id for r in value] else: return value @fields.depends('template', '_parent_template.code') def on_change_with_prefix_code(self, name=None): if self.template: return self.template.code @classmethod def multivalue_model(cls, field): pool = Pool() if field == 'cost_price': return pool.get('product.cost_price') return super(Product, cls).multivalue_model(field) def set_multivalue(self, name, value, save=True, **pattern): context = Transaction().context if name in {'cost_price', 'list_price'} and not value: if not pattern.get('company', context.get('company')): return [] return super().set_multivalue(name, value, save=save, **pattern) def get_multivalue(self, name, **pattern): if isinstance(self._fields[name], TemplateFunction): return self.template.get_multivalue(name, **pattern) else: return super().get_multivalue(name, **pattern) @classmethod def default_cost_price(cls, **pattern): context = Transaction().context if pattern.get('company', context.get('company')): return Decimal(0) @classmethod def search_template(cls, name, clause): return [('template.' + clause[0],) + tuple(clause[1:])] @classmethod def order_rec_name(cls, tables): pool = Pool() Template = pool.get('product.template') product, _ = tables[None] if 'template' not in tables: template = Template.__table__() tables['template'] = { None: (template, product.template == template.id), } else: template = tables['template'] return cls.order_code(tables) + Template.name.convert_order('name', tables['template'], Template) def get_rec_name(self, name): if self.code: return '[' + self.code + '] ' + self.name else: return self.name @classmethod def search_rec_name(cls, name, clause): _, operator, operand, *extra = clause if operator.startswith('!') or operator.startswith('not '): bool_op = 'AND' else: bool_op = 'OR' code_value = operand if operator.endswith('like') and is_full_text(operand): code_value = lstrip_wildcard(operand) return [bool_op, ('code', operator, code_value, *extra), ('identifiers.code', operator, code_value, *extra), ('template.name', operator, operand, *extra), ('template.code', operator, code_value, *extra), ] @staticmethod def get_price_uom(products, name): Uom = Pool().get('product.uom') res = {} field = name[:-4] if Transaction().context.get('uom'): to_uom = Uom(Transaction().context['uom']) else: to_uom = None for product in products: price = getattr(product, field) if to_uom and product.default_uom.category == to_uom.category: res[product.id] = Uom.compute_price( product.default_uom, price, to_uom) else: res[product.id] = price return res @classmethod def search_global(cls, text): for id_, rec_name, icon in super(Product, cls).search_global(text): icon = icon or 'tryton-product' yield id_, rec_name, icon @classmethod def default_code_readonly(cls): pool = Pool() Configuration = pool.get('product.configuration') config = Configuration(1) return bool(config.product_sequence) def get_code_readonly(self, name): return self.default_code_readonly() def identifier_get(self, types=None): "Return the first identifier for the given types" if isinstance(types, str) or types is None: types = {types} for identifier in self.identifiers: if identifier.type in types: return identifier @classmethod def _new_suffix_code(cls): pool = Pool() Configuration = pool.get('product.configuration') config = Configuration(1) sequence = config.product_sequence if sequence: return sequence.get() @classmethod def create(cls, vlist): vlist = [x.copy() for x in vlist] for values in vlist: if not values.get('suffix_code'): values['suffix_code'] = cls._new_suffix_code() products = super().create(vlist) cls.sync_code(products) return products @classmethod def write(cls, *args): super().write(*args) products = sum(args[0:None:2], []) cls.sync_code(products) @classmethod def copy(cls, products, default=None): if default is None: default = {} else: default = default.copy() default.setdefault('suffix_code', None) default.setdefault('code', None) return super().copy(products, default=default) @property def list_price_used(self): transaction = Transaction() with transaction.reset_context(), \ transaction.set_context(self._context): return self.template.get_multivalue('list_price') @classmethod def sync_code(cls, products): for product in products: code = ''.join(filter(None, [ product.prefix_code, product.suffix_code])) if cls.code.strip: if cls.code.strip == 'leading': code = code.lstrip() elif cls.code.strip == 'trailing': code = code.rstrip() else: code = code.strip() if not code: code = None if code != product.code: product.code = code cls.save(products) class ProductListPrice(ModelSQL, CompanyValueMixin): "Product List Price" __name__ = 'product.list_price' template = fields.Many2One( 'product.template', "Template", ondelete='CASCADE', context={ 'company': Eval('company', -1), }, depends={'company'}) list_price = fields.Numeric("List Price", digits=price_digits) @classmethod def __setup__(cls): super().__setup__() cls.company.required = True class ProductCostPriceMethod(ModelSQL, CompanyValueMixin): "Product Cost Price Method" __name__ = 'product.cost_price_method' template = fields.Many2One( 'product.template', "Template", ondelete='CASCADE', context={ 'company': Eval('company', -1), }, depends={'company'}) cost_price_method = fields.Selection( 'get_cost_price_methods', "Cost Price Method") @classmethod def get_cost_price_methods(cls): pool = Pool() Template = pool.get('product.template') field_name = 'cost_price_method' methods = Template.fields_get([field_name])[field_name]['selection'] methods.append((None, '')) return methods class ProductCostPrice(ModelSQL, CompanyValueMixin): "Product Cost Price" __name__ = 'product.cost_price' product = fields.Many2One( 'product.product', "Product", ondelete='CASCADE', context={ 'company': Eval('company', -1), }, depends={'company'}) cost_price = fields.Numeric( "Cost Price", required=True, digits=price_digits) @classmethod def __setup__(cls): super().__setup__() cls.company.required = True class TemplateCategory(ModelSQL): 'Template - Category' __name__ = 'product.template-product.category' template = fields.Many2One( 'product.template', "Template", ondelete='CASCADE', required=True) category = fields.Many2One( 'product.category', "Category", ondelete='CASCADE', required=True) class TemplateCategoryAll(UnionMixin, ModelSQL): "Template - Category All" __name__ = 'product.template-product.category.all' template = fields.Many2One('product.template', "Template") category = fields.Many2One('product.category', "Category") @classmethod def union_models(cls): return ['product.template-product.category'] class ProductIdentifier(sequence_ordered(), ModelSQL, ModelView): "Product Identifier" __name__ = 'product.identifier' _rec_name = 'code' product = fields.Many2One( 'product.product', "Product", ondelete='CASCADE', required=True, help="The product identified by the code.") type = fields.Selection([ (None, ''), ('ean', "International Article Number"), ('isan', "International Standard Audiovisual Number"), ('isbn', "International Standard Book Number"), ('isil', "International Standard Identifier for Libraries"), ('isin', "International Securities Identification Number"), ('ismn', "International Standard Music Number"), ], "Type") type_string = type.translated('type') code = fields.Char("Code", required=True) @classmethod def __setup__(cls): cls.code.search_unaccented = False super().__setup__() cls.__access__.add('product') t = cls.__table__() cls._sql_indexes.update({ Index( t, (t.product, Index.Equality()), (t.code, Index.Similarity())), }) @fields.depends('type', 'code') def on_change_with_code(self): if self.type and self.type != 'other': try: module = import_module('stdnum.%s' % self.type) return module.compact(self.code) except ImportError: pass except stdnum.exceptions.ValidationError: pass return self.code def pre_validate(self): super().pre_validate() self.check_code() @fields.depends('type', 'product', 'code') def check_code(self): if self.type: try: module = import_module('stdnum.%s' % self.type) except ModuleNotFoundError: return if not module.is_valid(self.code): if self.product and self.product.id > 0: product = self.product.rec_name else: product = '' raise InvalidIdentifierCode( gettext('product.msg_invalid_code', type=self.type_string, code=self.code, product=product)) def barcode(self, format='svg', **options): if barcode and self.type in barcode.BARCODES: generator = getattr(barcode, 'generate_%s' % format) return generator(self.type, self.on_change_with_code(), **options)