correct discount model
This commit is contained in:
parent
f5f4ec8ac9
commit
3896636f9b
6 changed files with 201 additions and 55 deletions
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
|
@ -7,4 +7,7 @@ Docker specific code is in the folder docker/.
|
||||||
Kubernetes deployment specific files in deployment/.
|
Kubernetes deployment specific files in deployment/.
|
||||||
GitLab CI is used as the main CI/CD system.
|
GitLab CI is used as the main CI/CD system.
|
||||||
|
|
||||||
|
The project uses Astral uv to manage the Pythong project, dependencies and the venv.
|
||||||
|
Execute Django with `uv run --extra dev manage.py`.
|
||||||
|
|
||||||
Answers should be short and concise, and should not include any unnecessary comments or explanations, but be clear on which file a code block should be placed in.
|
Answers should be short and concise, and should not include any unnecessary comments or explanations, but be clear on which file a code block should be placed in.
|
|
@ -297,8 +297,8 @@ class VSHNAppCatUnitRateInline(admin.TabularInline):
|
||||||
class DiscountTierInline(admin.TabularInline):
|
class DiscountTierInline(admin.TabularInline):
|
||||||
model = DiscountTier
|
model = DiscountTier
|
||||||
extra = 1
|
extra = 1
|
||||||
fields = ("threshold", "discount_percent")
|
fields = ("min_units", "max_units", "discount_percent")
|
||||||
ordering = ("threshold",)
|
ordering = ("min_units",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ProgressiveDiscountModel)
|
@admin.register(ProgressiveDiscountModel)
|
||||||
|
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Generated by Django 5.2 on 2025-05-23 14:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def delete_all_discount_tiers(apps, schema_editor):
|
||||||
|
DiscountTier = apps.get_model("services", "DiscountTier")
|
||||||
|
DiscountTier.objects.all().delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"services",
|
||||||
|
"0026_progressivediscountmodel_vshnappcatprice_valid_from_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(delete_all_discount_tiers),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="category",
|
||||||
|
options={
|
||||||
|
"ordering": ["order", "name"],
|
||||||
|
"verbose_name": "Service Category",
|
||||||
|
"verbose_name_plural": "Service Categories",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="discounttier",
|
||||||
|
options={"ordering": ["min_units"]},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="progressivediscountmodel",
|
||||||
|
options={"verbose_name": "Discount Model"},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="vshnappcatbasefee",
|
||||||
|
options={"ordering": ["currency"], "verbose_name": "Service Base Fee"},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="vshnappcatprice",
|
||||||
|
options={"verbose_name": "AppCat Price"},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="vshnappcatunitrate",
|
||||||
|
options={
|
||||||
|
"ordering": ["currency", "service_level"],
|
||||||
|
"verbose_name": "Service Unit Rate",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="discounttier",
|
||||||
|
name="min_units",
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
default=0, help_text="Minimum unit count for this tier (inclusive)"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="discounttier",
|
||||||
|
unique_together={("discount_model", "min_units")},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="discounttier",
|
||||||
|
name="max_units",
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Maximum unit count for this tier (exclusive). Leave blank for unlimited.",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="discounttier",
|
||||||
|
name="threshold",
|
||||||
|
),
|
||||||
|
]
|
|
@ -131,54 +131,98 @@ class ProgressiveDiscountModel(models.Model):
|
||||||
"""Calculate price using progressive percentage discounts."""
|
"""Calculate price using progressive percentage discounts."""
|
||||||
final_price = 0
|
final_price = 0
|
||||||
remaining_units = units
|
remaining_units = units
|
||||||
processed_units = 0
|
|
||||||
|
|
||||||
# Get all tiers sorted by threshold
|
discount_tiers = self.tiers.all().order_by("min_units")
|
||||||
discount_tiers = self.tiers.all().order_by("threshold")
|
|
||||||
|
|
||||||
for i, tier in enumerate(discount_tiers):
|
for tier in discount_tiers:
|
||||||
# Calculate how many units fall into this tier
|
|
||||||
if i < discount_tiers.count() - 1:
|
|
||||||
next_threshold = discount_tiers[i + 1].threshold
|
|
||||||
tier_units = min(remaining_units, next_threshold - processed_units)
|
|
||||||
else:
|
|
||||||
# Last tier handles all remaining units
|
|
||||||
tier_units = remaining_units
|
|
||||||
|
|
||||||
# Calculate discounted rate for this tier
|
|
||||||
discounted_rate = base_rate * (1 - tier.discount_percent / 100)
|
|
||||||
|
|
||||||
# Add the price for units in this tier
|
|
||||||
final_price += discounted_rate * tier_units
|
|
||||||
|
|
||||||
# Update tracking variables
|
|
||||||
remaining_units -= tier_units
|
|
||||||
processed_units += tier_units
|
|
||||||
|
|
||||||
# Exit if all units have been processed
|
|
||||||
if remaining_units <= 0:
|
if remaining_units <= 0:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Determine how many units fall into this tier
|
||||||
|
tier_min = tier.min_units
|
||||||
|
tier_max = tier.max_units if tier.max_units else float("inf")
|
||||||
|
|
||||||
|
# Skip if we haven't reached this tier yet
|
||||||
|
if units < tier_min:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate units in this tier
|
||||||
|
units_start = max(0, units - remaining_units)
|
||||||
|
units_end = min(units, tier_max if tier.max_units else units)
|
||||||
|
tier_units = max(0, units_end - max(units_start, tier_min - 1))
|
||||||
|
|
||||||
|
if tier_units > 0:
|
||||||
|
discounted_rate = base_rate * (1 - tier.discount_percent / 100)
|
||||||
|
final_price += discounted_rate * tier_units
|
||||||
|
remaining_units -= tier_units
|
||||||
|
|
||||||
return final_price
|
return final_price
|
||||||
|
|
||||||
|
def get_discount_breakdown(self, base_rate, units):
|
||||||
|
"""Get detailed breakdown of discount calculation."""
|
||||||
|
breakdown = []
|
||||||
|
remaining_units = units
|
||||||
|
|
||||||
|
discount_tiers = self.tiers.all().order_by("min_units")
|
||||||
|
|
||||||
|
for tier in discount_tiers:
|
||||||
|
if remaining_units <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
tier_min = tier.min_units
|
||||||
|
tier_max = tier.max_units if tier.max_units else float("inf")
|
||||||
|
|
||||||
|
if units < tier_min:
|
||||||
|
continue
|
||||||
|
|
||||||
|
units_start = max(0, units - remaining_units)
|
||||||
|
units_end = min(units, tier_max if tier.max_units else units)
|
||||||
|
tier_units = max(0, units_end - max(units_start, tier_min - 1))
|
||||||
|
|
||||||
|
if tier_units > 0:
|
||||||
|
discounted_rate = base_rate * (1 - tier.discount_percent / 100)
|
||||||
|
tier_total = discounted_rate * tier_units
|
||||||
|
|
||||||
|
breakdown.append(
|
||||||
|
{
|
||||||
|
"tier_range": f"{tier_min}-{tier_max-1 if tier.max_units else '∞'}",
|
||||||
|
"units": tier_units,
|
||||||
|
"discount_percent": tier.discount_percent,
|
||||||
|
"rate": discounted_rate,
|
||||||
|
"subtotal": tier_total,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
remaining_units -= tier_units
|
||||||
|
|
||||||
|
return breakdown
|
||||||
|
|
||||||
|
|
||||||
class DiscountTier(models.Model):
|
class DiscountTier(models.Model):
|
||||||
discount_model = models.ForeignKey(
|
discount_model = models.ForeignKey(
|
||||||
ProgressiveDiscountModel, on_delete=models.CASCADE, related_name="tiers"
|
ProgressiveDiscountModel, on_delete=models.CASCADE, related_name="tiers"
|
||||||
)
|
)
|
||||||
threshold = models.PositiveIntegerField(
|
min_units = models.PositiveIntegerField(
|
||||||
help_text="Starting unit count for this tier"
|
help_text="Minimum unit count for this tier (inclusive)", default=0
|
||||||
|
)
|
||||||
|
max_units = models.PositiveIntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Maximum unit count for this tier (exclusive). Leave blank for unlimited.",
|
||||||
)
|
)
|
||||||
discount_percent = models.DecimalField(
|
discount_percent = models.DecimalField(
|
||||||
max_digits=5, decimal_places=2, help_text="Percentage discount applied (0-100)"
|
max_digits=5, decimal_places=2, help_text="Percentage discount applied (0-100)"
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["threshold"]
|
ordering = ["min_units"]
|
||||||
unique_together = ["discount_model", "threshold"]
|
unique_together = ["discount_model", "min_units"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.discount_model.name}: {self.threshold}+ units → {self.discount_percent}% discount"
|
if self.max_units:
|
||||||
|
return f"{self.discount_model.name}: {self.min_units}-{self.max_units-1} units → {self.discount_percent}% discount"
|
||||||
|
else:
|
||||||
|
return f"{self.discount_model.name}: {self.min_units}+ units → {self.discount_percent}% discount"
|
||||||
|
|
||||||
|
|
||||||
class VSHNAppCatBaseFee(models.Model):
|
class VSHNAppCatBaseFee(models.Model):
|
||||||
|
|
|
@ -32,6 +32,7 @@
|
||||||
<th>SLA Per Unit</th>
|
<th>SLA Per Unit</th>
|
||||||
<th>SLA Price</th>
|
<th>SLA Price</th>
|
||||||
<th>Discount Model</th>
|
<th>Discount Model</th>
|
||||||
|
<th>Discount Details</th>
|
||||||
<th class="table-warning">Final Price</th>
|
<th class="table-warning">Final Price</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
@ -60,6 +61,24 @@
|
||||||
None
|
None
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if row.has_discount %}
|
||||||
|
<small class="text-muted">
|
||||||
|
<strong>Total Units:</strong> {{ row.total_units }}<br>
|
||||||
|
<strong>Standard Price:</strong> {{ row.standard_sla_price|floatformat:2 }}<br>
|
||||||
|
<strong>Discounted Price:</strong> {{ row.discounted_sla_price|floatformat:2 }}<br>
|
||||||
|
<strong>Savings:</strong> {{ row.discount_savings|floatformat:2 }} ({{ row.discount_percentage|floatformat:1 }}%)<br>
|
||||||
|
{% if row.discount_breakdown %}
|
||||||
|
<strong>Breakdown:</strong><br>
|
||||||
|
{% for tier in row.discount_breakdown %}
|
||||||
|
{{ tier.tier_range }} units: {{ tier.units }} × {{ tier.rate|floatformat:4 }} = {{ tier.subtotal|floatformat:2 }}<br>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
{% else %}
|
||||||
|
<small class="text-muted">No discount applied</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td class="table-warning fw-bold">{{ row.final_price|floatformat:2 }}</td>
|
<td class="table-warning fw-bold">{{ row.final_price|floatformat:2 }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
@ -5,13 +5,11 @@ from hub.services.models import ComputePlan, VSHNAppCatPrice
|
||||||
|
|
||||||
|
|
||||||
def natural_sort_key(name):
|
def natural_sort_key(name):
|
||||||
"""Extract numeric part from compute plan name for natural sorting"""
|
|
||||||
match = re.search(r"compute-std-(\d+)", name)
|
match = re.search(r"compute-std-(\d+)", name)
|
||||||
return int(match.group(1)) if match else 0
|
return int(match.group(1)) if match else 0
|
||||||
|
|
||||||
|
|
||||||
def pricelist(request):
|
def pricelist(request):
|
||||||
# Get all active compute plans with their pricing data
|
|
||||||
compute_plans = (
|
compute_plans = (
|
||||||
ComputePlan.objects.filter(active=True)
|
ComputePlan.objects.filter(active=True)
|
||||||
.select_related("cloud_provider")
|
.select_related("cloud_provider")
|
||||||
|
@ -19,12 +17,10 @@ def pricelist(request):
|
||||||
.order_by("cloud_provider__name")
|
.order_by("cloud_provider__name")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sort compute plans naturally by provider and plan number
|
|
||||||
compute_plans = sorted(
|
compute_plans = sorted(
|
||||||
compute_plans, key=lambda x: (x.cloud_provider.name, natural_sort_key(x.name))
|
compute_plans, key=lambda x: (x.cloud_provider.name, natural_sort_key(x.name))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get all VSHNAppCat pricing configurations
|
|
||||||
appcat_prices = (
|
appcat_prices = (
|
||||||
VSHNAppCatPrice.objects.all()
|
VSHNAppCatPrice.objects.all()
|
||||||
.select_related("service", "discount_model")
|
.select_related("service", "discount_model")
|
||||||
|
@ -33,16 +29,12 @@ def pricelist(request):
|
||||||
)
|
)
|
||||||
|
|
||||||
pricing_data_by_service_level = defaultdict(list)
|
pricing_data_by_service_level = defaultdict(list)
|
||||||
|
|
||||||
# Track processed combinations to avoid duplicates
|
|
||||||
processed_combinations = set()
|
processed_combinations = set()
|
||||||
|
|
||||||
for plan in compute_plans:
|
for plan in compute_plans:
|
||||||
# Get all currencies available for this compute plan
|
|
||||||
plan_currencies = set(plan.prices.values_list("currency", flat=True))
|
plan_currencies = set(plan.prices.values_list("currency", flat=True))
|
||||||
|
|
||||||
for appcat_price in appcat_prices:
|
for appcat_price in appcat_prices:
|
||||||
# Determine units based on variable unit type (RAM or CPU)
|
|
||||||
if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM:
|
if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM:
|
||||||
units = int(plan.ram)
|
units = int(plan.ram)
|
||||||
elif appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.CPU:
|
elif appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.CPU:
|
||||||
|
@ -50,36 +42,29 @@ def pricelist(request):
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get currencies available for base fees and unit rates
|
|
||||||
base_fee_currencies = set(
|
base_fee_currencies = set(
|
||||||
appcat_price.base_fees.values_list("currency", flat=True)
|
appcat_price.base_fees.values_list("currency", flat=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get all distinct service levels for this pricing config
|
|
||||||
service_levels = appcat_price.unit_rates.values_list(
|
service_levels = appcat_price.unit_rates.values_list(
|
||||||
"service_level", flat=True
|
"service_level", flat=True
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
for service_level in service_levels:
|
for service_level in service_levels:
|
||||||
# Get currencies available for this specific service level
|
|
||||||
unit_rate_currencies = set(
|
unit_rate_currencies = set(
|
||||||
appcat_price.unit_rates.filter(
|
appcat_price.unit_rates.filter(
|
||||||
service_level=service_level
|
service_level=service_level
|
||||||
).values_list("currency", flat=True)
|
).values_list("currency", flat=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Find currencies that exist in ALL three places: plan, base fee, and unit rate
|
|
||||||
matching_currencies = plan_currencies.intersection(
|
matching_currencies = plan_currencies.intersection(
|
||||||
base_fee_currencies
|
base_fee_currencies
|
||||||
).intersection(unit_rate_currencies)
|
).intersection(unit_rate_currencies)
|
||||||
|
|
||||||
# Skip if no common currencies found
|
|
||||||
if not matching_currencies:
|
if not matching_currencies:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Process each matching currency
|
|
||||||
for currency in matching_currencies:
|
for currency in matching_currencies:
|
||||||
# Create unique combination key to prevent duplicates
|
|
||||||
combination_key = (
|
combination_key = (
|
||||||
plan.cloud_provider.name,
|
plan.cloud_provider.name,
|
||||||
plan.name,
|
plan.name,
|
||||||
|
@ -88,13 +73,11 @@ def pricelist(request):
|
||||||
currency,
|
currency,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Skip if this combination was already processed
|
|
||||||
if combination_key in processed_combinations:
|
if combination_key in processed_combinations:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
processed_combinations.add(combination_key)
|
processed_combinations.add(combination_key)
|
||||||
|
|
||||||
# Get pricing data for this currency - skip if any is missing
|
|
||||||
compute_plan_price = plan.get_price(currency)
|
compute_plan_price = plan.get_price(currency)
|
||||||
if compute_plan_price is None:
|
if compute_plan_price is None:
|
||||||
continue
|
continue
|
||||||
|
@ -107,15 +90,15 @@ def pricelist(request):
|
||||||
if unit_rate is None:
|
if unit_rate is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Calculate replica enforcement based on service level
|
|
||||||
if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED:
|
if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED:
|
||||||
replica_enforce = appcat_price.ha_replica_min
|
replica_enforce = appcat_price.ha_replica_min
|
||||||
else:
|
else:
|
||||||
replica_enforce = 1
|
replica_enforce = 1
|
||||||
|
|
||||||
total_units = units * replica_enforce
|
total_units = units * replica_enforce
|
||||||
|
standard_sla_price = base_fee + (total_units * unit_rate)
|
||||||
|
|
||||||
# Apply discount model if available and active
|
discount_breakdown = None
|
||||||
if (
|
if (
|
||||||
appcat_price.discount_model
|
appcat_price.discount_model
|
||||||
and appcat_price.discount_model.active
|
and appcat_price.discount_model.active
|
||||||
|
@ -126,19 +109,28 @@ def pricelist(request):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
sla_price = base_fee + discounted_price
|
sla_price = base_fee + discounted_price
|
||||||
|
discount_savings = standard_sla_price - sla_price
|
||||||
|
discount_percentage = (
|
||||||
|
(discount_savings / standard_sla_price) * 100
|
||||||
|
if standard_sla_price > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
discount_breakdown = (
|
||||||
|
appcat_price.discount_model.get_discount_breakdown(
|
||||||
|
unit_rate, total_units
|
||||||
|
)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Standard pricing without discount
|
sla_price = standard_sla_price
|
||||||
sla_price = base_fee + (total_units * unit_rate)
|
discounted_price = total_units * unit_rate
|
||||||
|
discount_savings = 0
|
||||||
|
discount_percentage = 0
|
||||||
|
|
||||||
# Calculate final price (compute + SLA)
|
|
||||||
final_price = compute_plan_price + sla_price
|
final_price = compute_plan_price + sla_price
|
||||||
|
|
||||||
# Get human-readable service level name
|
|
||||||
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
|
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
|
||||||
service_level
|
service_level
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add row to the appropriate service level group
|
|
||||||
pricing_data_by_service_level[service_level_display].append(
|
pricing_data_by_service_level[service_level_display].append(
|
||||||
{
|
{
|
||||||
"cloud_provider": plan.cloud_provider.name,
|
"cloud_provider": plan.cloud_provider.name,
|
||||||
|
@ -153,10 +145,21 @@ def pricelist(request):
|
||||||
"variable_unit": appcat_price.get_variable_unit_display(),
|
"variable_unit": appcat_price.get_variable_unit_display(),
|
||||||
"units": units,
|
"units": units,
|
||||||
"replica_enforce": replica_enforce,
|
"replica_enforce": replica_enforce,
|
||||||
|
"total_units": total_units,
|
||||||
"service_level": service_level_display,
|
"service_level": service_level_display,
|
||||||
"sla_base": base_fee,
|
"sla_base": base_fee,
|
||||||
"sla_per_unit": unit_rate,
|
"sla_per_unit": unit_rate,
|
||||||
"sla_price": sla_price,
|
"sla_price": sla_price,
|
||||||
|
"standard_sla_price": standard_sla_price,
|
||||||
|
"discounted_sla_price": (
|
||||||
|
base_fee + discounted_price
|
||||||
|
if appcat_price.discount_model
|
||||||
|
and appcat_price.discount_model.active
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"discount_savings": discount_savings,
|
||||||
|
"discount_percentage": discount_percentage,
|
||||||
|
"discount_breakdown": discount_breakdown,
|
||||||
"final_price": final_price,
|
"final_price": final_price,
|
||||||
"discount_model": (
|
"discount_model": (
|
||||||
appcat_price.discount_model.name
|
appcat_price.discount_model.name
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue