Catégories globales + flag neutre + seed_reference

This commit is contained in:
2026-06-30 00:43:18 +02:00
parent 0398e45f2f
commit 54f9fc56f4
29 changed files with 210 additions and 56 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18 -8
View File
@@ -3,9 +3,18 @@ from django.utils import timezone
from .models import (
Bareme, Entreprise, Client, Facture, LigneFacture, Depense, Paiement,
Categorie,
)
@admin.register(Categorie)
class CategorieAdmin(admin.ModelAdmin):
list_display = ("nom", "usage", "exclure_calculs", "actif", "ordre")
list_filter = ("usage", "exclure_calculs", "actif")
list_editable = ("usage", "exclure_calculs", "actif", "ordre")
search_fields = ("nom",)
@admin.register(Bareme)
class BaremeAdmin(admin.ModelAdmin):
list_display = ("annee", "activite", "taux_urssaf", "abattement_fiscal",
@@ -15,8 +24,8 @@ class BaremeAdmin(admin.ModelAdmin):
@admin.register(Entreprise)
class EntrepriseAdmin(admin.ModelAdmin):
list_display = ("nom", "activite", "franchise_tva",
"versement_liberatoire", "num_tva_intracom", "email")
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")
@@ -58,8 +67,8 @@ class FactureAdmin(admin.ModelAdmin):
class DepenseInline(admin.TabularInline):
model = Depense
extra = 0
fields = ("date", "libelle", "activite", "regime_tva", "taux_tva",
"montant_ttc", "montant_tva")
fields = ("date", "libelle", "categorie", "activite", "regime_tva",
"taux_tva", "montant_ttc", "montant_tva")
@admin.register(Paiement)
@@ -86,10 +95,11 @@ class PaiementAdmin(admin.ModelAdmin):
@admin.register(Depense)
class DepenseAdmin(admin.ModelAdmin):
list_display = ("date", "entreprise", "libelle", "activite", "regime_tva",
"montant_ttc", "montant_tva", "tva_autoliq", "paiement")
list_filter = ("entreprise", "regime_tva", "activite", "categorie", "date")
search_fields = ("libelle", "categorie")
list_display = ("date", "entreprise", "libelle", "categorie", "activite",
"regime_tva", "montant_ttc", "montant_tva", "tva_autoliq",
"paiement")
list_filter = ("entreprise", "categorie", "regime_tva", "activite", "date")
search_fields = ("libelle",)
date_hierarchy = "date"
@admin.display(description="TVA auto-liquidée")
+18 -33
View File
@@ -1,5 +1,8 @@
"""
Charge les barèmes 2026 et un jeu de données d'exemple (multi-activité + autoliq).
Charge les données de RÉFÉRENCE (barèmes + catégories) puis un jeu de
données d'EXEMPLE (entreprise, clients, factures, dépenses).
Pour la prod, préférez `seed_reference` (référence seule, sans démo).
Usage : python manage.py seed_demo
"""
from decimal import Decimal
@@ -8,43 +11,24 @@ from datetime import date
from django.core.management.base import BaseCommand
from compta.models import (
Bareme, Entreprise, Client, Facture, LigneFacture, Depense, Activite,
Entreprise, Client, Facture, LigneFacture, Depense, Activite,
StatutFacture, RegimeTva,
)
# Barèmes officiels 2026 (voir docs/regles-fiscales-2026.md)
BAREMES_2026 = [
# activite, taux_urssaf, abattement, taux_vl, seuil_ca, tva_base, tva_majore
(Activite.VENTE, Decimal("12.30"), Decimal("71"), Decimal("1.0"),
Decimal("203100"), Decimal("85000"), Decimal("93500")),
(Activite.SERVICE_BIC, Decimal("21.20"), Decimal("50"), Decimal("1.7"),
Decimal("83600"), Decimal("37500"), Decimal("41250")),
(Activite.SERVICE_BNC, Decimal("25.60"), Decimal("34"), Decimal("2.2"),
Decimal("83600"), Decimal("37500"), Decimal("41250")),
]
from compta.management.commands.seed_reference import charger_reference
class Command(BaseCommand):
help = "Charge les barèmes 2026 et des données d'exemple."
help = "Charge la référence + des données d'exemple."
def handle(self, *args, **options):
for act, urssaf, ab, vl, sca, tb, tm in BAREMES_2026:
Bareme.objects.update_or_create(
activite=act, annee=2026,
defaults=dict(taux_urssaf=urssaf, abattement_fiscal=ab,
taux_vl=vl, seuil_ca=sca,
seuil_tva_base=tb, seuil_tva_majore=tm),
)
self.stdout.write(self.style.SUCCESS("Barèmes 2026 chargés."))
cat = charger_reference(self.stdout, self.style)
# Entreprise multi-activité : impression 3D (Vente BIC) + aide info (Presta BIC)
ent, _ = Entreprise.objects.get_or_create(
nom="JB Conseil",
defaults=dict(activite=Activite.SERVICE_BIC, franchise_tva=False,
taux_tva_defaut=Decimal("20.00"),
versement_liberatoire=True, email="jb@example.fr",
num_tva_intracom="FR00123456789"),
)
num_tva_intracom="FR00123456789"))
clients = {}
for nom, mail in [("Acme SARL", "contact@acme.fr"),
@@ -56,7 +40,6 @@ class Command(BaseCommand):
V, S = Activite.VENTE, Activite.SERVICE_BIC
factures = [
# numero, client, activite, emission, encaissement, statut, lignes
("2026-001", "Acme SARL", S, date(2026, 1, 10), date(2026, 1, 25),
StatutFacture.ENCAISSEE, [("Dépannage informatique", 2, 600)]),
("2026-002", "Studio Lumiere", V, date(2026, 1, 18), date(2026, 2, 5),
@@ -73,8 +56,7 @@ class Command(BaseCommand):
entreprise=ent, numero=numero,
defaults=dict(client=clients[client_nom], activite=activite,
date_emission=emis, date_encaissement=enc,
statut=statut),
)
statut=statut))
if created:
for des, qte, pu in lignes:
LigneFacture.objects.create(
@@ -84,18 +66,21 @@ class Command(BaseCommand):
Depense.objects.get_or_create(
entreprise=ent, libelle="Bobines PLA/PETG", date=date(2026, 1, 15),
defaults=dict(categorie="Matiere", activite=Activite.VENTE,
defaults=dict(categorie=cat["Matière première"], activite=Activite.VENTE,
montant_ttc=Decimal("96.00"), montant_tva=Decimal("16.00")))
Depense.objects.get_or_create(
entreprise=ent, libelle="Abonnement outils", date=date(2026, 2, 10),
defaults=dict(categorie="Logiciel", activite=Activite.SERVICE_BIC,
defaults=dict(categorie=cat["Abonnement logiciel"],
activite=Activite.SERVICE_BIC,
montant_ttc=Decimal("34.80"), montant_tva=Decimal("5.80")))
# Achat intracommunautaire (ex. imprimante depuis l'Allemagne) :
# le fournisseur facture HT, on auto-liquide la TVA à 20 %.
Depense.objects.get_or_create(
entreprise=ent, libelle="Imprimante 3D (DE)", date=date(2026, 2, 20),
defaults=dict(categorie="Materiel", activite=Activite.VENTE,
defaults=dict(categorie=cat["Matériel et outillage"], activite=Activite.VENTE,
regime_tva=RegimeTva.INTRACOM, taux_tva=Decimal("20"),
montant_ttc=Decimal("500.00"), montant_tva=Decimal("0")))
Depense.objects.get_or_create(
entreprise=ent, libelle="URSSAF DE LORRAINE", date=date(2026, 3, 3),
defaults=dict(categorie=cat["Cotisation sociale Urssaf"],
montant_ttc=Decimal("73.00"), montant_tva=Decimal("0")))
self.stdout.write(self.style.SUCCESS("Données d'exemple chargées."))
@@ -0,0 +1,60 @@
"""
Charge les DONNÉES DE RÉFÉRENCE (barèmes + catégories) — sans données de démo.
Idempotent : sûr à relancer en production après chaque mise à jour.
Usage : python manage.py seed_reference
"""
from decimal import Decimal
from django.core.management.base import BaseCommand
from compta.models import Bareme, Categorie, Activite
BAREMES_2026 = [
(Activite.VENTE, Decimal("12.30"), Decimal("71"), Decimal("1.0"),
Decimal("203100"), Decimal("85000"), Decimal("93500")),
(Activite.SERVICE_BIC, Decimal("21.20"), Decimal("50"), Decimal("1.7"),
Decimal("83600"), Decimal("37500"), Decimal("41250")),
(Activite.SERVICE_BNC, Decimal("25.60"), Decimal("34"), Decimal("2.2"),
Decimal("83600"), Decimal("37500"), Decimal("41250")),
]
# exclure_calculs=True => mouvement neutre (hors calculs fiscaux).
CATEGORIES = [
("Vente de produits finis", "vente", False, 1),
("Prestation de services", "vente", False, 2),
("Matériel et outillage", "depense", False, 1),
("Matière première", "depense", False, 2),
("Télécom, fournitures, documentation", "depense", False, 3),
("Frais de repas hors domicile", "depense", False, 4),
("Abonnement logiciel", "depense", False, 5),
("TVA payée", "neutre", True, 1),
("Cotisation sociale Urssaf", "neutre", True, 2),
("Remboursement de TVA", "neutre", True, 3),
("Prélèvement personnel", "neutre", True, 4),
("Virement interne", "neutre", True, 5),
]
def charger_reference(stdout=None, style=None):
for act, urssaf, ab, vl, sca, tb, tm in BAREMES_2026:
Bareme.objects.update_or_create(
activite=act, annee=2026,
defaults=dict(taux_urssaf=urssaf, abattement_fiscal=ab, taux_vl=vl,
seuil_ca=sca, seuil_tva_base=tb, seuil_tva_majore=tm))
cat = {}
for nom, usage, excl, ordre in CATEGORIES:
c, _ = Categorie.objects.update_or_create(
nom=nom, defaults=dict(usage=usage, exclure_calculs=excl, ordre=ordre))
cat[nom] = c
if stdout and style:
stdout.write(style.SUCCESS(
f"Référence chargée : {len(BAREMES_2026)} barèmes, {len(cat)} catégories."))
return cat
class Command(BaseCommand):
help = "Charge les barèmes et catégories de référence (sans démo)."
def handle(self, *args, **options):
charger_reference(self.stdout, self.style)
@@ -0,0 +1,46 @@
# Catégories globales + vue TVA + passage de Depense.categorie en clé étrangère.
# Note : on remplace l'ancien champ texte `categorie` par une FK plutôt que de
# l'altérer (un AlterField texte->FK échouerait sur PostgreSQL avec des données).
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('compta', '0004_paiement_depense_paiement'),
]
operations = [
migrations.CreateModel(
name='Categorie',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('nom', models.CharField(max_length=100, unique=True)),
('usage', models.CharField(choices=[('depense', 'Dépense'), ('vente', 'Vente'), ('neutre', 'Mouvement neutre')], default='depense', max_length=10)),
('exclure_calculs', models.BooleanField(default=False, help_text='Cochez pour les mouvements non fiscaux : TVA payée, cotisations URSSAF, remboursement de TVA, virements…', verbose_name='Neutre (exclue des calculs)')),
('actif', models.BooleanField(default=True)),
('ordre', models.IntegerField(default=0)),
],
options={
'verbose_name': 'Catégorie',
'verbose_name_plural': 'Catégories',
'ordering': ['usage', 'ordre', 'nom'],
},
),
migrations.AddField(
model_name='entreprise',
name='vue_tva',
field=models.CharField(choices=[('simplifie', 'Résumé simplifié'), ('ca3', 'Champs CA3 officiels')], default='simplifie', max_length=10, verbose_name="Mode d'affichage de la déclaration TVA"),
),
# Ancien champ texte abandonné, remplacé par une FK propre.
migrations.RemoveField(
model_name='depense',
name='categorie',
),
migrations.AddField(
model_name='depense',
name='categorie',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='depenses', to='compta.categorie'),
),
]
+46 -3
View File
@@ -31,6 +31,36 @@ class RegimeTva(models.TextChoices):
IMPORT = "import", "Import hors UE (autoliquidation)"
class Categorie(models.Model):
"""Catégorie globale (partagée entre entreprises).
Une catégorie « neutre » (exclure_calculs=True) sert au suivi de trésorerie
mais n'entre PAS dans les calculs fiscaux : paiement d'URSSAF, de TVA,
remboursement de TVA, prélèvement personnel, virement interne…
"""
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")
exclure_calculs = models.BooleanField(
"Neutre (exclue des calculs)", default=False,
help_text="Cochez pour les mouvements non fiscaux : TVA payée, "
"cotisations URSSAF, remboursement de TVA, virements…")
actif = models.BooleanField(default=True)
ordre = models.IntegerField(default=0)
class Meta:
verbose_name = "Catégorie"
verbose_name_plural = "Catégories"
ordering = ["usage", "ordre", "nom"]
def __str__(self):
return self.nom
class Bareme(models.Model):
"""Taux et seuils officiels, par activité et par année (paramétrable)."""
activite = models.CharField(max_length=20, choices=Activite.choices)
@@ -59,6 +89,7 @@ class Bareme(models.Model):
class Entreprise(models.Model):
VUE_TVA = [("simplifie", "Résumé simplifié"), ("ca3", "Champs CA3 officiels")]
nom = models.CharField(max_length=200)
siret = models.CharField(max_length=20, blank=True)
activite = models.CharField(
@@ -77,6 +108,9 @@ class Entreprise(models.Model):
"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")
email = models.EmailField(blank=True)
adresse = models.TextField(blank=True)
cree_le = models.DateTimeField(auto_now_add=True)
@@ -225,7 +259,8 @@ class Depense(models.Model):
activite = models.CharField(
max_length=20, choices=Activite.choices, blank=True,
help_text="Activité concernée (facultatif, pour la ventilation).")
categorie = models.CharField(max_length=100, blank=True)
categorie = models.ForeignKey(Categorie, on_delete=models.SET_NULL,
null=True, blank=True, related_name="depenses")
libelle = models.CharField(max_length=255)
montant_ttc = models.DecimalField(
"Montant payé (€)", max_digits=12, decimal_places=2,
@@ -251,6 +286,11 @@ class Depense(models.Model):
def __str__(self):
return f"{self.date}{self.libelle}"
@property
def neutre(self):
"""Mouvement neutre : exclu des calculs fiscaux."""
return bool(self.categorie and self.categorie.exclure_calculs)
@property
def autoliquidee(self):
"""True si la TVA est auto-liquidée (achat UE ou import)."""
@@ -266,14 +306,17 @@ class Depense(models.Model):
@property
def tva_autoliquidee(self):
"""TVA française auto-liquidée (comptée en collectée ET en déductible)."""
if self.autoliquidee:
if self.autoliquidee and not self.neutre:
return (self.montant_ttc * self.taux_tva / Decimal("100")).quantize(
Decimal("0.01"))
return Decimal("0.00")
@property
def tva_deductible_value(self):
"""TVA récupérable : facturée (national) ou auto-liquidée (UE/import)."""
"""TVA récupérable : facturée (national) ou auto-liquidée (UE/import).
Nulle pour un mouvement neutre."""
if self.neutre:
return Decimal("0.00")
if self.autoliquidee:
return self.tva_autoliquidee
return self.montant_tva
+11 -7
View File
@@ -80,11 +80,11 @@
<table>
<thead>
<tr>
<th style="width:24%">Libellé / vendeur</th>
<th style="width:14%">Catégorie</th>
<th style="width:16%">Activité</th>
<th style="width:16%">Régime TVA</th>
<th style="width:8%">Taux %</th>
<th style="width:22%">Libellé / vendeur</th>
<th style="width:16%">Catégorie</th>
<th style="width:15%">Activité</th>
<th style="width:15%">Régime TVA</th>
<th style="width:7%">Taux %</th>
<th style="width:11%">Montant €</th>
<th style="width:11%">TVA déduct. €</th>
<th></th>
@@ -111,7 +111,12 @@
<template id="rowtpl">
<tr>
<td><input type="text" name="l_libelle" placeholder="Vendeur / objet"></td>
<td><input type="text" name="l_categorie" placeholder="ex. Matériel"></td>
<td>
<select name="l_categorie">
<option value=""></option>
{% for c in categories %}<option value="{{ c.pk }}">{{ c.nom }}</option>{% endfor %}
</select>
</td>
<td>
<select name="l_activite">
<option value=""></option>
@@ -145,7 +150,6 @@
el.textContent = reste.toFixed(2);
el.className = Math.abs(reste) < 0.01 ? 'ok' : 'bad';
}
// 3 lignes au départ
addRow(); addRow(); addRow();
</script>
</body>
+7 -4
View File
@@ -8,7 +8,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect
from .models import Entreprise, Paiement, Depense, Activite, RegimeTva
from .models import Entreprise, Paiement, Depense, Activite, RegimeTva, Categorie
def _dec(val, defaut="0"):
@@ -41,10 +41,10 @@ def saisie_paiement(request):
m = _dec(montants[i] if i < len(montants) else "0")
lib = (libelles[i] or "").strip()
if m == 0 and not lib:
continue # ligne vide ignorée
continue
lignes.append({
"libelle": lib or "(sans libellé)",
"categorie": categories[i] if i < len(categories) else "",
"categorie_id": categories[i] if i < len(categories) else "",
"activite": activites[i] if i < len(activites) else "",
"regime": regimes[i] if i < len(regimes) else RegimeTva.NATIONALE,
"taux": _dec(taux[i] if i < len(taux) else "0"),
@@ -71,13 +71,14 @@ def saisie_paiement(request):
return render(request, "compta/saisie_paiement.html",
_context(entreprises, request.POST))
cats = {str(c.pk): c for c in Categorie.objects.all()}
paiement = Paiement.objects.create(
entreprise=entreprise, date=d, libelle=libelle,
montant_total=montant_total, sens="debit")
for l in lignes:
Depense.objects.create(
entreprise=entreprise, paiement=paiement, date=d,
libelle=l["libelle"], categorie=l["categorie"],
libelle=l["libelle"], categorie=cats.get(l["categorie_id"]),
activite=l["activite"] or "", regime_tva=l["regime"],
taux_tva=l["taux"], montant_ttc=l["montant"], montant_tva=l["tva"])
@@ -94,6 +95,8 @@ def _context(entreprises, post=None):
"entreprises": entreprises,
"activites": Activite.choices,
"regimes": RegimeTva.choices,
"categories": Categorie.objects.filter(
actif=True).exclude(usage="vente"),
"today": date.today().isoformat(),
"post": post,
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
+4 -1
View File
@@ -4,13 +4,16 @@ set -e
echo "→ Migrations de la base..."
python manage.py migrate --noinput
echo "→ Données de référence (barèmes + catégories)..."
python manage.py seed_reference || true
# Création automatique d'un compte admin si les variables sont fournies
if [ -n "$DJANGO_SUPERUSER_USERNAME" ] && [ -n "$DJANGO_SUPERUSER_PASSWORD" ]; then
echo "→ Vérification du compte admin..."
python manage.py createsuperuser --noinput 2>/dev/null || true
fi
# Chargement optionnel des données d'exemple (barèmes + démo)
# Chargement optionnel des données d'exemple (barèmes + catégories + démo)
if [ "$SEED_DEMO" = "1" ]; then
echo "→ Chargement des données d'exemple..."
python manage.py seed_demo || true