Implement discount prediction (very WIP!)

This commit is contained in:
Mira Weller
2024-06-08 02:26:06 +02:00
parent 97925e2d77
commit 8791280d0b
5 changed files with 103 additions and 45 deletions

View File

@@ -36,6 +36,11 @@ from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
ITEM_ID = 0
SUBEVENT_ID = 1
LINE_PRICE_GROSS = 2
IS_ADDON_TO = 3
VOUCHER_DISCOUNT = 4
class Discount(LoggedModel):
SUBEVENT_MODE_MIXED = 'mixed'
@@ -245,22 +250,26 @@ class Discount(LoggedModel):
return False
return True
def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result):
if self.condition_min_value and sum(positions[idx][2] for idx in condition_idx_group) < self.condition_min_value:
def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts):
if self.condition_min_value and sum(positions[idx][LINE_PRICE_GROSS] for idx in condition_idx_group) < self.condition_min_value:
return
if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches:
raise ValueError('Validation invariant violated.')
for idx in benefit_idx_group:
previous_price = positions[idx][2]
previous_price = positions[idx][LINE_PRICE_GROSS]
new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
self.event.currency,
)
result[idx] = new_price
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result):
if collect_potential_discounts is not None:
for idx in condition_idx_group:
collect_potential_discounts[idx] = [(self, None, -1)]
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts):
if len(condition_idx_group) < self.condition_min_count:
return
@@ -271,20 +280,41 @@ class Discount(LoggedModel):
if not self.condition_min_count:
raise ValueError('Validation invariant violated.')
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx][LINE_PRICE_GROSS], -idx)) # sort by line_price
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx][LINE_PRICE_GROSS], -idx)) # sort by line_price
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
# want to match multiples of 3
n_groups = min(len(condition_idx_group) // self.condition_min_count, ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches))
possible_applications_cond = len(condition_idx_group) // self.condition_min_count # how many discount applications are allowed according to condition products in cart
possible_applications_benefit = ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches) # how many discount applications are possible according to benefitting products in cart
n_groups = min(possible_applications_cond, possible_applications_benefit)
consume_idx = condition_idx_group[:n_groups * self.condition_min_count]
benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches]
if collect_potential_discounts is not None and n_groups * self.benefit_only_apply_to_cheapest_n_matches > len(
benefit_idx_group):
# "angebrochener" discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket but only 1 t-shirt) -> 1 shirt definitiv potential discount
for idx in consume_idx:
collect_potential_discounts[idx] += [(self, n_groups * self.benefit_only_apply_to_cheapest_n_matches - len(benefit_idx_group), -1)]
if collect_potential_discounts is not None and possible_applications_cond * self.benefit_only_apply_to_cheapest_n_matches > len(
benefit_idx_group):
# "ungenutzter" discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket but 0 t-shirts) -> 2 shirt maybe potential discount (if the 1 ticket is not consumed by a later discount)
for i, idx in enumerate(condition_idx_group[n_groups * self.condition_min_count:]):
collect_potential_discounts[idx] = [
(self, self.benefit_only_apply_to_cheapest_n_matches, i // self.condition_min_count)
]
else:
consume_idx = condition_idx_group
benefit_idx = benefit_idx_group
if collect_potential_discounts is not None:
for idx in consume_idx:
collect_potential_discounts[idx] = [(self, None, -1)]
for idx in benefit_idx:
previous_price = positions[idx][2]
previous_price = positions[idx][LINE_PRICE_GROSS]
new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
self.event.currency,
@@ -292,9 +322,9 @@ class Discount(LoggedModel):
result[idx] = new_price
for idx in consume_idx:
result.setdefault(idx, positions[idx][2])
result.setdefault(idx, positions[idx][LINE_PRICE_GROSS])
def apply(self, positions: Dict[int, Tuple[int, Optional[int], Decimal, bool, Decimal]]) -> Dict[int, Decimal]:
def apply(self, positions: Dict[int, Tuple[int, Optional[int], Decimal, bool, Decimal]], collect_potential_discounts=None) -> Dict[int, Decimal]:
"""
Tries to apply this discount to a cart
@@ -342,9 +372,9 @@ class Discount(LoggedModel):
if self.subevent_mode == self.SUBEVENT_MODE_MIXED: # also applies to non-series events
if self.condition_min_count:
self._apply_min_count(positions, condition_candidates, benefit_candidates, result)
self._apply_min_count(positions, condition_candidates, benefit_candidates, result, collect_potential_discounts)
else:
self._apply_min_value(positions, condition_candidates, benefit_candidates, result)
self._apply_min_value(positions, condition_candidates, benefit_candidates, result, collect_potential_discounts)
elif self.subevent_mode == self.SUBEVENT_MODE_SAME:
def key(idx):
@@ -357,11 +387,11 @@ class Discount(LoggedModel):
candidate_groups = [(k, list(g)) for k, g in _groups]
for subevent_id, g in candidate_groups:
benefit_g = [idx for idx in benefit_candidates if positions[idx][1] == subevent_id]
benefit_g = [idx for idx in benefit_candidates if positions[idx][SUBEVENT_ID] == subevent_id]
if self.condition_min_count:
self._apply_min_count(positions, g, benefit_g, result)
self._apply_min_count(positions, g, benefit_g, result, collect_potential_discounts)
else:
self._apply_min_value(positions, g, benefit_g, result)
self._apply_min_value(positions, g, benefit_g, result, collect_potential_discounts)
elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT:
if self.condition_min_value or not self.benefit_same_products:
@@ -377,9 +407,9 @@ class Discount(LoggedModel):
# Build a list of subevent IDs in descending order of frequency
subevent_to_idx = defaultdict(list)
for idx, p in positions.items():
subevent_to_idx[p[1]].append(idx)
subevent_to_idx[p[SUBEVENT_ID]].append(idx)
for v in subevent_to_idx.values():
v.sort(key=lambda idx: positions[idx][2])
v.sort(key=lambda idx: positions[idx][LINE_PRICE_GROSS])
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
# Build groups of exactly condition_min_count distinct subevents
@@ -394,7 +424,7 @@ class Discount(LoggedModel):
l = [ll for ll in l if ll in condition_candidates and ll not in current_group]
if cardinality and len(l) != cardinality:
continue
if se not in {positions[idx][1] for idx in current_group}:
if se not in {positions[idx][SUBEVENT_ID] for idx in current_group}:
candidates += l
cardinality = len(l)
@@ -403,7 +433,7 @@ class Discount(LoggedModel):
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
# and 2 from the end" scheme to optimize price distribution among groups
candidates = sorted(candidates, key=lambda idx: positions[idx][2])
candidates = sorted(candidates, key=lambda idx: positions[idx][LINE_PRICE_GROSS])
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
candidate = candidates[0]
else:
@@ -415,14 +445,14 @@ class Discount(LoggedModel):
if len(current_group) >= max(self.condition_min_count, 1):
candidate_groups.append(current_group)
for c in current_group:
subevent_to_idx[positions[c][1]].remove(c)
subevent_to_idx[positions[c][SUBEVENT_ID]].remove(c)
current_group = []
# Distribute "leftovers"
for se in subevent_order:
if subevent_to_idx[se]:
for group in candidate_groups:
if se not in {positions[idx][1] for idx in group}:
if se not in {positions[idx][SUBEVENT_ID] for idx in group}:
group.append(subevent_to_idx[se].pop())
if not subevent_to_idx[se]:
break
@@ -432,6 +462,7 @@ class Discount(LoggedModel):
positions,
[idx for idx in g if idx in condition_candidates],
[idx for idx in g if idx in benefit_candidates],
result
result,
collect_potential_discounts
)
return result

View File

@@ -38,7 +38,7 @@ import os
import sys
import uuid
import warnings
from collections import Counter, OrderedDict
from collections import Counter, OrderedDict, defaultdict
from datetime import date, datetime, time, timedelta
from decimal import Decimal, DecimalException
from typing import Optional, Tuple
@@ -142,17 +142,34 @@ class ItemCategory(LoggedModel):
verbose_name_plural = _("Product categories")
ordering = ('position', 'id')
def cross_sell_visible(self, cart_positions):
def cross_sell_visible(self, cart, event, sales_channel):
if self.cross_selling_mode is None:
return False
return []
if self.cross_selling_condition == 'always':
return True
return self.items.all()
if self.cross_selling_condition == 'products':
match = set(match.pk for match in self.cross_selling_match_products.only('pk')) # TODO prefetch this
return any(pos.item.pk in match for pos in cart_positions)
return self.items.all() if any(pos.item.pk in match for pos in cart) else []
if self.cross_selling_condition == 'discounts':
# TODO not sure how to do this yet
return False
# aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaarrrrgggghhhhhh
from ..services.pricing import apply_discounts
potential_discounts_dict = defaultdict(list)
discount_results = apply_discounts(
event,
sales_channel,
[
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled,
cp.listed_price - cp.price_after_voucher)
for cp in cart
],
collect_potential_discounts=potential_discounts_dict
)
print("potential_discounts_dict", potential_discounts_dict)
potential_discounts = {info for lst in potential_discounts_dict.values() for info in lst}
potential_discount_items = {item.pk for (discount_rule, max_count, i) in potential_discounts for item in discount_rule.benefit_limit_products.all()}
return self.items.filter(pk__in=potential_discount_items)
def __str__(self):
name = self.internal_name or self.name

View File

@@ -156,7 +156,8 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
def apply_discounts(event: Event, sales_channel: str,
positions: List[Tuple[int, Optional[int], Decimal, bool, bool]]) -> List[Decimal]:
positions: List[Tuple[int, Optional[int], Decimal, bool, bool]],
collect_potential_discounts=None) -> List[Decimal]:
"""
Applies any dynamic discounts to a cart
@@ -176,11 +177,13 @@ def apply_discounts(event: Event, sales_channel: str,
active=True,
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
for discount in discount_qs:
if collect_potential_discounts is not None:
print("checking discount",discount)
result = discount.apply({
idx: (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount) in enumerate(positions)
if not is_bundled and idx not in new_prices
})
}, collect_potential_discounts)
for k in result.keys():
result[k] = (result[k], discount)
new_prices.update(result)

