Sync Indy integre dans l'interface web + anti-doublon

This commit is contained in:
2026-06-30 14:08:35 +02:00
parent 458f80f201
commit 75af558b49
13 changed files with 665 additions and 48 deletions
+84 -33
View File
@@ -1,13 +1,6 @@
"""
Import d'opérations : CSV (collé/uploadé) ou OCR d'une capture d'écran.
OCR multi-format :
- LISTE : le tableau d'opérations (date empilée à gauche, lignes). Découpage
ancré sur le montant ; la date est reportée d'une ligne à l'autre
(Indy ne l'affiche qu'une fois par jour). « UE » => intracom.
- DÉTAIL : la fiche d'une opération (Libellé / Date / Montant / Ventilation).
Extraction au mieux (les petits libellés gris sont mal lus).
Dans tous les cas : aperçu ÉDITABLE + panneau de debug OCR.
Import d'opérations : CSV, OCR (capture), ou SYNC INDY (API).
Dans tous les cas : aperçu ÉDITABLE + anti-doublon (indy_id) + panneau debug OCR.
"""
import csv
import io
@@ -21,6 +14,7 @@ from django.shortcuts import render
from .models import (Entreprise, Client, Facture, LigneFacture, Paiement,
Depense, Categorie, Activite, RegimeTva)
from . import indy
CENT = Decimal("0.01")
MOIS = {"janvier": 1, "février": 2, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5,
@@ -50,6 +44,12 @@ def _date(s):
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"):
@@ -78,7 +78,7 @@ def _match_cat(text, cats):
def _row(dt, libelle, montant, taux=Decimal("0"), regime=RegimeTva.NATIONALE,
categorie=None, n=0):
categorie=None, activite="", indy_id="", n=0):
neutre = bool(categorie and categorie.exclure_calculs)
if montant > 0:
typ = "Mouvement neutre" if neutre else "Vente"
@@ -86,7 +86,8 @@ def _row(dt, libelle, montant, taux=Decimal("0"), regime=RegimeTva.NATIONALE,
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}
"taux": taux, "regime": regime, "montant": montant, "type": typ,
"activite": activite, "indy_id": indy_id}
# ---- CSV ----------------------------------------------------------------
@@ -109,7 +110,7 @@ def _parse_csv(text, cats):
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))
_dec(c[4]), _regime(c[5]), cat, _activite(c[3]), "", n))
return lignes, erreurs, []
@@ -198,7 +199,7 @@ def _parse_liste(words, annee, cats):
if cat:
libelle = re.sub(re.escape(cat.nom), "", texte, flags=re.I).strip(" -·,")
lignes.append(_row(dt, libelle or texte or "(sans libellé)", _dec(a["t"]),
taux, regime, cat, n))
taux, regime, cat, "", "", n))
return lignes
@@ -208,18 +209,17 @@ def _parse_detail(words, cats):
amounts = [w for w in words if "%" not in w["t"] and AMOUNT_RE.match(w["t"])]
montant = _dec(amounts[0]["t"]) if amounts else Decimal("0")
y_ref = dtok["yc"] if dtok else 99999
haut_gauche = [w for w in words if w["x"] < 450 and w["yc"] < y_ref
and w["t"].lower() not in ("informations", "date", "libellé", "libelle")]
libelle = " ".join(w["t"] for w in sorted(haut_gauche, key=lambda w: (w["yc"], w["x"])))
haut = [w for w in words if w["x"] < 450 and w["yc"] < y_ref
and w["t"].lower() not in ("informations", "date", "libellé", "libelle")]
libelle = " ".join(w["t"] for w in sorted(haut, key=lambda w: (w["yc"], w["x"])))
libelle = libelle.strip(" '\"") or "(à compléter)"
droite = [w for w in words if w["x"] > 480 and not AMOUNT_RE.match(w["t"]) and "%" not in w["t"]
and w["t"].lower() not in ("ventilation(s)", "ventilation", "catégorie",
"categorie", "montant", "ttc", "tva")]
texte_cat = " ".join(w["t"] for w in sorted(droite, key=lambda w: (w["yc"], w["x"])))
cat = _match_cat(texte_cat, cats)
cat = _match_cat(" ".join(w["t"] for w in sorted(droite, key=lambda w: (w["yc"], w["x"]))), cats)
if montant == 0:
return []
return [_row(dt, libelle, montant, Decimal("0"), RegimeTva.NATIONALE, cat, 1)]
return [_row(dt, libelle, montant, Decimal("0"), RegimeTva.NATIONALE, cat, "", "", 1)]
def _parse_ocr(image_file, annee, cats):
@@ -229,25 +229,49 @@ def _parse_ocr(image_file, annee, cats):
debug = _debug_lines(words)
allt = " ".join(w["t"] for w in words).lower()
is_detail = ("ventilation" in allt) or ("informations" in allt and "montant" in allt)
if is_detail:
lignes = _parse_detail(words, cats); fmt = "détail"
else:
lignes = _parse_liste(words, annee, cats); fmt = "liste"
lignes = _parse_detail(words, cats) if is_detail else _parse_liste(words, annee, cats)
if not lignes:
erreurs.append(f"Format « {fmt} » détecté mais aucune ligne exploitable. "
f"Regarde le texte OCR ci-dessous et complète à la main, "
f"ou utilise le CSV.")
erreurs.append("Aucune ligne exploitable détectée. Regarde le texte OCR ci-dessous "
"et complète à la main, ou utilise le CSV.")
return lignes, erreurs, debug
# ---- INDY ---------------------------------------------------------------
def _parse_indy(entreprise, token, date_debut, date_fin, cats_par_nom):
lignes_norm, err = indy.lignes_normalisees(token, date_debut or None, date_fin or None)
if err:
return [], [f"Indy : {err}"]
lignes = []
for n, ln in enumerate(lignes_norm, start=1):
dt = _date(ln["date"])
cat = cats_par_nom.get((ln["categorie_nom"] or "").strip().lower())
lignes.append(_row(dt, ln["libelle"], ln["montant"], ln["taux"],
_regime(ln["regime"]), cat, ln["activite"] or "",
ln["indy_id"], n))
return lignes, []
# ---- Anti-doublon -------------------------------------------------------
def _deja_importe(entreprise, indy_id):
if not indy_id:
return False
return (Facture.objects.filter(entreprise=entreprise, indy_id=indy_id).exists()
or Depense.objects.filter(entreprise=entreprise, indy_id=indy_id).exists()
or Paiement.objects.filter(entreprise=entreprise, indy_id=indy_id).exists())
# ---- Création -----------------------------------------------------------
def _creer(entreprise, dt, libelle, montant, taux, regime, categorie):
def _creer(entreprise, dt, libelle, montant, taux, regime, categorie,
activite="", indy_id=""):
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)
montant_total=montant, sens="credit",
categorie=categorie, indy_id=indy_id)
return "neutre"
client, _ = Client.objects.get_or_create(entreprise=entreprise, nom=libelle)
ht = (montant / (Decimal("1") + taux / Decimal("100"))).quantize(CENT)
@@ -258,15 +282,16 @@ def _creer(entreprise, dt, libelle, montant, taux, regime, categorie):
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")
activite=activite or entreprise.activite,
date_emission=dt, date_encaissement=dt,
statut="encaissee", indy_id=indy_id)
LigneFacture.objects.create(facture=f, designation=libelle, quantite=Decimal("1"),
prix_unitaire_ht=ht, taux_tva=taux)
f.recompute_totaux()
return "vente"
Depense.objects.create(entreprise=entreprise, date=dt, libelle=libelle,
categorie=categorie, regime_tva=regime,
taux_tva=taux, montant_ttc=-montant)
categorie=categorie, activite=activite, regime_tva=regime,
taux_tva=taux, montant_ttc=-montant, indy_id=indy_id)
return "depense"
@@ -296,21 +321,47 @@ def import_csv(request):
taux = request.POST.getlist("l_taux")
regimes = request.POST.getlist("l_regime")
montants = request.POST.getlist("l_montant")
activites = request.POST.getlist("l_activite")
indy_ids = request.POST.getlist("l_indy_id")
cats_by_id = {str(c.pk): c for c in Categorie.objects.all()}
compteur = {}
ignores = 0
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")
iid = indy_ids[i] if i < len(indy_ids) else ""
if dt is None or montant == 0:
continue
if iid and _deja_importe(entreprise, iid):
ignores += 1
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 ""))
cats_by_id.get(cat_ids[i] if i < len(cat_ids) else ""),
_activite(activites[i] if i < len(activites) else ""), iid)
compteur[k] = compteur.get(k, 0) + 1
if ignores:
compteur["déjà importées (ignorées)"] = ignores
ctx["resultat"] = compteur
return render(request, "compta/import_csv.html", ctx)
if action == "indy":
token = (request.POST.get("indy_token") or "").strip()
if entreprise and token:
entreprise.indy_token = token
entreprise.save(update_fields=["indy_token"])
elif entreprise and not token:
token = entreprise.indy_token
if not entreprise or not token:
ctx["erreurs"] = ["Choisis une entreprise et renseigne le token Indy."]
return render(request, "compta/import_csv.html", ctx)
lignes, erreurs = _parse_indy(entreprise, token, request.POST.get("date_debut"),
request.POST.get("date_fin"), cats)
ctx.update({"lignes": lignes, "erreurs": erreurs, "source": "indy"})
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)