From 65333314616a57a505a588fd42e893df573c48b6 Mon Sep 17 00:00:00 2001 From: Marc Date: Sun, 14 Sep 2014 19:36:27 +0000 Subject: [PATCH] Improvements on order billing --- orchestra/apps/orders/handlers.py | 94 ++++++++----------- orchestra/apps/orders/helpers.py | 72 +++++++++----- orchestra/apps/orders/models.py | 50 ++-------- .../orders/tests/functional_tests/tests.py | 54 ++++++++--- 4 files changed, 135 insertions(+), 135 deletions(-) diff --git a/orchestra/apps/orders/handlers.py b/orchestra/apps/orders/handlers.py index 3c8506bb..46879f1b 100644 --- a/orchestra/apps/orders/handlers.py +++ b/orchestra/apps/orders/handlers.py @@ -170,18 +170,12 @@ class ServiceHandler(plugins.Plugin): 'discounts': discounts, }) - def _generate_bill_lines(self, orders, **options): + def _generate_bill_lines(self, orders, account, **options): # For the "boundary conditions" just think that: # date(2011, 1, 1) is equivalent to datetime(2011, 1, 1, 0, 0, 0) # In most cases: # ini >= registered_date, end < registered_date - # TODO Perform compensations on cancelled services - if self.on_cancel in (self.COMPENSATE, self.REFOUND): - pass - # TODO compensations with commit=False, fuck commit or just fuck the transaction? - # compensate(orders, **options) - # TODO create discount per compensation bp = None lines = [] commit = options.get('commit', True) @@ -197,18 +191,45 @@ class ServiceHandler(plugins.Plugin): ini = min(ini, cini) end = max(end, bp) # TODO if all bp are the same ... - porders = orders.pricing_orders(ini=ini, end=end) - porders.sort(cmp=helpers.cmp_billed_until_or_registered_on) - # Compensation + related_orders = Order.objects.filter(service=self.service, account=account) + if self.on_cancel in (self.COMPENSATE, self.REFOUND): + # Get orders pending for compensation + givers = related_orders.filter_givers(ini, end) + givers.sort(cmp=helpers.cmp_billed_until_or_registered_on) + orders.sort(cmp=helpers.cmp_billed_until_or_registered_on) + self.compensate(givers, orders) + + # Get pricing orders + porders = related_orders.filter_pricing_orders(ini, end) + porders = set(orders).union(set(porders)) + for ini, end, orders in self.get_chunks(porders, ini, end): + if self.pricing_period == self.ANUAL: + pass + elif self.pricing_period == self.MONTHLY: + pass + else: + raise NotImplementedError + metric = len(orders) + for position, order in enumerate(orders): + # TODO position +1? + price = self.get_price(order, metric, position=position) + price *= size + + def compensate(self, givers, receivers): compensations = [] - receivers = [] - for order in porders: + for order in givers: if order.billed_until and order.cancelled_on and order.cancelled_on < order.billed_until: compensations.append[Interval(order.cancelled_on, order.billed_until, order)] - orders.sort(cmp=helpers.cmp_billed_until_or_registered_on) - for order in orders: - order_interval = Interval(order.billed_until or order.registered_on, order.new_billed_until) - helpers.compensate(order_interval, compensations) + for order in receivers: + if not order.billed_until or order.billed_until < order.new_billed_until: + # receiver + ini = order.billed_until or order.registered_on + end = order.cancelled_on or datetime.date.max + order_interval = helpers.Interval(ini, order.new_billed_until) # TODO beyond interval? + compensations, used_compensations = helpers.compensate(order_interval, compensations) + order._compensations = used_compensations + for comp in used_compensations: + comp.order.billed_until = min(comp.order.billed_until, comp.end) def get_chunks(self, porders, ini, end, ix=0): if ix >= len(porders): @@ -267,44 +288,3 @@ class ServiceHandler(plugins.Plugin): if commit: order.save() return lines - - def compensate(self, orders): - # TODO this compensation is a bit hard to write it propertly - # don't forget to think about weighted and num order prices. - # Greedy algorithm for maximizing discount (non-deterministic) - # Reduce and break orders in donors and receivers - donors = [] - receivers = [] - for order in orders: - if order.cancelled_on and order.billed_until > order.cancelled_on: - donors.append(order) - elif not order.cancelled_on or order.cancelled_on > order.billed_until: - receivers.append(order) - - # Assign weights to every donor-receiver combination - weights = [] - for donor in donors: - for receiver in receivers: - if receiver.cancelled_on: - if not receiver.cancelled_on or receiver.cancelled_on < donor.billed_until: - end = receiver.cancelled_on - else: - end = donor.billed_until - else: - end = donor.billed_until - ini = donor.billed_until or donor.registered_on - if donor.cancelled_on > ini: - ini = donor.cancelled_on - weight = (end-ini).days - weights.append((weight, ini, end, donor, receiver)) - - # Choose weightest pairs - choosen = [] - weights.sort(key=lambda n: n[0]) - for weight, ini, end, donor, receiver in weigths: - if donor not in choosen and receiver not in choosen: - choosen += [donor, receiver] - donor.billed_until = end - donor.save() - price = self.get_price()#TODO - receiver.__discount_per_compensation =None diff --git a/orchestra/apps/orders/helpers.py b/orchestra/apps/orders/helpers.py index bb4b1b2e..ef549e4f 100644 --- a/orchestra/apps/orders/helpers.py +++ b/orchestra/apps/orders/helpers.py @@ -1,5 +1,7 @@ import inspect +from django.utils import timezone + from orchestra.apps.accounts.models import Account @@ -116,32 +118,44 @@ class Interval(object): def __sub__(self, other): remaining = [] if self.ini < other.ini: - remaining.append(Interval(self.ini, min(self.end, other.ini))) + remaining.append(Interval(self.ini, min(self.end, other.ini), self.order)) if self.end > other.end: - remaining.append(Interval(max(self.ini,other.end), self.end)) + remaining.append(Interval(max(self.ini,other.end), self.end, self.order)) return remaining def __repr__(self): - return "Start: %s End: %s" % (self.ini, self.end) + now = timezone.now() + return "Start: %s End: %s" % ((self.ini-now).days, (self.end-now).days) def intersect(self, other, remaining_self=None, remaining_other=None): if remaining_self is not None: remaining_self += (self - other) if remaining_other is not None: remaining_other += (other - self) - result = Interval(max(self.ini, other.ini), min(self.end, other.end)) + result = Interval(max(self.ini, other.ini), min(self.end, other.end), self.order) if len(result)>0: return result else: return None + + def intersect_set(self, others, remaining_self=None, remaining_other=None): + intersections = [] + for interval in others: + intersection = self.intersect(interval, remaining_self, remaining_other) + if intersection: + intersections.append(intersection) + return intersections + - -def get_intersections(order, compensations): +def get_intersections(order_intervals, compensations): intersections = [] for compensation in compensations: - intersection = compensation.intersect(order) - if intersection: - intersections.append((len(intersection), intersection)) + intersection = compensation.intersect_set(order_intervals) + length = 0 + for intersection_interval in intersection: + length += len(intersection_interval) + intersections.append((length, compensation)) + intersections.sort() return intersections # Intervals should not overlap @@ -153,24 +167,32 @@ def intersect(compensation, order_intervals): compensated.append(compensation.intersect(interval, unused_compensation, not_compensated)) return (compensated, not_compensated, unused_compensation) +def apply_compensation(order, compensation): + remaining_order = [] + remaining_compensation = [] + applied_compensation = compensation.intersect_set(order, remaining_compensation, remaining_order) + return applied_compensation, remaining_order, remaining_compensation +# TODO can be optimized def update_intersections(not_compensated, compensations): - intersections = [] - for (_,compensation) in compensations: - intersections += get_intersections(compensation, not_compensated) - return intersections + compensation_intervals = [] + for __, compensation in compensations: + compensation_intervals.append(compensation) + return get_intersections(not_compensated, compensation_intervals) def compensate(order, compensations): - intersections = get_intersections(order, compensations) - not_compensated = [order] - result = [] - while intersections: - # Apply the biggest intersection - intersections.sort(reverse=True) - (_,intersection) = intersections.pop() - (compensated, not_compensated, unused_compensation) = intersect(intersection, not_compensated) - # Reorder de intersections: - intersections = update_intersections(not_compensated, intersections) - result += compensated - return result + remaining_interval = [order] + ordered_intersections = get_intersections(remaining_interval, compensations) + applied_compensations = [] + remaining_compensations = [] + while ordered_intersections and ordered_intersections[len(ordered_intersections)-1][0]>0: + # Apply the first compensation: + __, compensation = ordered_intersections.pop() + (applied_compensation, remaining_interval, remaining_compensation) = apply_compensation(remaining_interval, compensation) + remaining_compensations += remaining_compensation + applied_compensations += applied_compensation + ordered_intersections = update_intersections(remaining_interval, ordered_intersections) + for __, compensation in ordered_intersections: + remaining_compensations.append(compensation) + return remaining_compensations, applied_compensations diff --git a/orchestra/apps/orders/models.py b/orchestra/apps/orders/models.py index c0850195..ec5d887d 100644 --- a/orchestra/apps/orders/models.py +++ b/orchestra/apps/orders/models.py @@ -323,46 +323,16 @@ class OrderQuerySet(models.QuerySet): else: bills += [(account, bill_lines)] return bills - - def pricing_effect(self, ini=None, end=None, **options): - # TODO register but not billed duscard - if not ini: - for cini, ro in self.values_list('billed_until', 'registered_on'): - if not cini: - cini = ro - if not ini: - ini = cini - - ini = min(ini, cini) - if not end: - order = self.first() - if order: - service = order.service - service.billing_point == service.FIXED_DATE - end = service.handler.get_billing_point(order, **options) - else: - pass - return self.exclude( - cancelled_on__isnull=False, billed_until__isnull=False, - cancelled_on__lte=F('billed_until'), billed_until__lte=ini, - registered_on__gte=end) - def get_related(self, ini=None, end=None): - if not ini: - ini = '' - if not end: - end = '' - return self.pricing_effect().filter( - Q(billed_until__isnull=False, billed_until__lt=end) | - Q(billed_until__isnull=True, registered_on__lt=end)) - # TODO iterate over every order, calculate its billing point and find related - qs = self.exclude(cancelled_on__isnull=False, - billed_until__gte=F('cancelled_on')).distinct() - original_ids = self.values_list('id', flat=True) - return self.model.objects.exclude(id__in=original_ids).filter( - service__in=qs.values_list('service_id', flat=True), - account__in=qs.values_list('account_id', flat=True) - ) + def filter_givers(self, ini, end): + return self.filter( + cancelled_on__isnull=False, billed_until__isnull=False, + cancelled_on__lte=F('billed_until'), billed_until__gt=ini, + registered_on__lt=end) + + def filter_pricing_orders(self, ini, end): + return self.filter(billed_until__isnull=False, billed_until__gt=ini, + registered_on__lt=end) def by_object(self, obj, **kwargs): ct = ContentType.objects.get_for_model(obj) @@ -386,7 +356,7 @@ class Order(models.Model): object_id = models.PositiveIntegerField(null=True) service = models.ForeignKey(Service, verbose_name=_("service"), related_name='orders') - registered_on = models.DateField(_("registered on"), auto_now_add=True) + registered_on = models.DateField(_("registered on"), auto_now_add=True) # TODO datetime field? cancelled_on = models.DateField(_("cancelled on"), null=True, blank=True) billed_on = models.DateField(_("billed on"), null=True, blank=True) billed_until = models.DateField(_("billed until"), null=True, blank=True) diff --git a/orchestra/apps/orders/tests/functional_tests/tests.py b/orchestra/apps/orders/tests/functional_tests/tests.py index b3924949..f37db9fe 100644 --- a/orchestra/apps/orders/tests/functional_tests/tests.py +++ b/orchestra/apps/orders/tests/functional_tests/tests.py @@ -8,8 +8,7 @@ from orchestra.apps.accounts.models import Account from orchestra.apps.users.models import User from orchestra.utils.tests import BaseTestCase, random_ascii -from ... import settings -from ...helpers import cmp_billed_until_or_registered_on +from ... import settings, helpers from ...models import Service, Order @@ -175,44 +174,73 @@ class OrderTests(BaseTestCase): service=service, registered_on=now+datetime.timedelta(days=8)) orders = [order3, order, order1, order2, order4, order5, order6] - self.assertEqual(orders, sorted(orders, cmp=cmp_billed_until_or_registered_on)) + self.assertEqual(orders, sorted(orders, cmp=helpers.cmp_billed_until_or_registered_on)) def test_compensation(self): now = timezone.now() order = Order( + description='0', registered_on=now, - billed_until=now+datetime.timedelta(days=200), + billed_until=now+datetime.timedelta(days=220), cancelled_on=now+datetime.timedelta(days=100)) order1 = Order( + description='1', registered_on=now+datetime.timedelta(days=5), cancelled_on=now+datetime.timedelta(days=190), billed_until=now+datetime.timedelta(days=200)) order2 = Order( + description='2', registered_on=now+datetime.timedelta(days=6), cancelled_on=now+datetime.timedelta(days=200), billed_until=now+datetime.timedelta(days=200)) order3 = Order( + description='3', registered_on=now+datetime.timedelta(days=6), billed_until=now+datetime.timedelta(days=200)) + + tests = [] order4 = Order( - registered_on=now+datetime.timedelta(days=6)) + description='4', + registered_on=now+datetime.timedelta(days=6), + billed_until=now+datetime.timedelta(days=102)) + order4.new_billed_until = now+datetime.timedelta(days=200) + tests.append([ + [now+datetime.timedelta(days=102), now+datetime.timedelta(days=220), order], + ]) order5 = Order( - registered_on=now+datetime.timedelta(days=7)) + description='5', + registered_on=now+datetime.timedelta(days=7), + billed_until=now+datetime.timedelta(days=102)) + order5.new_billed_until = now+datetime.timedelta(days=195) + tests.append([ + [now+datetime.timedelta(days=190), now+datetime.timedelta(days=200), order1] + ]) order6 = Order( + description='6', registered_on=now+datetime.timedelta(days=8)) + order6.new_billed_until = now+datetime.timedelta(days=200) + tests.append([ + [now+datetime.timedelta(days=100), now+datetime.timedelta(days=102), order], + ]) porders = [order3, order, order1, order2, order4, order5, order6] - porders = sorted(porders, cmp=cmp_billed_until_or_registered_on) + porders = sorted(porders, cmp=helpers.cmp_billed_until_or_registered_on) service = self.create_service() compensations = [] - from ... import helpers + receivers = [] for order in porders: if order.billed_until and order.cancelled_on and order.cancelled_on < order.billed_until: compensations.append(helpers.Interval(order.cancelled_on, order.billed_until, order=order)) - for order in porders: - bp = service.handler.get_billing_point(order) - order_interval = helpers.Interval(order.billed_until or order.registered_on, bp) - print helpers.compensate(order_interval, compensations) - + elif hasattr(order, 'new_billed_until') and (not order.billed_until or order.billed_until < order.new_billed_until): + receivers.append(order) + for order, test in zip(receivers, tests): + ini = order.billed_until or order.registered_on + end = order.cancelled_on or now+datetime.timedelta(days=20000) + order_interval = helpers.Interval(ini, end) + (compensations, used_compensations) = helpers.compensate(order_interval, compensations) + for compensation, test_line in zip(used_compensations, test): + self.assertEqual(test_line[0], compensation.ini) + self.assertEqual(test_line[1], compensation.end) + self.assertEqual(test_line[2], compensation.order) # def test_ftp_account_1_year_fiexed(self): # service = self.create_service()