888bcfa213
Deploy via Portainer / deploy (push) Successful in 0s
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
282 lines
12 KiB
Python
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)
|