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
+6 -2
View File
@@ -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
+65 -46
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>JB Compta — Import CSV</title>
<title>JB Compta — Import</title>
<style>
:root { --bg:#0f172a; --card:#1e293b; --txt:#e2e8f0; --muted:#94a3b8;
--accent:#38bdf8; --ok:#34d399; --bad:#f87171; }
@@ -12,84 +12,103 @@
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; }
.sub { color:var(--muted); margin-bottom:14px; }
.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; }
textarea { min-height:110px; font-family:ui-monospace,monospace; }
.grid3 { display:grid; grid-template-columns:2fr 1fr 1fr; gap:12px; }
@media(max-width:700px){ .grid3{grid-template-columns:1fr;} }
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; }
th,td { padding:4px 6px; border-bottom:1px solid #334155; text-align:left; }
td input, td select { padding:5px 7px; }
.num { text-align:right; }
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>
<h1>Importer un relevé</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>
Voir <a href="/admin/compta/depense/">dépenses</a> · <a href="/admin/compta/facture/">factures</a>.</div>
{% endif %}
{% if not lignes %}
<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>
<div class="grid3">
<div>
<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>
</div>
<div>
<label>Année (pour l'OCR)</label>
<input type="number" name="annee" value="{{ annee_defaut }}">
</div>
<div>
<label>Capture d'écran (OCR)</label>
<input type="file" name="image" accept="image/*">
</div>
</div>
<label>…ou fichier CSV</label>
<input type="file" name="fichier" accept=".csv,.txt">
<label>…ou colle directement les lignes ici</label>
<label>…ou colle des lignes CSV <span class="sub">(date ; libelle ; categorie ; activite ; taux_tva ; regime ; montant)</span></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>
<button type="submit" name="action" value="preview" class="primary">Analyser / Aperçu</button>
</div>
<div class="sub" style="margin-top:8px">L'OCR pré-remplit les lignes ; tu corriges la catégorie et le taux dans l'aperçu avant d'enregistrer.</div>
</form>
{% endif %}
{% 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>
<form method="post" class="card">
{% csrf_token %}
<input type="hidden" name="entreprise" value="{{ entreprise_id }}">
<h2>Aperçu éditable — {{ lignes|length }} ligne(s) {% if source == 'ocr' %}(OCR){% endif %}</h2>
<table>
<thead><tr><th>Date</th><th>Libellé</th><th>Catégorie</th><th>Régime TVA</th><th>Taux %</th><th>Montant €</th><th>Type</th></tr></thead>
<tbody>
{% for l in lignes %}
<tr>
<td><input type="date" name="l_date" value="{{ l.date }}" style="width:140px"></td>
<td><input type="text" name="l_libelle" value="{{ l.libelle }}"></td>
<td>
<select name="l_categorie">
<option value=""></option>
{% for c in categories %}<option value="{{ c.pk }}" {% if c.pk == l.categorie_id %}selected{% endif %}>{{ c.nom }}</option>{% endfor %}
</select>
</td>
<td>
<select name="l_regime">
{% for val,lab in regimes %}<option value="{{ val }}" {% if val == l.regime %}selected{% endif %}>{{ lab }}</option>{% endfor %}
</select>
</td>
<td><input type="number" step="0.01" name="l_taux" value="{{ l.taux }}" class="num" style="width:70px"></td>
<td><input type="number" step="0.01" name="l_montant" value="{{ l.montant }}" class="num" style="width:100px"></td>
<td>{{ l.type }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="actions">
<button type="submit" name="action" value="save" class="primary">Enregistrer ces opérations</button>
<a href="/import/"><button type="button">Recommencer</button></a>
</div>
</form>
{% endif %}
</body>
</html>
+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)
+2
View File
@@ -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