facture: add invoicing periodicity on contracts (#326)

This commit is contained in:
Benjamin Dauvergne 2021-11-20 18:22:36 +01:00
parent b1f0842647
commit 91679603b1
12 changed files with 417 additions and 10 deletions

View File

@ -24,11 +24,12 @@ from django.conf.urls import url
from django.contrib import admin
from django.contrib.admin.options import BaseModelAdmin
from django.contrib.humanize.templatetags.humanize import ordinal
from django.db import transaction
from django.db.models import TextField
from django.forms import Textarea
from django.forms.models import BaseInlineFormSet
from django.shortcuts import render
from django.urls import reverse
from django.urls import path, reverse
from django.utils.html import format_html
from django.utils.six import BytesIO
@ -224,12 +225,66 @@ class ContratAdmin(LookupAllowed, admin.ModelAdmin):
return render(request, "admin/eo_facture/contrat/duplicate.html", context=context)
def facturer_echeance(self, request, object_id):
if request.method != 'POST':
raise http.Http404
contrat = self.get_object(request, object_id)
assert contrat.periodicite
echeance = int(request.POST['echeance'])
numero = None
for i, debut, _ in contrat.periodicite_echeances():
if i == echeance:
numero = i
break
assert numero is not None
with transaction.atomic():
facture, _ = models.Facture.objects.update_or_create(
client=contrat.client,
contrat=contrat,
echeance=debut,
numero_d_echeance=numero,
defaults={
'creator': request.user,
'numero_d_echeance': numero,
},
)
if facture.proforma:
facture.intitule = f'{contrat.intitule} {facture.periode}'
facture.clean()
facture.save()
facture.import_ligne()
return http.HttpResponseRedirect(reverse('admin:eo_facture_facture_change', args=(facture.id,)))
def get_urls(self):
urls = super().get_urls()
duplicate_view = self.admin_site.admin_view(self.duplicate)
my_urls = [url(r'^(.+)/duplicate/$', duplicate_view, name='eo_facture_contrat_duplicate')]
facturer_echeance_view = self.admin_site.admin_view(self.facturer_echeance)
my_urls = [
url(r'^(.+)/duplicate/$', duplicate_view, name='eo_facture_contrat_duplicate'),
path(
'<path:object_id>/facturer-echeance/',
facturer_echeance_view,
name='eo_facture_contrat_facturer_echeance',
),
]
return my_urls + urls
def get_fields(self, request, obj=None):
fields = list(super().get_fields(request, obj=obj))
if obj:
if obj.periodicite:
fields = [field for field in fields if field != 'percentage_per_year']
elif obj.percentage_per_year and len(obj.percentage_per_year) > 1:
fields = [field for field in fields if field != 'periodicite']
return fields
def index(facture):
return format_html('{0}', ordinal(facture.index()))
@ -272,7 +327,7 @@ class FactureAdmin(LookupAllowed, admin.ModelAdmin):
'montant_ttc',
'account_on_previous_period',
]
readonly_fields = ['creator', 'solde', 'ordre', 'montant', 'montant_ttc']
readonly_fields = ['creator', 'solde', 'ordre', 'montant', 'montant_ttc', 'periode']
date_hierarchy = 'emission'
list_select_related = True
save_on_top = True
@ -296,6 +351,12 @@ class FactureAdmin(LookupAllowed, admin.ModelAdmin):
column_solde.short_description = 'Solde'
def has_delete_permission(self, request, obj=None):
# ne pas supprimer les factures émises
if obj and not obj.proforma:
return False
return super().has_delete_permission(request, obj=obj)
def get_queryset(self, request):
from django.db import connection
@ -396,8 +457,9 @@ class FactureAdmin(LookupAllowed, admin.ModelAdmin):
return my_urls + urls
def show_client(self, obj):
url = reverse('admin:eo_facture_client_change', args=[obj.client.id])
return format_html('<a href="{0}">{1}</a>', url, obj.client)
if obj.client:
url = reverse('admin:eo_facture_client_change', args=[obj.client.id])
return format_html('<a href="{0}">{1}</a>', url, obj.client)
show_client.short_description = 'Client'
@ -431,6 +493,18 @@ class FactureAdmin(LookupAllowed, admin.ModelAdmin):
changelist.pk_attname = 'pk_or_code'
return changelist
def get_fields(self, request, obj=None):
fields = list(super().get_fields(request, obj=obj))
if not obj or not obj.contrat or obj.contrat.periodicite:
fields += ['numero_d_echeance', 'periode']
return fields
def get_readonly_fields(self, request, obj=None):
fields = list(super().get_readonly_fields(request, obj=obj))
if obj and obj.contrat and obj.contrat.periodicite:
fields += ['periode']
return fields
class PaymentAdmin(LookupAllowed, admin.ModelAdmin, CommonPaymentInline):
form = forms.PaymentForm

