invoicing: add invoice amount statistics (#83595)
gitea/lingo/pipeline/head This commit looks good Details

This commit is contained in:
Valentin Deniaud 2023-11-20 17:04:00 +01:00
parent 863194a3d2
commit 919b43df4f
4 changed files with 302 additions and 1 deletions

View File

@ -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)

View File

@ -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'),
]

View File

@ -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()

View File

@ -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']