diff --git a/Dockerfile b/Dockerfile index bdff38d..327f4f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,13 +5,17 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app -# psycopg2-binary embarque sa propre libpq : pas besoin de build-essential ni libpq-dev. +# Tesseract (OCR) + langue française, pour l'import par capture d'écran. +RUN apt-get update && apt-get install -y --no-install-recommends \ + tesseract-ocr tesseract-ocr-fra \ + && rm -rf /var/lib/apt/lists/* + +# psycopg2-binary embarque sa propre libpq : pas besoin de build-essential. COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . -# Fichiers statiques de l'admin (ne nécessite pas la base de données) RUN DJANGO_SECRET_KEY=build python manage.py collectstatic --noinput EXPOSE 8000 diff --git a/compta/templates/compta/import_csv.html b/compta/templates/compta/import_csv.html index 6ba137e..b4bc22c 100644 --- a/compta/templates/compta/import_csv.html +++ b/compta/templates/compta/import_csv.html @@ -3,7 +3,7 @@ -JB Compta — Import CSV +JB Compta — Import -

Import d'un relevé (CSV)

+

Importer un relevé

← Tableau de bord · Saisie manuelle
-
-

Format attendu

-
Colonnes séparées par ; (en-tête facultatif) :
- date ; libelle ; categorie ; activite ; taux_tva ; regime ; montant -
- • montant négatif = dépense, positif = vente · regime : nationale / intracom / import (vide = nationale) - · activite : vente / service_bic / service_bnc · une catégorie « neutre » (TVA payée, Urssaf, remboursement) n'entre pas dans les calculs.
- Exemple : 2026-06-25;ALTISSIMO;Vente de produits finis;vente;20;;168.40 -
-
- {% if erreurs %}{% for e in erreurs %}
{{ e }}
{% endfor %}{% endif %} - {% if resultat %}
Import terminé : {% for k, v in resultat.items %}{{ v }} {{ k }}{% if not forloop.last %} · {% endif %}{% endfor %}. - Va voir dans les dépenses et - les factures. -
+ Voir dépenses · factures. {% endif %} + {% if not lignes %}
{% csrf_token %} - - - +
+
+ + +
+
+ + +
+
+ + +
+
+ - +
- - +
+
L'OCR pré-remplit les lignes ; tu corriges la catégorie et le taux dans l'aperçu avant d'enregistrer.
+ {% endif %} {% if lignes %} -
-

Aperçu — {{ lignes|length }} ligne(s)

- - - - {% for l in lignes %} - - - - - - - {% endfor %} - -
#DateLibelléCatégorieTypeTauxRégimeMontant
{{ l.n }}{{ l.date }}{{ l.libelle }}{{ l.categorie_nom }}{% if l.categorie_nom and not l.categorie %} ⚠️{% endif %}{{ l.type }}{{ l.taux }} %{{ l.regime }}{{ l.montant }} €
-
⚠️ = catégorie non reconnue (sera laissée vide). Vérifie, puis clique « Importer ».
+
+ {% csrf_token %} + +

Aperçu éditable — {{ lignes|length }} ligne(s) {% if source == 'ocr' %}(OCR){% endif %}