View File

@ -165,18 +165,19 @@ class FactureForm(forms.ModelForm):
# réinitialiser la date d'émission quand une facture quitte le statut
# proforma, sauf si une date d'émission spécifique a été fixée à la
# main
cleaned_data = super().clean()
update_echeance = False
if self.instance.proforma and 'proforma' in self.changed_data and 'emission' not in self.changed_data:
self.cleaned_data['emission'] = models.today()
cleaned_data['emission'] = models.today()
update_echeance = True
if (
self.cleaned_data.get('emission')
cleaned_data.get('emission')
and 'emission' in self.changed_data
and 'echeance' not in self.changed_data
):
update_echeance = True
if update_echeance:
self.cleaned_data['echeance'] = self.cleaned_data['emission'] + datetime.timedelta(
cleaned_data['echeance'] = cleaned_data['emission'] + datetime.timedelta(
days=models.DELAI_PAIEMENT
)

View File

@ -0,0 +1,43 @@
# Generated by Django 2.2.24 on 2021-11-20 17:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('eo_facture', '0014_facture_annulation'),
]
operations = [
migrations.AddField(
model_name='contrat',
name='periodicite',
field=models.CharField(
blank=True,
choices=[('annuelle', 'Annuelle'), ('semestrielle', 'Semestrielle')],
max_length=16,
null=True,
verbose_name='Périodicité',
),
),
migrations.AddField(
model_name='contrat',
name='periodicite_debut',
field=models.DateField(blank=True, null=True, verbose_name='Périodicité début'),
),
migrations.AddField(
model_name='contrat',
name='periodicite_fin',
field=models.DateField(blank=True, null=True, verbose_name='Périodicité fin'),
),
migrations.AddField(
model_name='facture',
name='numero_d_echeance',
field=models.IntegerField(blank=True, null=True, verbose_name="Numéro d'échéance"),
),
migrations.AlterUniqueTogether(
name='facture',
unique_together={('contrat', 'numero_d_echeance')},
),
]

View File

