347 lines
12 KiB
Python
347 lines
12 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/>.
|
|
|
|
|
|
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
|
|
from django.dispatch import receiver
|
|
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
|
|
from eo_gestion.utils import percentage
|
|
|
|
from ...decorators import cache
|
|
|
|
register = template.Library()
|
|
|
|
|
|
@register.inclusion_tag('eo_facture/impayee.html')
|
|
@cache
|
|
def impayees():
|
|
factures_impayees = [
|
|
f
|
|
for f in Facture.objects.select_related('client', 'contrat', 'contrat__client')
|
|
.prefetch_related('lignes', 'payments')
|
|
.avec_solde()
|
|
.exclude(paid=True)
|
|
.exclude(annulation__isnull=False)
|
|
.exclude(factures_avoir__isnull=False)
|
|
.order_by('emission')
|
|
if f.solde() < 0
|
|
]
|
|
factures = []
|
|
soldes = [-f.solde() for f in factures_impayees]
|
|
soldes_ht = [
|
|
f.montant * (solde / f.montant_ttc) if f.montant_ttc else Decimal(0)
|
|
for solde, f in zip(soldes, factures_impayees)
|
|
]
|
|
solde = sum(soldes)
|
|
solde_ht = sum(soldes_ht)
|
|
today = date.today()
|
|
after = datetime.now() - timedelta(days=365)
|
|
after = after.date()
|
|
lignes = LigneBanquePop.objects.filter(date_operation__gte=after, montant__in=soldes, payments=None)
|
|
for f in factures_impayees:
|
|
vieille = False
|
|
if (today - f.emission) > timedelta(days=DELAI_PAIEMENT):
|
|
vieille = True
|
|
encaissements = [ligne for ligne in lignes if ligne.montant == f.montant_ttc]
|
|
factures.append({'facture': f, 'encaissements': encaissements, 'vieille': vieille})
|
|
return {'montant': solde, 'montant_ht': solde_ht, 'factures': factures}
|
|
|
|
|
|
@register.inclusion_tag('eo_facture/income.html')
|
|
@cache
|
|
def income():
|
|
def AmountByYear():
|
|
return defaultdict(lambda: Decimal(0))
|
|
|
|
invoices = (
|
|
Facture.objects.filter(proforma=False)
|
|
.select_related('client', 'contrat', 'contrat__client')
|
|
.prefetch_related('lignes', 'payments')
|
|
)
|
|
paid_by_year = AmountByYear()
|
|
sous_traite_by_year = AmountByYear()
|
|
for invoice in invoices:
|
|
if invoice.paid:
|
|
paid_by_year[invoice.accounting_year] += invoice.montant
|
|
else:
|
|
tva = invoice.tva
|
|
ht = invoice.montant
|
|
try:
|
|
taux_moyen = tva / ht
|
|
except (ZeroDivisionError, InvalidOperation):
|
|
taux_moyen = Decimal(0)
|
|
paid_by_year[invoice.accounting_year] += (
|
|
(invoice.montant_ttc + invoice.solde()) * Decimal(1) / (Decimal(1) + taux_moyen)
|
|
)
|
|
invoiced_by_year = defaultdict(lambda: Decimal(0))
|
|
invoiced_by_year_and_client = defaultdict(lambda: defaultdict(lambda: Decimal(0)))
|
|
for invoice in invoices:
|
|
montant = invoice.montant - invoice.sous_traite
|
|
invoiced_by_year[invoice.accounting_year] += montant
|
|
sous_traite_by_year[invoice.accounting_year] += invoice.sous_traite
|
|
invoiced_by_year_and_client[invoice.accounting_year][invoice.client.id] += montant
|
|
invoiced_clients_by_year = {}
|
|
for year in invoiced_by_year_and_client:
|
|
clients = list(
|
|
sorted(
|
|
invoiced_by_year_and_client[year].keys(),
|
|
key=lambda x, year=year: -invoiced_by_year_and_client[year][x],
|
|
)
|
|
)
|
|
invoiced_clients_by_year[year] = clients
|
|
|
|
# compute future income for each contract
|
|
contracted_by_year = defaultdict(lambda: Decimal(0))
|
|
for contract in Contrat.objects.all().prefetch_related('factures', 'factures__lignes', 'prestations'):
|
|
for year, montant in contract.montant_par_annee().items():
|
|
contracted_by_year[year] += montant
|
|
|
|
this_year = date.today().year
|
|
income_by_year = []
|
|
for year in invoiced_by_year:
|
|
invoiced_by_year[year] -= paid_by_year[year]
|
|
for year in range(this_year - 1, this_year + 2):
|
|
income_by_year.append(
|
|
{
|
|
'year': year,
|
|
'paid': paid_by_year[year],
|
|
'invoiced': invoiced_by_year[year],
|
|
'contracted': contracted_by_year[year],
|
|
'sous_traite': sous_traite_by_year[year],
|
|
'total': paid_by_year[year] + invoiced_by_year[year] + contracted_by_year[year],
|
|
}
|
|
)
|
|
return {'income_by_year': income_by_year}
|
|
|
|
|
|
def zero_dict():
|
|
return defaultdict(lambda: Decimal(0))
|
|
|
|
|
|
class StringWithHref(text_type):
|
|
def __new__(cls, v, href=None):
|
|
return text_type.__new__(cls, v)
|
|
|
|
def __init__(self, v, href=None):
|
|
super().__init__()
|
|
self.href = href
|
|
|
|
|
|
def client_and_link(c):
|
|
s = text_type(c)
|
|
url = reverse("admin:eo_facture_client_change", args=(c.id,))
|
|
return StringWithHref(s, url)
|
|
|
|
|
|
def dict_of_list():
|
|
return defaultdict(lambda: [])
|
|
|
|
|
|
@register.inclusion_tag("eo_facture/table.html")
|
|
@cache
|
|
def income_by_clients(year=None):
|
|
if not year:
|
|
year = date.today().year
|
|
contracted_by_clients = zero_dict()
|
|
invoiced_by_clients = zero_dict()
|
|
total_by_clients = zero_dict()
|
|
contracts_by_clients = dict_of_list()
|
|
invoices_by_clients = dict_of_list()
|
|
for contrat in Contrat.objects.select_related('client').prefetch_related(
|
|
'factures', 'factures__lignes', 'prestations'
|
|
):
|
|
# how much as already been invoiced before previsions
|
|
adjust = 0
|
|
invoiced = 0
|
|
for percent_year, fraction in contrat.percentage_per_year:
|
|
if percent_year > year:
|
|
continue
|
|
adjust += contrat.montant() * fraction
|
|
for facture in contrat.factures.all():
|
|
if facture.proforma:
|
|
continue
|
|
if facture.accounting_year <= year:
|
|
adjust -= facture.montant
|
|
if facture.accounting_year == year:
|
|
invoiced += facture.montant
|
|
if adjust > 0:
|
|
contracted_by_clients[contrat.client] += adjust
|
|
total_by_clients[contrat.client] += adjust
|
|
if invoiced:
|
|
invoiced_by_clients[contrat.client] += invoiced
|
|
total_by_clients[contrat.client] += invoiced
|
|
for facture in (
|
|
Facture.objects.select_related('client')
|
|
.filter(contrat__isnull=True)
|
|
.for_year(year)
|
|
.prefetch_related('lignes')
|
|
):
|
|
if facture.proforma:
|
|
continue
|
|
invoiced_by_clients[facture.client] += facture.montant
|
|
total_by_clients[facture.client] += facture.montant
|
|
total = sum(total_by_clients.values())
|
|
total_invoiced = sum(invoiced_by_clients.values())
|
|
total_contracted = sum(contracted_by_clients.values())
|
|
percent_by_clients = {i: Decimal(100) * v / total for i, v in total_by_clients.items()}
|
|
clients = sorted(total_by_clients.keys(), key=lambda x: -total_by_clients[x])
|
|
running_total = Decimal(0)
|
|
# compute pareto index
|
|
pareto = {}
|
|
for client in clients:
|
|
running_total += percent_by_clients[client]
|
|
pareto[client] = running_total
|
|
return dict(
|
|
title="Chiffre d'affaire prévisionnel par client pour %s" % year,
|
|
name="previsional-income-by-client-%s" % year,
|
|
headers=[
|
|
('client', 'Client'),
|
|
('income', 'Chiffre d\'affaire'),
|
|
('invoiced', 'Facturé'),
|
|
('percent_invoiced', 'Pourcentage facturé'),
|
|
('contracted', 'Contracté'),
|
|
('percent_contracted', 'Pourcentage contracté'),
|
|
('percent', 'Pourcentage'),
|
|
('pareto', 'Pareto'),
|
|
],
|
|
contracts_by_clients=list(contracts_by_clients.items()),
|
|
invoices_by_clients=list(invoices_by_clients.items()),
|
|
table=[
|
|
(
|
|
client_and_link(c),
|
|
total_by_clients[c],
|
|
invoiced_by_clients[c],
|
|
percentage(invoiced_by_clients[c], total_by_clients[c]),
|
|
contracted_by_clients[c],
|
|
percentage(contracted_by_clients[c], total_by_clients[c]),
|
|
percent_by_clients[c],
|
|
pareto[c],
|
|
)
|
|
for c in clients
|
|
]
|
|
+ [
|
|
(
|
|
'Total',
|
|
total,
|
|
total_invoiced,
|
|
percentage(total_invoiced, total),
|
|
total_contracted,
|
|
percentage(total_contracted, total),
|
|
Decimal(100),
|
|
'',
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
@register.inclusion_tag('eo_facture/a_facturer.html')
|
|
@cache
|
|
def a_facturer():
|
|
contrats_a_facturer = []
|
|
for contrat in (
|
|
Contrat.objects.all()
|
|
.select_related('client')
|
|
.prefetch_related('prestations', 'factures', 'factures__lignes')
|
|
):
|
|
facture = contrat.montant_facture()
|
|
a_facture = contrat.montant()
|
|
if a_facture and facture < a_facture:
|
|
if contrat.factures.count() > 0:
|
|
depuis = max(facture.emission for facture in contrat.factures.all())
|
|
else:
|
|
depuis = contrat.creation
|
|
depuis = (date.today() - depuis).days
|
|
contrats_a_facturer.append(
|
|
{
|
|
'contrat': contrat,
|
|
'pourcentage': (facture / a_facture) * Decimal(100),
|
|
'montant': a_facture - facture,
|
|
'depuis': depuis,
|
|
}
|
|
)
|
|
contrats_a_facturer.sort(key=lambda x: -x['depuis'])
|
|
montant = sum(x['montant'] for x in contrats_a_facturer)
|
|
return {'a_facturer': contrats_a_facturer, 'montant': montant}
|
|
|
|
|
|
@register.filter(name='ago')
|
|
def ago(date):
|
|
ago = timesince(date)
|
|
# selects only the first part of the returned string
|
|
return ago.split(",")[0]
|
|
|
|
|
|
@register.filter(is_safe=True)
|
|
def amountformat(value, use_l10n=True):
|
|
return number_format(Decimal(value).quantize(Decimal("0.01")), force_grouping=True)
|
|
|
|
|
|
# invalidate impayees() cache when Payment set changes
|
|
@receiver(post_save, sender=Payment)
|
|
def payment_post_save(raw, **kwargs):
|
|
if raw:
|
|
return
|
|
transaction.on_commit(impayees.recompute)
|
|
|
|
|
|
@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
|
|
],
|
|
}
|