+ + + + {% for l in lignes %} + + + + + + + + + + {% endfor %} + +
DateLibelléCatégorieRégime TVATaux %Montant €Type
+ + + + {{ l.type }}
+
+ +
+
{% endif %} diff --git a/compta/views_import.py b/compta/views_import.py index ec83fe7..1f638d3 100644 --- a/compta/views_import.py +++ b/compta/views_import.py @@ -1,13 +1,13 @@ """ -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 d'opérations : par CSV (collé/uploadé) ou par OCR d'une capture d'écran +(pytesseract). Dans les deux cas, un aperçu ÉDITABLE permet de corriger avant +d'enregistrer (l'OCR n'est jamais parfait). """ import csv import io +import re from decimal import Decimal, InvalidOperation -from datetime import datetime +from datetime import datetime, date as date_cls from django.contrib.auth.decorators import login_required from django.shortcuts import render @@ -15,13 +15,20 @@ 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") +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}$") def _dec(v, d="0"): try: - return Decimal(str(v or d).replace(",", ".").replace(" ", "").strip() or d) + s = str(v or d) + for ch in (" ", " ", " "): + s = s.replace(ch, "") + s = s.replace(",", ".").strip() + return Decimal(s or d) except (InvalidOperation, AttributeError): return Decimal(d) @@ -38,9 +45,8 @@ def _date(s): 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, "") + return {"vente": "vente", "service_bic": "service_bic", "service_bnc": "service_bnc", + "bic": "service_bic", "bnc": "service_bnc"}.get(s, "") def _regime(s): @@ -52,17 +58,36 @@ def _regime(s): return RegimeTva.NATIONALE -def _parse(text, cats): - """Retourne (lignes, erreurs). Chaque ligne = dict prêt à créer + aperçu.""" +def _match_cat(text, cats): + low = (text or "").lower() + for nom_low, c in cats.items(): + if nom_low and nom_low in low: + return c + return None + + +def _row(dt, libelle, montant, taux=Decimal("0"), regime=RegimeTva.NATIONALE, + categorie=None, n=0): + neutre = bool(categorie and categorie.exclure_calculs) + if montant > 0: + typ = "Mouvement neutre" if neutre else "Vente" + else: + typ = "Dépense neutre" if neutre else "Dépense" + return { + "n": n, "date": dt.isoformat() if dt else "", "libelle": libelle, + "categorie_id": categorie.pk if categorie else "", + "taux": taux, "regime": regime, "montant": montant, "type": typ, + } + + +# ---- Parsing CSV -------------------------------------------------------- + +def _parse_csv(text, cats): 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 + rows = list(csv.reader(io.StringIO(text), delimiter=delim)) lignes, erreurs = [], [] + start = 1 if rows and rows[0] and rows[0][0].strip().lower() == "date" else 0 for n, raw in enumerate(rows[start:], start=start + 1): if not any(c.strip() for c in raw): continue @@ -70,69 +95,145 @@ def _parse(text, cats): dt = _date(c[0]) montant = _dec(c[6]) if dt is None: - erreurs.append(f"Ligne {n} : date invalide « {c[0]} ».") - continue + erreurs.append(f"Ligne {n} : date invalide « {c[0]} »."); continue if montant == 0: - erreurs.append(f"Ligne {n} : montant nul.") - continue + 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, - }) + lignes.append(_row(dt, (c[1] or "").strip() or "(sans libellé)", montant, + _dec(c[4]), _regime(c[5]), cat, n)) return lignes, erreurs -def _creer(entreprise, l): - montant = l["montant"] +# ---- Parsing OCR -------------------------------------------------------- + +def _ocr_lang(): + try: + import pytesseract + return "fra" if "fra" in pytesseract.get_languages(config="") else None + except Exception: + return None + + +def _parse_ocr(image_file, annee, cats): + try: + import pytesseract + from pytesseract import Output + from PIL import Image + except Exception: + return [], ["OCR indisponible (pytesseract / Pillow non installés)."] + try: + img = Image.open(image_file) + except Exception: + return [], ["Image illisible."] + if img.width < 1000: + ratio = 1400 / img.width + img = img.resize((int(img.width * ratio), int(img.height * ratio))) + kwargs = {"output_type": Output.DICT} + lang = _ocr_lang() + if lang: + kwargs["lang"] = lang + try: + data = pytesseract.image_to_data(img, **kwargs) + except Exception as ex: + return [], [f"OCR impossible ({ex}). Vérifie l'installation de Tesseract, " + f"ou utilise l'import CSV."] + + words = [] + for i in range(len(data["text"])): + t = data["text"][i].strip() + try: + conf = int(float(data["conf"][i])) + except (ValueError, TypeError): + conf = -1 + if t and conf > 20: + words.append((data["top"][i], data["left"][i], t)) + words.sort() + + visual_rows, cur, cur_top = [], [], None + for top, left, t in words: + if cur_top is None or abs(top - cur_top) <= 18: + cur.append((left, t)) + cur_top = top if cur_top is None else cur_top + else: + visual_rows.append(cur); cur = [(left, t)]; cur_top = top + if cur: + visual_rows.append(cur) + + lignes = [] + for n, row in enumerate(visual_rows, start=1): + toks = [t for _, t in sorted(row)] + dt = None + for i, t in enumerate(toks): + if t.isdigit() and i + 1 < len(toks): + m = MOIS.get(toks[i + 1].lower().strip(".,")) + if m and 1 <= int(t) <= 31: + dt = date_cls(annee, m, int(t)); break + montant, amount_idx = None, None + for i in range(len(toks) - 1, -1, -1): + if "%" not in toks[i] and AMOUNT_RE.match(toks[i]): + montant = _dec(toks[i]); amount_idx = i; break + taux = Decimal("0") + for t in toks: + if "%" in t: + taux = _dec(t.replace("%", "")) + if dt is None or montant is None or montant == 0: + continue + reste, skip = [], False + for i, t in enumerate(toks): + if i == amount_idx or "%" in t or t.upper() == "TVA": + continue + if t.isdigit() and i + 1 < len(toks) and toks[i + 1].lower().strip(".,") in MOIS: + skip = True; continue + if skip and t.lower().strip(".,") in MOIS: + skip = False; continue + reste.append(t) + texte = " ".join(reste).strip() + cat = _match_cat(texte, cats) + libelle = texte + if cat: + libelle = re.sub(re.escape(cat.nom), "", texte, flags=re.I).strip(" -·,") + lignes.append(_row(dt, libelle or texte or "(sans libellé)", montant, taux, + RegimeTva.NATIONALE, cat, n)) + erreurs = [] if lignes else [ + "Aucune ligne exploitable détectée. Réessaie avec une capture plus nette, " + "ou utilise le CSV."] + return lignes, erreurs + + +# ---- Création ----------------------------------------------------------- + +def _creer(entreprise, dt, libelle, montant, taux, regime, categorie): + neutre = bool(categorie and categorie.exclure_calculs) 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 + if neutre: + Paiement.objects.create(entreprise=entreprise, date=dt, libelle=libelle, + montant_total=montant, sens="credit", categorie=categorie) + return "neutre" + client, _ = Client.objects.get_or_create(entreprise=entreprise, nom=libelle) + ht = (montant / (Decimal("1") + taux / Decimal("100"))).quantize(CENT) + annee = dt.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"]) + base += 1; numero = f"{annee}-{base:03d}" + f = Facture.objects.create(entreprise=entreprise, client=client, numero=numero, + activite=entreprise.activite, date_emission=dt, + date_encaissement=dt, statut="encaissee") + LigneFacture.objects.create(facture=f, designation=libelle, quantite=Decimal("1"), + prix_unitaire_ht=ht, taux_tva=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"]: + if regime in (RegimeTva.INTRACOM, RegimeTva.IMPORT) or 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) + tva = (abs_m * taux / (Decimal("100") + taux)).quantize(CENT) if taux else Decimal("0") + 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) return "depense" @@ -140,29 +241,57 @@ def _creer(entreprise, l): 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} + ctx = {"entreprises": entreprises, + "categories": Categorie.objects.filter(actif=True), + "activites": Activite.choices, "regimes": RegimeTva.choices, + "annee_defaut": date_cls.today().year} - if request.method == "POST": - entreprise = entreprises.filter(pk=request.POST.get("entreprise")).first() + if request.method != "POST": + return render(request, "compta/import_csv.html", ctx) + + action = request.POST.get("action") + ctx["entreprise_id"] = int(request.POST.get("entreprise") or 0) + entreprise = entreprises.filter(pk=request.POST.get("entreprise")).first() + + if action == "save": + if not entreprise: + ctx["erreurs"] = ["Sélectionne une entreprise."] + return render(request, "compta/import_csv.html", ctx) + dates = request.POST.getlist("l_date") + libelles = request.POST.getlist("l_libelle") + cat_ids = request.POST.getlist("l_categorie") + taux = request.POST.getlist("l_taux") + regimes = request.POST.getlist("l_regime") + montants = request.POST.getlist("l_montant") + cats_by_id = {str(c.pk): c for c in Categorie.objects.all()} + compteur = {} + for i in range(len(libelles)): + dt = _date(dates[i]) if i < len(dates) else None + montant = _dec(montants[i] if i < len(montants) else "0") + if dt is None or montant == 0: + continue + k = _creer(entreprise, dt, (libelles[i] or "").strip() or "(sans libellé)", + montant, _dec(taux[i] if i < len(taux) else "0"), + _regime(regimes[i] if i < len(regimes) else ""), + cats_by_id.get(cat_ids[i] if i < len(cat_ids) else "")) + compteur[k] = compteur.get(k, 0) + 1 + ctx["resultat"] = compteur + return render(request, "compta/import_csv.html", ctx) + + # Aperçu : CSV ou OCR + image = request.FILES.get("image") + if image: + annee = int(request.POST.get("annee") or date_cls.today().year) + lignes, erreurs = _parse_ocr(image, annee, cats) + ctx["source"] = "ocr" + else: 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 + lignes, erreurs = _parse_csv(texte, cats) + ctx["texte"] = texte + ctx["source"] = "csv" + ctx["lignes"] = lignes + ctx["erreurs"] = erreurs return render(request, "compta/import_csv.html", ctx) diff --git a/requirements.txt b/requirements.txt index 1d468f1..006dbc5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ psycopg2-binary>=2.9 gunicorn>=22.0 python-dotenv>=1.0 whitenoise>=6.6 +Pillow>=10.0 +pytesseract>=0.3.10