""" 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)