Files
admin 888bcfa213
Deploy via Portainer / deploy (push) Successful in 0s
Changes to be committed:
new file:   .gitea/workflows/deploy.yml
	modified:   compta/templates/compta/base.html
	modified:   compta/templates/compta/gestion.html
	modified:   compta/templates/compta/import_csv.html
	modified:   compta/templates/compta/saisie_paiement.html
	modified:   compta/views_gestion.py
	modified:   compta/views_import.py
	modified:   compta/views_saisie.py
2026-07-12 00:20:33 +02:00

282 lines
12 KiB
Python

"""
Page de gestion des entrées / sorties : vue unifiée de TOUTES les opérations
importées (recettes = factures, dépenses, opérations bancaires neutres).
- Filtrer (type, catégorie, régime, texte, dates), ordonner (date, montant, libellé).
- Modifier via une popup (avec explications sur les catégories) : la TVA est
recalculée automatiquement.
- Supprimer.
Tout est cloisonné par entreprise (Entreprise.accessibles).
"""
from decimal import Decimal, InvalidOperation
from datetime import datetime
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, render
from django.urls import reverse
from .models import (Entreprise, Facture, LigneFacture, Depense, Paiement,
Categorie, Activite, RegimeTva, StatutFacture, TAUX_TVA_CHOICES)
CENT = Decimal("0.01")
# Explications spécifiques (affichées dans la popup). Sinon, texte générique par usage.
EXPLIC = {
"vente": "Recette imposable : entre dans le chiffre d'affaires (CA) et dans "
"l'assiette des cotisations URSSAF. La TVA collectée est due.",
"depense": "Charge professionnelle : ne réduit PAS le CA micro, mais la TVA "
"payée est récupérable (TVA déductible) si vous êtes assujetti.",
"neutre": "Mouvement neutre : n'entre ni dans le CA, ni dans les cotisations, "
"ni dans la TVA. Pur mouvement de trésorerie (apport, prélèvement, "
"impôt, virement interne, remboursement…).",
}
EXPLIC_SPE = {
"Apport personnel": "Argent que vous injectez depuis vos fonds perso. Ce n'est "
"pas une recette : aucun impact fiscal.",
"Prélèvement personnel": "Argent que vous sortez pour vous. En micro, la "
"rémunération n'est pas une charge : mouvement neutre.",
"Remboursement reçu": "Remboursement d'un tiers (trop-perçu, avoir…). Ce n'est "
"pas un revenu d'activité : neutre.",
"Cotisation sociale Urssaf": "Vos cotisations URSSAF. Déjà prises en compte via "
"le taux URSSAF : ne pas les recompter en charge.",
"TVA payée": "Reversement de TVA à l'État. Neutre : c'est de la trésorerie, "
"pas une charge.",
"Remboursement de TVA": "Crédit de TVA remboursé par l'État. Neutre.",
"Virement interne": "Transfert entre vos propres comptes. Aucun impact.",
"CFE": "Cotisation Foncière des Entreprises. Impôt : suivi mais neutre côté TVA/CA.",
}
def _explication(cat):
if cat is None:
return ("Non catégorisé : pensez à choisir une catégorie pour un calcul "
"fiscal correct.")
return EXPLIC_SPE.get(cat.nom, EXPLIC.get(cat.usage, EXPLIC["depense"]))
def _entreprise(request, pk=None):
acc = Entreprise.accessibles(request.user)
pk = pk or request.GET.get("entreprise") or request.POST.get("entreprise")
if pk:
return acc.filter(pk=pk).first()
return acc.first()
def _dec(v, d="0"):
try:
return Decimal(str(v).replace(",", ".").strip() or d)
except (InvalidOperation, AttributeError):
return Decimal(d)
def _date(s):
for fmt in ("%Y-%m-%d", "%d/%m/%Y"):
try:
return datetime.strptime((s or "").strip(), fmt).date()
except ValueError:
continue
return None
def _rows(entreprise):
"""Liste unifiée des opérations de l'entreprise (recettes, dépenses, neutres)."""
rows = []
for f in (Facture.objects.filter(entreprise=entreprise)
.prefetch_related("lignes").select_related("client")):
une_ligne = f.lignes.count() == 1
rows.append({
"type": "recette", "type_label": "Recette", "id": f.pk,
"date": f.date_encaissement or f.date_emission,
"libelle": str(f.client), "categorie": f"Facture {f.numero}",
"categorie_id": "", "activite": f.activite,
"regime": "nationale", "taux": f.lignes.first().taux_tva if une_ligne else "",
"montant": f.montant_ttc, "tva": f.montant_tva, "neutre": False,
"signe": Decimal("1"), "statut": f.statut,
"date_encaissement": f.date_encaissement, "date_emission": f.date_emission,
"editable_montant": une_ligne,
})
for d in Depense.objects.filter(entreprise=entreprise).select_related("categorie"):
rows.append({
"type": "depense", "type_label": "Dépense", "id": d.pk,
"date": d.date, "libelle": d.libelle,
"categorie": d.categorie.nom if d.categorie else "(à catégoriser)",
"categorie_id": d.categorie_id or "", "activite": d.activite,
"regime": d.regime_tva, "taux": d.taux_tva,
"montant": -d.montant_ttc, "tva": d.tva_deductible_value,
"neutre": d.neutre, "signe": Decimal("-1"), "editable_montant": True,
})
for p in Paiement.objects.filter(entreprise=entreprise).select_related("categorie"):
rows.append({
"type": "operation", "type_label": "Opération", "id": p.pk,
"date": p.date, "libelle": p.libelle,
"categorie": p.categorie.nom if p.categorie else "(non catégorisée)",
"categorie_id": p.categorie_id or "", "activite": "",
"regime": "", "taux": "",
"montant": p.montant_total if p.sens == "credit" else -p.montant_total,
"tva": Decimal("0"),
"neutre": bool(p.categorie and p.categorie.exclure_calculs),
"signe": Decimal("1") if p.sens == "credit" else Decimal("-1"),
"sens": p.sens, "editable_montant": True,
})
return rows
def _filtrer_trier(rows, request):
q = (request.GET.get("q") or "").strip().lower()
ftype = request.GET.get("type") or ""
fcat = request.GET.get("cat") or ""
fregime = request.GET.get("regime") or ""
dfrom = _date(request.GET.get("du"))
dto = _date(request.GET.get("au"))
tri = request.GET.get("tri") or "date"
sens = request.GET.get("sens") or "desc"
def garde(r):
if ftype and r["type"] != ftype:
return False
if fcat and str(r["categorie_id"]) != fcat:
return False
if fregime and r["regime"] != fregime:
return False
if q and q not in (r["libelle"] + " " + r["categorie"]).lower():
return False
if dfrom and r["date"] and r["date"] < dfrom:
return False
if dto and r["date"] and r["date"] > dto:
return False
return True
rows = [r for r in rows if garde(r)]
cles = {"date": lambda r: (r["date"] or datetime(1900, 1, 1).date()),
"montant": lambda r: r["montant"],
"libelle": lambda r: r["libelle"].lower()}
rows.sort(key=cles.get(tri, cles["date"]), reverse=(sens == "desc"))
return rows, {"q": q, "type": ftype, "cat": fcat, "regime": fregime,
"du": request.GET.get("du") or "", "au": request.GET.get("au") or "",
"tri": tri, "sens": sens}
@login_required
def gestion(request):
if request.method == "POST":
return _traiter_post(request)
entreprise = _entreprise(request)
if entreprise is None:
return render(request, "compta/gestion.html", {"entreprise": None})
rows, filtres = _filtrer_trier(_rows(entreprise), request)
total = sum((r["montant"] for r in rows), Decimal("0"))
entrees = sum((r["montant"] for r in rows if r["montant"] > 0), Decimal("0"))
sorties = sum((r["montant"] for r in rows if r["montant"] < 0), Decimal("0"))
cats = list(Categorie.objects.filter(actif=True))
cats_json = {str(c.pk): {"nom": c.nom, "usage": c.usage,
"neutre": c.exclure_calculs, "compte": c.compte_pcg,
"explication": _explication(c)} for c in cats}
ctx = {
"entreprise": entreprise,
"entreprises": Entreprise.accessibles(request.user),
"rows": rows, "filtres": filtres,
"total": total, "entrees": entrees, "sorties": sorties, "nb": len(rows),
"categories": cats, "cats_json": cats_json,
"activites": Activite.choices, "regimes": RegimeTva.choices,
"statuts": StatutFacture.choices, "taux_choices": TAUX_TVA_CHOICES,
"vue": "gestion",
}
return render(request, "compta/gestion.html", ctx)
def _retour(request, entreprise):
base = reverse("gestion")
qs = request.POST.get("retour_qs", "")
url = f"{base}?entreprise={entreprise.pk}"
if qs:
url += "&" + qs
return redirect(url)
def _traiter_post(request):
entreprise = _entreprise(request)
if entreprise is None:
return redirect(reverse("gestion"))
action = request.POST.get("action")
modeles = {"recette": Facture, "depense": Depense, "operation": Paiement}
if action == "delete_bulk":
n = 0
for token in request.POST.getlist("sel"):
t, _, pk = token.partition(":")
Mod = modeles.get(t)
if not Mod or not pk:
continue
obj = Mod.objects.filter(entreprise=entreprise, pk=pk).first()
if obj:
obj.delete()
n += 1
messages.success(request, f"{n} opération(s) supprimée(s).")
return _retour(request, entreprise)
typ = request.POST.get("type")
oid = request.POST.get("id")
Modele = modeles.get(typ)
if not Modele:
return _retour(request, entreprise)
obj = Modele.objects.filter(entreprise=entreprise, pk=oid).first()
if not obj:
messages.error(request, "Opération introuvable.")
return _retour(request, entreprise)
if action == "delete":
obj.delete()
messages.success(request, "Opération supprimée.")
return _retour(request, entreprise)
# action == "edit"
dt = _date(request.POST.get("date"))
libelle = (request.POST.get("libelle") or "").strip()
montant = _dec(request.POST.get("montant"))
cat_id = request.POST.get("categorie") or ""
cat = Categorie.objects.filter(pk=cat_id).first() if cat_id else None
if typ == "depense":
if dt:
obj.date = dt
obj.libelle = libelle or obj.libelle
obj.categorie = cat
obj.activite = request.POST.get("activite") or ""
obj.regime_tva = request.POST.get("regime") or RegimeTva.NATIONALE
obj.taux_tva = _dec(request.POST.get("taux"), "0")
obj.montant_ttc = abs(montant)
obj.save() # recalcule la TVA
elif typ == "operation":
if dt:
obj.date = dt
obj.libelle = libelle or obj.libelle
obj.categorie = cat
obj.sens = request.POST.get("sens") or obj.sens
obj.montant_total = abs(montant)
obj.save()
elif typ == "recette":
de = _date(request.POST.get("date_emission")) or _date(request.POST.get("date"))
if de:
obj.date_emission = de
obj.date_encaissement = _date(request.POST.get("date_encaissement"))
obj.statut = request.POST.get("statut") or obj.statut
ligne = obj.lignes.first()
if ligne and obj.lignes.count() == 1:
taux = _dec(request.POST.get("taux"), str(ligne.taux_tva))
ttc = abs(montant) if montant else obj.montant_ttc
ligne.prix_unitaire_ht = (ttc / (Decimal("1") + taux / Decimal("100"))).quantize(CENT)
ligne.taux_tva = taux
ligne.quantite = Decimal("1")
ligne.save()
obj.save()
obj.recompute_totaux()
messages.success(request, "Opération modifiée.")
return _retour(request, entreprise)