Sync Indy integre dans l'interface web + anti-doublon
This commit is contained in:
+118
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Synchronisation Indy : récupère les transactions via l'API privée d'Indy et les
|
||||
normalise au format attendu par l'import (réutilisé par la page /import/).
|
||||
|
||||
La « catégorie » Indy est un numéro de compte du plan comptable (PCG) ; on le
|
||||
mappe vers nos catégories. Montants en centimes, signés. `tva_intracom` => UE.
|
||||
|
||||
⚠️ API non officielle d'Indy : pour les données de l'utilisateur. Le token JWT
|
||||
expire (~1 h) et doit être renouvelé.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = "https://app.indy.fr"
|
||||
TRANSACTIONS_PATH = "/api/transactions/transactions-list"
|
||||
|
||||
COMPTE_CATEGORIE = [
|
||||
("701", "Vente de produits finis"), ("707", "Vente de produits finis"),
|
||||
("706", "Prestation de services"),
|
||||
("605", "Matériel et outillage"), ("606", "Matériel et outillage"),
|
||||
("607", "Matériel et outillage"),
|
||||
("6256", "Frais de repas hors domicile"), ("6253", "Frais de repas hors domicile"),
|
||||
("626", "Télécom, fournitures, documentation"),
|
||||
("6181", "Télécom, fournitures, documentation"),
|
||||
("6183", "Télécom, fournitures, documentation"),
|
||||
("6136", "Abonnement logiciel"), ("6226", "Abonnement logiciel"),
|
||||
("646", "Cotisation sociale Urssaf"),
|
||||
("44551", "TVA payée"), ("44558", "TVA payée"),
|
||||
("44583", "Remboursement de TVA"),
|
||||
("108", "Prélèvement personnel"),
|
||||
]
|
||||
|
||||
COMPTE_ACTIVITE = [("701", "vente"), ("707", "vente"), ("706", "service_bic")]
|
||||
|
||||
|
||||
def _cat(num):
|
||||
num = str(num or "")
|
||||
best = None
|
||||
for pref, cat in COMPTE_CATEGORIE:
|
||||
if num.startswith(pref) and (best is None or len(pref) > len(best[0])):
|
||||
best = (pref, cat)
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def _act(num):
|
||||
num = str(num or "")
|
||||
for pref, act in COMPTE_ACTIVITE:
|
||||
if num.startswith(pref):
|
||||
return act
|
||||
return ""
|
||||
|
||||
|
||||
def normaliser(tx):
|
||||
"""Transaction Indy -> ligne normalisée, ou None si inexploitable."""
|
||||
try:
|
||||
montant = (Decimal(tx["totalAmountInCents"]) / 100).quantize(Decimal("0.01"))
|
||||
except Exception:
|
||||
return None
|
||||
subs = [s for s in tx.get("subdivisions", [])
|
||||
if not str((s.get("accounting_account") or {}).get("number", "")).startswith("512")]
|
||||
if not subs:
|
||||
return None
|
||||
main = max(subs, key=lambda s: abs(s.get("amount_in_cents", 0)))
|
||||
num = str((main.get("accounting_account") or {}).get("number", ""))
|
||||
intra = main.get("tva_intracom")
|
||||
if intra and intra.get("type") == "eu":
|
||||
regime, taux = "intracom", Decimal(intra.get("rate", 0)) / 100
|
||||
else:
|
||||
regime, taux = "nationale", Decimal(main.get("tva_rate", 0)) / 100
|
||||
return {
|
||||
"indy_id": tx.get("_id", ""),
|
||||
"date": tx.get("date", ""),
|
||||
"libelle": tx.get("description") or tx.get("raw_description") or "(sans libellé)",
|
||||
"categorie_nom": _cat(num),
|
||||
"activite": _act(num),
|
||||
"taux": taux,
|
||||
"regime": regime,
|
||||
"montant": montant,
|
||||
"compte": num,
|
||||
}
|
||||
|
||||
|
||||
def fetch_transactions(token, date_debut=None, date_fin=None, search="", page=1,
|
||||
base_url=BASE_URL):
|
||||
"""Appelle l'API Indy. Retourne (status, liste_transactions_brutes, erreur)."""
|
||||
headers = {
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/149.0 Safari/537.36"),
|
||||
"x-client-app-type": "web",
|
||||
}
|
||||
params = {"search": search or "", "page": page}
|
||||
if date_debut:
|
||||
params["dateFrom"] = date_debut
|
||||
if date_fin:
|
||||
params["dateTo"] = date_fin
|
||||
try:
|
||||
with httpx.Client(base_url=base_url.rstrip("/"), headers=headers,
|
||||
timeout=30, follow_redirects=True) as c:
|
||||
r = c.get(TRANSACTIONS_PATH, params=params)
|
||||
if r.status_code != 200:
|
||||
return r.status_code, [], f"HTTP {r.status_code} (token expiré ?)"
|
||||
data = r.json()
|
||||
return 200, data.get("transactions", []), None
|
||||
except Exception as ex: # noqa: BLE001
|
||||
return None, [], f"{type(ex).__name__}: {ex}"
|
||||
|
||||
|
||||
def lignes_normalisees(token, date_debut=None, date_fin=None, search="", page=1,
|
||||
base_url=BASE_URL):
|
||||
"""Retourne (lignes_normalisées, erreur)."""
|
||||
status, txs, err = fetch_transactions(token, date_debut, date_fin, search, page, base_url)
|
||||
if err:
|
||||
return [], err
|
||||
lignes = [n for n in (normaliser(t) for t in txs) if n]
|
||||
return lignes, None
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.15 on 2026-06-30 12:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('compta', '0008_alter_depense_montant_tva_alter_depense_taux_tva_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='depense',
|
||||
name='indy_id',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=40),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='entreprise',
|
||||
name='indy_token',
|
||||
field=models.TextField(blank=True, help_text='Token JWT capté dans le navigateur, pour la synchronisation. Expire ~1 h.', verbose_name='Token Indy (API)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='facture',
|
||||
name='indy_id',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=40),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='paiement',
|
||||
name='indy_id',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=40),
|
||||
),
|
||||
]
|
||||
+6
-1
@@ -88,6 +88,9 @@ class Entreprise(models.Model):
|
||||
num_tva_intracom = models.CharField("N° TVA intracommunautaire", max_length=20, blank=True)
|
||||
vue_tva = models.CharField(
|
||||
"Mode d'affichage de la déclaration TVA", max_length=10, choices=VUE_TVA, default="simplifie")
|
||||
indy_token = models.TextField(
|
||||
"Token Indy (API)", blank=True,
|
||||
help_text="Token JWT capté dans le navigateur, pour la synchronisation. Expire ~1 h.")
|
||||
utilisateurs = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL, blank=True, related_name="entreprises",
|
||||
verbose_name="Utilisateurs autorisés",
|
||||
@@ -151,6 +154,7 @@ class Facture(models.Model):
|
||||
montant_tva = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0"))
|
||||
montant_ttc = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0"))
|
||||
notes = models.TextField(blank=True)
|
||||
indy_id = models.CharField(max_length=40, blank=True, db_index=True)
|
||||
cree_le = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
@@ -207,6 +211,7 @@ class Paiement(models.Model):
|
||||
categorie = models.ForeignKey(Categorie, on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name="paiements",
|
||||
help_text="Surtout pour les mouvements neutres.")
|
||||
indy_id = models.CharField(max_length=40, blank=True, db_index=True)
|
||||
cree_le = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
@@ -255,6 +260,7 @@ class Depense(models.Model):
|
||||
"Taux de TVA (%)", max_digits=4, decimal_places=2, default=Decimal("20"),
|
||||
choices=TAUX_TVA_CHOICES,
|
||||
help_text="Achat national : la TVA déductible est calculée. UE/import : taux auto-liquidé.")
|
||||
indy_id = models.CharField(max_length=40, blank=True, db_index=True)
|
||||
cree_le = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
@@ -266,7 +272,6 @@ class Depense(models.Model):
|
||||
return f"{self.date} — {self.libelle}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# TVA déductible calculée : incluse dans le TTC (national), nulle en autoliq.
|
||||
t = self.taux_tva or Decimal("0")
|
||||
if self.autoliquidee:
|
||||
self.montant_tva = Decimal("0.00")
|
||||
|
||||
@@ -10,16 +10,18 @@
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; font-family:system-ui,Segoe UI,Roboto,sans-serif;
|
||||
background:var(--bg); color:var(--txt); padding:24px; }
|
||||
h1 { margin:0 0 4px; font-size:22px; } h2{font-size:15px;color:var(--muted);}
|
||||
h1 { margin:0 0 4px; font-size:22px; } h2{font-size:15px;color:var(--muted);margin:0 0 8px;}
|
||||
a { color:var(--accent); }
|
||||
.sub { color:var(--muted); margin-bottom:14px; }
|
||||
.card { background:var(--card); border:1px solid #334155; border-radius:12px; padding:16px; margin-bottom:16px; }
|
||||
.card.indy { border-color:#0e7490; }
|
||||
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:110px; font-family:ui-monospace,monospace; }
|
||||
textarea { min-height:90px; 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;} }
|
||||
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||
@media(max-width:700px){ .grid3,.grid2{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; }
|
||||
@@ -53,8 +55,11 @@
|
||||
{% endif %}
|
||||
|
||||
{% if not lignes %}
|
||||
<form method="post" enctype="multipart/form-data" class="card" id="srcForm">
|
||||
<!-- ===== Synchroniser depuis Indy ===== -->
|
||||
<form method="post" class="card indy">
|
||||
{% csrf_token %}
|
||||
<h2>① Synchroniser depuis Indy (recommandé)</h2>
|
||||
<div class="sub">Récupère tes transactions directement depuis l'API Indy. Anti-doublon automatique.</div>
|
||||
<div class="grid3">
|
||||
<div>
|
||||
<label>Entreprise</label>
|
||||
@@ -62,12 +67,30 @@
|
||||
{% 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><label>Du</label><input type="date" name="date_debut"></div>
|
||||
<div><label>Au</label><input type="date" name="date_fin"></div>
|
||||
</div>
|
||||
<label>Token Indy (Bearer, sans « Bearer ») — mémorisé sur l'entreprise, expire ~1 h</label>
|
||||
<textarea name="indy_token" placeholder="eyJ..."></textarea>
|
||||
<div class="actions">
|
||||
<button type="submit" name="action" value="indy" class="primary">Récupérer depuis Indy</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- ===== CSV / capture ===== -->
|
||||
<form method="post" enctype="multipart/form-data" class="card" id="srcForm">
|
||||
{% csrf_token %}
|
||||
<h2>② Ou : CSV / capture d'écran (OCR)</h2>
|
||||
<div class="grid3">
|
||||
<div>
|
||||
<label>Photo / capture (OCR) — fichier, appareil photo, ou <b>coller</b> (Ctrl/Cmd+V)</label>
|
||||
<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 (OCR)</label><input type="number" name="annee" value="{{ annee_defaut }}"></div>
|
||||
<div>
|
||||
<label>Photo / capture — fichier, photo, ou <b>coller</b> (Ctrl/Cmd+V)</label>
|
||||
<input type="file" name="image" id="imgInput" accept="image/*" capture="environment">
|
||||
<div id="pasteInfo" class="sub" style="margin-top:4px"></div>
|
||||
</div>
|
||||
@@ -79,7 +102,6 @@
|
||||
<div class="actions">
|
||||
<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 %}
|
||||
|
||||
@@ -87,7 +109,7 @@
|
||||
<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>
|
||||
<h2>Aperçu éditable — {{ lignes|length }} ligne(s){% if source == 'indy' %} (Indy){% elif 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>
|
||||
@@ -109,6 +131,8 @@
|
||||
<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>
|
||||
<input type="hidden" name="l_activite" value="{{ l.activite }}">
|
||||
<input type="hidden" name="l_indy_id" value="{{ l.indy_id }}">
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -127,13 +151,10 @@
|
||||
for(var i=0; i<e.clipboardData.items.length; i++){
|
||||
var it = e.clipboardData.items[i];
|
||||
if(it.type && it.type.indexOf('image') === 0){
|
||||
var dt = new DataTransfer();
|
||||
dt.items.add(it.getAsFile());
|
||||
input.files = dt.files;
|
||||
var dt = new DataTransfer(); dt.items.add(it.getAsFile()); input.files = dt.files;
|
||||
var info = document.getElementById('pasteInfo');
|
||||
if(info){ info.textContent = 'Image collée — clique « Analyser ».'; info.style.color = '#34d399'; }
|
||||
e.preventDefault();
|
||||
break;
|
||||
e.preventDefault(); break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+83
-32
@@ -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
|
||||
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_gauche, key=lambda w: (w["yc"], w["x"])))
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Intégration Indy via MCP (Claude Desktop)
|
||||
|
||||
Le serveur MCP [mcp-server-indy-compta](https://github.com/stephaneberle9/mcp-server-indy-compta)
|
||||
expose tes données Indy (transactions, clients, factures) en **données structurées**.
|
||||
C'est bien plus fiable que l'OCR. Deux usages : **analyse en langage naturel** et
|
||||
**pont vers l'import** de JB Compta.
|
||||
|
||||
> ⚠️ API non officielle d'Indy : ça peut casser si Indy change son interface, et c'est
|
||||
> une zone grise vis-à-vis des CGU. L'authentification se fait par login navigateur
|
||||
> (Chrome) ou un token JWT capturé qui expire et doit être renouvelé.
|
||||
|
||||
## 1. Installer et brancher le MCP sur Claude Desktop
|
||||
|
||||
Prérequis : Python 3.12+, [uv](https://docs.astral.sh/uv/), un compte Indy.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/stephaneberle9/fastmcp-creds.git
|
||||
git clone https://github.com/stephaneberle9/python-indy-compta.git
|
||||
git clone https://github.com/stephaneberle9/mcp-server-indy-compta.git
|
||||
cd mcp-server-indy-compta && uv sync
|
||||
```
|
||||
|
||||
Dans Claude Desktop → `Settings… > Developer > Edit Config`, ajoute :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"indy-compta": {
|
||||
"command": "uv",
|
||||
"args": ["run", "--with-editable", "/chemin/vers/mcp-server-indy-compta",
|
||||
"mcp-server-indy-compta"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Redémarre Claude Desktop. Au premier appel d'outil, un navigateur s'ouvre pour le login Indy.
|
||||
|
||||
## 2. Analyse en langage naturel
|
||||
|
||||
Exemples de questions à poser à Claude (avec le MCP connecté) :
|
||||
|
||||
- « Montre mes dépenses de juin 2026 groupées par catégorie. »
|
||||
- « Quel est mon cash-flow du 1er trimestre ? »
|
||||
- « Quelles transactions de dépense n'ont pas de justificatif attaché ? »
|
||||
- « Liste mes factures en retard. »
|
||||
|
||||
## 3. Pont MCP → import JB Compta (le plus utile)
|
||||
|
||||
Demande à Claude de **convertir tes transactions Indy au format CSV de l'app**, puis
|
||||
colle le résultat dans la page **Importer** (`/import/`).
|
||||
|
||||
Prompt type à utiliser dans Claude Desktop :
|
||||
|
||||
> En utilisant le MCP indy-compta, récupère toutes mes transactions de **juin 2026**.
|
||||
> Donne-moi le résultat en **CSV** avec exactement ces colonnes, séparées par `;` :
|
||||
> `date;libelle;categorie;activite;taux_tva;regime;montant`
|
||||
> Règles :
|
||||
> - `date` au format `AAAA-MM-JJ` ;
|
||||
> - `montant` signé (négatif = dépense, positif = encaissement) ;
|
||||
> - `taux_tva` = le taux affiché (0, 5.5, 10 ou 20) ;
|
||||
> - `regime` = `intracom` si la TVA est marquée « UE », sinon vide ;
|
||||
> - `categorie` = le nom exact de la catégorie Indy ;
|
||||
> - une seule ligne par transaction, pas de texte autour du CSV.
|
||||
|
||||
Tu colles ce CSV dans `/import/` → **Analyser** → tu vérifies l'aperçu → **Importer**.
|
||||
|
||||
Avantage : Claude + le MCP font le **fetch fiable et le mapping**, l'app fait la
|
||||
**création** (déjà testée). Aucun token Indy à héberger sur ton serveur.
|
||||
|
||||
## 4. Plus tard : sync automatique côté serveur (optionnel)
|
||||
|
||||
Pour une automatisation totale (sans Claude Desktop), on pourrait intégrer la
|
||||
librairie `python-indy-compta` directement dans l'app (tâche planifiée qui tire les
|
||||
transactions et crée les opérations). À évaluer car cela demande :
|
||||
|
||||
- un **token JWT statique** sur le serveur (le login navigateur est impossible en headless),
|
||||
à renouveler à son expiration ;
|
||||
- de mapper les objets de la librairie vers nos modèles (à valider avec ton compte) ;
|
||||
- d'accepter la dépendance à une API non officielle.
|
||||
|
||||
Le pont CSV (section 3) couvre déjà 90 % du besoin sans cette complexité.
|
||||
Submodule
+1
Submodule fastmcp-creds added at 7701decfdb
@@ -0,0 +1,16 @@
|
||||
# Copie en .env et renseigne tes valeurs (voir README pour la capture du token).
|
||||
|
||||
INDY_BASE_URL=https://app.indy.fr
|
||||
|
||||
# Token JWT capté dans l'onglet Réseau (en-tête Authorization). NE PAS COMMITTER.
|
||||
INDY_TOKEN=
|
||||
|
||||
# Si Indy utilise un en-tête / préfixe différent de "Authorization: Bearer ..."
|
||||
# INDY_AUTH_HEADER=Authorization
|
||||
# INDY_AUTH_PREFIX=Bearer
|
||||
|
||||
# Si l'auth passe par un cookie plutôt qu'un token (copie l'en-tête Cookie complet) :
|
||||
# INDY_COOKIE=
|
||||
|
||||
# Endpoint des transactions, repéré dans DevTools (ex. /api/v1/transactions) :
|
||||
INDY_TRANSACTIONS_PATH=
|
||||
@@ -0,0 +1,75 @@
|
||||
# MCP Indy custom
|
||||
|
||||
Petit serveur MCP qui appelle l'**API privée d'Indy** avec ton token, pour
|
||||
récupérer tes transactions (et plus) sans OCR. Tu le branches sur Claude Desktop,
|
||||
ou tu t'en sers comme base pour le sync de JB Compta.
|
||||
|
||||
> ⚠️ API non officielle, pour **tes propres données**. Zone grise vis-à-vis des CGU
|
||||
> d'Indy, peut casser si Indy change son interface, et le token JWT expire.
|
||||
|
||||
## Étape 1 — Capturer l'API d'Indy (le cœur du sujet)
|
||||
|
||||
On a besoin de voir la vraie requête que fait le site quand il charge tes transactions.
|
||||
|
||||
1. Ouvre **https://app.indy.fr** dans Chrome et connecte-toi.
|
||||
2. Appuie sur **F12** → onglet **Network** (Réseau). Coche **Fetch/XHR**.
|
||||
3. Va sur ta page **Transactions** (ou recharge-la). Des requêtes apparaissent.
|
||||
4. Repère celle qui **renvoie tes transactions en JSON** (clique dessus, onglet
|
||||
*Response/Aperçu* : tu dois voir tes opérations). Note :
|
||||
- l'**URL** complète (onglet *Headers* → *Request URL*) ;
|
||||
- la **méthode** (GET/POST) ;
|
||||
- les **paramètres** éventuels (dates, page) ;
|
||||
- l'en-tête **Authorization** (commence souvent par `Bearer eyJ...`) — c'est ton token ;
|
||||
ou, si pas d'Authorization, l'en-tête **Cookie**.
|
||||
5. **Copie-moi** : l'URL, la méthode, les paramètres, et un **extrait du JSON de réponse**
|
||||
(2-3 transactions suffisent). **Masque la valeur du token** (garde juste « Bearer … »).
|
||||
|
||||
Avec ça, je complète `lister_transactions` (et j'ajoute clients, factures…) avec les
|
||||
bons endpoints et le bon mapping.
|
||||
|
||||
## Étape 2 — Configurer et lancer
|
||||
|
||||
```bash
|
||||
cd indy-mcp
|
||||
python -m venv .venv && source .venv/bin/activate # ou ton uv
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # puis renseigne INDY_TOKEN (et INDY_TRANSACTIONS_PATH)
|
||||
python server.py # démarre le MCP (stdio)
|
||||
```
|
||||
|
||||
## Étape 3 — Brancher sur Claude Desktop
|
||||
|
||||
`Settings… > Developer > Edit Config` :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"indy-custom": {
|
||||
"command": "python",
|
||||
"args": ["/chemin/vers/indy-mcp/server.py"],
|
||||
"env": { "INDY_TOKEN": "eyJ...ton-token...", "INDY_BASE_URL": "https://app.indy.fr" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Redémarre Claude Desktop.
|
||||
|
||||
## Outils fournis
|
||||
|
||||
| Outil | Rôle |
|
||||
|-------|------|
|
||||
| `etat` | Vérifie la config (token présent, URL). |
|
||||
| `appel_api` | **Explorateur** : appelle n'importe quel endpoint. Sert à découvrir/valider une URL captée avant de la figer. |
|
||||
| `lister_transactions` | Liste les transactions (une fois `INDY_TRANSACTIONS_PATH` renseigné). |
|
||||
|
||||
**Workflow de découverte** : une fois connecté, demande à Claude
|
||||
« appelle `appel_api` avec path=`/l-url-que-tu-as-captée` » → si tu vois ton JSON,
|
||||
l'endpoint est bon, on le fige dans `lister_transactions`.
|
||||
|
||||
## Récupérer le token rapidement
|
||||
|
||||
Dans DevTools → onglet **Application** (ou **Storage**) → *Local Storage* /
|
||||
*Cookies* de `app.indy.fr` : le token JWT (`eyJ...`) y est souvent stocké. Sinon,
|
||||
copie l'en-tête `Authorization` d'une requête dans l'onglet **Network**.
|
||||
Le token **expire** : s'il ne marche plus, recapture-le.
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Mapping des transactions Indy -> lignes d'import JB Compta.
|
||||
|
||||
Indy ne renvoie pas un nom de catégorie mais un **numéro de compte du plan
|
||||
comptable (PCG)** dans `subdivisions[].accounting_account.number`. On mappe ce
|
||||
numéro vers nos catégories. Bien plus fiable que du texte.
|
||||
|
||||
Sortie : un dict par transaction au format attendu par l'import JB Compta :
|
||||
{date, libelle, categorie, activite, taux, regime, montant}
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
# Préfixe de compte PCG -> nom de catégorie JB Compta (le préfixe le plus long gagne).
|
||||
COMPTE_CATEGORIE = [
|
||||
("701", "Vente de produits finis"),
|
||||
("707", "Vente de produits finis"),
|
||||
("706", "Prestation de services"),
|
||||
("605", "Matériel et outillage"),
|
||||
("606", "Matériel et outillage"),
|
||||
("607", "Matériel et outillage"),
|
||||
("6256", "Frais de repas hors domicile"),
|
||||
("6253", "Frais de repas hors domicile"),
|
||||
("626", "Télécom, fournitures, documentation"),
|
||||
("6181", "Télécom, fournitures, documentation"),
|
||||
("6183", "Télécom, fournitures, documentation"),
|
||||
("6136", "Abonnement logiciel"),
|
||||
("6226", "Abonnement logiciel"),
|
||||
("646", "Cotisation sociale Urssaf"),
|
||||
("44551", "TVA payée"),
|
||||
("44558", "TVA payée"),
|
||||
("44583", "Remboursement de TVA"),
|
||||
("108", "Prélèvement personnel"),
|
||||
# 471 (compte d'attente) et 6252 (déplacements) -> non mappés (à catégoriser)
|
||||
]
|
||||
|
||||
# Compte de vente -> activité (pour le multi-activité)
|
||||
COMPTE_ACTIVITE = [
|
||||
("701", "vente"), ("707", "vente"),
|
||||
("706", "service_bic"),
|
||||
]
|
||||
|
||||
|
||||
def _cat(num):
|
||||
num = str(num or "")
|
||||
best = None
|
||||
for pref, cat in COMPTE_CATEGORIE:
|
||||
if num.startswith(pref) and (best is None or len(pref) > len(best[0])):
|
||||
best = (pref, cat)
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def _act(num):
|
||||
num = str(num or "")
|
||||
for pref, act in COMPTE_ACTIVITE:
|
||||
if num.startswith(pref):
|
||||
return act
|
||||
return ""
|
||||
|
||||
|
||||
def normaliser(tx):
|
||||
"""Transaction Indy -> ligne d'import (ou None si inexploitable)."""
|
||||
try:
|
||||
montant = (Decimal(tx["totalAmountInCents"]) / 100).quantize(Decimal("0.01"))
|
||||
except Exception:
|
||||
return None
|
||||
subs = [s for s in tx.get("subdivisions", [])
|
||||
if not str((s.get("accounting_account") or {}).get("number", "")).startswith("512")]
|
||||
if not subs:
|
||||
return None
|
||||
# Subdivision principale = plus gros montant absolu (hors compte bancaire 512).
|
||||
main = max(subs, key=lambda s: abs(s.get("amount_in_cents", 0)))
|
||||
num = str((main.get("accounting_account") or {}).get("number", ""))
|
||||
intra = main.get("tva_intracom")
|
||||
if intra and intra.get("type") == "eu":
|
||||
regime = "intracom"
|
||||
taux = (Decimal(intra.get("rate", 0)) / 100)
|
||||
else:
|
||||
regime = "nationale"
|
||||
taux = (Decimal(main.get("tva_rate", 0)) / 100)
|
||||
return {
|
||||
"date": tx.get("date", ""),
|
||||
"libelle": tx.get("description") or tx.get("raw_description") or "(sans libellé)",
|
||||
"categorie": _cat(num),
|
||||
"activite": _act(num),
|
||||
"taux": taux,
|
||||
"regime": regime,
|
||||
"montant": montant,
|
||||
"compte": num,
|
||||
}
|
||||
|
||||
|
||||
def vers_csv(transactions):
|
||||
"""Liste de transactions Indy -> CSV au format import JB Compta."""
|
||||
lignes = ["date;libelle;categorie;activite;taux_tva;regime;montant"]
|
||||
for tx in transactions:
|
||||
r = normaliser(tx)
|
||||
if not r:
|
||||
continue
|
||||
regime = "" if r["regime"] == "nationale" else r["regime"]
|
||||
taux = f"{r['taux']:.2f}".rstrip("0").rstrip(".")
|
||||
lignes.append(";".join([
|
||||
r["date"], r["libelle"], r["categorie"] or "", r["activite"],
|
||||
taux, regime, f"{r['montant']:.2f}"]))
|
||||
return "\n".join(lignes)
|
||||
@@ -0,0 +1,2 @@
|
||||
fastmcp>=2.0
|
||||
httpx>=0.27
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
MCP Indy custom — serveur FastMCP qui appelle l'API privee d'Indy avec ton token,
|
||||
recupere tes transactions et les convertit au format d'import de JB Compta.
|
||||
|
||||
API non officielle d'Indy : pour TES donnees uniquement. Peut casser si Indy
|
||||
change son interface ; le token JWT expire et doit etre renouvele.
|
||||
"""
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
|
||||
import indy_mapping
|
||||
|
||||
BASE_URL = os.getenv("INDY_BASE_URL", "https://app.indy.fr").rstrip("/")
|
||||
TOKEN = os.getenv("INDY_TOKEN", "")
|
||||
AUTH_HEADER = os.getenv("INDY_AUTH_HEADER", "Authorization")
|
||||
AUTH_PREFIX = os.getenv("INDY_AUTH_PREFIX", "Bearer ")
|
||||
COOKIE = os.getenv("INDY_COOKIE", "")
|
||||
TRANSACTIONS_PATH = os.getenv("INDY_TRANSACTIONS_PATH",
|
||||
"/api/transactions/transactions-list")
|
||||
|
||||
mcp = FastMCP("indy-compta-custom")
|
||||
|
||||
|
||||
def _client():
|
||||
headers = {
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0 Safari/537.36"),
|
||||
"x-client-app-type": "web",
|
||||
}
|
||||
if TOKEN:
|
||||
headers[AUTH_HEADER] = f"{AUTH_PREFIX}{TOKEN}"
|
||||
if COOKIE:
|
||||
headers["Cookie"] = COOKIE
|
||||
return httpx.Client(base_url=BASE_URL, headers=headers, timeout=30,
|
||||
follow_redirects=True)
|
||||
|
||||
|
||||
def _appel_api(path, method="GET", params=None, json_body=None):
|
||||
if not TOKEN and not COOKIE:
|
||||
return {"erreur": "INDY_TOKEN (ou INDY_COOKIE) non configure dans .env"}
|
||||
try:
|
||||
with _client() as c:
|
||||
r = c.request(method.upper(), path, params=params, json=json_body)
|
||||
ct = r.headers.get("content-type", "")
|
||||
body = r.json() if "json" in ct else r.text[:3000]
|
||||
return {"status": r.status_code, "url": str(r.request.url), "data": body}
|
||||
except Exception as ex: # noqa: BLE001
|
||||
return {"erreur": f"{type(ex).__name__}: {ex}"}
|
||||
|
||||
|
||||
def _fetch_transactions(search="", date_debut=None, date_fin=None, page=1):
|
||||
params = {"search": search or "", "page": page}
|
||||
if date_debut:
|
||||
params["dateFrom"] = date_debut
|
||||
if date_fin:
|
||||
params["dateTo"] = date_fin
|
||||
res = _appel_api(TRANSACTIONS_PATH, "GET", params=params)
|
||||
if "erreur" in res:
|
||||
return res, []
|
||||
data = res.get("data") or {}
|
||||
txs = data.get("transactions", []) if isinstance(data, dict) else []
|
||||
return res, txs
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def etat() -> dict:
|
||||
"""Verifie la configuration : URL de base, presence du token, endpoint."""
|
||||
return {"base_url": BASE_URL, "token_present": bool(TOKEN),
|
||||
"auth_header": AUTH_HEADER, "transactions_path": TRANSACTIONS_PATH}
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def appel_api(path: str, method: str = "GET", params: dict | None = None,
|
||||
json_body: dict | None = None) -> dict:
|
||||
"""Explorateur generique : appelle n'importe quel endpoint de l'API Indy."""
|
||||
return _appel_api(path, method, params, json_body)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def lister_transactions(search: str = "", date_debut: str | None = None,
|
||||
date_fin: str | None = None, page: int = 1) -> dict:
|
||||
"""Liste brute des transactions Indy (filtres : search, dates AAAA-MM-JJ, page)."""
|
||||
res, _ = _fetch_transactions(search, date_debut, date_fin, page)
|
||||
return res
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def transactions_pour_import(date_debut: str | None = None, date_fin: str | None = None,
|
||||
search: str = "", page: int = 1) -> dict:
|
||||
"""Recupere les transactions Indy au format CSV de JB Compta (a coller dans /import/).
|
||||
Colonnes : date;libelle;categorie;activite;taux_tva;regime;montant
|
||||
"""
|
||||
res, txs = _fetch_transactions(search, date_debut, date_fin, page)
|
||||
if "erreur" in res:
|
||||
return res
|
||||
data = res.get("data") or {}
|
||||
return {
|
||||
"nb_transactions_page": len(txs),
|
||||
"nb_total": data.get("nbTransactions") if isinstance(data, dict) else None,
|
||||
"csv": indy_mapping.vers_csv(txs),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
@@ -5,3 +5,4 @@ python-dotenv>=1.0
|
||||
whitenoise>=6.6
|
||||
Pillow>=10.0
|
||||
pytesseract>=0.3.10
|
||||
httpx>=0.27
|
||||
|
||||
Reference in New Issue
Block a user