Compute motherfucking rates

This commit is contained in:
Marc 2014-09-15 15:36:24 +00:00
parent 3c9b5a4c19
commit 97253d2d10
8 changed files with 204 additions and 266 deletions

View file

@ -1,85 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('accounts', '__first__'),
('contenttypes', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='MetricStorage',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('value', models.BigIntegerField(verbose_name='value')),
('created_on', models.DateField(auto_now_add=True, verbose_name='created on')),
('updated_on', models.DateField(auto_now=True, verbose_name='updated on')),
],
options={
'get_latest_by': 'created_on',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Order',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('object_id', models.PositiveIntegerField(null=True)),
('registered_on', models.DateField(auto_now_add=True, verbose_name='registered on')),
('cancelled_on', models.DateField(null=True, verbose_name='cancelled on', blank=True)),
('billed_on', models.DateField(null=True, verbose_name='billed on', blank=True)),
('billed_until', models.DateField(null=True, verbose_name='billed until', blank=True)),
('ignore', models.BooleanField(default=False, verbose_name='ignore')),
('description', models.TextField(verbose_name='description', blank=True)),
('account', models.ForeignKey(related_name=b'orders', verbose_name='account', to='accounts.Account')),
('content_type', models.ForeignKey(to='contenttypes.ContentType')),
],
options={
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Service',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('description', models.CharField(unique=True, max_length=256, verbose_name='description')),
('match', models.CharField(max_length=256, verbose_name='match', blank=True)),
('handler_type', models.CharField(blank=True, help_text='Handler used for processing this Service. A handler enables customized behaviour far beyond what options here allow to.', max_length=256, verbose_name='handler', choices=[(b'', 'Default')])),
('is_active', models.BooleanField(default=True, verbose_name='is active')),
('billing_period', models.CharField(default=b'ANUAL', choices=[(b'', 'One time service'), (b'MONTHLY', 'Monthly billing'), (b'ANUAL', 'Anual billing')], max_length=16, blank=True, help_text='Renewal period for recurring invoicing', verbose_name='billing period')),
('billing_point', models.CharField(default=b'ON_FIXED_DATE', help_text='Reference point for calculating the renewal date on recurring invoices', max_length=16, verbose_name='billing point', choices=[(b'ON_REGISTER', 'Registration date'), (b'ON_FIXED_DATE', 'Fixed billing date')])),
('delayed_billing', models.CharField(default=b'ONE_MONTH', choices=[(b'', 'No delay (inmediate billing)'), (b'TEN_DAYS', 'Ten days'), (b'ONE_MONTH', 'One month')], max_length=16, blank=True, help_text='Period in which this service will be ignored for billing', verbose_name='delayed billing')),
('is_fee', models.BooleanField(default=False, help_text='Designates whether this service should be billed as membership fee or not', verbose_name='is fee')),
('metric', models.CharField(help_text='Metric used to compute the pricing rate. Number of orders is used when left blank.', max_length=256, verbose_name='metric', blank=True)),
('tax', models.PositiveIntegerField(default=0, verbose_name='tax', choices=[(0, 'Duty free'), (7, '7%'), (21, '21%')])),
('pricing_period', models.CharField(default=b'BILLING_PERIOD', help_text='Period used for calculating the metric used on the pricing rate', max_length=16, verbose_name='pricing period', choices=[(b'BILLING_PERIOD', 'Same as billing period'), (b'MONTHLY', 'Monthly data'), (b'ANUAL', 'Anual data')])),
('rate_algorithm', models.CharField(default=b'BEST_PRICE', help_text='Algorithm used to interprete the rating table', max_length=16, verbose_name='rate algorithm', choices=[(b'BEST_PRICE', 'Best progressive price'), (b'PROGRESSIVE_PRICE', 'Conservative progressive price'), (b'MATCH_PRICE', 'Match price')])),
('orders_effect', models.CharField(default=b'CONCURRENT', help_text='Defines the lookup behaviour when using orders for the pricing rate computation of this service.', max_length=16, verbose_name='orders effect', choices=[(b'REGISTER_OR_RENEW', 'Register or renew events'), (b'CONCURRENT', 'Active at every given time')])),
('on_cancel', models.CharField(default=b'DISCOUNT', help_text='Defines the cancellation behaviour of this service', max_length=16, verbose_name='on cancel', choices=[(b'NOTHING', 'Nothing'), (b'DISCOUNT', 'Discount'), (b'COMPENSATE', 'Discount and compensate'), (b'REFOUND', 'Discount, compensate and refound')])),
('payment_style', models.CharField(default=b'PREPAY', help_text='Designates whether this service should be paid after consumtion (postpay/on demand) or prepaid', max_length=16, verbose_name='payment style', choices=[(b'PREPAY', 'Prepay'), (b'POSTPAY', 'Postpay (on demand)')])),
('trial_period', models.CharField(default=b'', choices=[(b'', 'No trial'), (b'TEN_DAYS', 'Ten days'), (b'ONE_MONTH', 'One month')], max_length=16, blank=True, help_text='Period in which no charge will be issued', verbose_name='trial period')),
('refound_period', models.CharField(default=b'', choices=[(b'', 'Never refound'), (b'TEN_DAYS', 'Ten days'), (b'ONE_MONTH', 'One month'), (b'ALWAYS', 'Always refound')], max_length=16, blank=True, help_text='Period in which automatic refound will be performed on service cancellation', verbose_name='refound period')),
('content_type', models.ForeignKey(verbose_name='content type', to='contenttypes.ContentType')),
],
options={
},
bases=(models.Model,),
),
migrations.AddField(
model_name='order',
name='service',
field=models.ForeignKey(related_name=b'orders', verbose_name='service', to='orders.Service'),
preserve_default=True,
),
migrations.AddField(
model_name='metricstorage',
name='order',
field=models.ForeignKey(verbose_name='order', to='orders.Order'),
preserve_default=True,
),
]