@ -16,15 +16,17 @@
import datetime
import itertools
import os.path
from collections import defaultdict
from decimal import ROUND_HALF_UP, Decimal
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator, validate_email
from django.db import models
from django.db import models, transaction
from django.db.models import F, Q, Sum
from django.db.models.query import QuerySet
from django.db.models.signals import post_delete, post_save
@ -119,11 +121,26 @@ class Contrat(models.Model):
tva = models.DecimalField(max_digits=8, decimal_places=2, default=Decimal(DEFAULT_TVA))
creation = models.DateField(default=today)
creator = models.ForeignKey(User, verbose_name='Créateur', on_delete=models.CASCADE)
percentage_per_year = fields.PercentagePerYearField(default=one_hundred_percent_this_year)
percentage_per_year = fields.PercentagePerYearField(
default=one_hundred_percent_this_year, verbose_name='Pourcentage par année'
)
montant_sous_traite = models.DecimalField(max_digits=8, decimal_places=2, default=Decimal('0'))
image = models.ImageField('Image', upload_to='images/', blank=True, null=True)
numero_marche = models.CharField(max_length=128, verbose_name='Numéro du marché', blank=True, default='')
periodicite = models.CharField(
verbose_name='Périodicité',
max_length=16,
choices=[
('annuelle', 'Annuelle'),
('semestrielle', 'Semestrielle'),
],
blank=True,
null=True,
)
periodicite_debut = models.DateField(verbose_name='Périodicité début', blank=True, null=True)
periodicite_fin = models.DateField(verbose_name='Périodicité fin', blank=True, null=True)
tags = taggit.TaggableManager(blank=True)
def montant_facture(self):
@ -156,6 +173,73 @@ class Contrat(models.Model):
def clean(self):
self.numero_marche = self.numero_marche.strip()
if self.periodicite and not self.periodicite_debut:
raise ValidationError(
{'periodicite_debut': 'Vous devez définir une date de début pour la période.'}
)
if self.periodicite_debut and not self.periodicite:
raise ValidationError({'periodicite': 'Vous devez définir une périodicité.'})
if self.periodicite_fin and not self.periodicite:
raise ValidationError({'periodicite': 'Vous devez définir une périodicité.'})
if self.periodicite and len(self.percentage_per_year) > 1:
raise ValidationError(
'Vous ne pouvez pas utiliser pourcentage par année en même temps que périodicité.'
)
@property
def periodicite_duration(self):
if self.periodicite == 'annuelle':
return relativedelta(years=1)
if self.periodicite == 'semestrielle':
return relativedelta(months=6)
raise ValueError
def periodicite_dates(self):
if self.periodicite == 'annuelle':
durations = (relativedelta(years=i) for i in itertools.count())
elif self.periodicite == 'semestrielle':
durations = (relativedelta(months=6 * i) for i in itertools.count())
else:
raise ValueError('aucune échéance')
dates = (self.periodicite_debut + duration for duration in durations)
if self.periodicite_fin:
return itertools.takewhile(lambda date: date < self.periodicite_fin, dates)
else:
return dates
def periodicite_nombre_d_echeances(self):
if not self.periodicite_fin:
return '*'
return len(list(self.periodicite_echeances(limit=1000)))
def periodicite_echeances(self, until=None, limit=3):
i = 1
count = 0
n = now().date()
for day in self.periodicite_dates():
if until and until < day:
break
periode_fin = day + self.periodicite_duration
if self.periodicite_fin and periode_fin > self.periodicite_fin:
break
if day > n:
count += 1
yield i, day, periode_fin
i += 1
if count == limit:
break
def save(self, *args, **kwargs):
with transaction.atomic(savepoint=False):
if not self.periodicite:
# supprimer les échéances des factures si pas de periodicite
self.factures.update(numero_d_echeance=None)
return super().save(*args, **kwargs)
def __str__(self): # pylint: disable=invalid-str-returned
return self.intitule
@ -257,6 +341,11 @@ class Facture(models.Model):
verbose_name='Notes privées',
help_text='À usage purement interne, ne seront jamais présentes sur la facture',
)
numero_d_echeance = models.IntegerField(
verbose_name='Numéro d\'échéance',
blank=True,
null=True,
)
objects = FactureQuerySet.as_manager()
@ -300,9 +389,30 @@ class Facture(models.Model):
raise ValidationError("Le client de la facture et du contrat doivent être identiques.")
else:
self.client = self.contrat.client
if self.contrat.periodicite:
if self.numero_d_echeance is None:
raise ValidationError(
{
'numero_d_echeance': 'Vous devez définir un numéro d\'échéance car le contrat est périodique.'
}
)
for i, dummy, dummy in self.contrat.periodicite_echeances():
if i == self.numero_d_echeance:
break
else:
raise ValidationError('Numéro d\'échéance invalide')
else:
if self.numero_d_echeance is not None:
raise ValidationError(
'Un numéro d\'échéance ne peut être défini que pour un contrat récurrent.'
)
else:
if not self.intitule:
raise ValidationError("La facture doit avoir un intitulé")
if self.numero_d_echeance is not None:
raise ValidationError(
'Un numéro d\'échéance ne peut être défini que pour un contrat récurrent.'
)
if not self.proforma:
try:
for ligne in self.lignes.all():
@ -444,8 +554,36 @@ class Facture(models.Model):
filename = f'{self.code()}-{self.client.nom}{avoir}.pdf'
return filename
@transaction.atomic(savepoint=False)
def import_ligne(self):
for prestation in self.contrat.prestations.all():
ligne, _ = Ligne.objects.update_or_create(
facture=self,
intitule=prestation.intitule,
defaults={
'prix_unitaire_ht': prestation.prix_unitaire_ht,
'quantite': prestation.quantite,
},
)
ligne.clean()
def periode(self):
if self.contrat and self.contrat.periodicite and self.numero_d_echeance is not None:
for i, debut, fin in self.contrat.periodicite_echeances():
if i == self.numero_d_echeance:
debut = debut.strftime('%d/%m/%Y')
fin = fin.strftime('%d/%m/%Y')
return f'du {debut} au {fin}'
periode.short_description = 'Dates de l\'échéance'
periode = property(periode)
class Meta:
ordering = ("-id",)
unique_together = [
('contrat', 'numero_d_echeance'),
]
class Ligne(models.Model):