View File

@@ -490,14 +490,21 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
if not hasattr(request, '_checkoutflow_addons_applicable'):
cart = get_cart(request)
self.request = request
request._checkoutflow_addons_applicable = (cart.filter(item__addons__isnull=False).exists()
or any(self.cross_selling_applicable_rules))
request._checkoutflow_addons_applicable =('/addons/' in request.path_info or cart.filter(item__addons__isnull=False).exists()
or any(self.cross_selling_applicable_categories))
return request._checkoutflow_addons_applicable
@cached_property
def cross_selling_applicable_rules(self):
cart = self.get_cart(self.request)
return [c for c in self.request.event.categories.filter(cross_selling_mode__isnull=False) if c.cross_sell_visible(cart['positions'])]
def cross_selling_applicable_categories(self):
cart = get_cart(self.request)
return [
(c, products) for (c, products) in
(
(c, c.cross_sell_visible(cart, self.request.event, self.request.sales_channel.identifier))
for c in self.request.event.categories.filter(cross_selling_mode__isnull=False)
)
if len(products) > 0
]
def is_completed(self, request, warn=False):
if getattr(self, '_completed', None) is not None:
@@ -622,23 +629,23 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
if self.event.has_subevents:
return [
(DummyCategory(rule, subevent), self._items_from_rule(rule, subevent), f'subevent_{subevent.pk}_')
for rule in self.cross_selling_applicable_rules
(DummyCategory(category, subevent), self._items_for_cross_selling(subevent, items), f'subevent_{subevent.pk}_')
for (category, items) in self.cross_selling_applicable_categories
for subevent in set(pos.subevent for pos in ctx['cart']['positions'])
]
else:
return [
(rule, self._items_from_rule(rule, None))
for rule in self.cross_selling_applicable_rules
(category, self._items_for_cross_selling(None, items))
for (category, items) in self.cross_selling_applicable_categories
]
def _items_from_rule(self, rule, subevent):
def _items_for_cross_selling(self, subevent, items):
items, _btn = get_grouped_items(
self.request.event,
subevent=subevent,
voucher=None,
channel=self.request.sales_channel.identifier,
base_qs=rule.items.all(),
base_qs=items,
allow_addons=True,
allow_cross_sell=True,
memberships=(
@@ -752,7 +759,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
'price': price,
})
add_to_cart_items = _items_from_post_data(self.request)
add_to_cart_items = _items_from_post_data(self.request, warn_if_empty=False)
return self.do(self.request.event.id, addons, add_to_cart_items, get_or_create_cart_id(self.request),
invoice_address=self.invoice_address.pk, locale=get_language(),

View File

@@ -227,7 +227,7 @@ def _item_from_post_value(request, key, value, voucher=None, voucher_ignore_if_r
except ValueError:
raise CartError(_('Please enter numbers only.'))
def _items_from_post_data(request):
def _items_from_post_data(request, warn_if_empty=True):
"""
Parses the POST data and returns a list of dictionaries
"""
@@ -254,7 +254,7 @@ def _items_from_post_data(request):
if item:
items.append(item)
if len(items) == 0:
if len(items) == 0 and warn_if_empty:
messages.warning(request, _('You did not select any products.'))
return []
return items