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.decimal import round_decimal
from pretix.base.models.base import LoggedModel 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): class Discount(LoggedModel):
SUBEVENT_MODE_MIXED = 'mixed' SUBEVENT_MODE_MIXED = 'mixed'
@@ -245,22 +250,26 @@ class Discount(LoggedModel):
return False return False
return True return True
def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result): 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][2] for idx in condition_idx_group) < self.condition_min_value: if self.condition_min_value and sum(positions[idx][LINE_PRICE_GROSS] for idx in condition_idx_group) < self.condition_min_value:
return return
if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches: if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches:
raise ValueError('Validation invariant violated.') raise ValueError('Validation invariant violated.')
for idx in benefit_idx_group: for idx in benefit_idx_group:
previous_price = positions[idx][2] previous_price = positions[idx][LINE_PRICE_GROSS]
new_price = round_decimal( new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'), previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
self.event.currency, self.event.currency,
) )
result[idx] = new_price 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: if len(condition_idx_group) < self.condition_min_count:
return return
@@ -271,20 +280,41 @@ class Discount(LoggedModel):
if not self.condition_min_count: if not self.condition_min_count:
raise ValueError('Validation invariant violated.') raise ValueError('Validation invariant violated.')
condition_idx_group = sorted(condition_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][2], -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 # Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
# want to match multiples of 3 # 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] 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] 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: else:
consume_idx = condition_idx_group consume_idx = condition_idx_group
benefit_idx = benefit_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: for idx in benefit_idx:
previous_price = positions[idx][2] previous_price = positions[idx][LINE_PRICE_GROSS]
new_price = round_decimal( new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'), previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
self.event.currency, self.event.currency,
@@ -292,9 +322,9 @@ class Discount(LoggedModel):
result[idx] = new_price result[idx] = new_price
for idx in consume_idx: 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 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.subevent_mode == self.SUBEVENT_MODE_MIXED: # also applies to non-series events
if self.condition_min_count: 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: 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: elif self.subevent_mode == self.SUBEVENT_MODE_SAME:
def key(idx): def key(idx):
@@ -357,11 +387,11 @@ class Discount(LoggedModel):
candidate_groups = [(k, list(g)) for k, g in _groups] candidate_groups = [(k, list(g)) for k, g in _groups]
for subevent_id, g in candidate_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: 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: 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: elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT:
if self.condition_min_value or not self.benefit_same_products: 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 # Build a list of subevent IDs in descending order of frequency
subevent_to_idx = defaultdict(list) subevent_to_idx = defaultdict(list)
for idx, p in positions.items(): 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(): 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) 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 # 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] l = [ll for ll in l if ll in condition_candidates and ll not in current_group]
if cardinality and len(l) != cardinality: if cardinality and len(l) != cardinality:
continue 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 candidates += l
cardinality = len(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 # 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 # 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): if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
candidate = candidates[0] candidate = candidates[0]
else: else:
@@ -415,14 +445,14 @@ class Discount(LoggedModel):
if len(current_group) >= max(self.condition_min_count, 1): if len(current_group) >= max(self.condition_min_count, 1):
candidate_groups.append(current_group) candidate_groups.append(current_group)
for c in 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 = [] current_group = []
# Distribute "leftovers" # Distribute "leftovers"
for se in subevent_order: for se in subevent_order:
if subevent_to_idx[se]: if subevent_to_idx[se]:
for group in candidate_groups: 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()) group.append(subevent_to_idx[se].pop())
if not subevent_to_idx[se]: if not subevent_to_idx[se]:
break break
@@ -432,6 +462,7 @@ class Discount(LoggedModel):
positions, positions,
[idx for idx in g if idx in condition_candidates], [idx for idx in g if idx in condition_candidates],
[idx for idx in g if idx in benefit_candidates], [idx for idx in g if idx in benefit_candidates],
result result,
collect_potential_discounts
) )
return result return result

View File

