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)
|
num_tva_intracom = models.CharField("N° TVA intracommunautaire", max_length=20, blank=True)
|
||||||
vue_tva = models.CharField(
|
vue_tva = models.CharField(
|
||||||
"Mode d'affichage de la déclaration TVA", max_length=10, choices=VUE_TVA, default="simplifie")
|
"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(
|
utilisateurs = models.ManyToManyField(
|
||||||
settings.AUTH_USER_MODEL, blank=True, related_name="entreprises",
|
settings.AUTH_USER_MODEL, blank=True, related_name="entreprises",
|
||||||
verbose_name="Utilisateurs autorisés",
|
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_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"))
|
montant_ttc = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0"))
|
||||||
notes = models.TextField(blank=True)
|
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)
|
cree_le = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -207,6 +211,7 @@ class Paiement(models.Model):
|
|||||||
categorie = models.ForeignKey(Categorie, on_delete=models.SET_NULL, null=True, blank=True,
|
categorie = models.ForeignKey(Categorie, on_delete=models.SET_NULL, null=True, blank=True,
|
||||||
related_name="paiements",
|
related_name="paiements",
|
||||||
help_text="Surtout pour les mouvements neutres.")
|
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)
|
cree_le = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -255,6 +260,7 @@ class Depense(models.Model):
|
|||||||
"Taux de TVA (%)", max_digits=4, decimal_places=2, default=Decimal("20"),
|
"Taux de TVA (%)", max_digits=4, decimal_places=2, default=Decimal("20"),
|
||||||
choices=TAUX_TVA_CHOICES,
|
choices=TAUX_TVA_CHOICES,
|
||||||
help_text="Achat national : la TVA déductible est calculée. UE/import : taux auto-liquidé.")
|
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)
|
cree_le = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -266,7 +272,6 @@ class Depense(models.Model):
|
|||||||
return f"{self.date} — {self.libelle}"
|
return f"{self.date} — {self.libelle}"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
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")
|
t = self.taux_tva or Decimal("0")
|
||||||
if self.autoliquidee:
|
if self.autoliquidee:
|
||||||
self.montant_tva = Decimal("0.00")
|
self.montant_tva = Decimal("0.00")
|
||||||
|
|||||||
@@ -10,16 +10,18 @@
|
|||||||
* { box-sizing:border-box; }
|
* { box-sizing:border-box; }
|
||||||
body { margin:0; font-family:system-ui,Segoe UI,Roboto,sans-serif;
|
body { margin:0; font-family:system-ui,Segoe UI,Roboto,sans-serif;
|
||||||
background:var(--bg); color:var(--txt); padding:24px; }
|
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); }
|
a { color:var(--accent); }
|
||||||
.sub { color:var(--muted); margin-bottom:14px; }
|
.sub { color:var(--muted); margin-bottom:14px; }
|
||||||
.card { background:var(--card); border:1px solid #334155; border-radius:12px; padding:16px; margin-bottom:16px; }
|
.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; }
|
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;
|
input, select, textarea { background:#0f172a; color:var(--txt); border:1px solid #334155;
|
||||||
border-radius:8px; padding:8px 10px; font-size:14px; width:100%; }
|
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; }
|
.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);
|
button { cursor:pointer; border-radius:8px; border:1px solid #334155; background:var(--card);
|
||||||
color:var(--txt); padding:9px 16px; font-size:14px; }
|
color:var(--txt); padding:9px 16px; font-size:14px; }
|
||||||
button.primary { background:var(--accent); color:#04263a; border:none; font-weight:600; }
|
button.primary { background:var(--accent); color:#04263a; border:none; font-weight:600; }
|
||||||
@@ -53,8 +55,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not lignes %}
|
{% 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 %}
|
{% 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 class="grid3">
|
||||||
<div>
|
<div>
|
||||||
<label>Entreprise</label>
|
<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 %}
|
{% for e in entreprises %}<option value="{{ e.pk }}" {% if e.pk == entreprise_id %}selected{% endif %}>{{ e.nom }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
<div>
|
||||||
<label>Année (pour l'OCR)</label>
|
<label>Entreprise</label>
|
||||||
<input type="number" name="annee" value="{{ annee_defaut }}">
|
<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>
|
||||||
|
<div><label>Année (OCR)</label><input type="number" name="annee" value="{{ annee_defaut }}"></div>
|
||||||
<div>
|
<div>
|
||||||
<label>Photo / capture (OCR) — fichier, appareil photo, ou <b>coller</b> (Ctrl/Cmd+V)</label>
|
<label>Photo / capture — fichier, photo, ou <b>coller</b> (Ctrl/Cmd+V)</label>
|
||||||
<input type="file" name="image" id="imgInput" accept="image/*" capture="environment">
|
<input type="file" name="image" id="imgInput" accept="image/*" capture="environment">
|
||||||
<div id="pasteInfo" class="sub" style="margin-top:4px"></div>
|
<div id="pasteInfo" class="sub" style="margin-top:4px"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +102,6 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" name="action" value="preview" class="primary">Analyser / Aperçu</button>
|
<button type="submit" name="action" value="preview" class="primary">Analyser / Aperçu</button>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -87,7 +109,7 @@
|
|||||||
<form method="post" class="card">
|
<form method="post" class="card">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="entreprise" value="{{ entreprise_id }}">
|
<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>
|
<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>
|
<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>
|
<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_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><input type="number" step="0.01" name="l_montant" value="{{ l.montant }}" class="num" style="width:100px"></td>
|
||||||
<td>{{ l.type }}</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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -127,13 +151,10 @@
|
|||||||
for(var i=0; i<e.clipboardData.items.length; i++){
|
for(var i=0; i<e.clipboardData.items.length; i++){
|
||||||
var it = e.clipboardData.items[i];
|
var it = e.clipboardData.items[i];
|
||||||
if(it.type && it.type.indexOf('image') === 0){
|
if(it.type && it.type.indexOf('image') === 0){
|
||||||
var dt = new DataTransfer();
|
var dt = new DataTransfer(); dt.items.add(it.getAsFile()); input.files = dt.files;
|
||||||
dt.items.add(it.getAsFile());
|
|
||||||
input.files = dt.files;
|
|
||||||
var info = document.getElementById('pasteInfo');
|
var info = document.getElementById('pasteInfo');
|
||||||
if(info){ info.textContent = 'Image collée — clique « Analyser ».'; info.style.color = '#34d399'; }
|
if(info){ info.textContent = 'Image collée — clique « Analyser ».'; info.style.color = '#34d399'; }
|
||||||
e.preventDefault();
|
e.preventDefault(); break;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+84
-33
@@ -1,13 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Import d'opérations : CSV (collé/uploadé) ou OCR d'une capture d'écran.
|
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.
|
||||||
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 csv
|
import csv
|
||||||
import io
|
import io
|
||||||
@@ -21,6 +14,7 @@ from django.shortcuts import render
|
|||||||
|
|
||||||
from .models import (Entreprise, Client, Facture, LigneFacture, Paiement,
|
from .models import (Entreprise, Client, Facture, LigneFacture, Paiement,
|
||||||
Depense, Categorie, Activite, RegimeTva)
|
Depense, Categorie, Activite, RegimeTva)
|
||||||
|
from . import indy
|
||||||
|
|
||||||
CENT = Decimal("0.01")
|
CENT = Decimal("0.01")
|
||||||
MOIS = {"janvier": 1, "février": 2, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5,
|
MOIS = {"janvier": 1, "février": 2, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5,
|
||||||
@@ -50,6 +44,12 @@ def _date(s):
|
|||||||
return None
|
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):
|
def _regime(s):
|
||||||
s = (s or "").strip().lower()
|
s = (s or "").strip().lower()
|
||||||
if s in ("intracom", "intracommunautaire", "ue"):
|
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,
|
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)
|
neutre = bool(categorie and categorie.exclure_calculs)
|
||||||
if montant > 0:
|
if montant > 0:
|
||||||
typ = "Mouvement neutre" if neutre else "Vente"
|
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"
|
typ = "Dépense neutre" if neutre else "Dépense"
|
||||||
return {"n": n, "date": dt.isoformat() if dt else "", "libelle": libelle,
|
return {"n": n, "date": dt.isoformat() if dt else "", "libelle": libelle,
|
||||||
"categorie_id": categorie.pk if categorie else "",
|
"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 ----------------------------------------------------------------
|
# ---- CSV ----------------------------------------------------------------
|
||||||
@@ -109,7 +110,7 @@ def _parse_csv(text, cats):
|
|||||||
erreurs.append(f"Ligne {n} : montant nul."); continue
|
erreurs.append(f"Ligne {n} : montant nul."); continue
|
||||||
cat = cats.get((c[2] or "").strip().lower())
|
cat = cats.get((c[2] or "").strip().lower())
|
||||||
lignes.append(_row(dt, (c[1] or "").strip() or "(sans libellé)", montant,
|
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, []
|
return lignes, erreurs, []
|
||||||
|
|
||||||
|
|
||||||
@@ -198,7 +199,7 @@ def _parse_liste(words, annee, cats):
|
|||||||
if cat:
|
if cat:
|
||||||
libelle = re.sub(re.escape(cat.nom), "", texte, flags=re.I).strip(" -·,")
|
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"]),
|
lignes.append(_row(dt, libelle or texte or "(sans libellé)", _dec(a["t"]),
|
||||||
taux, regime, cat, n))
|
taux, regime, cat, "", "", n))
|
||||||
return lignes
|
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"])]
|
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")
|
montant = _dec(amounts[0]["t"]) if amounts else Decimal("0")
|
||||||
y_ref = dtok["yc"] if dtok else 99999
|
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")]
|
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)"
|
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"]
|
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",
|
and w["t"].lower() not in ("ventilation(s)", "ventilation", "catégorie",
|
||||||
"categorie", "montant", "ttc", "tva")]
|
"categorie", "montant", "ttc", "tva")]
|
||||||
texte_cat = " ".join(w["t"] for w in sorted(droite, key=lambda w: (w["yc"], w["x"])))
|
cat = _match_cat(" ".join(w["t"] for w in sorted(droite, key=lambda w: (w["yc"], w["x"]))), cats)
|
||||||
cat = _match_cat(texte_cat, cats)
|
|
||||||
if montant == 0:
|
if montant == 0:
|
||||||
return []
|
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):
|
def _parse_ocr(image_file, annee, cats):
|
||||||
@@ -229,25 +229,49 @@ def _parse_ocr(image_file, annee, cats):
|
|||||||
debug = _debug_lines(words)
|
debug = _debug_lines(words)
|
||||||
allt = " ".join(w["t"] for w in words).lower()
|
allt = " ".join(w["t"] for w in words).lower()
|
||||||
is_detail = ("ventilation" in allt) or ("informations" in allt and "montant" in allt)
|
is_detail = ("ventilation" in allt) or ("informations" in allt and "montant" in allt)
|
||||||
if is_detail:
|
lignes = _parse_detail(words, cats) if is_detail else _parse_liste(words, annee, cats)
|
||||||
lignes = _parse_detail(words, cats); fmt = "détail"
|
|
||||||
else:
|
|
||||||
lignes = _parse_liste(words, annee, cats); fmt = "liste"
|
|
||||||
if not lignes:
|
if not lignes:
|
||||||
erreurs.append(f"Format « {fmt} » détecté mais aucune ligne exploitable. "
|
erreurs.append("Aucune ligne exploitable détectée. Regarde le texte OCR ci-dessous "
|
||||||
f"Regarde le texte OCR ci-dessous et complète à la main, "
|
"et complète à la main, ou utilise le CSV.")
|
||||||
f"ou utilise le CSV.")
|
|
||||||
return lignes, erreurs, debug
|
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 -----------------------------------------------------------
|
# ---- 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)
|
neutre = bool(categorie and categorie.exclure_calculs)
|
||||||
if montant > 0:
|
if montant > 0:
|
||||||
if neutre:
|
if neutre:
|
||||||
Paiement.objects.create(entreprise=entreprise, date=dt, libelle=libelle,
|
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"
|
return "neutre"
|
||||||
client, _ = Client.objects.get_or_create(entreprise=entreprise, nom=libelle)
|
client, _ = Client.objects.get_or_create(entreprise=entreprise, nom=libelle)
|
||||||
ht = (montant / (Decimal("1") + taux / Decimal("100"))).quantize(CENT)
|
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():
|
while Facture.objects.filter(entreprise=entreprise, numero=numero).exists():
|
||||||
base += 1; numero = f"{annee}-{base:03d}"
|
base += 1; numero = f"{annee}-{base:03d}"
|
||||||
f = Facture.objects.create(entreprise=entreprise, client=client, numero=numero,
|
f = Facture.objects.create(entreprise=entreprise, client=client, numero=numero,
|
||||||
activite=entreprise.activite, date_emission=dt,
|
activite=activite or entreprise.activite,
|
||||||
date_encaissement=dt, statut="encaissee")
|
date_emission=dt, date_encaissement=dt,
|
||||||
|
statut="encaissee", indy_id=indy_id)
|
||||||
LigneFacture.objects.create(facture=f, designation=libelle, quantite=Decimal("1"),
|
LigneFacture.objects.create(facture=f, designation=libelle, quantite=Decimal("1"),
|
||||||
prix_unitaire_ht=ht, taux_tva=taux)
|
prix_unitaire_ht=ht, taux_tva=taux)
|
||||||
f.recompute_totaux()
|
f.recompute_totaux()
|
||||||
return "vente"
|
return "vente"
|
||||||
Depense.objects.create(entreprise=entreprise, date=dt, libelle=libelle,
|
Depense.objects.create(entreprise=entreprise, date=dt, libelle=libelle,
|
||||||
categorie=categorie, regime_tva=regime,
|
categorie=categorie, activite=activite, regime_tva=regime,
|
||||||
taux_tva=taux, montant_ttc=-montant)
|
taux_tva=taux, montant_ttc=-montant, indy_id=indy_id)
|
||||||
return "depense"
|
return "depense"
|
||||||
|
|
||||||
|
|
||||||
@@ -296,21 +321,47 @@ def import_csv(request):
|
|||||||
taux = request.POST.getlist("l_taux")
|
taux = request.POST.getlist("l_taux")
|
||||||
regimes = request.POST.getlist("l_regime")
|
regimes = request.POST.getlist("l_regime")
|
||||||
montants = request.POST.getlist("l_montant")
|
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()}
|
cats_by_id = {str(c.pk): c for c in Categorie.objects.all()}
|
||||||
compteur = {}
|
compteur = {}
|
||||||
|
ignores = 0
|
||||||
for i in range(len(libelles)):
|
for i in range(len(libelles)):
|
||||||
dt = _date(dates[i]) if i < len(dates) else None
|
dt = _date(dates[i]) if i < len(dates) else None
|
||||||
montant = _dec(montants[i] if i < len(montants) else "0")
|
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:
|
if dt is None or montant == 0:
|
||||||
continue
|
continue
|
||||||
|
if iid and _deja_importe(entreprise, iid):
|
||||||
|
ignores += 1
|
||||||
|
continue
|
||||||
k = _creer(entreprise, dt, (libelles[i] or "").strip() or "(sans libellé)",
|
k = _creer(entreprise, dt, (libelles[i] or "").strip() or "(sans libellé)",
|
||||||
montant, _dec(taux[i] if i < len(taux) else "0"),
|
montant, _dec(taux[i] if i < len(taux) else "0"),
|
||||||
_regime(regimes[i] if i < len(regimes) else ""),
|
_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
|
compteur[k] = compteur.get(k, 0) + 1
|
||||||
|
if ignores:
|
||||||
|
compteur["déjà importées (ignorées)"] = ignores
|
||||||
ctx["resultat"] = compteur
|
ctx["resultat"] = compteur
|
||||||
return render(request, "compta/import_csv.html", ctx)
|
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")
|
image = request.FILES.get("image")
|
||||||
if image:
|
if image:
|
||||||
annee = int(request.POST.get("annee") or date_cls.today().year)
|
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
|
whitenoise>=6.6
|
||||||
Pillow>=10.0
|
Pillow>=10.0
|
||||||
pytesseract>=0.3.10
|
pytesseract>=0.3.10
|
||||||
|
httpx>=0.27
|
||||||
|
|||||||
Reference in New Issue
Block a user