mirror of
https://github.com/pretix/pretix.git
synced 2026-05-06 15:24:02 +00:00
Implement discount prediction (very WIP!)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user