View file

@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('orders', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='service',
name='nominal_price',
field=models.DecimalField(default=0.0, verbose_name='nominal price', max_digits=12, decimal_places=2),
preserve_default=False,
),
]

View file

@ -1,43 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('accounts', '__first__'),
('orders', '0002_service_nominal_price'),
]
operations = [
migrations.CreateModel(
name='Plan',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(default=b'basic', max_length=128, verbose_name='plan', choices=[(b'basic', 'Basic'), (b'advanced', 'Advanced')])),
('account', models.ForeignKey(related_name=b'plans', verbose_name='account', to='accounts.Account')),
],
options={
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Rate',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('plan', models.CharField(blank=True, max_length=128, verbose_name='plan', choices=[(b'', 'default'), (b'basic', 'Basic'), (b'advanced', 'Advanced')])),
('quantity', models.PositiveIntegerField(null=True, verbose_name='quantity', blank=True)),
('value', models.DecimalField(verbose_name='value', max_digits=12, decimal_places=2)),
('service', models.ForeignKey(related_name=b'rates', verbose_name='service', to='orders.Service')),
],
options={
},
bases=(models.Model,),
),
migrations.AlterUniqueTogether(
name='rate',
unique_together=set([('service', 'plan', 'quantity')]),
),
]

View file

@ -1,29 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('orders', '0003_auto_20140908_1409'),
]
operations = [
migrations.RemoveField(
model_name='rate',
name='value',
),
migrations.AddField(
model_name='rate',
name='price',
field=models.DecimalField(default=1, verbose_name='price', max_digits=12, decimal_places=2),
preserve_default=False,
),
migrations.AlterField(
model_name='rate',
name='plan',
field=models.CharField(blank=True, max_length=128, verbose_name='plan', choices=[(b'', 'Default'), (b'basic', 'Basic'), (b'advanced', 'Advanced')]),
),
]

View file

