298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""
|
||
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, date as date_cls
|
||
|
||
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)
|
||
|
||
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:
|
||
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)
|
||
|
||
|
||
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()
|
||
return {"vente": "vente", "service_bic": "service_bic", "service_bnc": "service_bnc",
|
||
"bic": "service_bic", "bnc": "service_bnc"}.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 _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 ","
|
||
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
|
||
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())
|
||
lignes.append(_row(dt, (c[1] or "").strip() or "(sans libellé)", montant,
|
||
_dec(c[4]), _regime(c[5]), cat, n))
|
||
return lignes, erreurs
|
||
|
||
|
||
# ---- 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 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=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"
|
||
abs_m = -montant
|
||
if regime in (RegimeTva.INTRACOM, RegimeTva.IMPORT) or neutre:
|
||
ttc, tva = abs_m, Decimal("0")
|
||
else:
|
||
ttc = abs_m
|
||
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"
|
||
|
||
|
||
@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,
|
||
"categories": Categorie.objects.filter(actif=True),
|
||
"activites": Activite.choices, "regimes": RegimeTva.choices,
|
||
"annee_defaut": date_cls.today().year}
|
||
|
||
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_csv(texte, cats)
|
||
ctx["texte"] = texte
|
||
ctx["source"] = "csv"
|
||
ctx["lignes"] = lignes
|
||
ctx["erreurs"] = erreurs
|
||
return render(request, "compta/import_csv.html", ctx)
|