Improvements on order billing
This commit is contained in:
parent
fba8dac8f5
commit
6533331461
|
@ -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
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
Loading…
Reference in New Issue