From 97253d2d101f42d01394cd01abb9926fccc8ded5 Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 15 Sep 2014 15:36:24 +0000 Subject: [PATCH] Compute motherfucking rates --- .../apps/orders/migrations/0001_initial.py | 85 ----------- .../migrations/0002_service_nominal_price.py | 20 --- .../migrations/0003_auto_20140908_1409.py | 43 ------ .../migrations/0004_auto_20140909_1426.py | 29 ---- orchestra/apps/orders/migrations/__init__.py | 0 orchestra/apps/orders/models.py | 50 +++---- orchestra/apps/orders/rating.py | 108 ++++++++++++++ .../orders/tests/functional_tests/tests.py | 135 ++++++++++-------- 8 files changed, 204 insertions(+), 266 deletions(-) delete mode 100644 orchestra/apps/orders/migrations/0001_initial.py delete mode 100644 orchestra/apps/orders/migrations/0002_service_nominal_price.py delete mode 100644 orchestra/apps/orders/migrations/0003_auto_20140908_1409.py delete mode 100644 orchestra/apps/orders/migrations/0004_auto_20140909_1426.py delete mode 100644 orchestra/apps/orders/migrations/__init__.py create mode 100644 orchestra/apps/orders/rating.py diff --git a/orchestra/apps/orders/migrations/0001_initial.py b/orchestra/apps/orders/migrations/0001_initial.py deleted file mode 100644 index 3647692c..00000000 --- a/orchestra/apps/orders/migrations/0001_initial.py +++ /dev/null @@ -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, - ), - ] diff --git a/orchestra/apps/orders/migrations/0002_service_nominal_price.py b/orchestra/apps/orders/migrations/0002_service_nominal_price.py deleted file mode 100644 index 8a877cc2..00000000 --- a/orchestra/apps/orders/migrations/0002_service_nominal_price.py +++ /dev/null @@ -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, - ), - ] diff --git a/orchestra/apps/orders/migrations/0003_auto_20140908_1409.py b/orchestra/apps/orders/migrations/0003_auto_20140908_1409.py deleted file mode 100644 index e09c3e95..00000000 --- a/orchestra/apps/orders/migrations/0003_auto_20140908_1409.py +++ /dev/null @@ -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')]), - ), - ] diff --git a/orchestra/apps/orders/migrations/0004_auto_20140909_1426.py b/orchestra/apps/orders/migrations/0004_auto_20140909_1426.py deleted file mode 100644 index b578afee..00000000 --- a/orchestra/apps/orders/migrations/0004_auto_20140909_1426.py +++ /dev/null @@ -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')]), - ), - ] diff --git a/orchestra/apps/orders/migrations/__init__.py b/orchestra/apps/orders/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/orchestra/apps/orders/models.py b/orchestra/apps/orders/models.py index a4ca2a0d..8aecc648 100644 --- a/orchestra/apps/orders/models.py +++ b/orchestra/apps/orders/models.py @@ -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) - return rates - self.__cached_rates[account.id] = (rates, cache) - # Caching depends on the specific rating method - return self.rate_method(rates, metric, cache=cache) + rates = self.__cached_rates.get(account.id, self.rates.by_account(account)) + return rates @property def rate_method(self): diff --git a/orchestra/apps/orders/rating.py b/orchestra/apps/orders/rating.py new file mode 100644 index 00000000..cc172548 --- /dev/null +++ b/orchestra/apps/orders/rating.py @@ -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, + })] diff --git a/orchestra/apps/orders/tests/functional_tests/tests.py b/orchestra/apps/orders/tests/functional_tests/tests.py index 31f639be..4c421182 100644 --- a/orchestra/apps/orders/tests/functional_tests/tests.py +++ b/orchestra/apps/orders/tests/functional_tests/tests.py @@ -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,89 +239,100 @@ 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() # now = timezone.now().date()etb