Files
jb-compta/compta/views_import.py
T

298 lines
11 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)