""" 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") typ = request.POST.get("type") oid = request.POST.get("id") modeles = {"recette": Facture, "depense": Depense, "operation": Paiement} 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)