Files
tradon/modules/product/product.py
2026-01-26 17:32:33 +01:00

891 lines
30 KiB
Python
Executable File

# 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.conditionals import Coalesce
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
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",
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 TemplateDeactivatableMixin(DeactivableMixin):
__slots__ = ()
@classmethod
def _active_expression(cls, tables):
pool = Pool()
Template = pool.get('product.template')
table, _ = tables[None]
if 'template' not in tables:
template = Template.__table__()
tables['template'] = {
None: (template, table.template == template.id),
}
else:
template, _ = tables['template'][None]
return table.active & template.active
@classmethod
def domain_active(cls, domain, tables):
expression = cls._active_expression(tables)
_, operator, value = domain
if operator in {'=', '!='}:
if (operator == '=') != value:
expression = ~expression
elif operator in {'in', 'not in'}:
if True in value and False not in value:
pass
elif False in value and True not in value:
expression = ~expression
else:
expression = Literal(True)
else:
expression = Literal(True)
return expression
class ProductDeactivatableMixin(TemplateDeactivatableMixin):
__slots__ = ()
@classmethod
def _active_expression(cls, tables):
pool = Pool()
Product = pool.get('product.product')
table, _ = tables[None]
if 'product' not in tables:
product = Product.__table__()
tables['product'] = {
None: (product, table.product == product.id),
}
else:
product, _ = tables['product'][None]
expression = super()._active_expression(tables)
return expression & Coalesce(product.active, expression)
class Product(
TemplateDeactivatableMixin, 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},
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),
}
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),
]
@classmethod
def get_by_name(cls, name, type_='goods'):
pool = Pool()
Template = pool.get('product.template')
Uom = pool.get('product.uom')
templates = Template.search([('name', '=', name)], limit=1)
if templates:
return templates[0].products[0]
unit_uom, = Uom.search([('name', '=', 'Mt')], limit=1)
template, = Template.create([{
'name': name,
'type': type_,
'default_uom': unit_uom.id,
'cost_price_method': 'fixed',
}])
return template.products[0]
@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"),
('brand', "Brand"),
('mpn', "Manufacturer Part 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())),
})
@property
@fields.depends('type')
def _is_stdnum(self):
return self.type in {'ean', 'isan', 'isbn', 'isil', 'isin', 'ismn'}
@fields.depends('type', 'code', methods=['_is_stdnum'])
def on_change_with_code(self):
if self._is_stdnum:
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', methods=['_is_stdnum'])
def check_code(self):
if self._is_stdnum:
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', type=None, **options):
if type is None:
type = self.type
if barcode and type in barcode.BARCODES:
generator = getattr(barcode, 'generate_%s' % format)
return generator(type, self.on_change_with_code(), **options)