@@ -38,7 +38,7 @@ import os
import sys import sys
import uuid import uuid
import warnings import warnings
from collections import Counter, OrderedDict from collections import Counter, OrderedDict, defaultdict
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from decimal import Decimal, DecimalException from decimal import Decimal, DecimalException
from typing import Optional, Tuple from typing import Optional, Tuple
@@ -142,17 +142,34 @@ class ItemCategory(LoggedModel):
verbose_name_plural = _("Product categories") verbose_name_plural = _("Product categories")
ordering = ('position', 'id') 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: if self.cross_selling_mode is None:
return False return []
if self.cross_selling_condition == 'always': if self.cross_selling_condition == 'always':
return True return self.items.all()
if self.cross_selling_condition == 'products': if self.cross_selling_condition == 'products':
match = set(match.pk for match in self.cross_selling_match_products.only('pk')) # TODO prefetch this 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': if self.cross_selling_condition == 'discounts':
# TODO not sure how to do this yet # aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaarrrrgggghhhhhh
return False
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): def __str__(self):
name = self.internal_name or self.name 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, 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 Applies any dynamic discounts to a cart
@@ -176,11 +177,13 @@ def apply_discounts(event: Event, sales_channel: str,
active=True, active=True,
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk') ).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
for discount in discount_qs: for discount in discount_qs:
if collect_potential_discounts is not None:
print("checking discount",discount)
result = discount.apply({ result = discount.apply({
idx: (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) 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) 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 if not is_bundled and idx not in new_prices
}) }, collect_potential_discounts)
for k in result.keys(): for k in result.keys():
result[k] = (result[k], discount) result[k] = (result[k], discount)
new_prices.update(result) new_prices.update(result)

View File

@@ -490,14 +490,21 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
if not hasattr(request, '_checkoutflow_addons_applicable'): if not hasattr(request, '_checkoutflow_addons_applicable'):
cart = get_cart(request) cart = get_cart(request)
self.request = request self.request = request
request._checkoutflow_addons_applicable = (cart.filter(item__addons__isnull=False).exists() request._checkoutflow_addons_applicable =('/addons/' in request.path_info or cart.filter(item__addons__isnull=False).exists()
or any(self.cross_selling_applicable_rules)) or any(self.cross_selling_applicable_categories))
return request._checkoutflow_addons_applicable return request._checkoutflow_addons_applicable
@cached_property @cached_property
def cross_selling_applicable_rules(self): def cross_selling_applicable_categories(self):
cart = self.get_cart(self.request) cart = 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'])] 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): def is_completed(self, request, warn=False):
if getattr(self, '_completed', None) is not None: if getattr(self, '_completed', None) is not None:
@@ -622,23 +629,23 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
if self.event.has_subevents: if self.event.has_subevents:
return [ return [
(DummyCategory(rule, subevent), self._items_from_rule(rule, subevent), f'subevent_{subevent.pk}_') (DummyCategory(category, subevent), self._items_for_cross_selling(subevent, items), f'subevent_{subevent.pk}_')
for rule in self.cross_selling_applicable_rules for (category, items) in self.cross_selling_applicable_categories
for subevent in set(pos.subevent for pos in ctx['cart']['positions']) for subevent in set(pos.subevent for pos in ctx['cart']['positions'])
] ]
else: else:
return [ return [
(rule, self._items_from_rule(rule, None)) (category, self._items_for_cross_selling(None, items))
for rule in self.cross_selling_applicable_rules 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( items, _btn = get_grouped_items(
self.request.event, self.request.event,
subevent=subevent, subevent=subevent,
voucher=None, voucher=None,
channel=self.request.sales_channel.identifier, channel=self.request.sales_channel.identifier,
base_qs=rule.items.all(), base_qs=items,
allow_addons=True, allow_addons=True,
allow_cross_sell=True, allow_cross_sell=True,
memberships=( memberships=(
@@ -752,7 +759,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
'price': price, '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), 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(), 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: except ValueError:
raise CartError(_('Please enter numbers only.')) 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 Parses the POST data and returns a list of dictionaries
""" """
@@ -254,7 +254,7 @@ def _items_from_post_data(request):
if item: if item:
items.append(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.')) messages.warning(request, _('You did not select any products.'))
return [] return []
return items return items