diff --git a/eo_gestion/eo_facture/admin.py b/eo_gestion/eo_facture/admin.py index f286d7c..28c6538 100644 --- a/eo_gestion/eo_facture/admin.py +++ b/eo_gestion/eo_facture/admin.py @@ -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( + '/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('{1}', url, obj.client) + if obj.client: + url = reverse('admin:eo_facture_client_change', args=[obj.client.id]) + return format_html('{1}', 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 diff --git a/eo_gestion/eo_facture/forms.py b/eo_gestion/eo_facture/forms.py index 54689ba..c062859 100644 --- a/eo_gestion/eo_facture/forms.py +++ b/eo_gestion/eo_facture/forms.py @@ -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 ) diff --git a/eo_gestion/eo_facture/migrations/0015_add_fields_for_periodic_invoicing.py b/eo_gestion/eo_facture/migrations/0015_add_fields_for_periodic_invoicing.py new file mode 100644 index 0000000..6734abf --- /dev/null +++ b/eo_gestion/eo_facture/migrations/0015_add_fields_for_periodic_invoicing.py @@ -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')}, + ), + ] diff --git a/eo_gestion/eo_facture/models.py b/eo_gestion/eo_facture/models.py index b7b6e69..bce7530 100644 --- a/eo_gestion/eo_facture/models.py +++ b/eo_gestion/eo_facture/models.py @@ -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): diff --git a/eo_gestion/eo_facture/templates/admin/eo_facture/contrat/change_form.html b/eo_gestion/eo_facture/templates/admin/eo_facture/contrat/change_form.html index 4e42b9e..0203ed3 100644 --- a/eo_gestion/eo_facture/templates/admin/eo_facture/contrat/change_form.html +++ b/eo_gestion/eo_facture/templates/admin/eo_facture/contrat/change_form.html @@ -6,11 +6,29 @@ {% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %} {% endif %}{% endif %} {% endblock %} +{% block after_field_sets %} +{% if original.periodicite and original.periodicite_debut %} +
+

Échéances

+
+
    + {% for echeance in original.periodicite_echeances %} +
  • du {{ echeance.1 }} au {{ echeance.2 }}
  • + {% endfor %} +
+
+
+{% endif %} +{% endblock %} diff --git a/eo_gestion/eo_facture/templates/admin/eo_facture/facture/change_form.html b/eo_gestion/eo_facture/templates/admin/eo_facture/facture/change_form.html index b6063d0..13b1b51 100644 --- a/eo_gestion/eo_facture/templates/admin/eo_facture/facture/change_form.html +++ b/eo_gestion/eo_facture/templates/admin/eo_facture/facture/change_form.html @@ -6,7 +6,9 @@