Improvements on order billing

This commit is contained in:
Marc 2014-09-14 19:36:27 +00:00
parent fba8dac8f5
commit 6533331461
4 changed files with 135 additions and 135 deletions

View file

@ -170,18 +170,12 @@ class ServiceHandler(plugins.Plugin):
'discounts': discounts, 'discounts': discounts,
}) })
def _generate_bill_lines(self, orders, **options): def _generate_bill_lines(self, orders, account, **options):
# For the "boundary conditions" just think that: # For the "boundary conditions" just think that:
# date(2011, 1, 1) is equivalent to datetime(2011, 1, 1, 0, 0, 0) # date(2011, 1, 1) is equivalent to datetime(2011, 1, 1, 0, 0, 0)
# In most cases: # In most cases:
# ini >= registered_date, end < registered_date # 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 bp = None
lines = [] lines = []
commit = options.get('commit', True) commit = options.get('commit', True)
@ -197,18 +191,45 @@ class ServiceHandler(plugins.Plugin):
ini = min(ini, cini) ini = min(ini, cini)
end = max(end, bp) # TODO if all bp are the same ... end = max(end, bp) # TODO if all bp are the same ...
porders = orders.pricing_orders(ini=ini, end=end) related_orders = Order.objects.filter(service=self.service, account=account)
porders.sort(cmp=helpers.cmp_billed_until_or_registered_on) if self.on_cancel in (self.COMPENSATE, self.REFOUND):
# Compensation # 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 = [] compensations = []
receivers = [] for order in givers:
for order in porders:
if order.billed_until and order.cancelled_on and order.cancelled_on < order.billed_until: 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)] compensations.append[Interval(order.cancelled_on, order.billed_until, order)]
orders.sort(cmp=helpers.cmp_billed_until_or_registered_on) for order in receivers:
for order in orders: if not order.billed_until or order.billed_until < order.new_billed_until:
order_interval = Interval(order.billed_until or order.registered_on, order.new_billed_until) # receiver
helpers.compensate(order_interval, compensations) 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): def get_chunks(self, porders, ini, end, ix=0):
if ix >= len(porders): if ix >= len(porders):
@ -267,44 +288,3 @@ class ServiceHandler(plugins.Plugin):
if commit: if commit:
order.save() order.save()
return lines 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

View file

@ -1,5 +1,7 @@
import inspect import inspect
from django.utils import timezone
from orchestra.apps.accounts.models import Account from orchestra.apps.accounts.models import Account
@ -116,32 +118,44 @@ class Interval(object):
def __sub__(self, other): def __sub__(self, other):
remaining = [] remaining = []
if self.ini < other.ini: 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: 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 return remaining
def __repr__(self): 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): def intersect(self, other, remaining_self=None, remaining_other=None):
if remaining_self is not None: if remaining_self is not None:
remaining_self += (self - other) remaining_self += (self - other)
if remaining_other is not None: if remaining_other is not None:
remaining_other += (other - self) 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: if len(result)>0:
return result return result
else: else:
return None 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_intervals, compensations):
def get_intersections(order, compensations):
intersections = [] intersections = []
for compensation in compensations: for compensation in compensations:
intersection = compensation.intersect(order) intersection = compensation.intersect_set(order_intervals)
if intersection: length = 0
intersections.append((len(intersection), intersection)) for intersection_interval in intersection:
length += len(intersection_interval)
intersections.append((length, compensation))
intersections.sort()
return intersections return intersections
# Intervals should not overlap # Intervals should not overlap
@ -153,24 +167,32 @@ def intersect(compensation, order_intervals):
compensated.append(compensation.intersect(interval, unused_compensation, not_compensated)) compensated.append(compensation.intersect(interval, unused_compensation, not_compensated))
return (compensated, not_compensated, unused_compensation) 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): def update_intersections(not_compensated, compensations):
intersections = [] compensation_intervals = []
for (_,compensation) in compensations: for __, compensation in compensations:
intersections += get_intersections(compensation, not_compensated) compensation_intervals.append(compensation)
return intersections return get_intersections(not_compensated, compensation_intervals)
def compensate(order, compensations): def compensate(order, compensations):
intersections = get_intersections(order, compensations) remaining_interval = [order]
not_compensated = [order] ordered_intersections = get_intersections(remaining_interval, compensations)
result = [] applied_compensations = []
while intersections: remaining_compensations = []
# Apply the biggest intersection while ordered_intersections and ordered_intersections[len(ordered_intersections)-1][0]>0:
intersections.sort(reverse=True) # Apply the first compensation:
(_,intersection) = intersections.pop() __, compensation = ordered_intersections.pop()
(compensated, not_compensated, unused_compensation) = intersect(intersection, not_compensated) (applied_compensation, remaining_interval, remaining_compensation) = apply_compensation(remaining_interval, compensation)
# Reorder de intersections: remaining_compensations += remaining_compensation
intersections = update_intersections(not_compensated, intersections) applied_compensations += applied_compensation
result += compensated ordered_intersections = update_intersections(remaining_interval, ordered_intersections)
return result for __, compensation in ordered_intersections:
remaining_compensations.append(compensation)
return remaining_compensations, applied_compensations

View file

@ -323,46 +323,16 @@ class OrderQuerySet(models.QuerySet):
else: else:
bills += [(account, bill_lines)] bills += [(account, bill_lines)]
return bills 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): def filter_givers(self, ini, end):
if not ini: return self.filter(
ini = '' cancelled_on__isnull=False, billed_until__isnull=False,
if not end: cancelled_on__lte=F('billed_until'), billed_until__gt=ini,
end = '' registered_on__lt=end)
return self.pricing_effect().filter(
Q(billed_until__isnull=False, billed_until__lt=end) | def filter_pricing_orders(self, ini, end):
Q(billed_until__isnull=True, registered_on__lt=end)) return self.filter(billed_until__isnull=False, billed_until__gt=ini,
# TODO iterate over every order, calculate its billing point and find related registered_on__lt=end)
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 by_object(self, obj, **kwargs): def by_object(self, obj, **kwargs):
ct = ContentType.objects.get_for_model(obj) ct = ContentType.objects.get_for_model(obj)
@ -386,7 +356,7 @@ class Order(models.Model):
object_id = models.PositiveIntegerField(null=True) object_id = models.PositiveIntegerField(null=True)
service = models.ForeignKey(Service, verbose_name=_("service"), service = models.ForeignKey(Service, verbose_name=_("service"),
related_name='orders') 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) cancelled_on = models.DateField(_("cancelled on"), null=True, blank=True)
billed_on = models.DateField(_("billed 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) billed_until = models.DateField(_("billed until"), null=True, blank=True)

View file

@ -8,8 +8,7 @@ from orchestra.apps.accounts.models import Account
from orchestra.apps.users.models import User from orchestra.apps.users.models import User
from orchestra.utils.tests import BaseTestCase, random_ascii from orchestra.utils.tests import BaseTestCase, random_ascii
from ... import settings from ... import settings, helpers
from ...helpers import cmp_billed_until_or_registered_on
from ...models import Service, Order from ...models import Service, Order
@ -175,44 +174,73 @@ class OrderTests(BaseTestCase):
service=service, service=service,
registered_on=now+datetime.timedelta(days=8)) registered_on=now+datetime.timedelta(days=8))
orders = [order3, order, order1, order2, order4, order5, order6] 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): def test_compensation(self):
now = timezone.now() now = timezone.now()
order = Order( order = Order(
description='0',
registered_on=now, registered_on=now,
billed_until=now+datetime.timedelta(days=200), billed_until=now+datetime.timedelta(days=220),
cancelled_on=now+datetime.timedelta(days=100)) cancelled_on=now+datetime.timedelta(days=100))
order1 = Order( order1 = Order(
description='1',
registered_on=now+datetime.timedelta(days=5), registered_on=now+datetime.timedelta(days=5),
cancelled_on=now+datetime.timedelta(days=190), cancelled_on=now+datetime.timedelta(days=190),
billed_until=now+datetime.timedelta(days=200)) billed_until=now+datetime.timedelta(days=200))
order2 = Order( order2 = Order(
description='2',
registered_on=now+datetime.timedelta(days=6), registered_on=now+datetime.timedelta(days=6),
cancelled_on=now+datetime.timedelta(days=200), cancelled_on=now+datetime.timedelta(days=200),
billed_until=now+datetime.timedelta(days=200)) billed_until=now+datetime.timedelta(days=200))
order3 = Order( order3 = Order(
description='3',
registered_on=now+datetime.timedelta(days=6), registered_on=now+datetime.timedelta(days=6),
billed_until=now+datetime.timedelta(days=200)) billed_until=now+datetime.timedelta(days=200))
tests = []
order4 = Order( 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( 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( order6 = Order(
description='6',
registered_on=now+datetime.timedelta(days=8)) 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 = [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() service = self.create_service()
compensations = [] compensations = []
from ... import helpers receivers = []
for order in porders: for order in porders:
if order.billed_until and order.cancelled_on and order.cancelled_on < order.billed_until: 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)) compensations.append(helpers.Interval(order.cancelled_on, order.billed_until, order=order))
for order in porders: elif hasattr(order, 'new_billed_until') and (not order.billed_until or order.billed_until < order.new_billed_until):
bp = service.handler.get_billing_point(order) receivers.append(order)
order_interval = helpers.Interval(order.billed_until or order.registered_on, bp) for order, test in zip(receivers, tests):
print helpers.compensate(order_interval, compensations) 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): # def test_ftp_account_1_year_fiexed(self):
# service = self.create_service() # service = self.create_service()