OCR pytesseract dans l'import + apercu editable

This commit is contained in:
2026-06-30 08:52:45 +02:00
parent ab8af8d4bb
commit 60aab53227
4 changed files with 287 additions and 133 deletions
+214 -85
View File
@@ -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)