@ -46,10 +46,10 @@ class RateQuerySet(models.QuerySet):
def by_account(self, account):
# Default allways selected
qset = Q(plan='')
for plan in account.plans.all():
qset |= Q(plan=plan)
return self.filter(qset)
return self.filter(
Q(plan__is_default=True) |
Q(plan__contracts__account=account)
).order_by('plan', 'quantity').select_related('plan').distinct()
class Rate(models.Model):
@ -89,10 +89,10 @@ class Service(models.Model):
COMPENSATE = 'COMPENSATE'
PREPAY = 'PREPAY'
POSTPAY = 'POSTPAY'
STEPED_PRICE = 'STEPED_PRICE'
STEP_PRICE = 'STEP_PRICE'
MATCH_PRICE = 'MATCH_PRICE'
RATE_METHODS = {
STEPED_PRICE: rating.steped_price,
STEP_PRICE: rating.step_price,
MATCH_PRICE: rating.match_price,
}
@ -153,10 +153,10 @@ class Service(models.Model):
rate_algorithm = models.CharField(_("rate algorithm"), max_length=16,
help_text=_("Algorithm used to interprete the rating table"),
choices=(
(STEPED_PRICE, _("Steped price")),
(STEP_PRICE, _("Step price")),
(MATCH_PRICE, _("Match price")),
),
default=STEPED_PRICE)
default=STEP_PRICE)
# orders_effect = models.CharField(_("orders effect"), max_length=16,
# help_text=_("Defines the lookup behaviour when using orders for "
# "the pricing rate computation of this service."),
@ -257,12 +257,19 @@ class Service(models.Model):
return self.billing_period
return self.pricing_period
def get_price(self, order, metric, position=None):
def get_price(self, order, metric, rates=None, position=None):
"""
if position is provided an specific price for that position is returned,
accumulated price is returned otherwise
"""
rates = self.get_rates(order.account, metric)
rates = self.get_rates(order.account)
if not rates:
rates = [{
'quantity': metric,
'price': self.nominal_price,
}]
else:
rates = self.rate_method(rates, metric)
counter = 0
if position is None:
ant_counter = 0
@ -281,25 +288,14 @@ class Service(models.Model):
if counter >= position:
return float(rate['price'])
def get_rates(self, account, metric):
def get_rates(self, account, cache=False):
# rates are cached per account
if not cache:
return self.rates.by_account(account)
if not hasattr(self, '__cached_rates'):
self.__cached_rates = {}
if account.id in self.__cached_rates:
rates, cache = self.__cached_rates.get(account.id)
else:
rates = self.rates.by_account(account)
cache = {}
if not rates:
rates = [{
'quantity': sys.maxint,
'price': self.nominal_price,
}]
self.__cached_rates[account.id] = (rates, cache)
rates = self.__cached_rates.get(account.id, self.rates.by_account(account))
return rates
self.__cached_rates[account.id] = (rates, cache)
# Caching depends on the specific rating method
return self.rate_method(rates, metric, cache=cache)
@property
def rate_method(self):

View file

