Finished billing prototype

This commit is contained in:
Marc 2014-09-03 13:56:02 +00:00
parent 9e8a76bc1b
commit f4c8ca06ca
13 changed files with 308 additions and 122 deletions

View File

@ -6,7 +6,7 @@ from orchestra.utils.system import run
def generate_bill(modeladmin, request, queryset): def generate_bill(modeladmin, request, queryset):
bill = queryset.get() bill = queryset.get()
bill.close() bill.close()
# return HttpResponse(bill.html) return HttpResponse(bill.html)
pdf = run('xvfb-run -a -s "-screen 0 640x4800x16" ' pdf = run('xvfb-run -a -s "-screen 0 640x4800x16" '
'wkhtmltopdf --footer-center "Page [page] of [topage]" --footer-font-size 9 - -', 'wkhtmltopdf --footer-center "Page [page] of [topage]" --footer-font-size 9 - -',
stdin=bill.html.encode('utf-8'), display=False) stdin=bill.html.encode('utf-8'), display=False)

View File

@ -1,12 +1,14 @@
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin from orchestra.admin import ExtendedModelAdmin
from orchestra.admin.utils import admin_link, admin_date from orchestra.admin.utils import admin_link, admin_date
from orchestra.apps.accounts.admin import AccountAdminMixin from orchestra.apps.accounts.admin import AccountAdminMixin
from . import settings
from .actions import generate_bill from .actions import generate_bill
from .filters import BillTypeListFilter from .filters import BillTypeListFilter
from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, Budget, from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, Budget,
@ -15,9 +17,16 @@ from .models import (Bill, Invoice, AmendmentInvoice, Fee, AmendmentFee, Budget,
class BillLineInline(admin.TabularInline): class BillLineInline(admin.TabularInline):
model = BillLine model = BillLine
fields = ( fields = ('description', 'rate', 'amount', 'tax', 'total', 'subtotal')
'description', 'initial_date', 'final_date', 'price', 'amount', 'tax' readonly_fields = ('subtotal',)
)
def subtotal(self, line):
if line.total:
subtotal = 0
for subline in line.sublines.all():
subtotal += subline.total
return line.total - subtotal
return ''
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
if obj and obj.status != Bill.OPEN: if obj and obj.status != Bill.OPEN:
@ -37,21 +46,20 @@ class BillLineInline(admin.TabularInline):
class BudgetLineInline(admin.TabularInline): class BudgetLineInline(admin.TabularInline):
model = Budget model = Budget
fields = ( fields = ('description', 'rate', 'amount', 'tax', 'total')
'description', 'initial_date', 'final_date', 'price', 'amount', 'tax'
)
class BillAdmin(AccountAdminMixin, ExtendedModelAdmin): class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
list_display = ( list_display = (
'number', 'status', 'type_link', 'account_link', 'created_on_display' 'number', 'status', 'type_link', 'account_link', 'created_on_display',
'num_lines', 'display_total'
) )
list_filter = (BillTypeListFilter, 'status',) list_filter = (BillTypeListFilter, 'status',)
add_fields = ('account', 'type', 'status', 'due_on', 'comments') add_fields = ('account', 'type', 'status', 'due_on', 'comments')
fieldsets = ( fieldsets = (
(None, { (None, {
'fields': ('number', 'account_link', 'type', 'status', 'due_on', 'fields': ('number', 'display_total', 'account_link', 'type',
'comments'), 'status', 'due_on', 'comments'),
}), }),
(_("Raw"), { (_("Raw"), {
'classes': ('collapse',), 'classes': ('collapse',),
@ -60,11 +68,21 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
) )
change_view_actions = [generate_bill] change_view_actions = [generate_bill]
change_readonly_fields = ('account_link', 'type', 'status') change_readonly_fields = ('account_link', 'type', 'status')
readonly_fields = ('number',) readonly_fields = ('number', 'display_total')
inlines = [BillLineInline] inlines = [BillLineInline]
created_on_display = admin_date('created_on') created_on_display = admin_date('created_on')
def num_lines(self, bill):
return bill.billlines__count
num_lines.admin_order_field = 'billlines__count'
num_lines.short_description = _("lines")
def display_total(self, bill):
return "%i &%s;" % (bill.get_total(), settings.BILLS_CURRENCY.lower())
display_total.allow_tags = True
display_total.short_description = _("total")
def type_link(self, bill): def type_link(self, bill):
bill_type = bill.type.lower() bill_type = bill.type.lower()
url = reverse('admin:bills_%s_changelist' % bill_type) url = reverse('admin:bills_%s_changelist' % bill_type)
@ -93,7 +111,13 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
if db_field.name == 'html': if db_field.name == 'html':
kwargs['widget'] = forms.Textarea(attrs={'cols': 150, 'rows': 20}) kwargs['widget'] = forms.Textarea(attrs={'cols': 150, 'rows': 20})
return super(BillAdmin, self).formfield_for_dbfield(db_field, **kwargs) return super(BillAdmin, self).formfield_for_dbfield(db_field, **kwargs)
def queryset(self, request):
qs = super(BillAdmin, self).queryset(request)
qs = qs.annotate(models.Count('billlines'))
qs = qs.prefetch_related('billlines', 'billlines__sublines')
return qs
admin.site.register(Bill, BillAdmin) admin.site.register(Bill, BillAdmin)
admin.site.register(Invoice, BillAdmin) admin.site.register(Invoice, BillAdmin)

View File

@ -8,6 +8,7 @@ from django.utils.translation import ugettext_lazy as _
from orchestra.apps.accounts.models import Account from orchestra.apps.accounts.models import Account
from orchestra.core import accounts from orchestra.core import accounts
from orchestra.utils.functional import cached
from . import settings from . import settings
@ -140,6 +141,25 @@ class Bill(models.Model):
if not self.number or (self.number.startswith('O') and self.status != self.OPEN): if not self.number or (self.number.startswith('O') and self.status != self.OPEN):
self.set_number() self.set_number()
super(Bill, self).save(*args, **kwargs) super(Bill, self).save(*args, **kwargs)
@cached
def get_subtotals(self):
subtotals = {}
for line in self.lines.all():
subtotal, taxes = subtotals.get(line.tax, (0, 0))
subtotal += line.total
for subline in line.sublines.all():
subtotal += subline.total
subtotals[line.tax] = (subtotal, (line.tax/100)*subtotal)
return subtotals
@cached
def get_total(self):
total = 0
for tax, subtotal in self.get_subtotals().iteritems():
subtotal, taxes = subtotal
total += subtotal + taxes
return total
class Invoice(Bill): class Invoice(Bill):
@ -176,11 +196,11 @@ class BaseBillLine(models.Model):
bill = models.ForeignKey(Bill, verbose_name=_("bill"), bill = models.ForeignKey(Bill, verbose_name=_("bill"),
related_name='%(class)ss') related_name='%(class)ss')
description = models.CharField(_("description"), max_length=256) description = models.CharField(_("description"), max_length=256)
initial_date = models.DateTimeField() rate = models.DecimalField(_("rate"), blank=True, null=True,
final_date = models.DateTimeField() max_digits=12, decimal_places=2)
price = models.DecimalField(max_digits=12, decimal_places=2)
amount = models.DecimalField(_("amount"), max_digits=12, decimal_places=2) amount = models.DecimalField(_("amount"), max_digits=12, decimal_places=2)
tax = models.DecimalField(_("tax"), max_digits=12, decimal_places=2) total = models.DecimalField(_("total"), max_digits=12, decimal_places=2)
tax = models.PositiveIntegerField(_("tax"))
class Meta: class Meta:
abstract = True abstract = True
@ -188,7 +208,7 @@ class BaseBillLine(models.Model):
def __unicode__(self): def __unicode__(self):
return "#%i" % self.number return "#%i" % self.number
@property @cached_property
def number(self): def number(self):
lines = type(self).objects.filter(bill=self.bill_id) lines = type(self).objects.filter(bill=self.bill_id)
return lines.filter(id__lte=self.id).order_by('id').count() return lines.filter(id__lte=self.id).order_by('id').count()
@ -207,7 +227,7 @@ class BillLine(BaseBillLine):
related_name='amendment_lines', null=True, blank=True) related_name='amendment_lines', null=True, blank=True)
class SubBillLine(models.Model): class BillSubline(models.Model):
""" Subline used for describing an item discount """ """ Subline used for describing an item discount """
bill_line = models.ForeignKey(BillLine, verbose_name=_("bill line"), bill_line = models.ForeignKey(BillLine, verbose_name=_("bill line"),
related_name='sublines') related_name='sublines')

View File

@ -50,7 +50,7 @@
</div> </div>
<div id="total"> <div id="total">
<span class="title">TOTAL</span><br> <span class="title">TOTAL</span><br>
<psan class="value">{{ bill.total }} &{{ currency.lower }};</span> <psan class="value">{{ bill.get_total }} &{{ currency.lower }};</span>
</div> </div>
<div id="bill-date"> <div id="bill-date">
<span class="title">{{ bill.get_type_display.upper }} DATE</span><br> <span class="title">{{ bill.get_type_display.upper }} DATE</span><br>
@ -79,20 +79,22 @@
<span class="value column-description">{{ line.description }}</span> <span class="value column-description">{{ line.description }}</span>
<span class="value column-quantity">{{ line.amount|default:"&nbsp;" }}</span> <span class="value column-quantity">{{ line.amount|default:"&nbsp;" }}</span>
<span class="value column-rate">{% if line.rate %}{{ line.rate }} &{{ currency.lower }};{% else %}&nbsp;{% endif %}</span> <span class="value column-rate">{% if line.rate %}{{ line.rate }} &{{ currency.lower }};{% else %}&nbsp;{% endif %}</span>
<span class="value column-subtotal">{{ line.price }} &{{ currency.lower }};</span> <span class="value column-subtotal">{{ line.total }} &{{ currency.lower }};</span>
<br> <br>
{% endfor %} {% endfor %}
</div> </div>
<div id="totals"> <div id="totals">
<br>&nbsp;<br> <br>&nbsp;<br>
<span class="subtotal column-title">subtotal</span> {% for tax, subtotal in bill.get_subtotals.iteritems %}
<span class="subtotal column-value">{{ bill.subtotal }} &{{ currency.lower }};</span> <span class="subtotal column-title">subtotal {{ tax }}% VAT</span>
<br> <span class="subtotal column-value">{{ subtotal | first }} &{{ currency.lower }};</span>
<span class="tax column-title">tax</span> <br>
<span class="tax column-value">{{ bill.taxes }} &{{ currency.lower }};</span> <span class="tax column-title">taxes {{ tax }}% VAT</span>
<br> <span class="tax column-value">{{ subtotal | last }} &{{ currency.lower }};</span>
<br>
{% endfor %}
<span class="total column-title">total</span> <span class="total column-title">total</span>
<span class="total column-value">{{ bill.total }} &{{ currency.lower }};</span> <span class="total column-value">{{ bill.get_total }} &{{ currency.lower }};</span>
<br> <br>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -97,7 +97,7 @@ class ContactInline(InvoiceContactInline):
def has_invoice(account): def has_invoice(account):
try: try:
account.invoicecontact.get() account.invoicecontact
except InvoiceContact.DoesNotExist: except InvoiceContact.DoesNotExist:
return False return False
return True return True

View File

@ -70,7 +70,7 @@ class BillSelectedOrders(object):
msg = _("Selected orders do not have pending billing") msg = _("Selected orders do not have pending billing")
self.modeladmin.message_user(request, msg, messages.WARNING) self.modeladmin.message_user(request, msg, messages.WARNING)
else: else:
ids = ','.join([bill.id for bill in bills]) ids = ','.join([str(bill.id) for bill in bills])
url = reverse('admin:bills_bill_changelist') url = reverse('admin:bills_bill_changelist')
context = { context = {
'url': url + '?id=%s' % ids, 'url': url + '?id=%s' % ids,

View File

@ -78,9 +78,9 @@ class ServiceAdmin(admin.ModelAdmin):
class OrderAdmin(AccountAdminMixin, ChangeListDefaultFilter, admin.ModelAdmin): class OrderAdmin(AccountAdminMixin, ChangeListDefaultFilter, admin.ModelAdmin):
list_display = ( list_display = (
'id', 'service', 'account_link', 'content_object_link', 'id', 'service', 'account_link', 'content_object_link',
'display_registered_on', 'display_cancelled_on' 'display_registered_on', 'display_billed_until', 'display_cancelled_on'
) )
list_display_link = ('id', 'service') list_display_links = ('id', 'service')
list_filter = (ActiveOrderListFilter, 'service',) list_filter = (ActiveOrderListFilter, 'service',)
actions = (BillSelectedOrders(),) actions = (BillSelectedOrders(),)
date_hierarchy = 'registered_on' date_hierarchy = 'registered_on'
@ -90,6 +90,7 @@ class OrderAdmin(AccountAdminMixin, ChangeListDefaultFilter, admin.ModelAdmin):
content_object_link = admin_link('content_object', order=False) content_object_link = admin_link('content_object', order=False)
display_registered_on = admin_date('registered_on') display_registered_on = admin_date('registered_on')
display_billed_until = admin_date('billed_until')
display_cancelled_on = admin_date('cancelled_on') display_cancelled_on = admin_date('cancelled_on')
def get_queryset(self, request): def get_queryset(self, request):

View File

@ -0,0 +1,42 @@
import datetime
from orchestra.apps.bills.models import Invoice, Fee, BillLine, BillSubline
class BillsBackend(object):
def create_bills(self, account, lines):
invoice = None
fees = []
for order, nominal_price, size, ini, end, discounts in lines:
service = order.service
if service.is_fee:
fee = Fee.objects.get_or_create(account=account, status=Fee.OPEN)
line = fee.lines.create(rate=service.nominal_price, amount=size,
total=nominal_price, tax=0)
self.create_sublines(line, discounts)
fees.append(fee)
else:
if invoice is None:
invoice, __ = Invoice.objects.get_or_create(account=account,
status=Invoice.OPEN)
description = order.description
if service.billing_period != service.NEVER:
description += " {ini} to {end}".format(
ini=ini.strftime("%b, %Y"),
end=(end-datetime.timedelta(seconds=1)).strftime("%b, %Y"))
line = invoice.lines.create(
description=description,
rate=service.nominal_price,
amount=size,
total=nominal_price,
tax=service.tax,
)
self.create_sublines(line, discounts)
return [invoice] + fees
def create_sublines(self, line, discounts):
for name, value in discounts:
line.sublines.create(
description=_("Discount per %s") % name,
total=value,
)

View File

@ -1,6 +1,7 @@
import calendar import calendar
import datetime
from dateutil.relativedelta import relativedelta from dateutil import relativedelta
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from django.utils import timezone from django.utils import timezone
@ -8,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _
from orchestra.utils import plugins from orchestra.utils import plugins
from . import settings
from .helpers import get_register_or_cancel_events, get_register_or_renew_events from .helpers import get_register_or_cancel_events, get_register_or_renew_events
@ -52,55 +54,59 @@ class ServiceHandler(plugins.Plugin):
return eval(self.metric, safe_locals) return eval(self.metric, safe_locals)
def get_billing_point(self, order, bp=None, **options): def get_billing_point(self, order, bp=None, **options):
not_cachable = self.billing_point is self.FIXED_DATE and options.get('fixed_point') not_cachable = self.billing_point == self.FIXED_DATE and options.get('fixed_point')
if not_cachable or bp is None: if not_cachable or bp is None:
bp = options.get('billing_point', timezone.now().date()) bp = options.get('billing_point', timezone.now().date())
if not options.get('fixed_point'): if not options.get('fixed_point'):
if self.billing_period is self.MONTHLY: msg = ("Support for '%s' period and '%s' point is not implemented"
% (self.get_billing_period_display(), self.get_billing_point_display()))
if self.billing_period == self.MONTHLY:
date = bp date = bp
if self.payment_style is self.PREPAY: if self.payment_style == self.PREPAY:
date += relativedelta(months=1) date += relativedelta.relativedelta(months=1)
if self.billing_point is self.ON_REGISTER: if self.billing_point == self.ON_REGISTER:
day = order.registered_on.day day = order.registered_on.day
elif self.billing_point is self.FIXED_DATE: elif self.billing_point == self.FIXED_DATE:
day = 1 day = 1
else:
raise NotImplementedError(msg)
bp = datetime.datetime(year=date.year, month=date.month, bp = datetime.datetime(year=date.year, month=date.month,
day=day, tzinfo=timezone.get_current_timezone()) day=day, tzinfo=timezone.get_current_timezone())
elif self.billing_period is self.ANUAL: elif self.billing_period == self.ANUAL:
if self.billing_point is self.ON_REGISTER: if self.billing_point == self.ON_REGISTER:
month = order.registered_on.month month = order.registered_on.month
day = order.registered_on.day day = order.registered_on.day
elif self.billing_point is self.FIXED_DATE: elif self.billing_point == self.FIXED_DATE:
month = settings.ORDERS_SERVICE_ANUAL_BILLING_MONTH month = settings.ORDERS_SERVICE_ANUAL_BILLING_MONTH
day = 1 day = 1
else:
raise NotImplementedError(msg)
year = bp.year year = bp.year
if self.payment_style is self.POSTPAY: if self.payment_style == self.POSTPAY:
year = bo.year - relativedelta(years=1) year = bo.year - relativedelta.relativedelta(years=1)
if bp.month >= month: if bp.month >= month:
year = bp.year + 1 year = bp.year + 1
bp = datetime.datetime(year=year, month=month, day=day, bp = datetime.datetime(year=year, month=month, day=day,
tzinfo=timezone.get_current_timezone()) tzinfo=timezone.get_current_timezone())
elif self.billing_period is self.NEVER: elif self.billing_period == self.NEVER:
bp = order.registered_on bp = order.registered_on
else: else:
raise NotImplementedError( raise NotImplementedError(msg)
"Support for '%s' billing period and '%s' billing point is not implemented" if self.on_cancel != self.NOTHING and order.cancelled_on and order.cancelled_on < bp:
% (self.display_billing_period(), self.display_billing_point())
)
if self.on_cancel is not self.NOTHING and order.cancelled_on < bp:
return order.cancelled_on return order.cancelled_on
return bp return bp
def get_pricing_size(self, ini, end): def get_pricing_size(self, ini, end):
rdelta = relativedelta.relativedelta(end, ini) rdelta = relativedelta.relativedelta(end, ini)
if self.get_pricing_period() is self.MONTHLY: if self.get_pricing_period() == self.MONTHLY:
size = rdelta.months size = rdelta.months
days = calendar.monthrange(bp.year, bp.month)[1] days = calendar.monthrange(end.year, end.month)[1]
size += float(bp.day)/days size += float(rdelta.days)/days
elif self.get_pricint_period() is self.ANUAL: elif self.get_pricing_period() == self.ANUAL:
size = rdelta.years size = rdelta.years
size += float(rdelta.days)/365 days = 366 if calendar.isleap(end.year) else 365
elif self.get_pricing_period() is self.NEVER: size += float((end-ini).days)/days
elif self.get_pricing_period() == self.NEVER:
size = 1 size = 1
else: else:
raise NotImplementedError raise NotImplementedError
@ -108,11 +114,11 @@ class ServiceHandler(plugins.Plugin):
def get_pricing_slots(self, ini, end): def get_pricing_slots(self, ini, end):
period = self.get_pricing_period() period = self.get_pricing_period()
if period is self.MONTHLY: if period == self.MONTHLY:
rdelta = relativedelta(months=1) rdelta = relativedelta.relativedelta(months=1)
elif period is self.ANUAL: elif period == self.ANUAL:
rdelta = relativedelta(years=1) rdelta = relativedelta.relativedelta(years=1)
elif period is self.NEVER: elif period == self.NEVER:
yield ini, end yield ini, end
raise StopIteration raise StopIteration
else: else:
@ -125,51 +131,107 @@ class ServiceHandler(plugins.Plugin):
yield ini, next yield ini, next
ini = next ini = next
def create_line(self, order, price, size): def get_price_with_orders(self, order, size, ini, end):
porders = self.orders.filter(account=order.account).filter(
Q(cancelled_on__isnull=True) | Q(cancelled_on__gt=ini)
).filter(registered_on__lt=end)
price = 0
if self.orders_effect == self.REGISTER_OR_RENEW:
events = get_register_or_renew_events(porders, ini, end)
elif self.orders_effect == self.CONCURRENT:
events = get_register_or_cancel_events(porders, ini, end)
else:
raise NotImplementedError
for metric, ratio in events:
price += self.get_rate(order, metric) * size * ratio
return price
def get_price_with_metric(self, order, size, ini, end):
metric = order.get_metric(ini, end)
price = self.get_rate(order, metric) * size
return price
def create_line(self, order, price, size, ini, end):
nominal_price = self.nominal_price * size nominal_price = self.nominal_price * size
discounts = []
if nominal_price > price: if nominal_price > price:
discount = nominal_price-price discounts.append(('volume', nominal_price-price))
# TODO Uncomment when prices are done
# elif nominal_price < price:
# raise ValueError("Something is wrong!")
return (order, nominal_price, size, ini, end, discounts)
def create_bill_lines(self, orders, **options): def create_bill_lines(self, orders, **options):
# Perform compensations on cancelled services # For the "boundary conditions" just think that:
# TODO WTF to do with day 1 of each month. # date(2011, 1, 1) is equivalent to datetime(2011, 1, 1, 0, 0, 0)
if self.on_cancel in (Order.COMPENSATE, Order.REFOUND): # 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? # TODO compensations with commit=False, fuck commit or just fuck the transaction?
compensate(orders, **options) # compensate(orders, **options)
# TODO create discount per compensation # TODO create discount per compensation
bp = None bp = None
lines = [] lines = []
for order in orders: for order in orders:
bp = self.get_billing_point(order, bp=bp, **options) bp = self.get_billing_point(order, bp=bp, **options)
ini = order.billed_until or order.registered_on ini = order.billed_until or order.registered_on
if bp < ini: if bp <= ini:
continue continue
if not self.metric: if not self.metric:
# Number of orders metric; bill line per order # Number of orders metric; bill line per order
porders = service.orders.filter(account=order.account).filter(
Q(is_active=True) | Q(cancelled_on__gt=order.billed_until)
).filter(registered_on__lt=bp)
price = 0
size = self.get_pricing_size(ini, bp) size = self.get_pricing_size(ini, bp)
if self.orders_effect is self.REGISTER_OR_RENEW: price = self.get_price_with_orders(order, size, ini, bp)
events = get_register_or_renew_events(porders, ini, bp) lines.append(self.create_line(order, price, size, ini, bp))
elif self.orders_effect is self.CONCURRENT:
events = get_register_or_cancel_events(porders, ini, bp)
else:
raise NotImplementedError
for metric, ratio in events:
price += self.get_rate(metric, account) * size * ratio
lines += self.create_line(order, price, size)
else: else:
# weighted metric; bill line per pricing period # weighted metric; bill line per pricing period
for ini, end in self.get_pricing_slots(ini, bp): for ini, end in self.get_pricing_slots(ini, bp):
metric = order.get_metric(ini, end)
size = self.get_pricing_size(ini, end) size = self.get_pricing_size(ini, end)
price = self.get_rate(metric, account) * size price = self.get_price_with_metric(order, size, ini, end)
lines += self.create_line(order, price, size) lines.append(self.create_line(order, price, size, ini, end))
order.billed_until = bp
order.save() # TODO if commit
return lines return lines
def compensate(self, orders): def compensate(self, orders):
# num orders and weights # TODO this compensation is a bit hard to write it propertly
# Discounts # don't forget to think about weighted and num order prices.
pass # 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

@ -37,7 +37,7 @@ def get_related_objects(origin, max_depth=2):
queue.append(new_models) queue.append(new_models)
def get_register_or_cancel_events(porders, ini, end): def get_register_or_cancel_events(porders, ini, end):
assert ini > end, "ini > end" assert ini <= end, "ini > end"
CANCEL = 'cancel' CANCEL = 'cancel'
REGISTER = 'register' REGISTER = 'register'
changes = {} changes = {}
@ -50,21 +50,22 @@ def get_register_or_cancel_events(porders, ini, end):
if cancel > ini and cancel < end: if cancel > ini and cancel < end:
changes.setdefault(cancel, []) changes.setdefault(cancel, [])
changes[cancel].append(CANCEL) changes[cancel].append(CANCEL)
if order.registered_on < ini: if order.registered_on <= ini:
counter += 1 counter += 1
elif order.registered_on < end: elif order.registered_on < end:
changes.setdefault(order.registered_on, []) changes.setdefault(order.registered_on, [])
changes[order.registered_on].append(REGISTER) changes[order.registered_on].append(REGISTER)
pointer = ini pointer = ini
total = float((end-ini).days) total = float((end-ini).days)
for date in changes.keys().sort(): for date in sorted(changes.keys()):
yield counter, (date-pointer).days/total
for change in changes[date]: for change in changes[date]:
if change is CANCEL: if change is CANCEL:
counter -= 1 counter -= 1
else: else:
counter += 1 counter += 1
yield counter, (date-pointer).days/total
pointer = date pointer = date
yield counter, (end-pointer).days/total
def get_register_or_renew_events(handler, porders, ini, end): def get_register_or_renew_events(handler, porders, ini, end):
@ -72,7 +73,7 @@ def get_register_or_renew_events(handler, porders, ini, end):
for sini, send in handler.get_pricing_slots(ini, end): for sini, send in handler.get_pricing_slots(ini, end):
counter = 0 counter = 0
for order in porders: for order in porders:
if order.registered_on > sini and order.registered_on < send: if order.registered_on >= sini and order.registered_on < send:
counter += 1 counter += 1
elif order.billed_until > send or order.cancelled_on > send: elif order.billed_until > send or order.cancelled_on > send:
counter += 1 counter += 1

View File

@ -84,7 +84,7 @@ class Service(models.Model):
metric = models.CharField(_("metric"), max_length=256, blank=True, metric = models.CharField(_("metric"), max_length=256, blank=True,
help_text=_("Metric used to compute the pricing rate. " help_text=_("Metric used to compute the pricing rate. "
"Number of orders is used when left blank.")) "Number of orders is used when left blank."))
tax = models.IntegerField(_("tax"), choices=settings.ORDERS_SERVICE_TAXES, tax = models.PositiveIntegerField(_("tax"), choices=settings.ORDERS_SERVICE_TAXES,
default=settings.ORDERS_SERVICE_DEFAUL_TAX) default=settings.ORDERS_SERVICE_DEFAUL_TAX)
pricing_period = models.CharField(_("pricing period"), max_length=16, pricing_period = models.CharField(_("pricing period"), max_length=16,
help_text=_("Period used for calculating the metric used on the " help_text=_("Period used for calculating the metric used on the "
@ -163,6 +163,11 @@ class Service(models.Model):
), ),
default=NEVER) default=NEVER)
@property
def nominal_price(self):
# FIXME delete and make it a model field
return 10
def __unicode__(self): def __unicode__(self):
return self.description return self.description
@ -215,24 +220,29 @@ class Service(models.Model):
msg = "{0} {1}: {2}".format(attr, name, message) msg = "{0} {1}: {2}".format(attr, name, message)
raise ValidationError(msg) raise ValidationError(msg)
def get_nominal_price(self, order): def get_pricing_period(self):
""" returns the price of an item """ if self.pricing_period == self.BILLING_PERIOD:
return self.billing_period
return self.pricing_period
def get_price(self, order, amount='TODO'): def get_rate(self, order, metric):
pass # TODO implement
return 12
class OrderQuerySet(models.QuerySet): class OrderQuerySet(models.QuerySet):
group_by = queryset.group_by group_by = queryset.group_by
def bill(self, **options): def bill(self, **options):
for account, services in self.group_by('account_id', 'service_id'): bills = []
bill_backend = Order.get_bill_backend()
for account, services in self.group_by('account', 'service'):
bill_lines = [] bill_lines = []
for service, orders in services: for service, orders in services:
lines = helpers.create_bill_lines(service, orders, **options) lines = service.handler.create_bill_lines(orders, **options)
bill_lines.extend(lines) bill_lines.extend(lines)
helpers.create_bills(account, bill_lines) bills += bill_backend.create_bills(account, bill_lines)
return bills
def get_related(self): def get_related(self):
pass pass
@ -259,10 +269,10 @@ 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.DateTimeField(_("registered on"), auto_now_add=True) registered_on = models.DateField(_("registered on"), auto_now_add=True)
cancelled_on = models.DateTimeField(_("cancelled on"), null=True, blank=True) cancelled_on = models.DateField(_("cancelled on"), null=True, blank=True)
billed_on = models.DateTimeField(_("billed on"), null=True, blank=True) billed_on = models.DateField(_("billed on"), null=True, blank=True)
billed_until = models.DateTimeField(_("billed until"), null=True, blank=True) billed_until = models.DateField(_("billed until"), null=True, blank=True)
ignore = models.BooleanField(_("ignore"), default=False) ignore = models.BooleanField(_("ignore"), default=False)
description = models.TextField(_("description"), blank=True) description = models.TextField(_("description"), blank=True)
@ -302,16 +312,26 @@ class Order(models.Model):
elif orders: elif orders:
orders.get().cancel() orders.get().cancel()
@classmethod
def get_bill_backend(cls):
# TODO
from .backends import BillsBackend
return BillsBackend()
def cancel(self): def cancel(self):
self.cancelled_on = timezone.now() self.cancelled_on = timezone.now()
self.save() self.save()
def get_metric(self, ini, end):
# TODO implement
return 10
class MetricStorage(models.Model): class MetricStorage(models.Model):
order = models.ForeignKey(Order, verbose_name=_("order")) order = models.ForeignKey(Order, verbose_name=_("order"))
value = models.BigIntegerField(_("value")) value = models.BigIntegerField(_("value"))
created_on = models.DateTimeField(_("created on"), auto_now_add=True) created_on = models.DateField(_("created on"), auto_now_add=True)
updated_on = models.DateTimeField(_("updated on"), auto_now=True) updated_on = models.DateField(_("updated on"), auto_now=True)
class Meta: class Meta:
get_latest_by = 'created_on' get_latest_by = 'created_on'

View File

@ -131,7 +131,7 @@ function install_requirements () {
wkhtmltopdf \ wkhtmltopdf \
xvfb" xvfb"
PIP="django==1.6.1 \ PIP="django==1.7 \
django-celery-email==1.0.4 \ django-celery-email==1.0.4 \
django-fluent-dashboard==0.3.5 \ django-fluent-dashboard==0.3.5 \
https://bitbucket.org/izi/django-admin-tools/get/a0abfffd76a0.zip \ https://bitbucket.org/izi/django-admin-tools/get/a0abfffd76a0.zip \

View File

@ -5,19 +5,27 @@ from django.utils.translation import ungettext, ugettext as _
def pluralize_year(n): def pluralize_year(n):
return ungettext(_('{num:.1f} year ago'), _('{num:.1f} years ago'), n) return ungettext(
_('{ahead}{num:.1f} year{ago}'),
_('{ahead}{num:.1f} years{ago}'), n)
def pluralize_month(n): def pluralize_month(n):
return ungettext(_('{num:.1f} month ago'), _('{num:.1f} months ago'), n) return ungettext(
_('{ahead}{num:.1f} month{ago}'),
_('{ahead}{num:.1f} months{ago}'), n)
def pluralize_week(n): def pluralize_week(n):
return ungettext(_('{num:.1f} week ago'), _('{num:.1f} weeks ago'), n) return ungettext(
_('{ahead}{num:.1f} week{ago}'),
_('{ahead}{num:.1f} weeks {ago}'), n)
def pluralize_day(n): def pluralize_day(n):
return ungettext(_('{num:.1f} day ago'), _('{num:.1f} days ago'), n) return ungettext(
_('{ahead}{num:.1f} day{ago}'),
_('{ahead}{num:.1f} days{ago}'), n)
OLDER_CHUNKS = ( OLDER_CHUNKS = (
@ -48,29 +56,34 @@ def naturaldate(date, include_seconds=False):
minutes = delta.seconds / 60 minutes = delta.seconds / 60
seconds = delta.seconds seconds = delta.seconds
ago = ' ago'
ahead = ''
if days < 0: if days < 0:
return _('just now') ago = ''
ahead = 'in '
days = abs(days)
if days == 0: if days == 0:
if hours == 0: if hours == 0:
if minutes > 0: if minutes > 0:
minutes += float(seconds)/60 minutes += float(seconds)/60
return ungettext( return ungettext(
_('{minutes:.1f} minute ago'), _('{ahead}{minutes:.1f} minute{ago}'),
_('{minutes:.1f} minutes ago'), minutes _('{ahead}{minutes:.1f} minutes{ago}'), minutes
).format(minutes=minutes) ).format(minutes=minutes, ago=ago, ahead=ahead)
else: else:
if include_seconds and seconds: if include_seconds and seconds:
return ungettext( return ungettext(
_('{seconds} second ago'), _('{ahead}{seconds} second{ago}'),
_('{seconds} seconds ago'), seconds _('{ahead}{seconds} seconds{ago}'), seconds
).format(seconds=seconds) ).format(seconds=seconds, ago=ago, ahead=ahead)
return _('just now') return _('just now')
else: else:
hours += float(minutes)/60 hours += float(minutes)/60
return ungettext( return ungettext(
_('{hours:.1f} hour ago'), _('{hours:.1f} hours ago'), hours _('{ahead}{hours:.1f} hour{ago}'),
).format(hours=hours) _('{ahead}{hours:.1f} hours{ago}'), hours
).format(hours=hours, ago=ago, ahead=ahead)
if delta_midnight.days == 0: if delta_midnight.days == 0:
return _('yesterday at {time}').format(time=date.strftime('%H:%M')) return _('yesterday at {time}').format(time=date.strftime('%H:%M'))
@ -80,8 +93,9 @@ def naturaldate(date, include_seconds=False):
if days < 7.0: if days < 7.0:
count = days + float(hours)/24 count = days + float(hours)/24
fmt = pluralize_day(count) fmt = pluralize_day(count)
return fmt.format(num=count) return fmt.format(num=count, ago=ago, ahead=ahead)
if days >= chunk: if days >= chunk:
count = (delta_midnight.days + 1) / chunk count = (delta_midnight.days + 1) / chunk
count = abs(count)
fmt = pluralizefun(count) fmt = pluralizefun(count)
return fmt.format(num=count) return fmt.format(num=count, ago=ago, ahead=ahead)