# =============================================================================
# CYCLE 1 SKU SELECTION RULES
# =============================================================================
CYCLE_RULES = {
"discount_pct": 10.0, # Standard discount percentage
"kvi_min_penetration": 10.0, # KVI must have high penetration
"kvi_min_margin_after_discount": 8.0, # KVI must survive discount
"crosssell_max_penetration": 15.0, # Cross-sell targets low penetration
"crosssell_min_margin": 25.0, # Cross-sell must have good margin
"crosssell_min_revenue": 30000.0, # Cross-sell must have proven demand
"max_items_per_category": 2, # Max 2 items from same product_category_1
"max_frozen_items": 3, # Respect freezer space (Category 5) - legacy, use max_items_per_category
}
# =============================================================================
# PENETRATION-FIRST CYCLE STRATEGY (Updated Dec 2025)
# =============================================================================
# KVI Selection Criteria (PENETRATION-FIRST):
# 1. Highest penetration item that meets margin threshold
# 2. Margin after discount ≥ 8% (sustainable profitability)
# 3. Max 2 items per product_category_1 per cycle (anti-cannibalization)
#
# KEY INSIGHT: KVI = King item to attract attention from non-exclusive customers
# - High penetration items are already well-known to customers
# - They serve as traffic drivers to bring customers to the promo
# - Cross-sell items introduce customers to new products
#
# ═══════════════════════════════════════════════════════════════════════════════
# MARGIN CALCULATION METHODOLOGY (CURRENT DATA - Dec 2024)
# ═══════════════════════════════════════════════════════════════════════════════
#
# Current Margin = ((price_standard - latest_cost) / price_standard) * 100
# Margin After Discount = ((promo_price - latest_cost) / promo_price) * 100
# where promo_price = price_standard * (1 - discount_pct/100)
#
# Data sources from product_information table:
# - price_standard (標準價): List price for individual unit sales
# - price_1 (箱價): Discounted price when customer buys a full box
# - latest_cost (最新成本): Most recent procurement cost
#
# CURRENT PENETRATION & MARGIN RANKINGS (Pingtung City):
# ═══════════════════════════════════════════════════════════════════════════════
# Item | Name | Pen% | 標準價 | 成本 | 毛利% | 折後毛利% | Status
# ---------|-------------|-------|--------|--------|-------|-----------|--------
# 25888 | 玉米粒340g | 28.0% | $26 | $11.94 | 54.1% | 49.0% | ✅
# 51130 | 小肉豆1K | 18.9% | $190 | $142.86| 24.8% | 16.5% | ✅
# 54021 | 蔥抓餅10入 | 13.8% | $85 | $72 | 15.3% | 5.9% | ⚠️ <8%
# 51296 | 可頌燒餅10片| 9.1% | $95 | $68 | 28.4% | 20.5% | ✅
# 41189 | 起司片84片 | 9.1% | $310 | $258 | 16.8% | 7.5% | ⚠️ <8%
# 59142 | 香酥雞塊3K | 6.3% | $310 | $285 | 8.1% | -2.2% | ❌ LOSS
# ═══════════════════════════════════════════════════════════════════════════════
# Role-based margin strategy:
# - KVI (King): ~5% margin after discount (aggressive traffic driver)
# - Volume Builder: 10-15% margin (moderate margin, drives basket)
# - Margin Defender: 20%+ margin (protects profitability)
#
# Key insight: Use LOW-margin items as KVIs (need small discounts to hit 5%)
# Use HIGH-margin items as margin defenders (capped at 15% max)
# **MAX DISCOUNT: 15%** - All discounts capped at 15%, actual margins may exceed target
EXPERT_CYCLE_OVERRIDES = {
1: {
"theme": "經典高滲透 (Classic High Penetration)",
# Category balance: Cat 2 (2), Cat 4 (1), Cat 5 (2) ✅
# Change: Replaced 小肉豆 with 馬鈴薯條 (cannot be with 熱狗)
"skus": [
{"code": "51171", "role": "kvi", "target_margin": 5.0, "discount": 4.8},
# 安美熱狗50條 - Cat 5 | $110, 成本$99.50, 基礎毛利9.5%
{"code": "51284", "role": "volume_builder", "target_margin": 12.0, "discount": 10.5},
# 馬鈴薯條2K - Cat 5 | $165, 成本$130, 基礎毛利21.2%
{"code": "21260", "role": "volume_builder", "target_margin": 12.0, "discount": 10.0},
# 香蒜抹醬800g - Cat 2 | $240, 成本$190, 基礎毛利20.8%
{"code": "25888", "role": "margin_defender", "target_margin": 20.0, "discount": 15.0},
# 玉米粒340g - Cat 2 | $26, 成本$11.94, 基礎毛利54.1% → 15% cap
{"code": "44051", "role": "margin_defender", "target_margin": 20.0, "discount": 15.0},
# 蘑菇醬包140g - Cat 4 | $12, 成本$6.51, 基礎毛利45.8% → 15% cap
]
},
2: {
"theme": "冷凍明星 (Frozen Stars)",
# Category balance: Cat 2 (2), Cat 4 (1), Cat 5 (2) ✅
# Change: Replaced 蔥抓餅 with 香香雞米花 (蔥抓餅 was consecutive with Cycle 3)
"skus": [
{"code": "51130", "role": "kvi", "target_margin": 5.0, "discount": 15.0},
# 小肉豆1K - Cat 5 | $190, 成本$142.86, 基礎毛利24.8% → 15% cap
{"code": "53112", "role": "volume_builder", "target_margin": 12.0, "discount": 7.8},
# 香香雞米花1K - Cat 5 | $180, 成本$146, 基礎毛利18.9%
{"code": "22138", "role": "volume_builder", "target_margin": 12.0, "discount": 14.9},
# 蜂蜜糖漿3K - Cat 2 | $167, 成本$125, 基礎毛利25.1%
{"code": "43599", "role": "margin_defender", "target_margin": 20.0, "discount": 15.0},
# 烏龍麵200g - Cat 4 | $13, 成本$8.75, 基礎毛利32.7% → 15% cap
{"code": "22020", "role": "margin_defender", "target_margin": 20.0, "discount": 12.9},
# 咖啡紅茶5包 - Cat 2 | $87, 成本$60.62, 基礎毛利30.3%
]
},
3: {
"theme": "早餐煎台 (Breakfast Griddle)",
# Category balance: Cat 1 (1), Cat 2 (1), Cat 4 (1), Cat 5 (2) ✅
"skus": [
{"code": "54021", "role": "kvi", "target_margin": 5.0, "discount": 10.8},
# 蔥抓餅10入 - Cat 5 | $85, 成本$72, 基礎毛利15.3%
{"code": "41018", "role": "volume_builder", "target_margin": 12.0, "discount": 15.0},
# 蛋餅皮原30入 - Cat 5 | $78, 成本$46, 基礎毛利41.0% → 15% cap
{"code": "11055", "role": "volume_builder", "target_margin": 12.0, "discount": 6.6},
# 原味奶酥700g - Cat 1 | $180, 成本$148, 基礎毛利17.8%
{"code": "44051", "role": "margin_defender", "target_margin": 20.0, "discount": 15.0},
# 蘑菇醬包140g - Cat 4 | $12, 成本$6.51, 基礎毛利45.8% → 15% cap
{"code": "22287", "role": "margin_defender", "target_margin": 20.0, "discount": 15.0},
# 高級精鹽1k - Cat 2 | $21, 成本$13.96, 基礎毛利33.5% → 15% cap
]
},
4: {
"theme": "起司與冷藏 (Cheese & Refrigerated)",
# Category balance: Cat 2 (1), Cat 4 (2), Cat 5 (2) ✅
"skus": [
{"code": "51296", "role": "kvi", "target_margin": 5.0, "discount": 15.0},
# 可頌燒餅10片 - Cat 5 | $95, 成本$68, 基礎毛利28.4% → 15% cap
{"code": "41189", "role": "volume_builder", "target_margin": 12.0, "discount": 5.4},
# 起司片84片 - Cat 4 | $310, 成本$258, 基礎毛利16.8%
{"code": "51284", "role": "volume_builder", "target_margin": 12.0, "discount": 10.5},
# 馬鈴薯條2K - Cat 5 | $165, 成本$130, 基礎毛利21.2%
{"code": "43599", "role": "margin_defender", "target_margin": 20.0, "discount": 15.0},
# 烏龍麵200g - Cat 4 | $13, 成本$8.75, 基礎毛利32.7% → 15% cap
{"code": "22138", "role": "margin_defender", "target_margin": 20.0, "discount": 6.4},
# 蜂蜜糖漿3K - Cat 2 | $167, 成本$125, 基礎毛利25.1%
]
},
5: {
"theme": "烘焙與調味 (Bakery & Seasoning)",
# Category balance: Cat 1 (1), Cat 2 (1), Cat 4 (1), Cat 5 (2) ✅
# Change: Replaced 香香雞米花 with 安美燻腸 (香香雞米花 was consecutive with Cycle 6)
"skus": [
{"code": "22107", "role": "kvi", "target_margin": 5.0, "discount": 5.9},
# 飛燕煉乳375g - Cat 2 | $48, 成本$42.91, 基礎毛利10.6%
{"code": "11055", "role": "volume_builder", "target_margin": 12.0, "discount": 6.6},
# 原味奶酥700g - Cat 1 | $180, 成本$148, 基礎毛利17.8%
{"code": "51172", "role": "volume_builder", "target_margin": 12.0, "discount": 3.2},
# 安美燻腸1K - Cat 5 | $185, 成本$166.67, 基礎毛利9.9%
{"code": "41018", "role": "margin_defender", "target_margin": 20.0, "discount": 15.0},
# 蛋餅皮原30入 - Cat 5 | $78, 成本$46, 基礎毛利41.0% → 15% cap
{"code": "44052", "role": "margin_defender", "target_margin": 20.0, "discount": 15.0},
# 黑胡椒醬包140g - Cat 4 | $12, 成本$6.51, 基礎毛利45.8% → 15% cap
]
},
6: {
"theme": "雞肉專區 (Chicken Zone)",
# Category balance: Cat 2 (2), Cat 4 (1), Cat 5 (2) ✅
"skus": [
{"code": "53112", "role": "kvi", "target_margin": 5.0, "discount": 14.6},
# 香香雞米花1K - Cat 5 | $180, 成本$146, 基礎毛利18.9%
{"code": "23627", "role": "volume_builder", "target_margin": 12.0, "discount": 12.3},
# 一番香Q雞絲50入 - Cat 2 | $350, 成本$270, 基礎毛利22.9%
{"code": "51144", "role": "volume_builder", "target_margin": 12.0, "discount": 1.6},
# 香讚黑椒雞堡50片 - Cat 5 | $220, 成本$191.40, 基礎毛利13.0%
{"code": "21260", "role": "margin_defender", "target_margin": 20.0, "discount": 1.0},
# 香蒜抹醬800g - Cat 2 | $240, 成本$190, 基礎毛利20.8%
{"code": "44051", "role": "margin_defender", "target_margin": 20.0, "discount": 15.0},
# 蘑菇醬包140g - Cat 4 | $12, 成本$6.51, 基礎毛利45.8% → 15% cap
]
}
}
# For backward compatibility, CYCLE_1_EXPERT_OVERRIDE references the first cycle
CYCLE_1_EXPERT_OVERRIDE = EXPERT_CYCLE_OVERRIDES[1]
# Set to None to disable ALL expert overrides:
# EXPERT_CYCLE_OVERRIDES = None
# CYCLE_1_EXPERT_OVERRIDE = None
def select_cycle_skus(
product_metrics: pl.DataFrame,
rules: dict,
excluded_items: list = []
) -> pl.DataFrame:
"""Select 1 KVI + 4 Cross-sell items based on rules."""
discount = rules["discount_pct"]
# Add margin after discount calculation
metrics_with_discount = product_metrics.with_columns([
(((pl.col("avg_price") * (1 - discount/100)) - pl.col("avg_cost"))
/ (pl.col("avg_price") * (1 - discount/100)) * 100)
.alias("margin_after_discount"),
(pl.col("avg_price") * (1 - discount/100)).alias("promo_price"),
pl.lit(discount).alias("discount_pct")
]).filter(~pl.col("item_code").is_in(excluded_items))
# Select KVI: highest penetration that survives discount
kvi_pool = (
metrics_with_discount
.filter(pl.col("penetration_pct") >= rules["kvi_min_penetration"])
.filter(pl.col("margin_after_discount") >= rules["kvi_min_margin_after_discount"])
.sort("penetration_pct", descending=True)
)
kvi = kvi_pool.head(1).with_columns(pl.lit("KVI").alias("slot"))
if len(kvi) == 0:
print("⚠️ No KVI candidates meet criteria!")
return pl.DataFrame()
kvi_code = kvi["item_code"][0]
kvi_category = kvi["product_category_1"][0]
# Select Cross-sell: high margin, lower penetration, category balanced
crosssell_pool = (
metrics_with_discount
.filter(pl.col("item_code") != kvi_code)
.filter(pl.col("penetration_pct") <= rules["crosssell_max_penetration"])
.filter(pl.col("penetration_pct") >= 2.0)
.filter(pl.col("margin_pct") >= rules["crosssell_min_margin"])
.filter(pl.col("total_revenue") >= rules["crosssell_min_revenue"])
.sort("margin_pct", descending=True)
)
# Apply category balance rules - track ALL categories, not just frozen
# Initialize category counts with KVI's category
category_counts = {}
if kvi_category:
category_counts[kvi_category] = 1
max_per_category = rules.get("max_items_per_category", 2)
selected_crosssell = []
for row in crosssell_pool.iter_rows(named=True):
cat = row["product_category_1"]
current_count = category_counts.get(cat, 0)
# Skip if category already has max items
if current_count >= max_per_category:
continue
if len(selected_crosssell) >= 4:
break
selected_crosssell.append(row)
category_counts[cat] = current_count + 1
if len(selected_crosssell) < 4:
print(f"⚠️ Only found {len(selected_crosssell)} cross-sell candidates")
# Combine results
crosssell_df = pl.DataFrame(selected_crosssell).with_columns([
pl.concat_str([pl.lit("Cross-sell #"), (pl.arange(1, len(selected_crosssell)+1)).cast(pl.Utf8)]).alias("slot")
])
return pl.concat([kvi, crosssell_df])
# Generate Cycle 1 SKUs
# Check for expert override first
if CYCLE_1_EXPERT_OVERRIDE is not None:
# Check if new format (per-SKU discounts) or legacy format
sku_configs = CYCLE_1_EXPERT_OVERRIDE.get("skus", [])
if sku_configs:
# New format: per-SKU discounts with roles
print("🎯 Using expert-recommended Cycle 1 SKUs (role-based margins)")
processed_rows = []
for sku_config in sku_configs:
code = sku_config["code"]
role = sku_config["role"]
discount = sku_config["discount"]
target_margin = sku_config["target_margin"]
item_row = PRODUCT_METRICS.filter(pl.col("item_code") == code)
if len(item_row) == 0:
print(f"⚠️ Item {code} not found")
continue
# Apply per-SKU discount
item_row = item_row.with_columns([
(((pl.col("avg_price") * (1 - discount/100)) - pl.col("avg_cost"))
/ (pl.col("avg_price") * (1 - discount/100)) * 100)
.alias("margin_after_discount"),
(pl.col("avg_price") * (1 - discount/100)).alias("promo_price"),
pl.lit(discount).alias("discount_pct"),
pl.lit(role).alias("role"),
pl.lit(target_margin).alias("target_margin")
])
# Assign slot label based on role
if role == "kvi":
slot_label = "KVI"
elif role == "volume_builder":
slot_label = "Volume Builder"
else:
slot_label = "Margin Defender"
item_row = item_row.with_columns(pl.lit(slot_label).alias("slot"))
processed_rows.append(item_row)
CYCLE_1_SKUS = pl.concat(processed_rows) if processed_rows else pl.DataFrame()
else:
# Legacy format: kvi + crosssell with single discount
print("🎯 Using expert-recommended Cycle 1 SKUs (override enabled)")
discount = CYCLE_RULES["discount_pct"]
expert_items = [CYCLE_1_EXPERT_OVERRIDE["kvi"]] + CYCLE_1_EXPERT_OVERRIDE["crosssell"]
CYCLE_1_SKUS = (
PRODUCT_METRICS
.filter(pl.col("item_code").is_in(expert_items))
.with_columns([
(((pl.col("avg_price") * (1 - discount/100)) - pl.col("avg_cost"))
/ (pl.col("avg_price") * (1 - discount/100)) * 100)
.alias("margin_after_discount"),
(pl.col("avg_price") * (1 - discount/100)).alias("promo_price"),
pl.lit(discount).alias("discount_pct")
])
.with_columns([
pl.when(pl.col("item_code") == CYCLE_1_EXPERT_OVERRIDE["kvi"])
.then(pl.lit("KVI"))
.otherwise(
pl.concat_str([
pl.lit("Cross-sell #"),
pl.lit("1")
])
)
.alias("slot")
])
)
# Properly assign cross-sell slot numbers
kvi_row = CYCLE_1_SKUS.filter(pl.col("item_code") == CYCLE_1_EXPERT_OVERRIDE["kvi"])
crosssell_rows = CYCLE_1_SKUS.filter(pl.col("item_code") != CYCLE_1_EXPERT_OVERRIDE["kvi"])
ordered_crosssell = []
for i, code in enumerate(CYCLE_1_EXPERT_OVERRIDE["crosssell"], 1):
row = crosssell_rows.filter(pl.col("item_code") == code)
if len(row) > 0:
ordered_crosssell.append(row.with_columns(pl.lit(f"Cross-sell #{i}").alias("slot")))
if ordered_crosssell:
CYCLE_1_SKUS = pl.concat([kvi_row] + ordered_crosssell)
else:
CYCLE_1_SKUS = kvi_row
else:
print("📊 Using algorithmic Cycle 1 SKU selection")
CYCLE_1_SKUS = select_cycle_skus(PRODUCT_METRICS, CYCLE_RULES)