Multi-utilisateur : droits par entreprise + renommage Operation bancaire

This commit is contained in:
2026-06-30 08:33:05 +02:00
parent 80042faf0f
commit 688fa3fc97
9 changed files with 157 additions and 71 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+48 -10
View File
@@ -2,11 +2,34 @@ from django.contrib import admin
from django.utils import timezone
from .models import (
Bareme, Entreprise, Client, Facture, LigneFacture, Depense, Paiement,
Categorie,
Bareme, Entreprise, Client, Facture, LigneFacture, Depense, Paiement, Categorie,
)
class ScopedAdmin(admin.ModelAdmin):
"""Cloisonne les données par entreprise pour les non-superadmins."""
scope_path = "entreprise"
def get_queryset(self, request):
qs = super().get_queryset(request)
if request.user.is_superuser:
return qs
return qs.filter(**{f"{self.scope_path}__in": Entreprise.accessibles(request.user)})
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if not request.user.is_superuser:
acc = Entreprise.accessibles(request.user)
if db_field.name == "entreprise":
kwargs["queryset"] = acc
elif db_field.name == "client":
kwargs["queryset"] = Client.objects.filter(entreprise__in=acc)
elif db_field.name == "paiement":
kwargs["queryset"] = Paiement.objects.filter(entreprise__in=acc)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
# ----- Référentiels mutualisés (visibles par tous) -----
@admin.register(Categorie)
class CategorieAdmin(admin.ModelAdmin):
list_display = ("nom", "usage", "exclure_calculs", "actif", "ordre")
@@ -22,16 +45,33 @@ class BaremeAdmin(admin.ModelAdmin):
list_filter = ("annee", "activite")
# ----- Entreprise (accès) -----
@admin.register(Entreprise)
class EntrepriseAdmin(admin.ModelAdmin):
list_display = ("nom", "activite", "franchise_tva", "versement_liberatoire",
"num_tva_intracom", "vue_tva", "email")
list_filter = ("activite", "franchise_tva", "versement_liberatoire")
search_fields = ("nom", "siret", "num_tva_intracom")
filter_horizontal = ("utilisateurs", "groupes")
def get_queryset(self, request):
qs = super().get_queryset(request)
if request.user.is_superuser:
return qs
return Entreprise.accessibles(request.user)
def get_readonly_fields(self, request, obj=None):
# Seul un superadmin gère les droits d'accès.
if not request.user.is_superuser:
return ("utilisateurs", "groupes")
return ()
# ----- Données cloisonnées par entreprise -----
@admin.register(Client)
class ClientAdmin(admin.ModelAdmin):
class ClientAdmin(ScopedAdmin):
list_display = ("nom", "entreprise", "email", "siret")
list_filter = ("entreprise",)
search_fields = ("nom", "email", "siret")
@@ -48,10 +88,9 @@ def marquer_encaissee(modeladmin, request, queryset):
@admin.register(Facture)
class FactureAdmin(admin.ModelAdmin):
class FactureAdmin(ScopedAdmin):
list_display = ("numero", "entreprise", "client", "date_emission",
"date_encaissement", "statut", "montant_ht",
"montant_tva", "montant_ttc")
"date_encaissement", "statut", "montant_ht", "montant_tva", "montant_ttc")
list_filter = ("entreprise", "statut", "activite", "date_emission")
search_fields = ("numero", "client__nom")
date_hierarchy = "date_emission"
@@ -72,7 +111,7 @@ class DepenseInline(admin.TabularInline):
@admin.register(Paiement)
class PaiementAdmin(admin.ModelAdmin):
class PaiementAdmin(ScopedAdmin):
list_display = ("date", "entreprise", "libelle", "sens", "montant_total",
"reparti", "reste", "equilibre")
list_filter = ("entreprise", "sens", "date")
@@ -94,10 +133,9 @@ class PaiementAdmin(admin.ModelAdmin):
@admin.register(Depense)
class DepenseAdmin(admin.ModelAdmin):
class DepenseAdmin(ScopedAdmin):
list_display = ("date", "entreprise", "libelle", "categorie", "activite",
"regime_tva", "montant_ttc", "montant_tva", "tva_autoliq",
"paiement")
"regime_tva", "montant_ttc", "montant_tva", "tva_autoliq", "paiement")
list_filter = ("entreprise", "categorie", "regime_tva", "activite", "date")
search_fields = ("libelle",)
date_hierarchy = "date"
@@ -0,0 +1,46 @@
# Generated by Django 5.2.15 on 2026-06-30 06:30
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('compta', '0006_paiement_categorie_alter_depense_paiement_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
name='paiement',
options={'ordering': ['-date'], 'verbose_name': 'Opération bancaire', 'verbose_name_plural': 'Opérations bancaires'},
),
migrations.AddField(
model_name='entreprise',
name='groupes',
field=models.ManyToManyField(blank=True, help_text='Tous les membres de ces groupes ont accès à cette entreprise.', related_name='entreprises', to='auth.group', verbose_name='Groupes autorisés'),
),
migrations.AddField(
model_name='entreprise',
name='utilisateurs',
field=models.ManyToManyField(blank=True, help_text='Utilisateurs ayant accès directement à cette entreprise.', related_name='entreprises', to=settings.AUTH_USER_MODEL, verbose_name='Utilisateurs autorisés'),
),
migrations.AlterField(
model_name='depense',
name='paiement',
field=models.ForeignKey(blank=True, help_text='Opération bancaire dont cette dépense fait partie.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='depenses', to='compta.paiement', verbose_name='Opération bancaire'),
),
migrations.AlterField(
model_name='entreprise',
name='franchise_tva',
field=models.BooleanField(default=False, help_text='Décochez si vous facturez la TVA.', verbose_name='En franchise de TVA'),
),
migrations.AlterField(
model_name='paiement',
name='categorie',
field=models.ForeignKey(blank=True, help_text='Surtout pour les mouvements neutres.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='paiements', to='compta.categorie'),
),
]
+33 -17
View File
@@ -3,8 +3,9 @@ Modèles de données — JB Compta.
"""
from decimal import Decimal
from django.conf import settings
from django.db import models
from django.db.models import Sum
from django.db.models import Sum, Q
class Activite(models.TextChoices):
@@ -27,7 +28,7 @@ class RegimeTva(models.TextChoices):
class Categorie(models.Model):
"""Catégorie globale. exclure_calculs=True => mouvement neutre (hors fiscal)."""
"""Catégorie globale (mutualisée). exclure_calculs=True => mouvement neutre."""
USAGE = [("depense", "Dépense"), ("vente", "Vente"), ("neutre", "Mouvement neutre")]
nom = models.CharField(max_length=100, unique=True)
usage = models.CharField(max_length=10, choices=USAGE, default="depense")
@@ -48,6 +49,7 @@ class Categorie(models.Model):
class Bareme(models.Model):
"""Barème mutualisé (partagé entre toutes les entreprises)."""
activite = models.CharField(max_length=20, choices=Activite.choices)
annee = models.PositiveIntegerField()
taux_urssaf = models.DecimalField("Taux cotisations URSSAF (%)", max_digits=5, decimal_places=2)
@@ -73,18 +75,24 @@ class Entreprise(models.Model):
siret = models.CharField(max_length=20, blank=True)
activite = models.CharField(
"Activité principale", max_length=20, choices=Activite.choices,
default=Activite.SERVICE_BNC,
help_text="Activité par défaut des nouvelles factures.")
default=Activite.SERVICE_BNC, help_text="Activité par défaut des nouvelles factures.")
franchise_tva = models.BooleanField(
"En franchise de TVA", default=False,
help_text="Décochez si vous facturez et déclarez la TVA.")
"En franchise de TVA", default=False, help_text="Décochez si vous facturez la TVA.")
taux_tva_defaut = models.DecimalField(
"Taux de TVA par défaut (%)", max_digits=4, decimal_places=2, default=Decimal("20.00"))
versement_liberatoire = models.BooleanField("Option versement libératoire", default=False)
num_tva_intracom = models.CharField("N° TVA intracommunautaire", max_length=20, blank=True)
vue_tva = models.CharField(
"Mode d'affichage de la déclaration TVA", max_length=10,
choices=VUE_TVA, default="simplifie")
"Mode d'affichage de la déclaration TVA", max_length=10, choices=VUE_TVA, default="simplifie")
# --- Droits d'accès (multi-utilisateur) ---
utilisateurs = models.ManyToManyField(
settings.AUTH_USER_MODEL, blank=True, related_name="entreprises",
verbose_name="Utilisateurs autorisés",
help_text="Utilisateurs ayant accès directement à cette entreprise.")
groupes = models.ManyToManyField(
"auth.Group", blank=True, related_name="entreprises",
verbose_name="Groupes autorisés",
help_text="Tous les membres de ces groupes ont accès à cette entreprise.")
email = models.EmailField(blank=True)
adresse = models.TextField(blank=True)
cree_le = models.DateTimeField(auto_now_add=True)
@@ -100,6 +108,16 @@ class Entreprise(models.Model):
def bareme(self, annee):
return Bareme.objects.filter(activite=self.activite, annee=annee).first()
@classmethod
def accessibles(cls, user):
"""Entreprises visibles par un utilisateur (toutes si superadmin)."""
if not user or not user.is_authenticated:
return cls.objects.none()
if user.is_superuser:
return cls.objects.all()
return cls.objects.filter(
Q(utilisateurs=user) | Q(groupes__in=user.groups.all())).distinct()
class Client(models.Model):
entreprise = models.ForeignKey(Entreprise, on_delete=models.CASCADE, related_name="clients")
@@ -125,8 +143,7 @@ class Facture(models.Model):
activite = models.CharField(max_length=20, choices=Activite.choices)
date_emission = models.DateField()
date_encaissement = models.DateField(
null=True, blank=True,
help_text="Le CA micro est compté à l'encaissement.")
null=True, blank=True, help_text="Le CA micro est compté à l'encaissement.")
statut = models.CharField(max_length=12, choices=StatutFacture.choices, default=StatutFacture.BROUILLON)
montant_ht = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0"))
montant_tva = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0"))
@@ -184,15 +201,14 @@ class Paiement(models.Model):
libelle = models.CharField("Libellé bancaire", max_length=255)
montant_total = models.DecimalField(max_digits=12, decimal_places=2)
sens = models.CharField(max_length=6, choices=SENS, default="debit")
categorie = models.ForeignKey(Categorie, on_delete=models.SET_NULL,
null=True, blank=True, related_name="paiements",
help_text="Surtout pour les mouvements neutres "
"(remboursement TVA, virement…).")
categorie = models.ForeignKey(Categorie, on_delete=models.SET_NULL, null=True, blank=True,
related_name="paiements",
help_text="Surtout pour les mouvements neutres.")
cree_le = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Paiement"
verbose_name_plural = "Paiements"
verbose_name = "Opération bancaire"
verbose_name_plural = "Opérations bancaires"
ordering = ["-date"]
def __str__(self):
@@ -215,7 +231,7 @@ class Paiement(models.Model):
class Depense(models.Model):
entreprise = models.ForeignKey(Entreprise, on_delete=models.CASCADE, related_name="depenses")
paiement = models.ForeignKey(Paiement, on_delete=models.SET_NULL, null=True, blank=True,
related_name="depenses",
related_name="depenses", verbose_name="Opération bancaire",
help_text="Opération bancaire dont cette dépense fait partie.")
date = models.DateField()
activite = models.CharField(
+10 -8
View File
@@ -12,20 +12,22 @@ MOIS = services.MOIS_FR
def _entreprise(request):
"""Entreprise courante, restreinte à celles accessibles par l'utilisateur."""
accessibles = Entreprise.accessibles(request.user)
eid = request.GET.get("entreprise")
if eid:
return get_object_or_404(Entreprise, pk=eid)
return Entreprise.objects.first()
return get_object_or_404(accessibles, pk=eid)
return accessibles.first()
def _annee(request):
return int(request.GET.get("annee", date.today().year))
def _base_ctx(entreprise, annee, vue):
def _base_ctx(request, entreprise, annee, vue):
return {
"entreprise": entreprise,
"entreprises": Entreprise.objects.all(),
"entreprises": Entreprise.accessibles(request.user),
"annee": annee,
"annees": services.annees_disponibles(entreprise) or [annee],
"vue": vue,
@@ -40,7 +42,7 @@ def dashboard(request):
return render(request, "compta/dashboard.html", {"entreprise": None})
annee = _annee(request)
ctx = services.synthese(entreprise, annee)
ctx.update(_base_ctx(entreprise, annee, "global"))
ctx.update(_base_ctx(request, entreprise, annee, "global"))
ctx.update({
"mois_labels": ["Jan", "Fév", "Mar", "Avr", "Mai", "Juin",
"Juil", "Août", "Sep", "Oct", "Nov", "Déc"],
@@ -55,7 +57,7 @@ def vue_mensuelle(request):
if entreprise is None:
return render(request, "compta/mensuel.html", {"entreprise": None})
annee = _annee(request)
ctx = _base_ctx(entreprise, annee, "mensuel")
ctx = _base_ctx(request, entreprise, annee, "mensuel")
ctx["rows"] = services.tableau_mensuel(entreprise, annee)
return render(request, "compta/mensuel.html", ctx)
@@ -67,7 +69,7 @@ def declaration_urssaf(request):
return render(request, "compta/decl_urssaf.html", {"entreprise": None})
annee = _annee(request)
mois = int(request.GET.get("mois", date.today().month))
ctx = _base_ctx(entreprise, annee, "urssaf")
ctx = _base_ctx(request, entreprise, annee, "urssaf")
ctx["mois_sel"] = mois
ctx["decl"] = services.declaration_urssaf(entreprise, annee, mois)
return render(request, "compta/decl_urssaf.html", ctx)
@@ -81,7 +83,7 @@ def declaration_tva(request):
annee = _annee(request)
mois = int(request.GET.get("mois", date.today().month))
mode = request.GET.get("mode") or entreprise.vue_tva
ctx = _base_ctx(entreprise, annee, "tva")
ctx = _base_ctx(request, entreprise, annee, "tva")
ctx["mois_sel"] = mois
ctx["mode"] = mode
ctx["decl"] = services.declaration_tva(entreprise, annee, mois)
+20 -36
View File
@@ -1,8 +1,9 @@
"""
Saisie unifiée : une opération bancaire signée.
- Montant négatif -> dépense(s) (éclatement possible).
- Montant positif -> facture (vente) avec création de client à la volée,
- Montant positif -> facture (vente) + création de client à la volée,
OU mouvement neutre (ex. remboursement de TVA).
Cloisonnée : un utilisateur ne voit que ses entreprises.
"""
from decimal import Decimal, InvalidOperation
from datetime import date
@@ -37,7 +38,7 @@ def _numero_unique(entreprise, annee):
def _context(entreprises, post=None):
return {
"entreprises": entreprises,
"clients": Client.objects.select_related("entreprise"),
"clients": Client.objects.filter(entreprise__in=entreprises).select_related("entreprise"),
"activites": Activite.choices,
"regimes": RegimeTva.choices,
"categories_depense": Categorie.objects.filter(actif=True).exclude(usage="vente"),
@@ -49,7 +50,7 @@ def _context(entreprises, post=None):
@login_required
def saisie_paiement(request):
entreprises = Entreprise.objects.all()
entreprises = Entreprise.accessibles(request.user)
if request.method != "POST":
return render(request, "compta/saisie_paiement.html", _context(entreprises))
@@ -61,34 +62,28 @@ def saisie_paiement(request):
erreurs = []
if not entreprise:
erreurs.append("Sélectionne une entreprise.")
erreurs.append("Sélectionne une entreprise autorisée.")
if not libelle:
erreurs.append("Renseigne le libellé du paiement.")
erreurs.append("Renseigne le libellé de l'opération.")
if montant_total == 0:
erreurs.append("Le montant ne peut pas être nul.")
if erreurs:
for e in erreurs:
messages.error(request, e)
return render(request, "compta/saisie_paiement.html",
_context(entreprises, request.POST))
return render(request, "compta/saisie_paiement.html", _context(entreprises, request.POST))
cats = {str(c.pk): c for c in Categorie.objects.all()}
# ---------------------------------------------------------------
# CRÉDIT (montant positif) -> facture (vente) ou mouvement neutre
# ---------------------------------------------------------------
# --- CRÉDIT (positif) -> facture (vente) ou mouvement neutre ---
if montant_total > 0:
cat = cats.get(request.POST.get("v_categorie"))
if cat and cat.exclure_calculs:
# Mouvement neutre (ex. remboursement de TVA) : pas de CA.
Paiement.objects.create(
entreprise=entreprise, date=d, libelle=libelle,
montant_total=montant_total, sens="credit", categorie=cat)
messages.success(request, f"Mouvement neutre « {libelle} » enregistré.")
return redirect("saisie_paiement")
# Vente -> facture encaissée
client = None
nouveau = (request.POST.get("v_client_nouveau") or "").strip()
if nouveau:
@@ -98,29 +93,23 @@ def saisie_paiement(request):
entreprise=entreprise, pk=request.POST.get("v_client")).first()
if not client:
messages.error(request, "Choisis un client existant ou saisis-en un nouveau.")
return render(request, "compta/saisie_paiement.html",
_context(entreprises, request.POST))
return render(request, "compta/saisie_paiement.html", _context(entreprises, request.POST))
activite = request.POST.get("v_activite") or entreprise.activite
taux = _dec(request.POST.get("v_taux"), "0")
designation = (request.POST.get("v_designation") or libelle).strip()
ttc = montant_total
ht = (ttc / (Decimal("1") + taux / Decimal("100"))).quantize(Decimal("0.01"))
f = Facture.objects.create(
entreprise=entreprise, client=client,
numero=_numero_unique(entreprise, annee), activite=activite,
date_emission=d, date_encaissement=d, statut="encaissee")
LigneFacture.objects.create(
facture=f, designation=designation, quantite=Decimal("1"),
prix_unitaire_ht=ht, taux_tva=taux)
entreprise=entreprise, client=client, numero=_numero_unique(entreprise, annee),
activite=activite, date_emission=d, date_encaissement=d, statut="encaissee")
LigneFacture.objects.create(facture=f, designation=designation, quantite=Decimal("1"),
prix_unitaire_ht=ht, taux_tva=taux)
f.recompute_totaux()
messages.success(request, f"Vente « {libelle} » enregistrée (facture {f.numero}).")
return redirect("saisie_paiement")
# ---------------------------------------------------------------
# DÉBIT (montant négatif) -> dépense(s), éclatement possible
# ---------------------------------------------------------------
# --- DÉBIT (négatif) -> dépense(s), éclatement possible ---
total_abs = -montant_total
libelles = request.POST.getlist("l_libelle")
categories = request.POST.getlist("l_categorie")
@@ -149,24 +138,19 @@ def saisie_paiement(request):
somme = sum((l["montant"] for l in lignes), Decimal("0"))
if not lignes:
messages.error(request, "Ajoute au moins une ligne de dépense.")
return render(request, "compta/saisie_paiement.html",
_context(entreprises, request.POST))
return render(request, "compta/saisie_paiement.html", _context(entreprises, request.POST))
if abs(somme - total_abs) >= Decimal("0.01"):
messages.error(request,
f"La somme des lignes ({somme} €) ne correspond pas au montant "
f"({total_abs} €). Écart : {somme - total_abs} €.")
return render(request, "compta/saisie_paiement.html",
_context(entreprises, request.POST))
return render(request, "compta/saisie_paiement.html", _context(entreprises, request.POST))
paiement = Paiement.objects.create(
entreprise=entreprise, date=d, libelle=libelle,
montant_total=total_abs, sens="debit")
entreprise=entreprise, date=d, libelle=libelle, montant_total=total_abs, sens="debit")
for l in lignes:
Depense.objects.create(
entreprise=entreprise, paiement=paiement, date=d,
libelle=l["libelle"], categorie=l["categorie"],
activite=l["activite"] or "", regime_tva=l["regime"],
entreprise=entreprise, paiement=paiement, date=d, libelle=l["libelle"],
categorie=l["categorie"], activite=l["activite"] or "", regime_tva=l["regime"],
taux_tva=l["taux"], montant_ttc=l["montant"], montant_tva=l["tva"])
messages.success(request,
f"Paiement « {libelle} » enregistré ({len(lignes)} dépense(s)).")
messages.success(request, f"Opération « {libelle} » enregistrée ({len(lignes)} dépense(s)).")
return redirect("saisie_paiement")