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): def by_account(self, account):
# Default allways selected # Default allways selected
qset = Q(plan='') return self.filter(
for plan in account.plans.all(): Q(plan__is_default=True) |
qset |= Q(plan=plan) Q(plan__contracts__account=account)
return self.filter(qset) ).order_by('plan', 'quantity').select_related('plan').distinct()
class Rate(models.Model): class Rate(models.Model):
@ -89,10 +89,10 @@ class Service(models.Model):
COMPENSATE = 'COMPENSATE' COMPENSATE = 'COMPENSATE'
PREPAY = 'PREPAY' PREPAY = 'PREPAY'
POSTPAY = 'POSTPAY' POSTPAY = 'POSTPAY'
STEPED_PRICE = 'STEPED_PRICE' STEP_PRICE = 'STEP_PRICE'
MATCH_PRICE = 'MATCH_PRICE' MATCH_PRICE = 'MATCH_PRICE'
RATE_METHODS = { RATE_METHODS = {
STEPED_PRICE: rating.steped_price, STEP_PRICE: rating.step_price,
MATCH_PRICE: rating.match_price, MATCH_PRICE: rating.match_price,
} }
@ -153,10 +153,10 @@ class Service(models.Model):
rate_algorithm = models.CharField(_("rate algorithm"), max_length=16, rate_algorithm = models.CharField(_("rate algorithm"), max_length=16,
help_text=_("Algorithm used to interprete the rating table"), help_text=_("Algorithm used to interprete the rating table"),
choices=( choices=(
(STEPED_PRICE, _("Steped price")), (STEP_PRICE, _("Step price")),
(MATCH_PRICE, _("Match price")), (MATCH_PRICE, _("Match price")),
), ),
default=STEPED_PRICE) default=STEP_PRICE)
# orders_effect = models.CharField(_("orders effect"), max_length=16, # orders_effect = models.CharField(_("orders effect"), max_length=16,
# help_text=_("Defines the lookup behaviour when using orders for " # help_text=_("Defines the lookup behaviour when using orders for "
# "the pricing rate computation of this service."), # "the pricing rate computation of this service."),
@ -257,12 +257,19 @@ class Service(models.Model):
return self.billing_period return self.billing_period
return self.pricing_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, if position is provided an specific price for that position is returned,
accumulated price is returned otherwise 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 counter = 0
if position is None: if position is None:
ant_counter = 0 ant_counter = 0
@ -281,25 +288,14 @@ class Service(models.Model):
if counter >= position: if counter >= position:
return float(rate['price']) return float(rate['price'])
def get_rates(self, account, cache=False):
def get_rates(self, account, metric): # rates are cached per account
if not cache:
return self.rates.by_account(account)
if not hasattr(self, '__cached_rates'): if not hasattr(self, '__cached_rates'):
self.__cached_rates = {} self.__cached_rates = {}
if account.id in self.__cached_rates: rates = self.__cached_rates.get(account.id, self.rates.by_account(account))
rates, cache = self.__cached_rates.get(account.id) return rates
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)
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 @property
def rate_method(self): 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, is_fee=False,
metric='', metric='',
pricing_period=Service.BILLING_PERIOD, pricing_period=Service.BILLING_PERIOD,
rate_algorithm=Service.STEPED_PRICE, rate_algorithm=Service.STEP_PRICE,
# orders_effect=Service.CONCURRENT, # orders_effect=Service.CONCURRENT,
on_cancel=Service.DISCOUNT, on_cancel=Service.DISCOUNT,
payment_style=Service.PREPAY, payment_style=Service.PREPAY,
@ -239,89 +239,100 @@ class OrderTests(BaseTestCase):
def get_rates(self, account): 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() 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): def test_rates(self):
from ...models import Plan
import sys
from decimal import Decimal
service = self.create_service() 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=1, price=0)
service.rates.create(plan=superplan, quantity=3, price=10) service.rates.create(plan=superplan, quantity=3, price=10)
service.rates.create(plan=superplan, quantity=4, price=9) service.rates.create(plan=superplan, quantity=4, price=9)
service.rates.create(plan=superplan, quantity=10, price=1) service.rates.create(plan=superplan, quantity=10, price=1)
account = self.create_account() account = self.create_account()
account.plans.create(plan=superplan) account.plans.create(plan=superplan)
result = service.get_rates(account, 1) results = service.get_rates(account)
import sys results = service.rate_method(results, 30)
from decimal import Decimal
rates = [ rates = [
{'price': Decimal('0.00'), 'quantity': 2}, {'price': Decimal('0.00'), 'quantity': 2},
{'price': Decimal('10.00'), 'quantity': 1}, {'price': Decimal('10.00'), 'quantity': 1},
{'price': Decimal('9.00'), 'quantity': 6}, {'price': Decimal('9.00'), 'quantity': 6},
{'price': Decimal('1.00'), 'quantity': sys.maxint} {'price': Decimal('1.00'), 'quantity': 21}
] ]
self.assertEqual(rates, result) for rate, result in zip(rates, results):
dupeplan = Plan.objects.create(name='DUPE', allow_multiple=True, is_combinable=False) 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=1, price=0)
service.rates.create(plan=dupeplan, quantity=3, price=9) service.rates.create(plan=dupeplan, quantity=3, price=9)
result = service.get_rates(account, 1) results = service.get_rates(account)
self.assertEqual(rates, result) 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) account.plans.create(plan=dupeplan)
results = service.get_rates(account)
results = service.rate_method(results, 30)
rates = [ rates = [
{'price': Decimal('0.00'), 'quantity': 4}, {'price': Decimal('0.00'), 'quantity': 4},
{'price': Decimal('10.00'), 'quantity': 1}, {'price': Decimal('9.00'), 'quantity': 5},
{'price': Decimal('9.00'), 'quantity': 6}, {'price': Decimal('1.00'), 'quantity': 21},
{'price': Decimal('1.00'), 'quantity': sys.maxint}
] ]
result = service.get_rates(account, 1) for rate, result in zip(rates, results):
print 'b', result self.assertEqual(rate['price'], result.price)
self.assertEqual(rates, result) self.assertEqual(rate['quantity'], result.quantity)
service.rates.create(plan='HYPER', quantity=1, price=10)
service.rates.create(plan='HYPER', quantity=5, price=0) hyperplan = Plan.objects.create(name='HYPER', allow_multiple=True, is_combinable=False)
service.rates.create(plan='HYPER', quantity=6, price=10) service.rates.create(plan=hyperplan, quantity=1, price=0)
account.plans.create(name='HYPER') 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 = [ rates = [
{'price': Decimal('0.00'), 'quantity': 4}, {'price': Decimal('0.00'), 'quantity': 19},
{'price': Decimal('10.00'), 'quantity': 1}, {'price': Decimal('5.00'), 'quantity': 11}
{'price': Decimal('0.00'), 'quantity': 1},
{'price': Decimal('9.00'), 'quantity': 6},
{'price': Decimal('1.00'), 'quantity': sys.maxint}
] ]
result = service.get_rates(account, 1) for rate, result in zip(rates, results):
self.assertEqual(rates, result) 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): # def test_ftp_account_1_year_fiexed(self):
# service = self.create_service() # service = self.create_service()
# now = timezone.now().date()etb # now = timezone.now().date()etb