invoicing: add invoice amount statistics (#83595)
gitea/lingo/pipeline/head This commit looks good
Details
gitea/lingo/pipeline/head This commit looks good
Details
This commit is contained in:
parent
863194a3d2
commit
919b43df4f
|
@ -453,3 +453,22 @@ class BasketLineItemSerializer(serializers.ModelSerializer):
|
|||
'quantity',
|
||||
'unit_amount',
|
||||
]
|
||||
|
||||
|
||||
MEASURE_CHOICES = {
|
||||
'count': _('Invoice count'),
|
||||
'total_amount': _('Total amount'),
|
||||
'paid_amount': _('Paid amount'),
|
||||
'remaining_amount': _('Remaining amount'),
|
||||
}
|
||||
|
||||
|
||||
class StatisticsFiltersSerializer(serializers.Serializer):
|
||||
time_interval = serializers.ChoiceField(choices=('day', _('Day')), default='day')
|
||||
start = serializers.DateTimeField(required=False, input_formats=['iso-8601', '%Y-%m-%d'])
|
||||
end = serializers.DateTimeField(required=False, input_formats=['iso-8601', '%Y-%m-%d'])
|
||||
measures = serializers.ListField(
|
||||
required=False, child=serializers.ChoiceField(choices=list(MEASURE_CHOICES)), default=['total_amount']
|
||||
)
|
||||
regie = serializers.CharField(required=False, allow_blank=False, max_length=256)
|
||||
activity = serializers.CharField(required=False, allow_blank=False, max_length=256)
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
from django.urls import path
|
||||
|
||||
from .views import agendas, basket, invoicing, pricing
|
||||
from .views import agendas, basket, invoicing, pricing, statistics
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
|
@ -119,4 +119,6 @@ urlpatterns = [
|
|||
basket.basket_basket_line_close,
|
||||
name='api-basket-basket-line-close',
|
||||
),
|
||||
path('statistics/', statistics.statistics_list, name='api-statistics-list'),
|
||||
path('statistics/invoice/', statistics.invoice_statistics, name='api-statistics-invoice'),
|
||||
]
|
||||
|
|
|
@ -0,0 +1,142 @@
|
|||
# lingo - payment and billing system
|
||||
# Copyright (C) 2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.db.models import Count, Sum
|
||||
from django.db.models.functions import TruncDay
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext_noop as N_
|
||||
from django.utils.translation import pgettext
|
||||
from rest_framework import permissions
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from lingo.agendas.models import Agenda
|
||||
from lingo.api.serializers import MEASURE_CHOICES, StatisticsFiltersSerializer
|
||||
from lingo.api.utils import APIErrorBadRequest, Response
|
||||
from lingo.invoicing.models import Invoice, Regie
|
||||
|
||||
|
||||
class StatisticsList(APIView):
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
regie_options = [{'id': '_all', 'label': pgettext('regies', 'All')}] + [
|
||||
{'id': x.slug, 'label': x.label} for x in Regie.objects.all()
|
||||
]
|
||||
activity_options = [{'id': '_all', 'label': pgettext('activity', 'All')}] + [
|
||||
{'id': x.slug, 'label': x.label} for x in Agenda.objects.all()
|
||||
]
|
||||
|
||||
return Response(
|
||||
{
|
||||
'data': [
|
||||
{
|
||||
'name': _('Invoice'),
|
||||
'url': request.build_absolute_uri(reverse('api-statistics-invoice')),
|
||||
'id': 'invoice',
|
||||
'filters': [
|
||||
{
|
||||
'id': 'time_interval',
|
||||
'label': _('Interval'),
|
||||
'options': [{'id': 'day', 'label': _('Day')}],
|
||||
'required': True,
|
||||
'default': 'day',
|
||||
},
|
||||
{
|
||||
'id': 'measures',
|
||||
'label': _('Measures'),
|
||||
'options': [{'id': x, 'label': y} for x, y in MEASURE_CHOICES.items()],
|
||||
'required': True,
|
||||
'multiple': True,
|
||||
'default': 'total_amount',
|
||||
},
|
||||
{
|
||||
'id': 'regie',
|
||||
'label': _('Regie'),
|
||||
'options': regie_options,
|
||||
'required': True,
|
||||
'default': '_all',
|
||||
},
|
||||
{
|
||||
'id': 'activity',
|
||||
'label': _('Activity'),
|
||||
'options': activity_options,
|
||||
'required': True,
|
||||
'default': '_all',
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
statistics_list = StatisticsList.as_view()
|
||||
|
||||
|
||||
class InvoiceStatistics(APIView):
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
serializer_class = StatisticsFiltersSerializer
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
serializer = self.serializer_class(data=request.query_params)
|
||||
if not serializer.is_valid():
|
||||
raise APIErrorBadRequest(N_('invalid statistics filters'), errors=serializer.errors)
|
||||
data = serializer.validated_data
|
||||
|
||||
invoices = Invoice.objects.all()
|
||||
if 'start' in data:
|
||||
invoices = invoices.filter(date_publication__gte=data['start'])
|
||||
if 'end' in data:
|
||||
invoices = invoices.filter(date_publication__lte=data['end'])
|
||||
|
||||
regie_slug = data.get('regie', '_all')
|
||||
if regie_slug != '_all':
|
||||
invoices = invoices.filter(regie__slug=regie_slug)
|
||||
|
||||
activity_slug = data.get('activity', '_all')
|
||||
if activity_slug != '_all':
|
||||
invoices = invoices.filter(regie__agenda__slug=activity_slug)
|
||||
|
||||
invoices = invoices.annotate(day=TruncDay('date_publication')).values('day').order_by('day')
|
||||
|
||||
aggregates = {}
|
||||
for field in data['measures']:
|
||||
if field == 'count':
|
||||
aggregates['count'] = Count('id')
|
||||
else:
|
||||
aggregates[field] = Sum(field)
|
||||
invoices = invoices.annotate(**aggregates)
|
||||
|
||||
series = []
|
||||
if invoices:
|
||||
for field in data['measures']:
|
||||
series.append(
|
||||
{'label': MEASURE_CHOICES.get(field), 'data': [invoice[field] for invoice in invoices]}
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'data': {
|
||||
'x_labels': [invoice['day'].strftime('%Y-%m-%d') for invoice in invoices],
|
||||
'series': series,
|
||||
},
|
||||
'err': 0,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
invoice_statistics = InvoiceStatistics.as_view()
|
|
@ -0,0 +1,138 @@
|
|||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from lingo.agendas.models import Agenda
|
||||
from lingo.invoicing.models import Invoice, InvoiceLine, InvoiceLinePayment, Payment, PaymentType, Regie
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_statistics_list(app, user):
|
||||
regie = Regie.objects.create(label='Regie 1')
|
||||
agenda = Agenda.objects.create(label='Activity 1', regie=regie)
|
||||
|
||||
regie2 = Regie.objects.create(label='Regie 2')
|
||||
|
||||
# unauthorized
|
||||
app.get('/api/statistics/', status=403)
|
||||
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
resp = app.get('/api/statistics/')
|
||||
regie_filter = [x for x in resp.json['data'][0]['filters'] if x['id'] == 'regie'][0]
|
||||
assert regie_filter['options'] == [
|
||||
{'id': '_all', 'label': 'All'},
|
||||
{'id': 'regie-1', 'label': 'Regie 1'},
|
||||
{'id': 'regie-2', 'label': 'Regie 2'},
|
||||
]
|
||||
|
||||
activity_filter = [x for x in resp.json['data'][0]['filters'] if x['id'] == 'activity'][0]
|
||||
assert activity_filter['options'] == [
|
||||
{'id': '_all', 'label': 'All'},
|
||||
{'id': 'activity-1', 'label': 'Activity 1'},
|
||||
]
|
||||
|
||||
|
||||
def test_statistics_invoice(app, user):
|
||||
app.get('/api/statistics/invoice/', status=403)
|
||||
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
resp = app.get('/api/statistics/invoice/', status=200)
|
||||
assert resp.json['data']['series'] == []
|
||||
|
||||
regie = Regie.objects.create(label='Regie 1')
|
||||
Agenda.objects.create(label='Activity 1', regie=regie)
|
||||
PaymentType.create_defaults(regie)
|
||||
payment = Payment.objects.create(
|
||||
regie=regie,
|
||||
amount=2,
|
||||
payment_type=PaymentType.objects.get(regie=regie, slug='cash'),
|
||||
)
|
||||
|
||||
regie2 = Regie.objects.create(label='Regie 2')
|
||||
|
||||
for i in range(1, 4):
|
||||
invoice = Invoice.objects.create(
|
||||
date_publication=datetime.date(2022, 10, i),
|
||||
date_payment_deadline=datetime.date.today(),
|
||||
date_due=datetime.date(2022, 10, 31),
|
||||
regie=regie if i == 1 else regie2,
|
||||
)
|
||||
|
||||
for j in range(1, 5):
|
||||
line = InvoiceLine.objects.create(
|
||||
event_date=datetime.date.today(),
|
||||
invoice=invoice,
|
||||
quantity=i,
|
||||
unit_amount=j,
|
||||
)
|
||||
if i == 1:
|
||||
InvoiceLinePayment.objects.create(
|
||||
payment=payment,
|
||||
amount=1,
|
||||
line=line,
|
||||
)
|
||||
|
||||
resp = app.get('/api/statistics/invoice/')
|
||||
assert resp.json['data']['x_labels'] == ['2022-10-01', '2022-10-02', '2022-10-03']
|
||||
assert resp.json['data']['series'] == [{'data': [10, 20, 30], 'label': 'Total amount'}]
|
||||
|
||||
# period filter
|
||||
resp = app.get('/api/statistics/invoice/?start=2022-10-02&end=2022-10-02')
|
||||
assert resp.json['data']['x_labels'] == ['2022-10-02']
|
||||
assert resp.json['data']['series'][0]['data'] == [20]
|
||||
|
||||
# regie filter
|
||||
resp = app.get('/api/statistics/invoice/?regie=regie-1')
|
||||
assert resp.json['data']['x_labels'] == ['2022-10-01']
|
||||
assert resp.json['data']['series'][0]['data'] == [10]
|
||||
|
||||
resp = app.get('/api/statistics/invoice/?regie=regie-2')
|
||||
assert resp.json['data']['x_labels'] == ['2022-10-02', '2022-10-03']
|
||||
assert resp.json['data']['series'][0]['data'] == [20, 30]
|
||||
|
||||
resp = app.get('/api/statistics/invoice/?regie=unknown')
|
||||
assert resp.json['data']['series'] == []
|
||||
|
||||
# activity filter
|
||||
resp = app.get('/api/statistics/invoice/?activity=activity-1')
|
||||
assert resp.json['data']['x_labels'] == ['2022-10-01']
|
||||
assert resp.json['data']['series'][0]['data'] == [10]
|
||||
|
||||
resp = app.get('/api/statistics/invoice/?activity=unknown')
|
||||
assert resp.json['data']['series'] == []
|
||||
|
||||
# count measure
|
||||
invoice = Invoice.objects.create(
|
||||
date_publication=datetime.date(2022, 10, 2),
|
||||
date_payment_deadline=datetime.date.today(),
|
||||
date_due=datetime.date(2022, 10, 31),
|
||||
regie=regie,
|
||||
)
|
||||
|
||||
resp = app.get('/api/statistics/invoice/?measures=count')
|
||||
assert resp.json['data']['x_labels'] == ['2022-10-01', '2022-10-02', '2022-10-03']
|
||||
assert resp.json['data']['series'] == [{'data': [1, 2, 1], 'label': 'Invoice count'}]
|
||||
|
||||
# remaining amount measure
|
||||
resp = app.get('/api/statistics/invoice/?measures=remaining_amount')
|
||||
assert resp.json['data']['x_labels'] == ['2022-10-01', '2022-10-02', '2022-10-03']
|
||||
assert resp.json['data']['series'] == [{'data': [6, 20, 30], 'label': 'Remaining amount'}]
|
||||
|
||||
# paid amount measure
|
||||
resp = app.get('/api/statistics/invoice/?measures=paid_amount')
|
||||
assert resp.json['data']['x_labels'] == ['2022-10-01', '2022-10-02', '2022-10-03']
|
||||
assert resp.json['data']['series'] == [{'data': [4, 0, 0], 'label': 'Paid amount'}]
|
||||
|
||||
# multiple measures
|
||||
resp = app.get('/api/statistics/invoice/?measures=total_amount&measures=count')
|
||||
assert resp.json['data']['x_labels'] == ['2022-10-01', '2022-10-02', '2022-10-03']
|
||||
assert resp.json['data']['series'] == [
|
||||
{'data': [10, 20, 30], 'label': 'Total amount'},
|
||||
{'data': [1, 2, 1], 'label': 'Invoice count'},
|
||||
]
|
||||
|
||||
# invalid measures choice
|
||||
resp = app.get('/api/statistics/invoice/?measures=unknown', status=400)
|
||||
assert resp.json['err'] == 1
|
||||
assert 'measures' in resp.json['errors']
|
Loading…
Reference in New Issue