@ -0,0 +1,108 @@
import sys
from orchestra.utils.python import AttributeDict
def _compute(rates, metric):
value = 0
num = len(rates)
accumulated = 0
end = False
ix = 0
steps = []
while ix < num and not end:
if ix+1 == num:
quantity = metric - accumulated
else:
quantity = rates[ix+1].quantity - rates[ix].quantity
if accumulated+quantity > metric:
quantity = metric - accumulated
end = True
price = rates[ix].price
steps.append(AttributeDict(**{
'quantity': quantity,
'price': price,
'barrier': accumulated+1,
}))
accumulated += quantity
value += quantity*price
ix += 1
return value, steps
def step_price(rates, metric):
# Step price
group = []
minimal = (sys.maxint, [])
for plan, rates in rates.group_by('plan'):
value, steps = _compute(rates, metric)
if plan.is_combinable:
group.append(steps)
else:
minimal = min(minimal, (value, steps), key=lambda v: v[0])
if len(group) == 1:
value, steps = _compute(rates, metric)
minimal = min(minimal, (value, steps), key=lambda v: v[0])
elif len(group) > 1:
# Merge
steps = []
for rates in group:
steps += rates
steps.sort(key=lambda s: s.price)
result = []
counter = 0
value = 0
ix = 0
targets = []
while counter < metric:
barrier = steps[ix].barrier
if barrier <= counter+1:
price = steps[ix].price
quantity = steps[ix].quantity
if quantity + counter > metric:
quantity = metric - counter
else:
for target in targets:
if counter + quantity >= target:
quantity = (counter+quantity+1) - target
steps[ix].quantity -= quantity
if not steps[ix].quantity:
steps.pop(ix)
break
else:
steps.pop(ix)
counter += quantity
value += quantity*price
if result and result[-1].price == price:
result[-1].quantity += quantity
else:
result.append(AttributeDict(quantity=quantity, price=price))
ix = 0
targets = []
else:
targets.append(barrier)
ix += 1
minimal = min(minimal, (value, result), key=lambda v: v[0])
return minimal[1]
def match_price(rates, metric):
candidates = []
selected = False
prev = None
for rate in rates:
if prev and prev.plan != rate.plan:
if not selected and prev.quantity <= metric:
candidates.append(prev)
selected = False
if not selected and rate.quantity > metric:
candidates.append(prev)
selected = True
prev = rate
if not selected and prev.quantity <= metric:
candidates.append(prev)
candidates.sort(key=lambda r: r.price)
return [AttributeDict(**{
'quantity': metric,
'price': candidates[0].price,
})]

View file

