Taux TVA auto + seed enrichi + import photo collee

This commit is contained in:
2026-06-30 09:09:47 +02:00
parent 60aab53227
commit 3cc6ede572
8 changed files with 145 additions and 77 deletions
+3 -8
View File
@@ -28,8 +28,6 @@ class ScopedAdmin(admin.ModelAdmin):
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")
@@ -45,8 +43,6 @@ 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",
@@ -62,14 +58,11 @@ class EntrepriseAdmin(admin.ModelAdmin):
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(ScopedAdmin):
list_display = ("nom", "entreprise", "email", "siret")
@@ -108,6 +101,7 @@ class DepenseInline(admin.TabularInline):
extra = 0
fields = ("date", "libelle", "categorie", "activite", "regime_tva",
"taux_tva", "montant_ttc", "montant_tva")
readonly_fields = ("montant_tva",)
@admin.register(Paiement)
@@ -135,10 +129,11 @@ class PaiementAdmin(ScopedAdmin):
@admin.register(Depense)
class DepenseAdmin(ScopedAdmin):
list_display = ("date", "entreprise", "libelle", "categorie", "activite",
"regime_tva", "montant_ttc", "montant_tva", "tva_autoliq", "paiement")
"regime_tva", "taux_tva", "montant_ttc", "montant_tva", "tva_autoliq", "paiement")
list_filter = ("entreprise", "categorie", "regime_tva", "activite", "date")
search_fields = ("libelle",)
date_hierarchy = "date"
readonly_fields = ("montant_tva",)
@admin.display(description="TVA auto-liquidée")
def tva_autoliq(self, obj):
+44 -34
View File
@@ -1,7 +1,7 @@
"""
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).
Référence (barèmes + catégories + groupe) PUIS un jeu de données d'exemple
couvrant tous les modèles : entreprise, clients, factures + lignes, dépenses,
et opérations bancaires (débit éclaté, crédit neutre).
Usage : python manage.py seed_demo
"""
@@ -11,14 +11,14 @@ from datetime import date
from django.core.management.base import BaseCommand
from compta.models import (
Entreprise, Client, Facture, LigneFacture, Depense, Activite,
Entreprise, Client, Facture, LigneFacture, Depense, Paiement, Activite,
StatutFacture, RegimeTva,
)
from compta.management.commands.seed_reference import charger_reference
class Command(BaseCommand):
help = "Charge la référence + des données d'exemple."
help = "Charge la référence + des données d'exemple (tous les modèles)."
def handle(self, *args, **options):
cat = charger_reference(self.stdout, self.style)
@@ -26,16 +26,14 @@ class Command(BaseCommand):
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"))
taux_tva_defaut=Decimal("20.00"), versement_liberatoire=True,
email="jb@example.fr", num_tva_intracom="FR00123456789"))
clients = {}
for nom, mail in [("Acme SARL", "contact@acme.fr"),
("Studio Lumiere", "hello@studiolumiere.fr"),
("Mairie de Ville", "compta@ville.fr")]:
c, _ = Client.objects.get_or_create(entreprise=ent, nom=nom,
defaults=dict(email=mail))
c, _ = Client.objects.get_or_create(entreprise=ent, nom=nom, defaults=dict(email=mail))
clients[nom] = c
V, S = Activite.VENTE, Activite.SERVICE_BIC
@@ -55,32 +53,44 @@ class Command(BaseCommand):
f, created = Facture.objects.get_or_create(
entreprise=ent, numero=numero,
defaults=dict(client=clients[client_nom], activite=activite,
date_emission=emis, date_encaissement=enc,
statut=statut))
date_emission=emis, date_encaissement=enc, statut=statut))
if created:
for des, qte, pu in lignes:
LigneFacture.objects.create(
facture=f, designation=des, quantite=Decimal(qte),
prix_unitaire_ht=Decimal(pu), taux_tva=Decimal("20"))
LigneFacture.objects.create(facture=f, designation=des, quantite=Decimal(qte),
prix_unitaire_ht=Decimal(pu), taux_tva=Decimal("20"))
f.recompute_totaux()
Depense.objects.get_or_create(
entreprise=ent, libelle="Bobines PLA/PETG", date=date(2026, 1, 15),
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=cat["Abonnement logiciel"],
activite=Activite.SERVICE_BIC,
montant_ttc=Decimal("34.80"), montant_tva=Decimal("5.80")))
Depense.objects.get_or_create(
entreprise=ent, libelle="Imprimante 3D (DE)", date=date(2026, 2, 20),
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")))
def depense(libelle, jour, montant_ttc, cat_nom, taux="20",
regime=RegimeTva.NATIONALE, activite="", paiement=None):
# montant_tva est calculé automatiquement dans Depense.save().
Depense.objects.get_or_create(
entreprise=ent, libelle=libelle, date=date(2026, *jour),
defaults=dict(categorie=cat[cat_nom], activite=activite, paiement=paiement,
regime_tva=regime, taux_tva=Decimal(taux),
montant_ttc=Decimal(montant_ttc)))
self.stdout.write(self.style.SUCCESS("Données d'exemple chargées."))
# Opération bancaire DÉBIT éclatée : un paiement Amazon = 2 dépenses
amazon, _ = Paiement.objects.get_or_create(
entreprise=ent, libelle="AMAZON PAYMENTS", date=date(2026, 6, 22),
defaults=dict(montant_total=Decimal("24.52"), sens="debit"))
depense("Amazon - visserie", (6, 22), "15.82", "Matériel et outillage",
activite=Activite.VENTE, paiement=amazon)
depense("Amazon - filament", (6, 18), "8.70", "Matériel et outillage",
activite=Activite.VENTE, paiement=amazon)
# Dépenses variées (taux 20 / 10 / intracom / neutre)
depense("Bobines PLA/PETG", (1, 15), "96.00", "Matière première", activite=Activite.VENTE)
depense("Burger King", (6, 11), "16.15", "Frais de repas hors domicile", taux="10")
depense("Abonnement outils", (2, 10), "34.80", "Abonnement logiciel",
activite=Activite.SERVICE_BIC)
depense("Imprimante 3D (DE)", (2, 20), "500.00", "Matériel et outillage",
regime=RegimeTva.INTRACOM, activite=Activite.VENTE)
depense("URSSAF DE LORRAINE", (6, 3), "73.00", "Cotisation sociale Urssaf", taux="0")
# Opération bancaire CRÉDIT neutre : remboursement de TVA
Paiement.objects.get_or_create(
entreprise=ent, libelle="SIE EPINAL", date=date(2026, 6, 15),
defaults=dict(montant_total=Decimal("103.00"), sens="credit",
categorie=cat["Remboursement de TVA"]))
self.stdout.write(self.style.SUCCESS("Données d'exemple chargées (tous les modèles)."))
@@ -0,0 +1,29 @@
# Generated by Django 5.2.15 on 2026-06-30 07:05
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('compta', '0007_alter_paiement_options_entreprise_groupes_and_more'),
]
operations = [
migrations.AlterField(
model_name='depense',
name='montant_tva',
field=models.DecimalField(decimal_places=2, default=Decimal('0'), help_text='Calculée automatiquement depuis le montant payé et le taux.', max_digits=12, verbose_name='TVA déductible (€)'),
),
migrations.AlterField(
model_name='depense',
name='taux_tva',
field=models.DecimalField(choices=[(Decimal('0'), '0 %'), (Decimal('5.5'), '5,5 %'), (Decimal('10'), '10 %'), (Decimal('20'), '20 %')], decimal_places=2, default=Decimal('20'), help_text='Achat national : la TVA déductible est calculée. UE/import : taux auto-liquidé.', max_digits=4, verbose_name='Taux de TVA (%)'),
),
migrations.AlterField(
model_name='lignefacture',
name='taux_tva',
field=models.DecimalField(choices=[(Decimal('0'), '0 %'), (Decimal('5.5'), '5,5 %'), (Decimal('10'), '10 %'), (Decimal('20'), '20 %')], decimal_places=2, default=Decimal('0'), max_digits=4),
),
]
+23 -7
View File
@@ -27,6 +27,10 @@ class RegimeTva(models.TextChoices):
IMPORT = "import", "Import hors UE (autoliquidation)"
TAUX_TVA_CHOICES = [(Decimal("0"), "0 %"), (Decimal("5.5"), "5,5 %"),
(Decimal("10"), "10 %"), (Decimal("20"), "20 %")]
class Categorie(models.Model):
"""Catégorie globale (mutualisée). exclure_calculs=True => mouvement neutre."""
USAGE = [("depense", "Dépense"), ("vente", "Vente"), ("neutre", "Mouvement neutre")]
@@ -84,7 +88,6 @@ class Entreprise(models.Model):
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")
# --- Droits d'accès (multi-utilisateur) ---
utilisateurs = models.ManyToManyField(
settings.AUTH_USER_MODEL, blank=True, related_name="entreprises",
verbose_name="Utilisateurs autorisés",
@@ -110,7 +113,6 @@ class Entreprise(models.Model):
@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:
@@ -179,7 +181,8 @@ class LigneFacture(models.Model):
designation = models.CharField(max_length=255)
quantite = models.DecimalField(max_digits=12, decimal_places=3, default=Decimal("1"))
prix_unitaire_ht = models.DecimalField(max_digits=12, decimal_places=2)
taux_tva = models.DecimalField(max_digits=4, decimal_places=2, default=Decimal("0"))
taux_tva = models.DecimalField(max_digits=4, decimal_places=2, default=Decimal("0"),
choices=TAUX_TVA_CHOICES)
class Meta:
verbose_name = "Ligne de facture"
@@ -244,13 +247,14 @@ class Depense(models.Model):
"Montant payé (€)", max_digits=12, decimal_places=2,
help_text="Pour un achat UE/import, c'est le montant HT (sans TVA facturée).")
montant_tva = models.DecimalField(
"TVA déductible facturée (€)", max_digits=12, decimal_places=2, default=Decimal("0"),
help_text="TVA payée au fournisseur (achats nationaux uniquement).")
"TVA déductible (€)", max_digits=12, decimal_places=2, default=Decimal("0"),
help_text="Calculée automatiquement depuis le montant payé et le taux.")
regime_tva = models.CharField(
"Régime de TVA", max_length=12, choices=RegimeTva.choices, default=RegimeTva.NATIONALE)
taux_tva = models.DecimalField(
"Taux TVA autoliquidation (%)", max_digits=4, decimal_places=2, default=Decimal("0"),
help_text="Taux français à auto-liquider pour les achats UE/import (ex. 20).")
"Taux de TVA (%)", max_digits=4, decimal_places=2, default=Decimal("20"),
choices=TAUX_TVA_CHOICES,
help_text="Achat national : la TVA déductible est calculée. UE/import : taux auto-liquidé.")
cree_le = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -261,6 +265,18 @@ class Depense(models.Model):
def __str__(self):
return f"{self.date}{self.libelle}"
def save(self, *args, **kwargs):
# TVA déductible calculée : incluse dans le TTC (national), nulle en autoliq.
t = self.taux_tva or Decimal("0")
if self.autoliquidee:
self.montant_tva = Decimal("0.00")
elif t:
self.montant_tva = ((self.montant_ttc or Decimal("0")) * t
/ (Decimal("100") + t)).quantize(Decimal("0.01"))
else:
self.montant_tva = Decimal("0.00")
super().save(*args, **kwargs)
@property
def neutre(self):
return bool(self.categorie and self.categorie.exclure_calculs)
+23 -3
View File
@@ -45,7 +45,7 @@
{% endif %}
{% if not lignes %}
<form method="post" enctype="multipart/form-data" class="card">
<form method="post" enctype="multipart/form-data" class="card" id="srcForm">
{% csrf_token %}
<div class="grid3">
<div>
@@ -59,8 +59,9 @@
<input type="number" name="annee" value="{{ annee_defaut }}">
</div>
<div>
<label>Capture d'écran (OCR)</label>
<input type="file" name="image" accept="image/*">
<label>Photo / capture (OCR) — fichier, appareil photo, ou <b>coller</b> (Ctrl/Cmd+V)</label>
<input type="file" name="image" id="imgInput" accept="image/*" capture="environment">
<div id="pasteInfo" class="sub" style="margin-top:4px"></div>
</div>
</div>
<label>…ou fichier CSV</label>
@@ -110,5 +111,24 @@
</div>
</form>
{% endif %}
<script>
window.addEventListener('paste', function(e){
var input = document.getElementById('imgInput');
if(!input || !e.clipboardData) return;
for(var i=0; i<e.clipboardData.items.length; i++){
var it = e.clipboardData.items[i];
if(it.type && it.type.indexOf('image') === 0){
var dt = new DataTransfer();
dt.items.add(it.getAsFile());
input.files = dt.files;
var info = document.getElementById('pasteInfo');
if(info){ info.textContent = 'Image collée — clique « Analyser ».'; info.style.color = '#34d399'; }
e.preventDefault();
break;
}
}
});
</script>
</body>
</html>
+17 -12
View File
@@ -47,7 +47,7 @@
<body>
<h1>Saisie d'une opération</h1>
<div class="sub">Montant <b>négatif</b> = dépense · <b>positif</b> = vente/encaissement.
· <a href="/dashboard/">← Tableau de bord</a></div>
· <a href="/dashboard/">← Tableau de bord</a> · <a href="/import/">Importer un relevé</a></div>
{% if messages %}{% for m in messages %}<div class="msg {{ m.tags }}">{{ m }}</div>{% endfor %}{% endif %}
@@ -83,13 +83,12 @@
<table>
<thead>
<tr>
<th style="width:22%">Libellé / vendeur</th>
<th style="width:24%">Libellé / vendeur</th>
<th style="width:16%">Catégorie</th>
<th style="width:14%">Activité</th>
<th style="width:15%">Activité</th>
<th style="width:16%">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 style="width:9%">Taux TVA</th>
<th style="width:13%">Montant €</th>
<th></th>
</tr>
</thead>
@@ -125,7 +124,10 @@
</div>
<div>
<label>Taux TVA (%)</label>
<input type="number" step="0.01" name="v_taux" value="20">
<select name="v_taux">
<option value="20">20 %</option><option value="10">10 %</option>
<option value="5.5">5,5 %</option><option value="0">0 %</option>
</select>
</div>
<div>
<label>Désignation</label>
@@ -139,9 +141,8 @@
</select>
</div>
</div>
<div class="hint">Le montant TTC encaissé = le montant signé saisi plus haut. HT et TVA
sont calculés depuis le taux. Si tu choisis une catégorie <b>neutre</b>, aucune facture
n'est créée (simple mouvement de trésorerie).</div>
<div class="hint">Le montant TTC encaissé = le montant signé saisi plus haut.
Une catégorie <b>neutre</b> n'enregistre pas de vente (mouvement de trésorerie).</div>
</div>
<div class="actions">
@@ -169,9 +170,13 @@
{% for val,lab in regimes %}<option value="{{ val }}">{{ lab }}</option>{% endfor %}
</select>
</td>
<td><input type="number" step="0.01" name="l_taux" value="0" class="num"></td>
<td>
<select name="l_taux">
<option value="20">20 %</option><option value="10">10 %</option>
<option value="5.5">5,5 %</option><option value="0">0 %</option>
</select>
</td>
<td><input type="number" step="0.01" name="l_montant" class="num montant" oninput="recalc()"></td>
<td><input type="number" step="0.01" name="l_tva" value="0" class="num"></td>
<td><button type="button" class="mini" onclick="this.closest('tr').remove();recalc()"></button></td>
</tr>
</template>
+4 -10
View File
@@ -19,13 +19,13 @@ CENT = Decimal("0.01")
MOIS = {"janvier": 1, "février": 2, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5,
"juin": 6, "juillet": 7, "août": 8, "aout": 8, "septembre": 9,
"octobre": 10, "novembre": 11, "décembre": 12, "decembre": 12}
AMOUNT_RE = re.compile(r"^-?\d[\d  .]*[.,]\d{2}$")
AMOUNT_RE = re.compile(r"^-?\d[\d .]*[.,]\d{2}$")
def _dec(v, d="0"):
try:
s = str(v or d)
for ch in (" ", "", " "):
for ch in (" ", " ", " "):
s = s.replace(ch, "")
s = s.replace(",", ".").strip()
return Decimal(s or d)
@@ -224,16 +224,10 @@ def _creer(entreprise, dt, libelle, montant, taux, regime, categorie):
prix_unitaire_ht=ht, taux_tva=taux)
f.recompute_totaux()
return "vente"
abs_m = -montant
if regime in (RegimeTva.INTRACOM, RegimeTva.IMPORT) or neutre:
ttc, tva = abs_m, Decimal("0")
else:
ttc = abs_m
tva = (abs_m * taux / (Decimal("100") + taux)).quantize(CENT) if taux else Decimal("0")
# montant_tva est calculé automatiquement dans Depense.save() depuis le taux.
Depense.objects.create(entreprise=entreprise, date=dt, libelle=libelle,
categorie=categorie, regime_tva=regime,
taux_tva=taux if regime != RegimeTva.NATIONALE else Decimal("0"),
montant_ttc=ttc, montant_tva=tva)
taux_tva=taux, montant_ttc=-montant)
return "depense"
+2 -3
View File
@@ -117,7 +117,6 @@ def saisie_paiement(request):
regimes = request.POST.getlist("l_regime")
taux = request.POST.getlist("l_taux")
montants = request.POST.getlist("l_montant")
tvas = request.POST.getlist("l_tva")
lignes = []
for i in range(len(libelles)):
@@ -132,7 +131,6 @@ def saisie_paiement(request):
"regime": regimes[i] if i < len(regimes) else RegimeTva.NATIONALE,
"taux": _dec(taux[i] if i < len(taux) else "0"),
"montant": m,
"tva": _dec(tvas[i] if i < len(tvas) else "0"),
})
somme = sum((l["montant"] for l in lignes), Decimal("0"))
@@ -148,9 +146,10 @@ def saisie_paiement(request):
paiement = Paiement.objects.create(
entreprise=entreprise, date=d, libelle=libelle, montant_total=total_abs, sens="debit")
for l in lignes:
# montant_tva est calculé automatiquement dans Depense.save() depuis le taux.
Depense.objects.create(
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"])
taux_tva=l["taux"], montant_ttc=l["montant"])
messages.success(request, f"Opération « {libelle} » enregistrée ({len(lignes)} dépense(s)).")
return redirect("saisie_paiement")