Finished billing prototype
This commit is contained in:
parent
9e8a76bc1b
commit
f4c8ca06ca
|
@ -6,7 +6,7 @@ from orchestra.utils.system import run
|
|||
def generate_bill(modeladmin, request, queryset):
|
||||
bill = queryset.get()
|
||||
bill.close()
|
||||
# return HttpResponse(bill.html)
|
||||
return HttpResponse(bill.html)
|
||||
pdf = run('xvfb-run -a -s "-screen 0 640x4800x16" '
|
||||
'wkhtmltopdf --footer-center "Page [page] of [topage]" --footer-font-size 9 - -',
|
||||
stdin=bill.html.encode('utf-8'), display=False)
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from orchestra.admin import ExtendedModelAdmin
|
||||
from orchestra.admin.utils import admin_link, admin_date
|
||||
from orchestra.apps.accounts.admin import AccountAdminMixin
|
||||
|
||||
from . import settings
|
||||
from .actions import generate_bill
|
||||
from .filters import BillTypeListFilter
|
||||
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):
|
||||
model = BillLine
|
||||
fields = (
|
||||
'description', 'initial_date', 'final_date', 'price', 'amount', 'tax'
|
||||
)
|
||||
fields = ('description', 'rate', 'amount', 'tax', 'total', 'subtotal')
|
||||
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):
|
||||
if obj and obj.status != Bill.OPEN:
|
||||
|
@ -37,21 +46,20 @@ class BillLineInline(admin.TabularInline):
|
|||
|
||||
class BudgetLineInline(admin.TabularInline):
|
||||
model = Budget
|
||||
fields = (
|
||||
'description', 'initial_date', 'final_date', 'price', 'amount', 'tax'
|
||||
)
|
||||
fields = ('description', 'rate', 'amount', 'tax', 'total')
|
||||
|
||||
|
||||
class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
||||
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',)
|
||||
add_fields = ('account', 'type', 'status', 'due_on', 'comments')
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('number', 'account_link', 'type', 'status', 'due_on',
|
||||
'comments'),
|
||||
'fields': ('number', 'display_total', 'account_link', 'type',
|
||||
'status', 'due_on', 'comments'),
|
||||
}),
|
||||
(_("Raw"), {
|
||||
'classes': ('collapse',),
|
||||
|
@ -60,11 +68,21 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
|||
)
|
||||
change_view_actions = [generate_bill]
|
||||
change_readonly_fields = ('account_link', 'type', 'status')
|
||||
readonly_fields = ('number',)
|
||||
readonly_fields = ('number', 'display_total')
|
||||
inlines = [BillLineInline]
|
||||
|
||||
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):
|
||||
bill_type = bill.type.lower()
|
||||
url = reverse('admin:bills_%s_changelist' % bill_type)
|
||||
|
@ -93,7 +111,13 @@ class BillAdmin(AccountAdminMixin, ExtendedModelAdmin):
|
|||
if db_field.name == 'html':
|
||||
kwargs['widget'] = forms.Textarea(attrs={'cols': 150, 'rows': 20})
|
||||
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(Invoice, BillAdmin)
|
||||
|
|
|
@ -8,6 +8,7 @@ from django.utils.translation import ugettext_lazy as _
|
|||
|
||||
from orchestra.apps.accounts.models import Account
|
||||
from orchestra.core import accounts
|
||||
from orchestra.utils.functional import cached
|
||||
|
||||
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):
|
||||
self.set_number()
|
||||
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):
|
||||
|
@ -176,11 +196,11 @@ class BaseBillLine(models.Model):
|
|||
bill = models.ForeignKey(Bill, verbose_name=_("bill"),
|
||||
related_name='%(class)ss')
|
||||
description = models.CharField(_("description"), max_length=256)
|
||||
initial_date = models.DateTimeField()
|
||||
final_date = models.DateTimeField()
|
||||
price = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
rate = models.DecimalField(_("rate"), blank=True, null=True,
|
||||
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:
|
||||
abstract = True
|
||||
|
@ -188,7 +208,7 @@ class BaseBillLine(models.Model):
|
|||
def __unicode__(self):
|
||||
return "#%i" % self.number
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def number(self):
|
||||
lines = type(self).objects.filter(bill=self.bill_id)
|
||||
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)
|
||||
|
||||
|
||||
class SubBillLine(models.Model):
|
||||
class BillSubline(models.Model):
|
||||
""" Subline used for describing an item discount """
|
||||
bill_line = models.ForeignKey(BillLine, verbose_name=_("bill line"),
|
||||
related_name='sublines')
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
</div>
|
||||
<div id="total">
|
||||
<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 id="bill-date">
|
||||
<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-quantity">{{ line.amount|default:" " }}</span>
|
||||
<span class="value column-rate">{% if line.rate %}{{ line.rate }} &{{ currency.lower }};{% else %} {% endif %}</span>
|
||||
<span class="value column-subtotal">{{ line.price }} &{{ currency.lower }};</span>
|
||||
<span class="value column-subtotal">{{ line.total }} &{{ currency.lower }};</span>
|
||||
<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="totals">
|
||||
<br> <br>
|
||||
<span class="subtotal column-title">subtotal</span>
|
||||
<span class="subtotal column-value">{{ bill.subtotal }} &{{ currency.lower }};</span>
|
||||
<br>
|
||||
<span class="tax column-title">tax</span>
|
||||
<span class="tax column-value">{{ bill.taxes }} &{{ currency.lower }};</span>
|
||||
<br>
|
||||
{% for tax, subtotal in bill.get_subtotals.iteritems %}
|
||||
<span class="subtotal column-title">subtotal {{ tax }}% VAT</span>
|
||||
<span class="subtotal column-value">{{ subtotal | first }} &{{ currency.lower }};</span>
|
||||
<br>
|
||||
<span class="tax column-title">taxes {{ tax }}% VAT</span>
|
||||
<span class="tax column-value">{{ subtotal | last }} &{{ currency.lower }};</span>
|
||||
<br>
|
||||
{% endfor %}
|
||||
<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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -97,7 +97,7 @@ class ContactInline(InvoiceContactInline):
|
|||
|
||||
def has_invoice(account):
|
||||
try:
|
||||
account.invoicecontact.get()
|
||||
account.invoicecontact
|
||||
except InvoiceContact.DoesNotExist:
|
||||
return False
|
||||
return True
|
||||
|
|
|
@ -70,7 +70,7 @@ class BillSelectedOrders(object):
|
|||
msg = _("Selected orders do not have pending billing")
|
||||
self.modeladmin.message_user(request, msg, messages.WARNING)
|
||||
else:
|
||||
ids = ','.join([bill.id for bill in bills])
|
||||
ids = ','.join([str(bill.id) for bill in bills])
|
||||
url = reverse('admin:bills_bill_changelist')
|
||||
context = {
|
||||
'url': url + '?id=%s' % ids,
|
||||
|
|
|
@ -78,9 +78,9 @@ class ServiceAdmin(admin.ModelAdmin):
|
|||
class OrderAdmin(AccountAdminMixin, ChangeListDefaultFilter, admin.ModelAdmin):
|
||||
list_display = (
|
||||
'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',)
|
||||
actions = (BillSelectedOrders(),)
|
||||
date_hierarchy = 'registered_on'
|
||||
|
@ -90,6 +90,7 @@ class OrderAdmin(AccountAdminMixin, ChangeListDefaultFilter, admin.ModelAdmin):
|
|||
|
||||
content_object_link = admin_link('content_object', order=False)
|
||||
display_registered_on = admin_date('registered_on')
|
||||
display_billed_until = admin_date('billed_until')
|
||||
display_cancelled_on = admin_date('cancelled_on')
|
||||
|
||||
def get_queryset(self, request):
|
||||
|
|
42
orchestra/apps/orders/backends.py
Normal file
42
orchestra/apps/orders/backends.py
Normal 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,
|
||||
)
|
|
@ -1,6 +1,7 @@
|
|||
import calendar
|
||||
import datetime
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from dateutil import relativedelta
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
@ -8,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _
|
|||
|
||||
from orchestra.utils import plugins
|
||||
|
||||
from . import settings
|
||||
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)
|
||||
|
||||
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:
|
||||
bp = options.get('billing_point', timezone.now().date())
|
||||
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
|
||||
if self.payment_style is self.PREPAY:
|
||||
date += relativedelta(months=1)
|
||||
if self.billing_point is self.ON_REGISTER:
|
||||
if self.payment_style == self.PREPAY:
|
||||
date += relativedelta.relativedelta(months=1)
|
||||
if self.billing_point == self.ON_REGISTER:
|
||||
day = order.registered_on.day
|
||||
elif self.billing_point is self.FIXED_DATE:
|
||||
elif self.billing_point == self.FIXED_DATE:
|
||||
day = 1
|
||||
else:
|
||||
raise NotImplementedError(msg)
|
||||
bp = datetime.datetime(year=date.year, month=date.month,
|
||||
day=day, tzinfo=timezone.get_current_timezone())
|
||||
elif self.billing_period is self.ANUAL:
|
||||
if self.billing_point is self.ON_REGISTER:
|
||||
elif self.billing_period == self.ANUAL:
|
||||
if self.billing_point == self.ON_REGISTER:
|
||||
month = order.registered_on.month
|
||||
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
|
||||
day = 1
|
||||
else:
|
||||
raise NotImplementedError(msg)
|
||||
year = bp.year
|
||||
if self.payment_style is self.POSTPAY:
|
||||
year = bo.year - relativedelta(years=1)
|
||||
if self.payment_style == self.POSTPAY:
|
||||
year = bo.year - relativedelta.relativedelta(years=1)
|
||||
if bp.month >= month:
|
||||
year = bp.year + 1
|
||||
bp = datetime.datetime(year=year, month=month, day=day,
|
||||
tzinfo=timezone.get_current_timezone())
|
||||
elif self.billing_period is self.NEVER:
|
||||
elif self.billing_period == self.NEVER:
|
||||
bp = order.registered_on
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
"Support for '%s' billing period and '%s' billing point is not implemented"
|
||||
% (self.display_billing_period(), self.display_billing_point())
|
||||
)
|
||||
if self.on_cancel is not self.NOTHING and order.cancelled_on < bp:
|
||||
raise NotImplementedError(msg)
|
||||
if self.on_cancel != self.NOTHING and order.cancelled_on and order.cancelled_on < bp:
|
||||
return order.cancelled_on
|
||||
return bp
|
||||
|
||||
def get_pricing_size(self, ini, end):
|
||||
rdelta = relativedelta.relativedelta(end, ini)
|
||||
if self.get_pricing_period() is self.MONTHLY:
|
||||
if self.get_pricing_period() == self.MONTHLY:
|
||||
size = rdelta.months
|
||||
days = calendar.monthrange(bp.year, bp.month)[1]
|
||||
size += float(bp.day)/days
|
||||
elif self.get_pricint_period() is self.ANUAL:
|
||||
days = calendar.monthrange(end.year, end.month)[1]
|
||||
size += float(rdelta.days)/days
|
||||
elif self.get_pricing_period() == self.ANUAL:
|
||||
size = rdelta.years
|
||||
size += float(rdelta.days)/365
|
||||
elif self.get_pricing_period() is self.NEVER:
|
||||
days = 366 if calendar.isleap(end.year) else 365
|
||||
size += float((end-ini).days)/days
|
||||
elif self.get_pricing_period() == self.NEVER:
|
||||
size = 1
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
@ -108,11 +114,11 @@ class ServiceHandler(plugins.Plugin):
|
|||
|
||||
def get_pricing_slots(self, ini, end):
|
||||
period = self.get_pricing_period()
|
||||
if period is self.MONTHLY:
|
||||
rdelta = relativedelta(months=1)
|
||||
elif period is self.ANUAL:
|
||||
rdelta = relativedelta(years=1)
|
||||
elif period is self.NEVER:
|
||||
if period == self.MONTHLY:
|
||||
rdelta = relativedelta.relativedelta(months=1)
|
||||
elif period == self.ANUAL:
|
||||
rdelta = relativedelta.relativedelta(years=1)
|
||||
elif period == self.NEVER:
|
||||
yield ini, end
|
||||
raise StopIteration
|
||||
else:
|
||||
|
@ -125,51 +131,107 @@ class ServiceHandler(plugins.Plugin):
|
|||
yield 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
|
||||
discounts = []
|
||||
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):
|
||||
# Perform compensations on cancelled services
|
||||
# TODO WTF to do with day 1 of each month.
|
||||
if self.on_cancel in (Order.COMPENSATE, Order.REFOUND):
|
||||
# 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)
|
||||
# compensate(orders, **options)
|
||||
# TODO create discount per compensation
|
||||
bp = None
|
||||
lines = []
|
||||
for order in orders:
|
||||
bp = self.get_billing_point(order, bp=bp, **options)
|
||||
ini = order.billed_until or order.registered_on
|
||||
if bp < ini:
|
||||
if bp <= ini:
|
||||
continue
|
||||
if not self.metric:
|
||||
# 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)
|
||||
if self.orders_effect is self.REGISTER_OR_RENEW:
|
||||
events = get_register_or_renew_events(porders, 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)
|
||||
price = self.get_price_with_orders(order, size, ini, bp)
|
||||
lines.append(self.create_line(order, price, size, ini, bp))
|
||||
else:
|
||||
# weighted metric; bill line per pricing period
|
||||
for ini, end in self.get_pricing_slots(ini, bp):
|
||||
metric = order.get_metric(ini, end)
|
||||
size = self.get_pricing_size(ini, end)
|
||||
price = self.get_rate(metric, account) * size
|
||||
lines += self.create_line(order, price, size)
|
||||
price = self.get_price_with_metric(order, size, ini, end)
|
||||
lines.append(self.create_line(order, price, size, ini, end))
|
||||
order.billed_until = bp
|
||||
order.save() # TODO if commit
|
||||
return lines
|
||||
|
||||
def compensate(self, orders):
|
||||
# num orders and weights
|
||||
# Discounts
|
||||
pass
|
||||
# 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
|
||||
|
|
|
@ -37,7 +37,7 @@ def get_related_objects(origin, max_depth=2):
|
|||
queue.append(new_models)
|
||||
|
||||
def get_register_or_cancel_events(porders, ini, end):
|
||||
assert ini > end, "ini > end"
|
||||
assert ini <= end, "ini > end"
|
||||
CANCEL = 'cancel'
|
||||
REGISTER = 'register'
|
||||
changes = {}
|
||||
|
@ -50,21 +50,22 @@ def get_register_or_cancel_events(porders, ini, end):
|
|||
if cancel > ini and cancel < end:
|
||||
changes.setdefault(cancel, [])
|
||||
changes[cancel].append(CANCEL)
|
||||
if order.registered_on < ini:
|
||||
if order.registered_on <= ini:
|
||||
counter += 1
|
||||
elif order.registered_on < end:
|
||||
changes.setdefault(order.registered_on, [])
|
||||
changes[order.registered_on].append(REGISTER)
|
||||
pointer = ini
|
||||
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]:
|
||||
if change is CANCEL:
|
||||
counter -= 1
|
||||
else:
|
||||
counter += 1
|
||||
yield counter, (date-pointer).days/total
|
||||
pointer = date
|
||||
yield counter, (end-pointer).days/total
|
||||
|
||||
|
||||
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):
|
||||
counter = 0
|
||||
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
|
||||
elif order.billed_until > send or order.cancelled_on > send:
|
||||
counter += 1
|
||||
|
|
|
@ -84,7 +84,7 @@ class Service(models.Model):
|
|||
metric = models.CharField(_("metric"), max_length=256, blank=True,
|
||||
help_text=_("Metric used to compute the pricing rate. "
|
||||
"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)
|
||||
pricing_period = models.CharField(_("pricing period"), max_length=16,
|
||||
help_text=_("Period used for calculating the metric used on the "
|
||||
|
@ -163,6 +163,11 @@ class Service(models.Model):
|
|||
),
|
||||
default=NEVER)
|
||||
|
||||
@property
|
||||
def nominal_price(self):
|
||||
# FIXME delete and make it a model field
|
||||
return 10
|
||||
|
||||
def __unicode__(self):
|
||||
return self.description
|
||||
|
||||
|
@ -215,24 +220,29 @@ class Service(models.Model):
|
|||
msg = "{0} {1}: {2}".format(attr, name, message)
|
||||
raise ValidationError(msg)
|
||||
|
||||
def get_nominal_price(self, order):
|
||||
""" returns the price of an item """
|
||||
|
||||
def get_pricing_period(self):
|
||||
if self.pricing_period == self.BILLING_PERIOD:
|
||||
return self.billing_period
|
||||
return self.pricing_period
|
||||
|
||||
def get_price(self, order, amount='TODO'):
|
||||
pass
|
||||
def get_rate(self, order, metric):
|
||||
# TODO implement
|
||||
return 12
|
||||
|
||||
|
||||
class OrderQuerySet(models.QuerySet):
|
||||
group_by = queryset.group_by
|
||||
|
||||
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 = []
|
||||
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)
|
||||
helpers.create_bills(account, bill_lines)
|
||||
bills += bill_backend.create_bills(account, bill_lines)
|
||||
return bills
|
||||
|
||||
def get_related(self):
|
||||
pass
|
||||
|
@ -259,10 +269,10 @@ class Order(models.Model):
|
|||
object_id = models.PositiveIntegerField(null=True)
|
||||
service = models.ForeignKey(Service, verbose_name=_("service"),
|
||||
related_name='orders')
|
||||
registered_on = models.DateTimeField(_("registered on"), auto_now_add=True)
|
||||
cancelled_on = models.DateTimeField(_("cancelled on"), null=True, blank=True)
|
||||
billed_on = models.DateTimeField(_("billed on"), null=True, blank=True)
|
||||
billed_until = models.DateTimeField(_("billed until"), null=True, blank=True)
|
||||
registered_on = models.DateField(_("registered on"), auto_now_add=True)
|
||||
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)
|
||||
ignore = models.BooleanField(_("ignore"), default=False)
|
||||
description = models.TextField(_("description"), blank=True)
|
||||
|
||||
|
@ -302,16 +312,26 @@ class Order(models.Model):
|
|||
elif orders:
|
||||
orders.get().cancel()
|
||||
|
||||
@classmethod
|
||||
def get_bill_backend(cls):
|
||||
# TODO
|
||||
from .backends import BillsBackend
|
||||
return BillsBackend()
|
||||
|
||||
def cancel(self):
|
||||
self.cancelled_on = timezone.now()
|
||||
self.save()
|
||||
|
||||
def get_metric(self, ini, end):
|
||||
# TODO implement
|
||||
return 10
|
||||
|
||||
|
||||
class MetricStorage(models.Model):
|
||||
order = models.ForeignKey(Order, verbose_name=_("order"))
|
||||
value = models.BigIntegerField(_("value"))
|
||||
created_on = models.DateTimeField(_("created on"), auto_now_add=True)
|
||||
updated_on = models.DateTimeField(_("updated on"), auto_now=True)
|
||||
created_on = models.DateField(_("created on"), auto_now_add=True)
|
||||
updated_on = models.DateField(_("updated on"), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
get_latest_by = 'created_on'
|
||||
|
|
|
@ -131,7 +131,7 @@ function install_requirements () {
|
|||
wkhtmltopdf \
|
||||
xvfb"
|
||||
|
||||
PIP="django==1.6.1 \
|
||||
PIP="django==1.7 \
|
||||
django-celery-email==1.0.4 \
|
||||
django-fluent-dashboard==0.3.5 \
|
||||
https://bitbucket.org/izi/django-admin-tools/get/a0abfffd76a0.zip \
|
||||
|
|
|
@ -5,19 +5,27 @@ from django.utils.translation import ungettext, ugettext as _
|
|||
|
||||
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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 = (
|
||||
|
@ -48,29 +56,34 @@ def naturaldate(date, include_seconds=False):
|
|||
minutes = delta.seconds / 60
|
||||
seconds = delta.seconds
|
||||
|
||||
ago = ' ago'
|
||||
ahead = ''
|
||||
if days < 0:
|
||||
return _('just now')
|
||||
ago = ''
|
||||
ahead = 'in '
|
||||
days = abs(days)
|
||||
|
||||
if days == 0:
|
||||
if hours == 0:
|
||||
if minutes > 0:
|
||||
minutes += float(seconds)/60
|
||||
return ungettext(
|
||||
_('{minutes:.1f} minute ago'),
|
||||
_('{minutes:.1f} minutes ago'), minutes
|
||||
).format(minutes=minutes)
|
||||
_('{ahead}{minutes:.1f} minute{ago}'),
|
||||
_('{ahead}{minutes:.1f} minutes{ago}'), minutes
|
||||
).format(minutes=minutes, ago=ago, ahead=ahead)
|
||||
else:
|
||||
if include_seconds and seconds:
|
||||
return ungettext(
|
||||
_('{seconds} second ago'),
|
||||
_('{seconds} seconds ago'), seconds
|
||||
).format(seconds=seconds)
|
||||
_('{ahead}{seconds} second{ago}'),
|
||||
_('{ahead}{seconds} seconds{ago}'), seconds
|
||||
).format(seconds=seconds, ago=ago, ahead=ahead)
|
||||
return _('just now')
|
||||
else:
|
||||
hours += float(minutes)/60
|
||||
return ungettext(
|
||||
_('{hours:.1f} hour ago'), _('{hours:.1f} hours ago'), hours
|
||||
).format(hours=hours)
|
||||
_('{ahead}{hours:.1f} hour{ago}'),
|
||||
_('{ahead}{hours:.1f} hours{ago}'), hours
|
||||
).format(hours=hours, ago=ago, ahead=ahead)
|
||||
|
||||
if delta_midnight.days == 0:
|
||||
return _('yesterday at {time}').format(time=date.strftime('%H:%M'))
|
||||
|
@ -80,8 +93,9 @@ def naturaldate(date, include_seconds=False):
|
|||
if days < 7.0:
|
||||
count = days + float(hours)/24
|
||||
fmt = pluralize_day(count)
|
||||
return fmt.format(num=count)
|
||||
return fmt.format(num=count, ago=ago, ahead=ahead)
|
||||
if days >= chunk:
|
||||
count = (delta_midnight.days + 1) / chunk
|
||||
count = abs(count)
|
||||
fmt = pluralizefun(count)
|
||||
return fmt.format(num=count)
|
||||
return fmt.format(num=count, ago=ago, ahead=ahead)
|
||||
|
|
Loading…
Reference in a new issue