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