View File

@ -6,11 +6,29 @@
{% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %}
<ul class="object-tools"><li><a href="{{ history_url }}" class="historylink">{% trans "History" %}</a></li>
{% if has_absolute_url %}<li><a href="../../../r/{{ content_type_id }}/{{ object_id }}/" class="viewsitelink">{% trans "View on site" %}</a></li>{% endif%}
{% if not original.periodicite %}
<li><a href="{% url "admin:eo_facture_contrat_duplicate" original.id %}">Dupliquer</a></li>
{% endif %}
<li><a href="{% url "admin:eo_facture_facture_changelist" %}?contrat={{ original.id }}">Factures</a></li>
{% if not original.periodicite %}
<li><a href="{% url "admin:eo_facture_facture_add" %}?contrat={{ original.id }}&client={{ original.client.id }}&taux_tva={{ original.tva }}">Ajouter une facture</a></li>
<li><a href="{% url "admin:eo_facture_facture_add_simple" %}?contrat={{ original.id }}">Ajouter une facture comme pourcentage du total</a></li>
{% endif %}
</ul>
{% endif %}{% endif %}
{% endblock %}
{% block after_field_sets %}
{% if original.periodicite and original.periodicite_debut %}
<fieldset class="module">
<h2>Échéances</h2>
<div>
<ul>
{% for echeance in original.periodicite_echeances %}
<li>du {{ echeance.1 }} au {{ echeance.2 }}</li>
{% endfor %}
</ul>
</div>
</fieldset>
{% endif %}
{% endblock %}

View File

@ -6,7 +6,9 @@
<ul class="object-tools">
{% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %}
<li><a href="{{ history_url }}" class="historylink">{% trans "History" %}</a></li>
{% if original.client %}
<li><a href="{% url "admin:eo_facture_client_change" original.client.id %}" class="historylink">Client</a></li>
{% endif %}
{% if original.contrat %}
<li><a href="{% url "admin:eo_facture_contrat_change" original.contrat.id %}" class="historylink">Contrat</a></li>
{% endif %}

View File

@ -0,0 +1,38 @@
{% load eo_facture %}
{% if echeances %}
<div class="module" id="echeances">
<h2>Contrats récurrents à facturer</h2>
<table class="table">
<thead>
<th class="debut">Date anniversaire</th>
<th class="client">Client</th>
<th class="contrat">Contrat</th>
<th class="occurence">Échéance</th>
<th class="periodicite">Périodicité</th>
<th></th>
</thead>
<tbody>
{% for echeance in echeances %}
<tr>
<td class="debut">{{ echeance.debut }} - {{ echeance.fin }}</td>
<th class="client"><a href="{% url "admin:eo_facture_client_change" echeance.contrat.client.id %}">{{ echeance.contrat.client }}</a></th>
<td class="contrat">
<a href="{% url "admin:eo_facture_contrat_change" echeance.contrat.id %}">
{{ echeance.contrat.intitule }}
<a/>
</td>
<td class="occurence">{{ echeance.occurrence }} / {{ echeance.contrat.periodicite_nombre_d_echeances }}</td>
<td class="periodicite">{{ echeance.contrat.get_periodicite_display }}</td>
<td>
<form method="post" action="{% url "admin:eo_facture_contrat_facturer_echeance" echeance.contrat.id %}">
{% csrf_token %}
<input type="hidden" name="echeance" value="{{ echeance.occurrence }}"/>
<input type="submit" value="Facturer" style="padding: 2px 3px"/>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}

