|
| 1 | +from decimal import Decimal |
| 2 | +from cards.models import RewardRule, UserCard, Card |
| 3 | + |
| 4 | + |
| 5 | +# Most of code logic are generated by AI, but with human fix the code and correct logic. |
| 6 | +# eg, at first the code is calculate the best card for a category from card database, |
| 7 | +# but it should be calculate the best card for a category from user's cards. |
| 8 | + |
| 9 | +SELECTED = "SELECTED_CATEGORIES" |
| 10 | +BASE_ANYWHERE = "OTHER" |
| 11 | +BIG = Decimal("1000000000") # treat None cap as "very large" |
| 12 | + |
| 13 | +def _rank(items): |
| 14 | + # items: list of (card, multiplier_float, cap_or_BIG) |
| 15 | + # Sort by: higher multiplier → higher cap → lower annual fee → issuer/name |
| 16 | + items.sort( |
| 17 | + key=lambda t: (t[1], t[2], -float(t[0].annual_fee or 0), t[0].issuer, t[0].name), |
| 18 | + reverse=True, |
| 19 | + ) |
| 20 | + |
| 21 | + # Best card = first after sort |
| 22 | + best_card, best_mult, _ = items[0] |
| 23 | + |
| 24 | + # Collect other cards that tie with the best multiplier (exclude best itself) |
| 25 | + ties = [ |
| 26 | + (c, m, cap) |
| 27 | + for (c, m, cap) in items[1:] # skip the best one |
| 28 | + if m == best_mult |
| 29 | + ][:2] # +2 because we already have one best → total 3 max |
| 30 | + |
| 31 | + # Prepare top3 list = alternatives (no duplication of the best card) |
| 32 | + top3 = [ |
| 33 | + {"card_id": c.id, "card_name": f"{c.issuer} {c.name}", "multiplier": m} |
| 34 | + for (c, m, _cap) in ties |
| 35 | + ] |
| 36 | + |
| 37 | + return best_card, best_mult, top3 |
| 38 | + |
| 39 | + |
| 40 | +def best_cards_for_category(category_tag: str, user) -> dict: |
| 41 | + # 0) user's active cards |
| 42 | + my_card_ids = list( |
| 43 | + UserCard.objects.filter(user=user, is_active=True).values_list("card_id", flat=True) |
| 44 | + ) |
| 45 | + if not my_card_ids: |
| 46 | + return { |
| 47 | + "best_card": None, |
| 48 | + "multiplier": 1.0, |
| 49 | + "rationale": "You have no active cards. Showing baseline 1.0× recommendation.", |
| 50 | + "top3": [], |
| 51 | + } |
| 52 | + |
| 53 | + # 1) exact category among user's cards |
| 54 | + qs = ( |
| 55 | + RewardRule.objects |
| 56 | + .exclude(category__contains=SELECTED) |
| 57 | + .filter(category__contains=category_tag, card_id__in=my_card_ids) |
| 58 | + .select_related("card") |
| 59 | + ) |
| 60 | + primary = [] |
| 61 | + for r in qs: |
| 62 | + m = float(r.multiplier or 0) |
| 63 | + cap = r.cap_amount if r.cap_amount is not None else BIG |
| 64 | + primary.append((r.card, m, cap)) |
| 65 | + |
| 66 | + if primary: |
| 67 | + best_card, best_mult, top3 = _rank(primary) |
| 68 | + return { |
| 69 | + "best_card": {"card_id": best_card.id, "card_name": f"{best_card.issuer} {best_card.name}"}, |
| 70 | + "multiplier": best_mult, |
| 71 | + "rationale": f"{best_mult}× on {category_tag} (from your wallet).", |
| 72 | + "top3": top3, |
| 73 | + } |
| 74 | + |
| 75 | + # 2) fallback: use only each card's base/anywhere rate (OTHER) |
| 76 | + alt_qs = ( |
| 77 | + RewardRule.objects |
| 78 | + .exclude(category__contains=SELECTED) |
| 79 | + .filter(card_id__in=my_card_ids, category__contains=BASE_ANYWHERE) |
| 80 | + .select_related("card") |
| 81 | + ) |
| 82 | + |
| 83 | + # pick the best OTHER rule per card (dedupe by card) |
| 84 | + by_card = {} |
| 85 | + for r in alt_qs: |
| 86 | + c = r.card |
| 87 | + m = float(r.multiplier or 0) |
| 88 | + cap = r.cap_amount if r.cap_amount is not None else BIG |
| 89 | + cur = by_card.get(c.id) |
| 90 | + if cur is None or (m, cap) > (cur[1], cur[2]): |
| 91 | + by_card[c.id] = (c, m, cap) |
| 92 | + |
| 93 | + if by_card: |
| 94 | + items = list(by_card.values()) |
| 95 | + best_card, best_mult, top3 = _rank(items) |
| 96 | + return { |
| 97 | + "best_card": {"card_id": best_card.id, "card_name": f"{best_card.issuer} {best_card.name}"}, |
| 98 | + "multiplier": best_mult, |
| 99 | + "rationale": ( |
| 100 | + f"No {category_tag} bonus among your cards. " |
| 101 | + f"Showing your best base rate (OTHER): {best_mult}×." |
| 102 | + ), |
| 103 | + "top3": top3, |
| 104 | + } |
| 105 | + |
| 106 | + # 3) final fallback: baseline 1.0× on lowest-AF card |
| 107 | + any_card = ( |
| 108 | + Card.objects.filter(id__in=my_card_ids).order_by("annual_fee", "issuer", "name").first() |
| 109 | + ) |
| 110 | + return { |
| 111 | + "best_card": ( |
| 112 | + {"card_id": any_card.id, "card_name": f"{any_card.issuer} {any_card.name}"} |
| 113 | + if any_card else None |
| 114 | + ), |
| 115 | + "multiplier": 1.0, |
| 116 | + "rationale": ( |
| 117 | + f"No base (OTHER) rates found for your cards. Showing baseline 1.0×" |
| 118 | + f"{' on ' + any_card.name if any_card else ''}." |
| 119 | + ), |
| 120 | + "top3": [], |
| 121 | + } |
0 commit comments