|
@@ -5,11 +5,13 @@ import datetime
|
|
|
import random
|
|
|
import uuid
|
|
|
import os
|
|
|
+import re
|
|
|
from decimal import Decimal
|
|
|
|
|
|
-from django.db import models
|
|
|
+from django.db import models, transaction
|
|
|
from django.db.models.signals import post_save
|
|
|
from django.dispatch import receiver
|
|
|
+from django.utils.encoding import python_2_unicode_compatible
|
|
|
|
|
|
from coin.offers.models import OfferSubscription
|
|
|
from coin.members.models import Member
|
|
@@ -18,13 +20,6 @@ from coin.utils import private_files_storage, start_of_month, end_of_month, \
|
|
|
disable_for_loaddata
|
|
|
from coin.isp_database.context_processors import branding
|
|
|
|
|
|
-def next_invoice_number():
|
|
|
- """Détermine un numéro de facture aléatoire"""
|
|
|
- return '%s%02i-%i-%i' % (datetime.date.today().year,
|
|
|
- datetime.date.today().month,
|
|
|
- random.randrange(100, 999),
|
|
|
- random.randrange(100, 999))
|
|
|
-
|
|
|
|
|
|
def invoice_pdf_filename(instance, filename):
|
|
|
"""Nom et chemin du fichier pdf à stocker pour les factures"""
|
|
@@ -33,6 +28,66 @@ def invoice_pdf_filename(instance, filename):
|
|
|
instance.number,
|
|
|
uuid.uuid4())
|
|
|
|
|
|
+@python_2_unicode_compatible
|
|
|
+class InvoiceNumber:
|
|
|
+ """ Logic and validation of invoice numbers
|
|
|
+
|
|
|
+ Defines invoice numbers serie in a way that is legal in france.
|
|
|
+
|
|
|
+ https://www.service-public.fr/professionnels-entreprises/vosdroits/F23208#fiche-item-3
|
|
|
+
|
|
|
+ Our format is YYYY-MM-XXXXXX
|
|
|
+ - YYYY the year of the bill
|
|
|
+ - MM month of the bill
|
|
|
+ - XXXXXX a per-month sequence
|
|
|
+ """
|
|
|
+ RE_INVOICE_NUMBER = re.compile(
|
|
|
+ r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<index>\d{6})')
|
|
|
+
|
|
|
+ def __init__(self, date, index):
|
|
|
+ self.date = date
|
|
|
+ self.index = index
|
|
|
+
|
|
|
+ def get_next(self):
|
|
|
+ return InvoiceNumber(self.date, self.index + 1)
|
|
|
+
|
|
|
+ def __str__(self):
|
|
|
+ return '{:%Y-%m}-{:0>6}'.format(self.date, self.index)
|
|
|
+
|
|
|
+ @classmethod
|
|
|
+ def parse(cls, string):
|
|
|
+ m = cls.RE_INVOICE_NUMBER.match(string)
|
|
|
+ if not m:
|
|
|
+ raise ValueError('Not a valid invoice number: "{}"'.format(string))
|
|
|
+
|
|
|
+ return cls(
|
|
|
+ datetime.date(
|
|
|
+ year=int(m.group('year')),
|
|
|
+ month=int(m.group('month')),
|
|
|
+ day=1),
|
|
|
+ int(m.group('index')))
|
|
|
+
|
|
|
+
|
|
|
+class InvoiceQuerySet(models.QuerySet):
|
|
|
+ def get_next_invoice_number(self, date):
|
|
|
+ last_invoice_number_str = self._get_last_invoice_number(date)
|
|
|
+
|
|
|
+ if last_invoice_number_str is None:
|
|
|
+ # It's the first bill of the month
|
|
|
+ invoice_number = InvoiceNumber(date, 1)
|
|
|
+ else:
|
|
|
+ invoice_number = InvoiceNumber.parse(last_invoice_number_str).get_next()
|
|
|
+
|
|
|
+ return str(invoice_number)
|
|
|
+
|
|
|
+ def _get_last_invoice_number(self, date):
|
|
|
+ return self.with_valid_number().aggregate(
|
|
|
+ models.Max('number'))['number__max']
|
|
|
+
|
|
|
+ def with_valid_number(self):
|
|
|
+ """ Excludes previous numbering schemes or draft invoices
|
|
|
+ """
|
|
|
+ return self.filter(number__regex=InvoiceNumber.RE_INVOICE_NUMBER.pattern)
|
|
|
|
|
|
class Invoice(models.Model):
|
|
|
|
|
@@ -46,14 +101,14 @@ class Invoice(models.Model):
|
|
|
help_text='Once validated, a PDF is generated'
|
|
|
' and the invoice cannot be modified')
|
|
|
number = models.CharField(max_length=25,
|
|
|
- default=next_invoice_number,
|
|
|
unique=True,
|
|
|
verbose_name='numéro')
|
|
|
status = models.CharField(max_length=50, choices=INVOICES_STATUS_CHOICES,
|
|
|
default='open',
|
|
|
verbose_name='statut')
|
|
|
date = models.DateField(
|
|
|
- default=datetime.date.today, null=True, verbose_name='date')
|
|
|
+ default=datetime.date.today, null=True, verbose_name='date',
|
|
|
+ help_text='Cette date sera définie à la date de validation dans la facture finale')
|
|
|
date_due = models.DateField(
|
|
|
default=end_of_month,
|
|
|
null=True,
|
|
@@ -67,6 +122,14 @@ class Invoice(models.Model):
|
|
|
null=True, blank=True,
|
|
|
verbose_name='PDF')
|
|
|
|
|
|
+ def save(self, *args, **kwargs):
|
|
|
+ # First save to get a PK
|
|
|
+ super(Invoice, self).save(*args, **kwargs)
|
|
|
+ # Then use that pk to build draft invoice number
|
|
|
+ if not self.validated and self.pk and not self.number:
|
|
|
+ self.number = 'DRAFT-{}'.format(self.pk)
|
|
|
+ self.save()
|
|
|
+
|
|
|
def amount(self):
|
|
|
"""
|
|
|
Calcul le montant de la facture
|
|
@@ -111,17 +174,20 @@ class Invoice(models.Model):
|
|
|
pdf_file = render_as_pdf('billing/invoice_pdf.html', context)
|
|
|
self.pdf.save('%s.pdf' % self.number, pdf_file)
|
|
|
|
|
|
+ @transaction.atomic
|
|
|
def validate(self):
|
|
|
"""
|
|
|
Switch invoice to validate mode. This set to False the draft field
|
|
|
and generate the pdf
|
|
|
"""
|
|
|
- if not self.is_pdf_exists():
|
|
|
- self.validated = True
|
|
|
- self.save()
|
|
|
- self.generate_pdf()
|
|
|
-
|
|
|
- def is_pdf_exists(self):
|
|
|
+ self.date = datetime.date.today()
|
|
|
+ self.number = Invoice.objects.get_next_invoice_number(self.date)
|
|
|
+ self.validated = True
|
|
|
+ self.save()
|
|
|
+ self.generate_pdf()
|
|
|
+ assert self.pdf_exists()
|
|
|
+
|
|
|
+ def pdf_exists(self):
|
|
|
return (self.validated
|
|
|
and bool(self.pdf)
|
|
|
and private_files_storage.exists(self.pdf.name))
|
|
@@ -136,6 +202,7 @@ class Invoice(models.Model):
|
|
|
class Meta:
|
|
|
verbose_name = 'facture'
|
|
|
|
|
|
+ objects = InvoiceQuerySet().as_manager()
|
|
|
|
|
|
class InvoiceDetail(models.Model):
|
|
|
|