View File

@ -19,6 +19,7 @@ from collections import defaultdict
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation
from dateutil.relativedelta import relativedelta
from django import template
from django.db import transaction
from django.db.models.signals import post_delete, post_save
@ -27,6 +28,7 @@ from django.urls import reverse
from django.utils.formats import number_format
from django.utils.six import text_type
from django.utils.timesince import timesince
from django.utils.timezone import now
from eo_gestion.eo_banque.models import LigneBanquePop
from eo_gestion.eo_facture.models import DELAI_PAIEMENT, Contrat, Facture, Payment
@ -320,3 +322,33 @@ def payment_post_save(raw, **kwargs):
@receiver(post_delete, sender=Payment)
def payment_post_delete(**kwargs):
transaction.on_commit(impayees.recompute)
@register.inclusion_tag('eo_facture/echeances.html')
def echeances():
qs = Contrat.objects.filter(periodicite__isnull=False).prefetch_related('factures')
until = (now() + relativedelta(months=6)).date()
echeances = []
for contrat in qs:
facture_par_numero_d_echeance = {
facture.numero_d_echeance: facture
for facture in contrat.factures.all()
if facture.echeance and not facture.proforma
}
for i, periode_debut, periode_fin in contrat.periodicite_echeances(until=until):
if i in facture_par_numero_d_echeance:
continue
echeances.append((periode_debut, periode_fin, i, contrat))
echeances.sort(key=lambda x: x[:3])
return {
'echeances': [
{
'debut': echeance[0],
'fin': echeance[1],
'occurrence': echeance[2],
'contrat': echeance[3],
}
for echeance in echeances
],
}

View File

@ -6,6 +6,7 @@
{% block content %}
<div id="content-main">
{% echeances %}
{% income_by_clients %}
{% impayees %}
{% a_facturer %}

View File

@ -20,6 +20,7 @@ install_requires = [
'XStatic-Select2',
'gadjo',
'django-mellon',
'python-dateutil',
]

View File

@ -6,3 +6,4 @@ pytest-freezegun
httmock
django-webtest
uwsgidecorators
pyquery

58
tests/test_admin.py Normal file
View File

@ -0,0 +1,58 @@
# barbacompta - invoicing for dummies
# Copyright (C) 2022 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/>.
import pytest
class TestLoggedIn:
@pytest.fixture
def app(self, app):
# login
response = app.get('/').follow()
response.form['username'] = 'admin'
response.form['password'] = 'admin'
response.form.submit()
return app
def test_contrat_recurrent_no_debut(self, app):
response = app.get('/eo_facture/contrat/add/')
response.form['client'].force_value('1')
response.form['intitule'] = 'Contrat 1'
response.form['periodicite'] = 'annuelle'
response = response.form.submit('_continue')
assert len(response.pyquery('.errorlist'))
def test_contrat_recurrent_debut_changed(self, app):
response = app.get('/eo_facture/contrat/add/')
response.form['client'].force_value('1')
response.form['intitule'] = 'Contrat 1'
response.form['periodicite'] = 'annuelle'
response.form['periodicite_debut'] = '2018-12-01'
response = response.form.submit('_continue').follow()
response.form['periodicite_debut'] = '2018-12-02'
response = response.form.submit('_continue').follow()
# Créer la facture de première échéance
response = app.get('/')
assert 'facturer-echeance' in response.form.action
response = response.form.submit().follow()
# Essayons de modifier le début
response = response.click('Contrat', index=1)
response.form['periodicite_debut'] = '2018-12-01'
response = response.form.submit('_continue')
# Il y a une erreur
assert len(response.pyquery('.errorlist.nonfield'))