Groupe Gestionnaire + import CSV des operations

This commit is contained in:
2026-06-30 08:40:53 +02:00
parent 688fa3fc97
commit ab8af8d4bb
21 changed files with 297 additions and 5 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.
+31 -5
View File
@@ -1,12 +1,13 @@
"""
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.
Données de RÉFÉRENCE (barèmes + catégories + groupe Gestionnaire) — sans démo.
Idempotent : sûr à relancer en production. Appelé par l'entrypoint au démarrage.
Usage : python manage.py seed_reference
"""
from decimal import Decimal
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission
from compta.models import Bareme, Categorie, Activite
@@ -19,7 +20,6 @@ BAREMES_2026 = [
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),
@@ -35,6 +35,30 @@ CATEGORIES = [
("Virement interne", "neutre", True, 5),
]
# Permissions du groupe "Gestionnaire entreprise"
GROUPE_NOM = "Gestionnaire entreprise"
PERMS_COMPLETES = ["client", "facture", "lignefacture", "depense", "paiement"] # add/change/delete/view
PERMS_CHANGE = ["entreprise"] # view + change (pas add/delete, ni gestion des accès)
PERMS_VUE = ["categorie", "bareme"] # view seulement
def charger_groupes():
g, _ = Group.objects.get_or_create(name=GROUPE_NOM)
perms = []
for model in PERMS_COMPLETES:
for action in ("add", "change", "delete", "view"):
perms += list(Permission.objects.filter(
content_type__app_label="compta", codename=f"{action}_{model}"))
for model in PERMS_CHANGE:
for action in ("view", "change"):
perms += list(Permission.objects.filter(
content_type__app_label="compta", codename=f"{action}_{model}"))
for model in PERMS_VUE:
perms += list(Permission.objects.filter(
content_type__app_label="compta", codename=f"view_{model}"))
g.permissions.set(perms)
return g
def charger_reference(stdout=None, style=None):
for act, urssaf, ab, vl, sca, tb, tm in BAREMES_2026:
@@ -47,14 +71,16 @@ def charger_reference(stdout=None, style=None):
c, _ = Categorie.objects.update_or_create(
nom=nom, defaults=dict(usage=usage, exclure_calculs=excl, ordre=ordre))
cat[nom] = c
g = charger_groupes()
if stdout and style:
stdout.write(style.SUCCESS(
f"Référence chargée : {len(BAREMES_2026)} barèmes, {len(cat)} catégories."))
f"Référence : {len(BAREMES_2026)} barèmes, {len(cat)} catégories, "
f"groupe « {g.name} » ({g.permissions.count()} permissions)."))
return cat
class Command(BaseCommand):
help = "Charge les barèmes et catégories de référence (sans démo)."
help = "Charge barèmes, catégories et le groupe Gestionnaire (sans démo)."
def handle(self, *args, **options):
charger_reference(self.stdout, self.style)
+1
View File
@@ -54,6 +54,7 @@
<a class="{% if vue == 'urssaf' %}on{% endif %}" href="/declaration/urssaf/?entreprise={{ entreprise.pk }}&annee={{ annee }}">Déclaration URSSAF</a>
<a class="{% if vue == 'tva' %}on{% endif %}" href="/declaration/tva/?entreprise={{ entreprise.pk }}&annee={{ annee }}">Déclaration TVA</a>
<a class="saisie" href="/saisie/">+ Saisir</a>
<a href="/import/">Importer</a>
<a href="/admin/">Admin</a>
</div>
+95
View File
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>JB Compta — Import CSV</title>
<style>
:root { --bg:#0f172a; --card:#1e293b; --txt:#e2e8f0; --muted:#94a3b8;
--accent:#38bdf8; --ok:#34d399; --bad:#f87171; }
* { box-sizing:border-box; }
body { margin:0; font-family:system-ui,Segoe UI,Roboto,sans-serif;
background:var(--bg); color:var(--txt); padding:24px; }
h1 { margin:0 0 4px; font-size:22px; } h2{font-size:15px;color:var(--muted);}
a { color:var(--accent); }
.sub { color:var(--muted); margin-bottom:16px; }
.card { background:var(--card); border:1px solid #334155; border-radius:12px; padding:16px; margin-bottom:16px; }
label { display:block; font-size:12px; color:var(--muted); margin:8px 0 4px; }
input, select, textarea { background:#0f172a; color:var(--txt); border:1px solid #334155;
border-radius:8px; padding:8px 10px; font-size:14px; width:100%; }
textarea { min-height:140px; font-family:ui-monospace,monospace; }
button { cursor:pointer; border-radius:8px; border:1px solid #334155; background:var(--card);
color:var(--txt); padding:9px 16px; font-size:14px; }
button.primary { background:var(--accent); color:#04263a; border:none; font-weight:600; }
.actions { display:flex; gap:12px; margin-top:12px; }
table { width:100%; border-collapse:collapse; font-size:13px; }
th,td { padding:6px 8px; border-bottom:1px solid #334155; text-align:right; }
th:first-child,td:first-child,th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3) { text-align:left; }
code { background:#0f172a; padding:1px 6px; border-radius:5px; }
.msg { padding:10px 14px; border-radius:8px; margin-bottom:8px; }
.msg.bad { background:#7f1d1d; } .msg.ok { background:#064e3b; }
.pill { padding:1px 8px; border-radius:99px; font-size:12px; background:#334155; }
</style>
</head>
<body>
<h1>Import d'un relevé (CSV)</h1>
<div class="sub"><a href="/dashboard/">← Tableau de bord</a> · <a href="/saisie/">Saisie manuelle</a></div>
<div class="card">
<h2>Format attendu</h2>
<div class="sub">Colonnes séparées par <code>;</code> (en-tête facultatif) :</div>
<code>date ; libelle ; categorie ; activite ; taux_tva ; regime ; montant</code>
<div class="sub" style="margin-top:8px">
<b>montant</b> négatif = dépense, positif = vente · <b>regime</b> : nationale / intracom / import (vide = nationale)
· <b>activite</b> : vente / service_bic / service_bnc · une catégorie « neutre » (TVA payée, Urssaf, remboursement) n'entre pas dans les calculs.<br>
Exemple : <code>2026-06-25;ALTISSIMO;Vente de produits finis;vente;20;;168.40</code>
</div>
</div>
{% if erreurs %}{% for e in erreurs %}<div class="msg bad">{{ e }}</div>{% endfor %}{% endif %}
{% if resultat %}
<div class="msg ok">Import terminé :
{% for k, v in resultat.items %}{{ v }} {{ k }}{% if not forloop.last %} · {% endif %}{% endfor %}.
Va voir dans <a href="/admin/compta/depense/">les dépenses</a> et
<a href="/admin/compta/facture/">les factures</a>.
</div>
{% endif %}
<form method="post" enctype="multipart/form-data" class="card">
{% csrf_token %}
<label>Entreprise</label>
<select name="entreprise" required>
{% for e in entreprises %}<option value="{{ e.pk }}" {% if e.pk == entreprise_id %}selected{% endif %}>{{ e.nom }}</option>{% endfor %}
</select>
<label>Fichier CSV</label>
<input type="file" name="fichier" accept=".csv,.txt">
<label>…ou colle directement les lignes ici</label>
<textarea name="texte" placeholder="2026-06-25;ALTISSIMO;Vente de produits finis;vente;20;;168.40">{{ texte }}</textarea>
<div class="actions">
<button type="submit" name="action" value="preview">Aperçu</button>
<button type="submit" name="action" value="import" class="primary">Importer</button>
</div>
</form>
{% if lignes %}
<div class="card">
<h2>Aperçu — {{ lignes|length }} ligne(s)</h2>
<table>
<thead><tr><th>#</th><th>Date</th><th>Libellé</th><th>Catégorie</th><th>Type</th><th>Taux</th><th>Régime</th><th>Montant</th></tr></thead>
<tbody>
{% for l in lignes %}
<tr>
<td>{{ l.n }}</td><td>{{ l.date }}</td><td>{{ l.libelle }}</td>
<td>{{ l.categorie_nom }}{% if l.categorie_nom and not l.categorie %} ⚠️{% endif %}</td>
<td><span class="pill">{{ l.type }}</span></td>
<td>{{ l.taux }} %</td><td>{{ l.regime }}</td><td>{{ l.montant }} €</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="sub" style="margin-top:8px">⚠️ = catégorie non reconnue (sera laissée vide). Vérifie, puis clique « Importer ».</div>
</div>
{% endif %}
</body>
</html>
+2
View File
@@ -1,6 +1,7 @@
from django.urls import path
from . import views
from . import views_saisie
from . import views_import
urlpatterns = [
path("dashboard/", views.dashboard, name="dashboard"),
@@ -8,6 +9,7 @@ urlpatterns = [
path("declaration/urssaf/", views.declaration_urssaf, name="declaration_urssaf"),
path("declaration/tva/", views.declaration_tva, name="declaration_tva"),
path("saisie/", views_saisie.saisie_paiement, name="saisie_paiement"),
path("import/", views_import.import_csv, name="import_csv"),
path("export/ventes/", views.export_ventes, name="export_ventes"),
path("export/tva/", views.export_tva, name="export_tva"),
path("export/urssaf/", views.export_urssaf, name="export_urssaf"),
+168
View File
@@ -0,0 +1,168 @@
"""
Import d'un relevé d'opérations au format CSV.
Colonnes : date ; libelle ; categorie ; activite ; taux_tva ; regime ; montant
- montant négatif = dépense, positif = vente (ou neutre si catégorie neutre).
Aperçu avant enregistrement.
"""
import csv
import io
from decimal import Decimal, InvalidOperation
from datetime import datetime
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from .models import (Entreprise, Client, Facture, LigneFacture, Paiement,
Depense, Categorie, Activite, RegimeTva)
COLONNES = ["date", "libelle", "categorie", "activite", "taux_tva", "regime", "montant"]
CENT = Decimal("0.01")
def _dec(v, d="0"):
try:
return Decimal(str(v or d).replace(",", ".").replace(" ", "").strip() or d)
except (InvalidOperation, AttributeError):
return Decimal(d)
def _date(s):
s = (s or "").strip()
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
return None
def _activite(s):
s = (s or "").strip().lower()
table = {"vente": "vente", "service_bic": "service_bic", "service_bnc": "service_bnc",
"bic": "service_bic", "bnc": "service_bnc"}
return table.get(s, "")
def _regime(s):
s = (s or "").strip().lower()
if s in ("intracom", "intracommunautaire", "ue"):
return RegimeTva.INTRACOM
if s in ("import", "hors ue"):
return RegimeTva.IMPORT
return RegimeTva.NATIONALE
def _parse(text, cats):
"""Retourne (lignes, erreurs). Chaque ligne = dict prêt à créer + aperçu."""
text = text.lstrip("")
delim = ";" if text.count(";") >= text.count(",") else ","
reader = csv.reader(io.StringIO(text), delimiter=delim)
rows = list(reader)
if not rows:
return [], ["Fichier vide."]
# En-tête optionnel
start = 1 if rows and rows[0] and rows[0][0].strip().lower() in ("date", "date ") else 0
lignes, erreurs = [], []
for n, raw in enumerate(rows[start:], start=start + 1):
if not any(c.strip() for c in raw):
continue
c = (raw + [""] * 7)[:7]
dt = _date(c[0])
montant = _dec(c[6])
if dt is None:
erreurs.append(f"Ligne {n} : date invalide « {c[0]} ».")
continue
if montant == 0:
erreurs.append(f"Ligne {n} : montant nul.")
continue
cat = cats.get((c[2] or "").strip().lower())
taux = _dec(c[4])
regime = _regime(c[5])
neutre = bool(cat and cat.exclure_calculs)
if montant > 0:
typ = "Mouvement neutre" if neutre else "Vente"
else:
typ = "Dépense neutre" if neutre else "Dépense"
lignes.append({
"n": n, "date": dt, "libelle": (c[1] or "").strip() or "(sans libellé)",
"categorie": cat, "categorie_nom": (c[2] or "").strip(),
"activite": _activite(c[3]), "taux": taux, "regime": regime,
"montant": montant, "neutre": neutre, "type": typ,
})
return lignes, erreurs
def _creer(entreprise, l):
montant = l["montant"]
if montant > 0:
if l["neutre"]:
Paiement.objects.create(entreprise=entreprise, date=l["date"],
libelle=l["libelle"], montant_total=montant,
sens="credit", categorie=l["categorie"])
return "vente_neutre"
client, _ = Client.objects.get_or_create(entreprise=entreprise, nom=l["libelle"])
ttc = montant
ht = (ttc / (Decimal("1") + l["taux"] / Decimal("100"))).quantize(CENT)
annee = l["date"].year
base = Facture.objects.filter(entreprise=entreprise,
numero__startswith=f"{annee}-").count() + 1
numero = f"{annee}-{base:03d}"
while Facture.objects.filter(entreprise=entreprise, numero=numero).exists():
base += 1
numero = f"{annee}-{base:03d}"
f = Facture.objects.create(
entreprise=entreprise, client=client, numero=numero,
activite=l["activite"] or entreprise.activite, date_emission=l["date"],
date_encaissement=l["date"], statut="encaissee")
LigneFacture.objects.create(facture=f, designation=l["libelle"],
quantite=Decimal("1"), prix_unitaire_ht=ht,
taux_tva=l["taux"])
f.recompute_totaux()
return "vente"
# Dépense
abs_m = -montant
if l["regime"] in (RegimeTva.INTRACOM, RegimeTva.IMPORT):
ttc, tva = abs_m, Decimal("0")
elif l["neutre"]:
ttc, tva = abs_m, Decimal("0")
else:
ttc = abs_m
tva = (abs_m * l["taux"] / (Decimal("100") + l["taux"])).quantize(CENT) if l["taux"] else Decimal("0")
Depense.objects.create(
entreprise=entreprise, date=l["date"], libelle=l["libelle"],
categorie=l["categorie"], activite=l["activite"], regime_tva=l["regime"],
taux_tva=l["taux"] if l["regime"] != RegimeTva.NATIONALE else Decimal("0"),
montant_ttc=ttc, montant_tva=tva)
return "depense"
@login_required
def import_csv(request):
entreprises = Entreprise.accessibles(request.user)
cats = {c.nom.strip().lower(): c for c in Categorie.objects.all()}
ctx = {"entreprises": entreprises, "colonnes": COLONNES}
if request.method == "POST":
entreprise = entreprises.filter(pk=request.POST.get("entreprise")).first()
texte = request.POST.get("texte", "")
fichier = request.FILES.get("fichier")
if fichier:
texte = fichier.read().decode("utf-8-sig", "ignore")
lignes, erreurs = _parse(texte, cats)
ctx.update({"lignes": lignes, "erreurs": erreurs, "texte": texte,
"entreprise_id": int(request.POST.get("entreprise") or 0)})
if request.POST.get("action") == "import":
if not entreprise:
erreurs.append("Sélectionne une entreprise.")
if not lignes:
erreurs.append("Aucune ligne valide à importer.")
if not erreurs:
compteur = {}
for l in lignes:
k = _creer(entreprise, l)
compteur[k] = compteur.get(k, 0) + 1
ctx["resultat"] = compteur
ctx["lignes"] = None # import fait
ctx["erreurs"] = erreurs
return render(request, "compta/import_csv.html", ctx)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.