301 lines
10 KiB
Python
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)}
|