OCR pytesseract dans l'import + apercu editable
This commit is contained in:
+214
-85
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user