Files
tradon/modules/sale_product_recommendation_association_rule/sale.py
2026-03-14 09:42:12 +00:00

301 lines
10 KiB
Python

# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from itertools import groupby
from efficient_apriori import apriori
from trytond.cache import Cache
from trytond.model import Index, ModelSQL, ModelView, fields
from trytond.pool import Pool, PoolMeta
from trytond.pyson import TimeDelta
from trytond.transaction import Transaction
class Configuration(metaclass=PoolMeta):
__name__ = 'sale.configuration'
product_association_rule_transactions_up_to = fields.TimeDelta(
"Transactions Up to",
domain=['OR',
('product_association_rule_transactions_up_to', '=', None),
('product_association_rule_transactions_up_to', '>=', TimeDelta()),
])
product_association_rule_min_support = fields.Float(
"Minimum Support", required=True,
domain=[
('product_association_rule_min_support', '>=', 0),
('product_association_rule_min_support', '<=', 1),
],
help="The minimum frequency of which the items in the rule "
"appear together in the data set.")
product_association_rule_min_confidence = fields.Float(
"Minimum Confidence", required=True,
domain=[
('product_association_rule_min_confidence', '>=', 0),
('product_association_rule_min_confidence', '<=', 1),
],
help="The minimum probability of the rule.")
product_association_rule_max_length = fields.Integer(
"The maximal number of products for a rule.", required=True,
domain=[
('product_association_rule_max_length', '>', 0),
])
@classmethod
def __setup__(cls):
super().__setup__()
cls.product_recommendation_method.selection.append(
('association_rule', "Association Rule"))
@classmethod
def default_product_association_rule_min_support(cls):
return 0.3
@classmethod
def default_product_association_rule_min_confidence(cls):
return 0.5
@classmethod
def default_product_association_rule_max_length(cls):
return 8
class Sale(metaclass=PoolMeta):
__name__ = 'sale.sale'
@fields.depends(methods=['_recommended_products_association_rule'])
def on_change_with_recommended_products(self, name=None):
return super().on_change_with_recommended_products(name=name)
@fields.depends('lines')
def _recommended_products_association_rule(self):
pool = Pool()
Rule = pool.get('sale.product.association.rule')
if self.lines:
products = [
l.product for l in self.lines
if getattr(l, 'product', None)]
yield from Rule.recommend(products)
class ProductAssociationRule(ModelSQL, ModelView):
__name__ = 'sale.product.association.rule'
antecedents = fields.Many2Many(
'sale.product.association.rule.antecedent', 'rule', 'product',
"Antecedents")
antecedent_names = fields.Function(fields.Char(
"Antecedents"), 'get_product_names')
consequents = fields.Many2Many(
'sale.product.association.rule.consequent', 'rule', 'product',
"Consequents")
consequent_names = fields.Function(fields.Char(
"Consequents"), 'get_product_names')
confidence = fields.Float(
"Confidence",
domain=[
('confidence', '>=', 0),
('confidence', '<=', 1),
],
help="Probability of consequents, given antecedents.")
support = fields.Float(
"Support",
domain=[
('support', '>=', 0),
('support', '<=', 1),
],
help="The frequency of consequents and antecedents appear together.")
lift = fields.Float(
"Lift",
domain=[
('lift', '>=', 0),
],
help="If equals to 1, the two occurrences are "
"independent of each other.\n"
"If greater than 1, the degree to which the two occurrences are "
"dependent on one another.\n"
"If less than 1, the items are substitute to each other.")
conviction = fields.Float(
"Conviction",
domain=[
('conviction', '>=', 0),
],
help="The frequency that the rule makes an incorrect prediction.")
_find_rules_cache = Cache(__name__ + '._find_rules', context=False)
@classmethod
def __setup__(cls):
super().__setup__()
t = cls.__table__()
cls._sql_indexes.update({
Index(
t,
(t.lift, Index.Range(order='DESC')),
(t.conviction, Index.Range(order='ASC'))),
Index(
t,
(t.id, Index.Range(cardinality='high')),
(t.lift, Index.Range(order='DESC'))),
})
def get_product_names(self, name):
return ', '.join(
p.rec_name for p in getattr(self, name[:-len('_names')] + 's'))
@classmethod
def clean(cls, domain=None):
table = cls.__table__()
cursor = Transaction().connection.cursor()
if domain:
query = cls.search(domain, query=True)
where = table.id.in_(query)
else:
where = None
cursor.execute(*table.delete(where=where))
@classmethod
def transactions(cls):
"Yield transaction as sets of product ids"
yield from cls.transactions_sale()
@classmethod
def transactions_sale(cls, domain=None):
pool = Pool()
Date = pool.get('ir.date')
Sale = pool.get('sale.sale')
SaleLine = pool.get('sale.line')
Configuration = pool.get('sale.configuration')
config = Configuration(1)
today = Date.today()
date = today
if not config.product_association_rule_transactions_up_to:
return
date -= config.product_association_rule_transactions_up_to
lines = SaleLine.search([
('sale.sale_date', '>=', date),
('sale.state', 'not in', ['draft', 'cancelled']),
domain or [],
],
order=[('sale.id', 'DESC')])
for sale, lines in groupby(lines, lambda l: l.sale):
yield {
l.product.id for l in lines
if l.product and Sale._is_recommendable_product(l.product)}
@classmethod
def compute(cls):
pool = Pool()
Configuration = pool.get('sale.configuration')
config = Configuration(1)
cls.clean()
cls._find_rules_cache.clear()
transactions = cls.transactions()
_, rules = apriori(
transactions,
min_support=config.product_association_rule_min_support,
min_confidence=config.product_association_rule_min_confidence,
max_length=config.product_association_rule_max_length)
cls.save([cls.from_rule(rule) for rule in rules])
@classmethod
def from_rule(cls, rule):
return cls(
antecedents=rule.lhs,
consequents=rule.rhs,
confidence=rule.confidence,
support=rule.support,
lift=rule.lift,
conviction=rule.conviction)
@classmethod
def _find_rules(cls, products, domain, lift='DESC'):
product_ids = {p.id for p in products}
key = (sorted(product_ids), domain, lift)
rules = cls._find_rules_cache.get(key)
if rules is not None:
rules = cls.browse(rules)
else:
rules = cls.search([
('antecedents', 'in', list(product_ids)),
domain,
],
order=[('lift', lift), ('conviction', 'ASC')])
rules = [
r for r in rules
if set(map(int, r.antecedents)) <= product_ids]
cls._find_rules_cache.set(key, [r.id for r in rules])
products = set(products)
for rule in rules:
yield from (set(rule.consequents) - products)
products.update(rule.consequents)
@classmethod
def recommend(cls, products):
return cls._find_rules(products, [('lift', '>', 1)], lift='DESC')
@classmethod
def substitute(cls, products):
return cls._find_rules(products, [('lift', '<', 1)], lift='DESC')
class ProductAssociationRuleAntecedent(ModelSQL):
__name__ = 'sale.product.association.rule.antecedent'
rule = fields.Many2One(
'sale.product.association.rule', "Rule",
required=True, ondelete='CASCADE')
product = fields.Many2One(
'product.product', "Product", required=True, ondelete='CASCADE')
class ProductAssociationRuleConsequent(ModelSQL):
__name__ = 'sale.product.association.rule.consequent'
rule = fields.Many2One(
'sale.product.association.rule', "Rule",
required=True, ondelete='CASCADE')
product = fields.Many2One(
'product.product', "Product", required=True, ondelete='CASCADE')
class ProductAssociationRulePOS(metaclass=PoolMeta):
__name__ = 'sale.product.association.rule'
@classmethod
def transactions(cls):
yield from super().transactions()
yield from cls.transactions_pos()
@classmethod
def transactions_pos(cls, domain=None):
pool = Pool()
Date = pool.get('ir.date')
Sale = pool.get('sale.point.sale')
SaleLine = pool.get('sale.point.sale.line')
Configuration = pool.get('sale.configuration')
config = Configuration(1)
today = Date.today()
date = today
if not config.product_association_rule_transactions_up_to:
return
date -= config.product_association_rule_transactions_up_to
lines = SaleLine.search([
('sale.date', '>=', date),
('sale.state', 'not in', ['open', 'cancelled']),
domain or [],
],
order=[('sale.id', 'DESC')])
for sale, lines in groupby(lines, lambda l: l.sale):
yield {
l.product.id for l in lines
if l.product and Sale._is_recommendable_product(l.product)}