first commit
This commit is contained in:
300
modules/sale_product_recommendation_association_rule/sale.py
Normal file
300
modules/sale_product_recommendation_association_rule/sale.py
Normal file
@@ -0,0 +1,300 @@
|
||||
# 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)}
|
||||
Reference in New Issue
Block a user