620 lines
21 KiB
Python
620 lines
21 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 as dt
|
|
import re
|
|
from io import BytesIO
|
|
|
|
from adminsortable2.admin import SortableInlineAdminMixin
|
|
from django import http
|
|
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 Exists, OuterRef, TextField
|
|
from django.forms import Textarea
|
|
from django.forms.models import BaseInlineFormSet
|
|
from django.shortcuts import render
|
|
from django.urls import path, re_path, reverse
|
|
from django.utils.html import format_html
|
|
|
|
import eo_gestion.admin
|
|
|
|
from .. import actions, ods
|
|
from . import forms, models, views
|
|
from .templatetags.eo_facture import amountformat
|
|
|
|
|
|
class LookupAllowed:
|
|
def lookup_allowed(self, *args, **kwargs):
|
|
return True
|
|
|
|
|
|
class SelectRelatedMixin:
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related()
|
|
|
|
|
|
class CommonPaymentInline(BaseModelAdmin):
|
|
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
|
if db_field.name == 'facture' and request.path.endswith('/add/'):
|
|
kwargs['queryset'] = models.Facture.objects.avec_solde()
|
|
if db_field.name == 'ligne_banque_pop' and request.path.endswith('/add/'):
|
|
kwargs['queryset'] = models.encaissements_avec_solde_non_affecte()
|
|
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
|
|
|
|
|
class PrestationInline(SelectRelatedMixin, admin.TabularInline):
|
|
model = models.Prestation
|
|
form = forms.PrestationForm
|
|
|
|
|
|
class FactureInline(SelectRelatedMixin, admin.TabularInline):
|
|
model = models.Facture
|
|
form = forms.FactureForm
|
|
|
|
|
|
class PaymentInlineFormset(BaseInlineFormSet):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
for form in self.forms:
|
|
if form.instance.id is None:
|
|
field = form.fields['ligne_banque_pop']
|
|
if not hasattr(field, 'parent_instance'):
|
|
field.queryset = models.encaissements_avec_solde_non_affecte()
|
|
field = form.fields['facture']
|
|
if not hasattr(field, 'parent_instance'):
|
|
field.queryset = models.Facture.objects.avec_solde()
|
|
|
|
|
|
class PaymentInline(SelectRelatedMixin, admin.TabularInline, CommonPaymentInline):
|
|
form = forms.PaymentForm
|
|
|
|
model = models.Payment
|
|
list_display = ['facture', 'ligne_banque_pop', 'montant_affecte']
|
|
formset = PaymentInlineFormset
|
|
extra = 0
|
|
|
|
|
|
class LigneInline(SelectRelatedMixin, SortableInlineAdminMixin, admin.TabularInline):
|
|
form = forms.LigneForm
|
|
model = models.Ligne
|
|
original = False
|
|
verbose_name_plural = 'Lignes de facture (vous pouvez les réordonner par drag&drop)'
|
|
formfield_overrides = {
|
|
TextField: {
|
|
'widget': Textarea(attrs={'cols': 30, 'rows': 1}),
|
|
}
|
|
}
|
|
|
|
|
|
class ActiveFilter(admin.SimpleListFilter):
|
|
title = 'statut'
|
|
parameter_name = 'active'
|
|
|
|
def value(self):
|
|
value = super().value()
|
|
return 'True' if value in (None, True) else value
|
|
|
|
def choices(self, changelist):
|
|
choices = list(super().choices(changelist))
|
|
return choices[1:] # don't include automatic "All"
|
|
|
|
def lookups(self, request, model_admin):
|
|
return [(True, 'actif'), (False, 'inactif'), ('all', 'tous')]
|
|
|
|
def queryset(self, request, queryset):
|
|
if self.value() == 'all':
|
|
return queryset
|
|
return queryset.filter(active=bool(self.value() == 'True'))
|
|
|
|
|
|
class MyClientsFilter(admin.SimpleListFilter):
|
|
title = 'CPF'
|
|
parameter_name = 'cpf'
|
|
|
|
def __init__(self, request, params, model, model_admin):
|
|
super().__init__(request, params, model, model_admin)
|
|
self.request = request
|
|
|
|
def value(self):
|
|
from eo_gestion.eo_redmine.models import Project
|
|
|
|
value = super().value()
|
|
default_value = 'all'
|
|
user = self.request.user
|
|
if user and user.project_set.exists():
|
|
default_value = 'True'
|
|
|
|
return value if value in ('True', 'all') else default_value
|
|
|
|
def choices(self, changelist):
|
|
choices = list(super().choices(changelist))
|
|
return choices[1:] # don't include automatic "All"
|
|
|
|
def lookups(self, request, model_admin):
|
|
return [('all', 'tous'), ('True', 'mes clients')]
|
|
|
|
def queryset(self, request, queryset):
|
|
if self.value() == 'True':
|
|
return queryset.filter(
|
|
Exists(models.Client.objects.filter(project__cpfs=request.user, id=OuterRef('pk')))
|
|
)
|
|
else:
|
|
return queryset
|
|
|
|
|
|
class LastBillAgeFilter(admin.SimpleListFilter):
|
|
title = 'last bill age'
|
|
parameter_name = 'last_bill_age'
|
|
|
|
def value(self):
|
|
value = super().value()
|
|
return value if value in ('3', '6', '12') else 'all'
|
|
|
|
def choices(self, changelist):
|
|
choices = list(super().choices(changelist))
|
|
return choices[1:] # don't include automatic "All"
|
|
|
|
def lookups(self, request, model_admin):
|
|
return [('all', 'tous'), ('3', 'plus de 3 mois'), ('6', 'plus de 6 mois'), ('12', 'plus de 12 mois')]
|
|
|
|
def queryset(self, request, queryset):
|
|
if self.value() == 'all':
|
|
return queryset
|
|
else:
|
|
min_date = dt.date.today() - dt.timedelta(days=30 * int(self.value()))
|
|
subquery = models.Facture.objects.filter(emission__gte=min_date, client=OuterRef('pk'))
|
|
return queryset.exclude(Exists(subquery))
|
|
|
|
|
|
class ClientAdmin(admin.ModelAdmin):
|
|
form = forms.ClientForm
|
|
list_display = ['nom', 'adresse', 'email', 'telephone']
|
|
list_editable = ['email', 'telephone']
|
|
list_filter = [ActiveFilter, MyClientsFilter, LastBillAgeFilter]
|
|
ordering = ['nom']
|
|
search_fields = ['nom', 'email']
|
|
save_on_top = True
|
|
list_select_related = True
|
|
raw_id_fields = ['chorus_structure']
|
|
|
|
def get_readonly_fields(self, request, obj=None):
|
|
readonly_fields = super().get_readonly_fields(request, obj=obj)
|
|
if obj and obj.chorus_structure:
|
|
readonly_fields += ('siret', 'service_code')
|
|
return readonly_fields
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
if not obj.id:
|
|
obj.creator = request.user
|
|
obj.save()
|
|
|
|
class Media:
|
|
css = {'all': ('css/client.css',)}
|
|
|
|
|
|
@admin.display(
|
|
description='Client',
|
|
ordering='client',
|
|
)
|
|
def show_client(obj):
|
|
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)
|
|
else:
|
|
return ''
|
|
|
|
|
|
class MyContratsFilter(admin.SimpleListFilter):
|
|
title = 'CPF'
|
|
parameter_name = 'cpf'
|
|
|
|
def __init__(self, request, params, model, model_admin):
|
|
super().__init__(request, params, model, model_admin)
|
|
self.request = request
|
|
|
|
def value(self):
|
|
from eo_gestion.eo_redmine.models import Project
|
|
|
|
value = super().value()
|
|
default_value = 'all'
|
|
user = self.request.user
|
|
if user and user.project_set.exists():
|
|
default_value = 'True'
|
|
|
|
return value if value in ('True', 'all') else default_value
|
|
|
|
def choices(self, changelist):
|
|
choices = list(super().choices(changelist))
|
|
return choices[1:] # don't include automatic "All"
|
|
|
|
def lookups(self, request, model_admin):
|
|
return [('all', 'tous'), ('True', 'mes clients')]
|
|
|
|
def queryset(self, request, queryset):
|
|
if self.value() == 'True':
|
|
return queryset.filter(client__in=models.Client.objects.filter(project__cpfs=request.user))
|
|
else:
|
|
return queryset
|
|
|
|
|
|
class ContratAdmin(LookupAllowed, admin.ModelAdmin):
|
|
form = forms.ContratForm
|
|
inlines = [
|
|
PrestationInline,
|
|
]
|
|
list_display = [
|
|
'__str__',
|
|
show_client,
|
|
'column_montant',
|
|
'pourcentage_facture',
|
|
'creation',
|
|
'creator',
|
|
'tag_list',
|
|
]
|
|
list_filter = ['tags', MyContratsFilter, 'client']
|
|
list_select_related = True
|
|
save_on_top = True
|
|
search_fields = ['intitule', 'client__nom', 'tags__name']
|
|
readonly_fields = ['creator']
|
|
list_select_related = True
|
|
actions = [actions.export_references_as_fodt]
|
|
autocomplete_fields = ['client']
|
|
|
|
@admin.display(description='Montant')
|
|
def column_montant(self, obj):
|
|
return amountformat(obj.montant())
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.prefetch_related('prestations', 'factures__lignes', 'tags')
|
|
|
|
def tag_list(self, obj):
|
|
return ', '.join(o.name for o in obj.tags.all())
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
if not obj.id:
|
|
obj.creator = request.user
|
|
obj.save()
|
|
|
|
class Media:
|
|
css = {'all': ('css/contrat.css',)}
|
|
|
|
def duplicate(self, request, contract):
|
|
contrat = models.Contrat.objects.get(id=contract)
|
|
if request.method == 'POST':
|
|
form = forms.DuplicateContractForm(request=request, data=request.POST)
|
|
if form.is_valid():
|
|
return http.HttpResponseRedirect(
|
|
reverse('admin:eo_facture_contrat_change', args=[form.cleaned_data['new_contrat'].id])
|
|
)
|
|
else:
|
|
new_intitule = contrat.intitule
|
|
new_intitule += ' dupliqué le %s' % dt.datetime.now()
|
|
form = forms.DuplicateContractForm(initial={'contrat': contrat.id, 'new_intitule': new_intitule})
|
|
context = {
|
|
'form': form,
|
|
'contrat': contrat,
|
|
'app_label': self.model._meta.app_label,
|
|
'has_change_permission': self.has_change_permission(request),
|
|
'opts': self.model._meta,
|
|
}
|
|
|
|
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)
|
|
facturer_echeance_view = self.admin_site.admin_view(self.facturer_echeance)
|
|
my_urls = [
|
|
re_path(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
|
|
|
|
|
|
@admin.display(description='Ordre')
|
|
def index(facture):
|
|
return format_html('{0}', ordinal(facture.index()))
|
|
|
|
|
|
class FactureAdmin(LookupAllowed, admin.ModelAdmin):
|
|
form = forms.FactureForm
|
|
inlines = [LigneInline, PaymentInline]
|
|
list_display = [
|
|
'column_code',
|
|
index,
|
|
'show_contrat',
|
|
'show_client',
|
|
'numero_engagement',
|
|
'emission',
|
|
'creator',
|
|
'column_montant',
|
|
'column_montant_ttc',
|
|
'column_solde',
|
|
'paid',
|
|
]
|
|
list_filter = ['proforma', 'paid', 'emission', 'creator', 'client']
|
|
fields = [
|
|
'proforma',
|
|
'ordre',
|
|
'accounting_year',
|
|
'client',
|
|
'contrat',
|
|
'intitule',
|
|
'numero_engagement',
|
|
'notes',
|
|
'private_notes',
|
|
'taux_tva',
|
|
'emission',
|
|
'echeance',
|
|
'paid',
|
|
'solde',
|
|
'montant',
|
|
'montant_ttc',
|
|
]
|
|
readonly_fields = ['creator', 'solde', 'ordre', 'montant', 'montant_ttc', 'periode']
|
|
date_hierarchy = 'emission'
|
|
list_select_related = True
|
|
save_on_top = True
|
|
search_fields = ['contrat__intitule', 'contrat__client__nom', 'numero_engagement', 'notes', 'intitule']
|
|
list_select_related = True
|
|
actions = [actions.export_invoices_as_zip]
|
|
autocomplete_fields = ['client']
|
|
|
|
@admin.display(description='Code')
|
|
def column_code(self, obj):
|
|
if obj.montant < 0:
|
|
return 'Avoir %s' % obj.code()
|
|
else:
|
|
return obj.code()
|
|
|
|
@admin.display(description='Montant')
|
|
def column_montant(self, obj):
|
|
return amountformat(obj.montant)
|
|
|
|
@admin.display(description='Montant TTC')
|
|
def column_montant_ttc(self, obj):
|
|
return amountformat(obj.montant_ttc)
|
|
|
|
@admin.display(description='Solde')
|
|
def column_solde(self, obj):
|
|
return amountformat(obj.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
|
|
|
|
qs = super().get_queryset(request)
|
|
qs = qs.prefetch_related('lignes__facture__client')
|
|
qs = qs.prefetch_related('payments__ligne_banque_pop')
|
|
qs = qs.prefetch_related('contrat__factures')
|
|
if connection.vendor == 'postgresql':
|
|
qs = qs.extra({'year': 'EXTRACT(year FROM emission)'})
|
|
elif connection.vendor == 'sqlite':
|
|
qs = qs.extra({'year': "strftime('%Y', emission)"})
|
|
else:
|
|
qs = qs.extra({'year': 'YEAR(emission)'})
|
|
qs = qs.order_by('-year', '-proforma', '-ordre')
|
|
return qs
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
if not obj.id:
|
|
obj.creator = request.user
|
|
obj.save()
|
|
|
|
def add_simple(self, request):
|
|
context = {}
|
|
if request.method == 'POST':
|
|
form = forms.RapidFactureForm(request=request, data=request.POST)
|
|
if form.is_valid():
|
|
return http.HttpResponseRedirect(
|
|
reverse('admin:eo_facture_facture_change', args=[form.cleaned_data['facture'].id])
|
|
)
|
|
else:
|
|
form = forms.RapidFactureForm(initial=request.GET)
|
|
context = {
|
|
'form': form,
|
|
'app_label': self.model._meta.app_label,
|
|
'has_change_permission': self.has_change_permission(request),
|
|
'opts': self.model._meta,
|
|
}
|
|
|
|
return render(request, 'admin/eo_facture/facture/add_simple.html', context=context)
|
|
|
|
def sheet(self, request):
|
|
workbook = ods.Workbook()
|
|
ws = workbook.add_sheet('Factures')
|
|
for i, header_name in enumerate(
|
|
['Référence', 'Nom client', 'Date', 'Montant HT', 'Montant TTC', 'TVA']
|
|
):
|
|
ws.write(0, i, header_name)
|
|
|
|
qs = self.get_queryset(request)
|
|
qs = sorted(qs, key=lambda i: (i.emission.year, i.ordre or -1, i.emission))
|
|
|
|
for j, facture in enumerate(qs):
|
|
ws.write(j + 1, 0, facture.code())
|
|
ws.write(j + 1, 1, facture.client.nom)
|
|
ws.write(j + 1, 2, facture.emission)
|
|
ws.write(j + 1, 3, facture.montant)
|
|
ws.write(j + 1, 4, facture.montant_ttc)
|
|
ws.write(j + 1, 5, facture.tva)
|
|
stream = BytesIO()
|
|
workbook.save(stream)
|
|
response = http.HttpResponse(
|
|
stream.getvalue(), content_type='application/vnd.oasis.opendocument.spreadsheet'
|
|
)
|
|
response['Content-Disposition'] = 'attachment; filename="factures.ods"'
|
|
return response
|
|
|
|
def get_urls(self):
|
|
urls = super().get_urls()
|
|
my_urls = [
|
|
re_path(r'^view/([^/]*)', views.facture),
|
|
path(
|
|
'add_simple/',
|
|
self.admin_site.admin_view(self.add_simple),
|
|
name='eo_facture_facture_add_simple',
|
|
),
|
|
re_path(
|
|
r'^(.+)/view_pdf/',
|
|
self.admin_site.admin_view(views.facture_pdf),
|
|
name='eo_facture_facture_print',
|
|
),
|
|
re_path(
|
|
r'^(.+)/view/$', self.admin_site.admin_view(views.facture), name='eo_facture_facture_html'
|
|
),
|
|
re_path(
|
|
r'^(.+)/send-to-chorus/',
|
|
self.admin_site.admin_view(views.send_to_chorus),
|
|
name='eo_facture_facture_send_to_chorus',
|
|
),
|
|
path(
|
|
'sheet/',
|
|
self.admin_site.admin_view(self.sheet),
|
|
name='eo_facture_facture_sheet',
|
|
),
|
|
re_path(
|
|
r'^(.+)/cancel/',
|
|
self.admin_site.admin_view(views.cancel),
|
|
name='eo_facture_facture_cancel',
|
|
),
|
|
]
|
|
return my_urls + urls
|
|
|
|
@admin.display(description='Client')
|
|
def show_client(self, obj):
|
|
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)
|
|
|
|
@admin.display(description='Contrat / Intitulé')
|
|
def show_contrat(self, obj):
|
|
if obj.contrat:
|
|
url = reverse('admin:eo_facture_contrat_change', args=[obj.contrat.id])
|
|
if str(obj.contrat) != obj.intitule:
|
|
return format_html('<a href="{0}">{1}</a><br />{2}', url, obj.contrat, obj.intitule)
|
|
else:
|
|
return format_html('<a href="{0}">{1}</a>', url, obj.contrat)
|
|
return format_html('Pas de contrat<br />{0}', obj.intitule)
|
|
|
|
# adapt get_object and get_changelist_instance to produce and accept URL
|
|
# with facture's code
|
|
FACTURE_RE = re.compile('^F(?P<year>20[0-9]{2})(?P<ordre>[0-9]{4})$')
|
|
|
|
def get_object(self, request, object_id, from_field=None):
|
|
m = self.FACTURE_RE.match(object_id)
|
|
if m:
|
|
queryset = self.get_queryset(request)
|
|
model = queryset.model
|
|
year = int(m.group('year'))
|
|
ordre = int(m.group('ordre'))
|
|
try:
|
|
return queryset.get(emission__year=year, ordre=ordre)
|
|
except model.DoesNotExist:
|
|
pass
|
|
return super().get_object(request, object_id, from_field=from_field)
|
|
|
|
def get_changelist_instance(self, request):
|
|
changelist = super().get_changelist_instance(request)
|
|
changelist.pk_attname = 'pk_or_code'
|
|
return changelist
|
|
|
|
def get_fields(self, request, obj=None):
|
|
fields = list(super().get_fields(request, obj=obj))
|
|
if obj and obj.contrat and obj.contrat.periodicite and not obj.annulation:
|
|
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
|
|
list_display = ['facture', 'ligne_banque_pop', 'montant_affecte']
|
|
list_filter = ['ligne_banque_pop__date_valeur', 'facture', 'facture__contrat', 'facture__contrat__client']
|
|
list_select_related = True
|
|
|
|
|
|
class PrestationAdmin(admin.ModelAdmin):
|
|
form = forms.PrestationForm
|
|
list_select_related = True
|
|
|
|
|
|
admin.site.register(models.Client, ClientAdmin)
|
|
admin.site.register(models.Contrat, ContratAdmin)
|
|
admin.site.register(models.Facture, FactureAdmin)
|
|
admin.site.register(models.Prestation, PrestationAdmin)
|
|
|
|
eo_gestion.admin.site.register(models.Client, ClientAdmin)
|
|
eo_gestion.admin.site.register(models.Contrat, ContratAdmin)
|
|
eo_gestion.admin.site.register(models.Facture, FactureAdmin)
|
|
eo_gestion.admin.site.register(models.Payment, PaymentAdmin)
|