729 lines
27 KiB
Python
729 lines
27 KiB
Python
# barbacompta - invoicing for dummies
|
|
# Copyright (C) 2019-2020 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 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, 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
|
|
from django.template.loader import get_template
|
|
from django.utils.timezone import now
|
|
from django.utils.translation import ugettext_lazy as _
|
|
from weasyprint import HTML
|
|
|
|
from eo_gestion.utils import percentage_str
|
|
|
|
from ..eo_banque import models as banque_models
|
|
from . import facturx, fields, taggit, validators
|
|
|
|
User = get_user_model()
|
|
|
|
validate_telephone = RegexValidator(r"[. 0-9]*")
|
|
|
|
DEFAULT_TVA = getattr(settings, "TVA", "20")
|
|
|
|
|
|
def accounting_rounding(amount):
|
|
return amount.quantize(Decimal("0.01"), ROUND_HALF_UP)
|
|
|
|
|
|
def today():
|
|
return now().date()
|
|
|
|
|
|
class Client(models.Model):
|
|
nom = models.CharField(max_length=255, unique=True)
|
|
adresse = models.TextField()
|
|
email = models.CharField(max_length=50, validators=[validate_email], verbose_name='Courriel', blank=True)
|
|
telephone = models.CharField(
|
|
max_length=20, validators=[validate_telephone], verbose_name='Téléphone', blank=True
|
|
)
|
|
contacts = models.TextField(verbose_name=_('Contacts'), blank=True)
|
|
monnaie = models.CharField(max_length=10, default='€')
|
|
tva = models.DecimalField(
|
|
verbose_name='TVA par défaut', max_digits=8, decimal_places=2, default=Decimal(DEFAULT_TVA)
|
|
)
|
|
picture = models.ImageField('Logo', upload_to='logos/', blank=True, null=True)
|
|
siret = models.CharField(
|
|
max_length=len('29202001300010'),
|
|
validators=[validators.validate_siret],
|
|
db_index=True,
|
|
blank=True,
|
|
default='',
|
|
)
|
|
service_code = models.CharField(
|
|
max_length=128,
|
|
db_index=True,
|
|
blank=True,
|
|
default='',
|
|
)
|
|
active = models.BooleanField(verbose_name='Actif', default=True)
|
|
|
|
chorus_structure = models.ForeignKey(
|
|
'chorus.Structure',
|
|
verbose_name='Structure ChorusPro',
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
)
|
|
|
|
def __str__(self): # pylint: disable=invalid-str-returned
|
|
return self.nom
|
|
|
|
def clean(self):
|
|
self.service_code = self.service_code.strip()
|
|
if self.chorus_structure:
|
|
self.siret = self.chorus_structure.siret
|
|
self.service_code = self.chorus_structure.service_code
|
|
|
|
class Meta:
|
|
ordering = ('nom',)
|
|
|
|
|
|
def one_hundred_percent_this_year():
|
|
return "%s:100" % now().date().year
|
|
|
|
|
|
class Contrat(models.Model):
|
|
client = models.ForeignKey(Client, related_name="contrats", on_delete=models.CASCADE)
|
|
intitule = models.CharField(max_length=150)
|
|
description = models.TextField(blank=True)
|
|
public_description = models.TextField(
|
|
verbose_name='Description publique',
|
|
blank=True,
|
|
help_text='Si elle est présente, cette description sera reprise dans la liste des références sur le site web.',
|
|
)
|
|
url = models.URLField(verbose_name=_('URL'), blank=True)
|
|
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, 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):
|
|
return sum(facture.montant for facture in self.factures.non_proforma())
|
|
|
|
def pourcentage_facture(self):
|
|
return percentage_str(self.montant_facture(), self.montant())
|
|
|
|
pourcentage_facture.short_description = 'Pourcentage facturé'
|
|
|
|
def montant(self):
|
|
"""
|
|
Montant total d'un contrat, y compris les prestations
|
|
optionnelles. Si pas de prestation définie, montant des factures émises.
|
|
"""
|
|
if self.prestations.exists():
|
|
return Decimal(sum(p.montant() for p in self.prestations.all()))
|
|
else:
|
|
return self.montant_facture()
|
|
|
|
def solde(self):
|
|
return self.montant() - self.montant_facture()
|
|
|
|
def nom_client(self):
|
|
"""
|
|
Le nom du client qui a signé ce contrat avec EO.
|
|
"""
|
|
return self.client.nom
|
|
|
|
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
|
|
|
|
class Meta:
|
|
ordering = ('-id',)
|
|
|
|
|
|
class Prestation(models.Model):
|
|
contrat = models.ForeignKey(Contrat, related_name='prestations', on_delete=models.CASCADE)
|
|
intitule = models.CharField(max_length=255, verbose_name='Intitulé')
|
|
optionnel = models.BooleanField(blank=True, default=False)
|
|
prix_unitaire_ht = models.DecimalField(max_digits=8, decimal_places=2)
|
|
quantite = models.DecimalField(max_digits=8, decimal_places=3)
|
|
|
|
def montant(self):
|
|
return accounting_rounding(self.prix_unitaire_ht * self.quantite)
|
|
|
|
def duplicate(self):
|
|
return Prestation(
|
|
contrat=self.contrat,
|
|
intitule=self.intitule,
|
|
optionnel=self.optionnel,
|
|
prix_unitaire_ht=self.prix_unitaire_ht,
|
|
quantite=self.quantite,
|
|
)
|
|
|
|
def __str__(self):
|
|
return '%s pour %5.2f € HT' % (
|
|
self.intitule,
|
|
accounting_rounding(self.prix_unitaire_ht * self.quantite),
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ('contrat', 'id')
|
|
|
|
|
|
DELAI_PAIEMENT = getattr(settings, "DELAI_PAIEMENT", 45)
|
|
|
|
|
|
def today_plus_delai():
|
|
return today() + datetime.timedelta(days=DELAI_PAIEMENT)
|
|
|
|
|
|
echeance_verbose_name = 'Échéance (par défaut %d jours)' % getattr(settings, 'DELAI_PAIEMENT', 45)
|
|
|
|
|
|
class FactureQuerySet(QuerySet):
|
|
def avec_solde(self):
|
|
return self.non_proforma().filter(paid=False)
|
|
|
|
def non_proforma(self):
|
|
return self.filter(proforma=False)
|
|
|
|
def for_year(self, year):
|
|
return self.filter(
|
|
Q(emission__year=year, account_on_previous_period=False)
|
|
| Q(emission__year=year - 1, account_on_previous_period=True)
|
|
)
|
|
|
|
|
|
class Facture(models.Model):
|
|
proforma = models.BooleanField(default=True, verbose_name='Facture proforma', db_index=True)
|
|
ordre = models.IntegerField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name='Numéro de la facture dans l\'année',
|
|
unique_for_year='emission',
|
|
editable=False,
|
|
)
|
|
client = models.ForeignKey(
|
|
Client, related_name='direct_factures', null=True, blank=True, on_delete=models.CASCADE
|
|
)
|
|
contrat = models.ForeignKey(
|
|
Contrat, related_name='factures', blank=True, null=True, on_delete=models.CASCADE
|
|
)
|
|
annulation = models.ForeignKey(
|
|
'Facture', related_name='factures_avoir', blank=True, null=True, on_delete=models.CASCADE
|
|
)
|
|
intitule = models.CharField(max_length=150, verbose_name='Intitulé', blank=True)
|
|
notes = models.TextField(blank=True)
|
|
emission = models.DateField(verbose_name="Émission", default=today, db_index=True)
|
|
echeance = models.DateField(verbose_name=echeance_verbose_name, default=today_plus_delai)
|
|
taux_tva = models.DecimalField(max_digits=8, decimal_places=2, default=Decimal(DEFAULT_TVA))
|
|
sous_traite = fields.EuroField(
|
|
default=Decimal(0),
|
|
verbose_name='Montant sous-traité',
|
|
help_text='indiquer une somme pas un pourcentage, hors-taxe',
|
|
)
|
|
paid = models.BooleanField(blank=True, verbose_name="Soldée", default=False, db_index=True)
|
|
creator = models.ForeignKey(User, verbose_name='Créateur', on_delete=models.CASCADE)
|
|
account_on_previous_period = models.BooleanField(
|
|
verbose_name='Mettre cette facture sur l\'exercice précédent', default=False
|
|
)
|
|
numero_engagement = models.CharField(
|
|
max_length=128, verbose_name='Numéro d\'engagement', blank=True, default=''
|
|
)
|
|
private_notes = models.TextField(
|
|
blank=True,
|
|
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()
|
|
|
|
def last_ordre_plus_one(self):
|
|
ordre_max = Facture.objects.filter(emission__year=self.emission.year).aggregate(models.Max('ordre'))[
|
|
'ordre__max'
|
|
]
|
|
return (ordre_max or 0) + 1
|
|
|
|
def code(self):
|
|
if not self.ordre and self.proforma:
|
|
return 'Facture proforma du %s' % self.emission
|
|
ctx = {}
|
|
ctx.update(self.__dict__)
|
|
ctx.update({'year': self.emission.year})
|
|
if ctx["ordre"] is None:
|
|
return "Ordre is missing"
|
|
format = getattr(settings, "FACTURE_CODE_FORMAT", "F%(year)s%(ordre)04d")
|
|
return format % ctx
|
|
|
|
@property
|
|
def pk_or_code(self):
|
|
# used in ModelAdmin to create URLs for humans
|
|
if self.ordre is not None:
|
|
return self.code()
|
|
return self.pk
|
|
|
|
def save(self, *args, **kwargs):
|
|
if self.ordre is None and not self.proforma:
|
|
self.ordre = self.last_ordre_plus_one()
|
|
super().save(*args, **kwargs)
|
|
|
|
def clean(self):
|
|
if not (self.contrat or self.client):
|
|
raise ValidationError("La facture doit avoir un client ou un contrat")
|
|
if self.contrat:
|
|
if not self.intitule:
|
|
self.intitule = self.contrat.intitule
|
|
if self.client:
|
|
if self.client != self.contrat.client:
|
|
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():
|
|
ligne.clean()
|
|
except ValidationError:
|
|
raise ValidationError("Il y a un problème avec les lignes de cette facture")
|
|
self.update_paid(save=False)
|
|
self.numero_engagement = self.numero_engagement.strip()
|
|
if (
|
|
not self.proforma
|
|
and self.client
|
|
and self.client.chorus_structure
|
|
and self.client.chorus_structure.engagement_obligatoire
|
|
):
|
|
if not self.numero_engagement:
|
|
raise ValidationError('Numéro d\'engagement obligatoire pour Chorus')
|
|
|
|
def index(self):
|
|
if self.contrat:
|
|
return list(self.contrat.factures.order_by('emission')).index(self) + 1
|
|
else:
|
|
return 1
|
|
|
|
def __str__(self):
|
|
return self.code()
|
|
|
|
@property
|
|
def tva(self):
|
|
amount_by_vat = defaultdict(lambda: Decimal(0))
|
|
for ligne in self.lignes.all():
|
|
amount_by_vat[ligne.tva] += ligne.montant
|
|
vat = Decimal(0)
|
|
for vat_percentage, amount in amount_by_vat.items():
|
|
var_ratio = vat_percentage / Decimal("100")
|
|
vat += accounting_rounding(var_ratio * amount)
|
|
return vat
|
|
|
|
@property
|
|
def montant_ttc(self):
|
|
return self.montant + self.tva
|
|
|
|
@property
|
|
def montant(self):
|
|
return sum(ligne.montant for ligne in self.lignes.all())
|
|
|
|
def solde(self):
|
|
payments = self.payments.all()
|
|
if payments.count():
|
|
amount_paid = sum(payment.montant_affecte for payment in payments)
|
|
else:
|
|
amount_paid = Decimal(0)
|
|
return amount_paid - self.montant_ttc
|
|
|
|
def update_paid(self, save=True):
|
|
new_paid = self.solde() >= 0
|
|
if self.paid != new_paid:
|
|
self.paid = new_paid
|
|
if save:
|
|
self.save()
|
|
|
|
@property
|
|
def accounting_year(self):
|
|
if self.account_on_previous_period:
|
|
return self.emission.year - 1
|
|
return self.emission.year
|
|
|
|
DEFAULT_FACTURE_TEMPLATE = 'facture.html'
|
|
|
|
def html(self, template_name=None, base_uri=None):
|
|
template = get_template(template_name or self.DEFAULT_FACTURE_TEMPLATE)
|
|
return template.render({'facture': self, 'base_uri': base_uri or ''})
|
|
|
|
def pdf(self, template_name=None, base_uri=None):
|
|
html = HTML(string=self.html(template_name=template_name, base_uri=base_uri))
|
|
pdf = html.write_pdf()
|
|
|
|
if hasattr(settings, 'FACTURE_DIR'):
|
|
filename = os.path.join(settings.FACTURE_DIR, self.filename_with_client())
|
|
with open(filename, 'wb') as fd:
|
|
fd.write(pdf)
|
|
return pdf
|
|
|
|
def facturx_pdf(self, template_name=None, base_uri=None):
|
|
if self.proforma:
|
|
raise RuntimeError('facturx_pdf() can only be called on non proforma invoices')
|
|
facturx_ctx = {
|
|
'numero_de_facture': self.code(),
|
|
'type_facture': '380' if not self.annulation else '381', # 380 => facture, 381 => avoir
|
|
'date_emission': self.emission,
|
|
'vendeur': settings.VENDOR_NAME,
|
|
'vendeur_siret': settings.VENDOR_SIRET,
|
|
'vendeur_tvai': settings.VENDOR_TVAI,
|
|
'client_siret': self.client.siret,
|
|
'client_name': self.client.nom,
|
|
'chorus_service_code': self.client.service_code,
|
|
'numero_engagement': self.numero_engagement,
|
|
'numero_marche': (self.contrat and self.contrat.numero_marche) or '',
|
|
'montant_ht': str(self.montant),
|
|
'montant_ttc': str(self.montant_ttc),
|
|
'montant_tva': str(self.tva),
|
|
'taux_tva': self.taux_tva or (self.contrat and self.contrat.tva) or self.client.tva or 0,
|
|
'annulation_code': self.annulation.code() if self.annulation else None,
|
|
}
|
|
return facturx.add_facturx_from_bytes(
|
|
self.pdf(template_name=template_name, base_uri=base_uri), facturx_ctx
|
|
)
|
|
|
|
def cancel(self, creator):
|
|
assert not self.annulation, 'cannot cancel a canceled invoice'
|
|
assert not self.factures_avoir.count(), 'cannot cancel twice an invoice'
|
|
|
|
facture_avoir = Facture.objects.get(pk=self.pk)
|
|
facture_avoir.pk = None
|
|
facture_avoir.ordre = None
|
|
facture_avoir.annulation = self
|
|
facture_avoir.proforma = True
|
|
facture_avoir.intitule = "AVOIR POUR LA FACTURE " + self.intitule
|
|
facture_avoir.paid = False
|
|
facture_avoir.creator = creator
|
|
facture_avoir.account_on_previous_period = False
|
|
facture_avoir.save()
|
|
for ligne in self.lignes.all():
|
|
ligne.pk = None
|
|
ligne.facture = facture_avoir
|
|
ligne.quantite = ligne.quantite * -1
|
|
ligne.save()
|
|
for payment in self.payments.all():
|
|
payment.pk = None
|
|
payment.save()
|
|
return facture_avoir
|
|
|
|
def filename(self):
|
|
avoir = '-AVOIR' if self.annulation else ''
|
|
filename = f'{self.code()}{avoir}.pdf'
|
|
return filename
|
|
|
|
def filename_with_client(self):
|
|
avoir = '-AVOIR' if self.annulation else ''
|
|
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):
|
|
facture = models.ForeignKey(Facture, related_name="lignes", on_delete=models.CASCADE)
|
|
intitule = models.TextField(blank=True)
|
|
prix_unitaire_ht = models.DecimalField(max_digits=8, decimal_places=2, default=Decimal('0'))
|
|
quantite = models.DecimalField(max_digits=8, decimal_places=3, default=Decimal('1.0'))
|
|
taux_tva = models.DecimalField(max_digits=4, decimal_places=2, blank=True, null=True)
|
|
order = models.IntegerField(blank=True, null=True, default=0)
|
|
|
|
@property
|
|
def montant(self):
|
|
return accounting_rounding(self.prix_unitaire_ht * self.quantite)
|
|
|
|
@property
|
|
def tva(self):
|
|
if self.taux_tva is not None:
|
|
return self.taux_tva
|
|
elif self.facture.taux_tva:
|
|
return self.facture.taux_tva
|
|
elif self.facture.contrat and self.facture.contrat.tva:
|
|
return self.facture.contrat.tva
|
|
else:
|
|
return self.facture.client.tva
|
|
|
|
def clean(self):
|
|
errors = []
|
|
if self.taux_tva and self.taux_tva < 0:
|
|
errors.append("Le taux de tva doit être une valeur positive ou nulle.")
|
|
if self.prix_unitaire_ht < 0:
|
|
errors.append("Le prix unitaire hors taxe doit être une valeur positive ou nulle.")
|
|
if self.facture.contrat and not self.facture.proforma:
|
|
facture = self.facture
|
|
contrat = facture.contrat
|
|
deja_facture = sum(f.montant for f in contrat.factures.exclude(id=facture.id))
|
|
deja_facture += sum(l.montant for l in facture.lignes.all() if l != self)
|
|
if deja_facture + self.montant > contrat.montant():
|
|
errors.append(
|
|
'Cette ligne fait dépasser le montant initial du contrat de %.2f %s'
|
|
% (deja_facture + self.montant - contrat.montant(), contrat.client.monnaie)
|
|
)
|
|
if errors:
|
|
raise ValidationError(errors)
|
|
|
|
class Meta:
|
|
ordering = ("order",)
|
|
|
|
def __str__(self):
|
|
return "%s pour %s %s" % (
|
|
self.intitule,
|
|
self.montant,
|
|
self.facture.client.monnaie,
|
|
)
|
|
|
|
|
|
def encaissements_avec_solde_non_affecte():
|
|
query = Q(montant_affecte__lt=F('montant')) | Q(montant_affecte__isnull=True)
|
|
return (
|
|
banque_models.LigneBanquePop.objects.filter(montant__gt=0)
|
|
.annotate(montant_affecte=Sum('payments__montant_affecte'))
|
|
.filter(query)
|
|
.order_by('-date_valeur')
|
|
)
|
|
|
|
|
|
class Payment(models.Model):
|
|
facture = models.ForeignKey(Facture, related_name='payments', on_delete=models.CASCADE)
|
|
ligne_banque_pop = models.ForeignKey(
|
|
banque_models.LigneBanquePop,
|
|
related_name='payments',
|
|
verbose_name='Encaissement',
|
|
limit_choices_to={'montant__gt': 0},
|
|
on_delete=models.CASCADE,
|
|
)
|
|
montant_affecte = models.DecimalField(
|
|
max_digits=8,
|
|
decimal_places=2,
|
|
blank=True,
|
|
verbose_name='Montant affecté',
|
|
help_text="Si vide, le montant non affecté de l'encaissement est pris comme valeur",
|
|
)
|
|
|
|
def clean(self):
|
|
"""Vérifie la cohérence des paiements"""
|
|
try:
|
|
if self.montant_affecte is None:
|
|
self.montant_affecte = min(self.ligne_banque_pop.montant, self.facture.montant_ttc)
|
|
except (banque_models.LigneBanquePop.DoesNotExist, Facture.DoesNotExist):
|
|
pass
|
|
aggregate = models.Sum("montant_affecte")
|
|
# from the ligne de banque pov
|
|
try:
|
|
other_payments = self.ligne_banque_pop.payments
|
|
if self.ligne_banque_pop.montant < 0 or (self.montant_affecte and self.montant_affecte <= 0):
|
|
raise ValidationError("Un paiement ne peut être que d'un montant positif")
|
|
except banque_models.LigneBanquePop.DoesNotExist:
|
|
pass
|
|
else:
|
|
deja_affecte = other_payments.aggregate(aggregate).get("montant_affecte", 0)
|
|
if deja_affecte + self.montant_affecte > self.ligne_banque_pop.montant:
|
|
raise ValidationError(
|
|
'Le montant affecté aux différentes factures '
|
|
'est supérieur au montant de l\'encaissement.'
|
|
)
|
|
# from the facture pov
|
|
try:
|
|
other_payments = self.facture.payments
|
|
except Facture.DoesNotExist:
|
|
pass
|
|
else:
|
|
deja_affecte = other_payments.aggregate(aggregate).get("montant_affecte") or 0
|
|
if (
|
|
self.montant_affecte is not None
|
|
and deja_affecte + self.montant_affecte > self.facture.montant_ttc
|
|
):
|
|
raise ValidationError(
|
|
'Le montant affecté aux différentes factures '
|
|
'est supérieur au montant de l\'encaissement.'
|
|
)
|
|
|
|
def __str__(self):
|
|
return 'Paiement de %.2f € sur facture %s à %s' % (
|
|
self.ligne_banque_pop.montant,
|
|
self.facture,
|
|
self.facture.client,
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = 'Paiement'
|
|
ordering = ('-ligne_banque_pop__date_valeur',)
|
|
|
|
|
|
def update_paid(sender, instance, raw=False, **kwargs):
|
|
if raw:
|
|
return
|
|
instance.facture.update_paid()
|
|
|
|
|
|
post_save.connect(update_paid, sender=Payment)
|
|
post_delete.connect(update_paid, sender=Payment)
|
|
post_save.connect(update_paid, sender=Ligne)
|
|
post_delete.connect(update_paid, sender=Ligne)
|