@ -37,7 +37,7 @@ class OrderTests(BaseTestCase):
is_fee=False,
metric='',
pricing_period=Service.BILLING_PERIOD,
rate_algorithm=Service.STEPED_PRICE,
rate_algorithm=Service.STEP_PRICE,
# orders_effect=Service.CONCURRENT,
on_cancel=Service.DISCOUNT,
payment_style=Service.PREPAY,
@ -239,88 +239,99 @@ class OrderTests(BaseTestCase):
def get_rates(self, account):
rates = self.rates.filter(Q(plan__is_default=True) | Q(plan__contracts__account=account)).order_by('plan', 'quantity').select_related('plan').disctinct()
# match price
candidates = []
selected = False
for rate in rates:
if prev and prev.plan != rate.plan:
if not selected:
candidates.append(prev)
selected = False
if not selected and rate.quantity >= metric:
candidates.append(rate)
selected = True
if not selected:
candidates.append(prev)
candidates.sort(key=lambda r: r.price)
return candidates[0]
# Step price
groups = []
prev = None
for rate in rates:
if rate.quantity <= metric:
if not prev or (not rate.is_combinable and prev.plan != rate.plan):
groups.append([rate])
else:
groups[-1].append(rate)
results = []
for group in groups:
ini = None
for rate in group:
if not ini:
ini = rate.quantity
return result
def test_rates(self):
from ...models import Plan
import sys
from decimal import Decimal
service = self.create_service()
superplan = Plan.objects.create(name='SUPER', allow_multiple=False, is_combinable=False)
superplan = Plan.objects.create(name='SUPER', allow_multiple=False, is_combinable=True)
service.rates.create(plan=superplan, quantity=1, price=0)
service.rates.create(plan=superplan, quantity=3, price=10)
service.rates.create(plan=superplan, quantity=4, price=9)
service.rates.create(plan=superplan, quantity=10, price=1)
account = self.create_account()
account.plans.create(plan=superplan)
result = service.get_rates(account, 1)
import sys
from decimal import Decimal
results = service.get_rates(account)
results = service.rate_method(results, 30)
rates = [
{'price': Decimal('0.00'), 'quantity': 2},
{'price': Decimal('10.00'), 'quantity': 1},
{'price': Decimal('9.00'), 'quantity': 6},
{'price': Decimal('1.00'), 'quantity': sys.maxint}
{'price': Decimal('1.00'), 'quantity': 21}
]
self.assertEqual(rates, result)
dupeplan = Plan.objects.create(name='DUPE', allow_multiple=True, is_combinable=False)
for rate, result in zip(rates, results):
self.assertEqual(rate['price'], result.price)
self.assertEqual(rate['quantity'], result.quantity)
dupeplan = Plan.objects.create(name='DUPE', allow_multiple=True, is_combinable=True)
service.rates.create(plan=dupeplan, quantity=1, price=0)
service.rates.create(plan=dupeplan, quantity=3, price=9)
result = service.get_rates(account, 1)
self.assertEqual(rates, result)
results = service.get_rates(account)
results = service.rate_method(results, 30)
for rate, result in zip(rates, results):
self.assertEqual(rate['price'], result.price)
self.assertEqual(rate['quantity'], result.quantity)
account.plans.create(plan=dupeplan)
results = service.get_rates(account)
results = service.rate_method(results, 30)
rates = [
{'price': Decimal('0.00'), 'quantity': 4},
{'price': Decimal('10.00'), 'quantity': 1},
{'price': Decimal('9.00'), 'quantity': 6},
{'price': Decimal('1.00'), 'quantity': sys.maxint}
{'price': Decimal('9.00'), 'quantity': 5},
{'price': Decimal('1.00'), 'quantity': 21},
]
result = service.get_rates(account, 1)
print 'b', result
self.assertEqual(rates, result)
service.rates.create(plan='HYPER', quantity=1, price=10)
service.rates.create(plan='HYPER', quantity=5, price=0)
service.rates.create(plan='HYPER', quantity=6, price=10)
account.plans.create(name='HYPER')
for rate, result in zip(rates, results):
self.assertEqual(rate['price'], result.price)
self.assertEqual(rate['quantity'], result.quantity)
hyperplan = Plan.objects.create(name='HYPER', allow_multiple=True, is_combinable=False)
service.rates.create(plan=hyperplan, quantity=1, price=0)
service.rates.create(plan=hyperplan, quantity=20, price=5)
account.plans.create(plan=hyperplan)
results = service.get_rates(account)
results = service.rate_method(results, 30)
rates = [
{'price': Decimal('0.00'), 'quantity': 4},
{'price': Decimal('10.00'), 'quantity': 1},
{'price': Decimal('0.00'), 'quantity': 1},
{'price': Decimal('9.00'), 'quantity': 6},
{'price': Decimal('1.00'), 'quantity': sys.maxint}
{'price': Decimal('0.00'), 'quantity': 19},
{'price': Decimal('5.00'), 'quantity': 11}
]
result = service.get_rates(account, 1)
self.assertEqual(rates, result)
for rate, result in zip(rates, results):
self.assertEqual(rate['price'], result.price)
self.assertEqual(rate['quantity'], result.quantity)
hyperplan.is_combinable = True
hyperplan.save()
results = service.get_rates(account)
results = service.rate_method(results, 30)
rates = [
{'price': Decimal('0.00'), 'quantity': 23},
{'price': Decimal('1.00'), 'quantity': 7}
]
for rate, result in zip(rates, results):
self.assertEqual(rate['price'], result.price)
self.assertEqual(rate['quantity'], result.quantity)
service.rate_algorithm = service.MATCH_PRICE
service.save()
results = service.get_rates(account)
results = service.rate_method(results, 30)
self.assertEqual(1, len(results))
self.assertEqual(Decimal('1.00'), results[0].price)
self.assertEqual(30, results[0].quantity)
hyperplan.delete()
results = service.get_rates(account)
results = service.rate_method(results, 8)
self.assertEqual(1, len(results))
self.assertEqual(Decimal('9.00'), results[0].price)
self.assertEqual(8, results[0].quantity)
superplan.delete()
results = service.get_rates(account)
results = service.rate_method(results, 30)
self.assertEqual(1, len(results))
self.assertEqual(Decimal('9.00'), results[0].price)
self.assertEqual(30, results[0].quantity)
# def test_ftp_account_1_year_fiexed(self):
# service = self.create_service()