#!/usr/bin/env python3 """Analyse des bilans AIRTRIA — outil OpenClaw (python3 >= 3.11, bibliothèque standard seule). Lit bilans/airtria-balances.json (balance par compte de chaque exercice, produite par extract.py) et répond en texte français prêt à relayer tel quel. bilans.py liste exercices disponibles, CA et résultat bilans.py resume [AN] synthèse d'un exercice (défaut : le dernier) bilans.py compare AN1 AN2 évolution des indicateurs entre deux exercices bilans.py evolution [INDICATEUR] série sur tous les exercices (sans argument : liste) bilans.py charges [AN] détail des charges par compte bilans.py compte MOTIF [AN] compte(s) dont le numéro commence par / le libellé contient MOTIF bilans.py ratios [AN] ratios avec leur formule bilans.py sources provenance des chiffres et contrôles de cohérence Option globale : --json (sortie brute pour débogage). Exercice N = du 01/10/N-1 au 30/09/N. """ import json import os import sys from pathlib import Path DATA = Path(os.environ.get("BILANS_DATA", Path(__file__).resolve().parent.parent / "bilans/airtria-balances.json")) # ── Chargement ──────────────────────────────────────────────────────────────── def charger(): try: return json.loads(DATA.read_text(encoding="utf-8")) except FileNotFoundError: sortir(f"ERREUR : fichier de données introuvable : {DATA}", 2) def sortir(msg, code=1): print(msg) sys.exit(code) # ── Agrégats (Plan comptable général) ───────────────────────────────────────── def somme(comptes, *prefixes, signe=1): """Somme des soldes (débit - crédit) des comptes commençant par l'un des préfixes. signe=-1 pour les comptes à solde normalement créditeur (produits, passif).""" return signe * sum(c["solde"] for n, c in comptes.items() if n.startswith(prefixes)) def somme_signee(comptes, prefixes, debiteur): """Somme des seuls comptes débiteurs (actif) ou créditeurs (passif), compte par compte : un compte fournisseur débiteur est une créance, une banque créditrice une dette.""" tot = 0.0 for n, c in comptes.items(): if n.startswith(prefixes): if debiteur and c["solde"] > 0: tot += c["solde"] elif not debiteur and c["solde"] < 0: tot -= c["solde"] return tot def indicateurs(comptes): s = lambda *p: somme(comptes, *p) p = lambda *p: somme(comptes, *p, signe=-1) i = {} # Compte de résultat i["ca"] = p("70") i["autres_produits_exploit"] = p("71", "72", "74", "75", "781", "791") i["achats_charges_externes"] = s("60", "61", "62") i["impots_taxes"] = s("63") i["remuneration_dirigeant"] = s("641") i["charges_sociales"] = s("645", "646", "647", "648") i["dotations_amortissements"] = s("681") i["autres_charges_exploit"] = s("65") i["resultat_exploitation"] = (i["ca"] + i["autres_produits_exploit"] - i["achats_charges_externes"] - i["impots_taxes"] - s("64") - i["dotations_amortissements"] - i["autres_charges_exploit"]) i["produits_financiers"] = p("76", "786", "796") i["charges_financieres"] = s("66", "686") i["resultat_financier"] = i["produits_financiers"] - i["charges_financieres"] i["resultat_exceptionnel"] = p("77", "787", "797") - s("67", "687") i["impot_societes"] = s("69") i["resultat_net"] = p("6", "7") # Soldes intermédiaires de gestion i["valeur_ajoutee"] = i["ca"] + p("71", "72") - i["achats_charges_externes"] i["ebe"] = i["valeur_ajoutee"] + p("74") - i["impots_taxes"] - s("64") i["caf"] = (i["resultat_net"] + s("68") - p("78") + s("675") - p("775")) # Bilan i["immobilisations_brutes"] = s("20", "21", "22", "23", "26", "27") i["amortissements_cumules"] = p("28", "29") i["actif_immobilise_net"] = i["immobilisations_brutes"] - i["amortissements_cumules"] i["creances"] = somme_signee(comptes, ("40", "41", "42", "43", "44", "45", "46", "47", "48"), True) i["vmp"] = s("50") i["disponibilites"] = somme_signee(comptes, ("51", "53"), True) i["total_actif"] = i["actif_immobilise_net"] + i["creances"] + i["vmp"] + i["disponibilites"] i["capitaux_propres"] = p("10", "11", "12", "13", "14") + i["resultat_net"] i["emprunts"] = p("16") + somme_signee(comptes, ("51",), False) i["dettes_fournisseurs"] = somme_signee(comptes, ("40",), False) i["dettes_fiscales_sociales"] = somme_signee(comptes, ("42", "43", "44"), False) i["compte_courant_associe"] = somme_signee(comptes, ("455",), False) i["autres_dettes"] = somme_signee(comptes, ("41", "45", "46", "47", "48"), False) - i["compte_courant_associe"] i["total_passif"] = (i["capitaux_propres"] + i["emprunts"] + i["dettes_fournisseurs"] + i["dettes_fiscales_sociales"] + i["compte_courant_associe"] + i["autres_dettes"]) i["tresorerie_nette"] = i["disponibilites"] + i["vmp"] - somme_signee(comptes, ("51",), False) i["fonds_de_roulement"] = i["capitaux_propres"] + p("16") - i["actif_immobilise_net"] i["bfr"] = i["creances"] - (i["dettes_fournisseurs"] + i["dettes_fiscales_sociales"] + i["compte_courant_associe"] + i["autres_dettes"]) return {k: round(v, 2) for k, v in i.items()} def ratios(i): def pct(a, b): return None if not b else round(100 * a / b, 1) return { "marge_nette": (pct(i["resultat_net"], i["ca"]), "résultat net / CA"), "taux_ebe": (pct(i["ebe"], i["ca"]), "EBE / CA"), "taux_valeur_ajoutee": (pct(i["valeur_ajoutee"], i["ca"]), "valeur ajoutée / CA"), "poids_charges_externes": (pct(i["achats_charges_externes"], i["ca"]), "achats et charges externes / CA"), "poids_remuneration": (pct(i["remuneration_dirigeant"] + i["charges_sociales"], i["ca"]), "(rémunération dirigeant + charges sociales) / CA"), "autonomie_financiere": (pct(i["capitaux_propres"], i["total_passif"]), "capitaux propres / total bilan"), "endettement": (pct(i["emprunts"], i["capitaux_propres"]), "emprunts et découverts / capitaux propres"), "liquidite_immediate": (pct(i["disponibilites"] + i["vmp"], i["dettes_fournisseurs"] + i["dettes_fiscales_sociales"] + i["autres_dettes"]), "(disponibilités + VMP) / dettes à court terme hors compte courant"), } LIBELLES = { "ca": "Chiffre d'affaires", "autres_produits_exploit": "Autres produits d'exploitation", "achats_charges_externes": "Achats et charges externes", "impots_taxes": "Impôts et taxes", "remuneration_dirigeant": "Rémunération du dirigeant", "charges_sociales": "Charges sociales", "dotations_amortissements": "Dotations aux amortissements", "autres_charges_exploit": "Autres charges d'exploitation", "resultat_exploitation": "Résultat d'exploitation", "produits_financiers": "Produits financiers", "charges_financieres": "Charges financières", "resultat_financier": "Résultat financier", "resultat_exceptionnel": "Résultat exceptionnel", "impot_societes": "Impôt sur les sociétés", "resultat_net": "Résultat net", "valeur_ajoutee": "Valeur ajoutée", "ebe": "EBE", "caf": "Capacité d'autofinancement", "immobilisations_brutes": "Immobilisations brutes", "amortissements_cumules": "Amortissements cumulés", "actif_immobilise_net": "Actif immobilisé net", "creances": "Créances", "vmp": "Valeurs mobilières de placement", "disponibilites": "Disponibilités", "total_actif": "Total bilan", "capitaux_propres": "Capitaux propres", "emprunts": "Emprunts et découverts", "dettes_fournisseurs": "Dettes fournisseurs", "dettes_fiscales_sociales": "Dettes fiscales et sociales", "compte_courant_associe": "Compte courant d'associé", "autres_dettes": "Autres dettes", "total_passif": "Total passif", "tresorerie_nette": "Trésorerie nette (dispo + VMP - découverts)", "fonds_de_roulement": "Fonds de roulement", "bfr": "Besoin en fonds de roulement", } NOMS_RATIOS = {"marge_nette": "Marge nette", "taux_ebe": "Taux d'EBE", "taux_valeur_ajoutee": "Taux de valeur ajoutée", "poids_charges_externes": "Poids des charges externes", "poids_remuneration": "Poids de la rémunération", "autonomie_financiere": "Autonomie financière", "endettement": "Endettement", "liquidite_immediate": "Liquidité immédiate"} ALIAS = {"chiffre_affaires": "ca", "chiffre": "ca", "resultat": "resultat_net", "benefice": "resultat_net", "tresorerie": "tresorerie_nette", "treso": "tresorerie_nette", "fr": "fonds_de_roulement", "remuneration": "remuneration_dirigeant", "salaire": "remuneration_dirigeant", "charges_externes": "achats_charges_externes", "cp": "capitaux_propres", "total_bilan": "total_actif", "va": "valeur_ajoutee", "amortissements": "dotations_amortissements", "dettes": "emprunts"} # ── Mise en forme ───────────────────────────────────────────────────────────── def eur(v): if v is None: return "n.d." n = round(v) s = f"{abs(n):,}".replace(",", " ") return f"{'-' if n < 0 else ''}{s} €" def pc(v): return "n.d." if v is None else f"{v:.1f} %".replace(".", ",") def variation(a, b): """Évolution de a (ancien) vers b (récent).""" d = b - a sgn = "+" if d >= 0 else "-" txt = f"{sgn}{eur(abs(d))}" if a and abs(a) >= 1: txt += f" ({sgn}{pc(abs(100 * d / a))})" if (a > 0) else "" return txt def exercice(data, an): if an is None: an = max(data["exercices"]) an = str(an) if an not in data["exercices"]: sortir(f"Exercice {an} absent. Exercices disponibles : {', '.join(data['exercices'])} " f"(exercice N = du 01/10/N-1 au 30/09/N).") return an, data["exercices"][an] def entete(an, e): return f"AIRTRIA — exercice {an} (du {fr_date(e['debut'])} au {fr_date(e['fin'])})" def fr_date(iso): y, m, d = iso.split("-") return f"{d}/{m}/{y}" # ── Commandes ───────────────────────────────────────────────────────────────── def cmd_liste(data, args): lignes = ["AIRTRIA — exercices disponibles (clôture au 30/09)", ""] for an, e in data["exercices"].items(): i = indicateurs(e["comptes"]) lignes.append(f"{an} : CA {eur(i['ca'])}, résultat {eur(i['resultat_net'])}" + (" (*)" if "note" in e else "")) if any("note" in e for e in data["exercices"].values()): lignes += ["", "(*) remarque sur la source : voir « resume AN » ou « sources »."] return "\n".join(lignes) def cmd_resume(data, args): an, e = exercice(data, args[0] if args else None) i = indicateurs(e["comptes"]) r = ratios(i) prec = data["exercices"].get(str(int(an) - 1)) ip = indicateurs(prec["comptes"]) if prec else None vs = lambda k: f" ({variation(ip[k], i[k])} vs {int(an) - 1})" if ip else "" L = [entete(an, e), "", "ACTIVITÉ ET RÉSULTAT", f"- Chiffre d'affaires : {eur(i['ca'])}{vs('ca')}", f"- Achats et charges externes : {eur(i['achats_charges_externes'])}", f"- Impôts et taxes : {eur(i['impots_taxes'])}", f"- Rémunération dirigeant + charges sociales : {eur(i['remuneration_dirigeant'] + i['charges_sociales'])}", f"- EBE : {eur(i['ebe'])} ({pc(r['taux_ebe'][0])} du CA)", f"- Dotations aux amortissements : {eur(i['dotations_amortissements'])}", f"- Résultat d'exploitation : {eur(i['resultat_exploitation'])}", f"- Résultat financier : {eur(i['resultat_financier'])}", ] if i["resultat_exceptionnel"]: L.append(f"- Résultat exceptionnel : {eur(i['resultat_exceptionnel'])}") if i["impot_societes"]: L.append(f"- Impôt sur les sociétés : {eur(i['impot_societes'])}") L += [f"- RÉSULTAT NET : {eur(i['resultat_net'])}{vs('resultat_net')}", f"- Capacité d'autofinancement : {eur(i['caf'])}", "", "BILAN AU " + fr_date(e["fin"]), f"- Actif immobilisé net : {eur(i['actif_immobilise_net'])}", f"- Disponibilités : {eur(i['disponibilites'])} ; placements (VMP) : {eur(i['vmp'])}", f"- Créances : {eur(i['creances'])}", f"- Capitaux propres : {eur(i['capitaux_propres'])}", f"- Emprunts et découverts : {eur(i['emprunts'])}", f"- Dettes fournisseurs, fiscales et sociales : {eur(i['dettes_fournisseurs'] + i['dettes_fiscales_sociales'])}", f"- Compte courant d'associé : {eur(i['compte_courant_associe'])}", f"- Total bilan : {eur(i['total_actif'])}", "", f"Marge nette {pc(r['marge_nette'][0])} · autonomie financière {pc(r['autonomie_financiere'][0])}" f" · trésorerie nette {eur(i['tresorerie_nette'])}", ] off = e.get("officiel") if off: bits = [f"{lbl} {eur(off[k])}" for k, lbl in (("chiffre_affaires", "CA"), ("resultat_net", "résultat"), ("total_bilan", "total bilan")) if k in off] L += ["", f"Chiffres officiels déposés : {', '.join(bits)}."] if "note" in e: L += ["", "Remarque : " + e["note"]] return "\n".join(L) def cmd_compare(data, args): if len(args) != 2: sortir("Usage : bilans.py compare AN1 AN2") a1, e1 = exercice(data, min(args)) a2, e2 = exercice(data, max(args)) i1, i2 = indicateurs(e1["comptes"]), indicateurs(e2["comptes"]) cles = ["ca", "achats_charges_externes", "impots_taxes", "remuneration_dirigeant", "charges_sociales", "ebe", "dotations_amortissements", "resultat_exploitation", "resultat_financier", "resultat_net", "caf", "disponibilites", "vmp", "capitaux_propres", "emprunts", "total_actif"] L = [f"AIRTRIA — comparaison des exercices {a1} et {a2}", ""] for k in cles: if i1[k] or i2[k]: L.append(f"- {LIBELLES[k]} : {eur(i1[k])} → {eur(i2[k])} ({variation(i1[k], i2[k])})") r1, r2 = ratios(i1), ratios(i2) L += ["", f"Marge nette : {pc(r1['marge_nette'][0])} → {pc(r2['marge_nette'][0])}", f"Autonomie financière : {pc(r1['autonomie_financiere'][0])} → {pc(r2['autonomie_financiere'][0])}"] for a, e in ((a1, e1), (a2, e2)): if "note" in e: L.append(f"Remarque {a} : {e['note']}") return "\n".join(L) def cmd_evolution(data, args): if not args: return ("Indicateurs disponibles pour « evolution » :\n" + "\n".join(f"- {k} : {v}" for k, v in LIBELLES.items()) + "\nAlias acceptés : " + ", ".join(sorted(ALIAS))) k = args[0].lower().replace("-", "_").replace("'", "_") k = ALIAS.get(k, k) if k not in LIBELLES: sortir(f"Indicateur « {args[0]} » inconnu. Lance « bilans.py evolution » pour la liste.") serie = [(an, indicateurs(e["comptes"])[k]) for an, e in data["exercices"].items()] L = [f"AIRTRIA — {LIBELLES[k]} par exercice", ""] prev = None for an, v in serie: L.append(f"{an} : {eur(v)}" + (f" ({variation(prev, v)})" if prev is not None else "")) prev = v vals = [v for _, v in serie] hi = max(serie, key=lambda x: x[1]) lo = min(serie, key=lambda x: x[1]) L += ["", f"Plus haut : {hi[0]} ({eur(hi[1])}) · plus bas : {lo[0]} ({eur(lo[1])})" f" · moyenne : {eur(sum(vals) / len(vals))}"] return "\n".join(L) def cmd_charges(data, args): an, e = exercice(data, args[0] if args else None) ch = [(n, c["lib"], c["solde"]) for n, c in e["comptes"].items() if n.startswith("6") and c["solde"]] ch.sort(key=lambda x: -x[2]) tot = sum(x[2] for x in ch) ca = indicateurs(e["comptes"])["ca"] L = [entete(an, e), f"Charges : {eur(tot)} au total ({pc(100 * tot / ca if ca else None)} du CA)", ""] for n, lib, v in ch: L.append(f"- {n} {lib.capitalize()} : {eur(v)} ({pc(100 * v / tot)})") return "\n".join(L) def cmd_compte(data, args): if not args: sortir("Usage : bilans.py compte MOTIF [AN] (MOTIF = début de numéro, ex. 626, ou mot du libellé, ex. assurance)") motif = args[0].upper() ans = [str(args[1])] if len(args) > 1 else list(data["exercices"]) for a in ans: exercice(data, a) trouves = {} for a in ans: for n, c in data["exercices"][a]["comptes"].items(): if n.startswith(motif) or motif in c["lib"].upper(): trouves.setdefault((n, c["lib"]), {})[a] = c if not trouves: return f"Aucun compte ne correspond à « {args[0]} » ({', '.join(ans) if len(ans) < 3 else 'tous exercices'})." L = [f"AIRTRIA — comptes correspondant à « {args[0]} »", ""] for (n, lib), par_an in sorted(trouves.items()): L.append(f"{n} {lib}") for a, c in par_an.items(): sens = "débiteur" if c["solde"] > 0 else "créditeur" if c["solde"] < 0 else "soldé" L.append(f" {a} : solde {eur(abs(c['solde']))} {sens} (débit {eur(c['debit'])}, crédit {eur(c['credit'])})") return "\n".join(L) def cmd_ratios(data, args): an, e = exercice(data, args[0] if args else None) i = indicateurs(e["comptes"]) L = [entete(an, e), ""] for k, (v, formule) in ratios(i).items(): L.append(f"- {NOMS_RATIOS[k]} : {pc(v)} [{formule}]") L += [f"- Fonds de roulement : {eur(i['fonds_de_roulement'])} [capitaux propres + emprunts - actif immobilisé net]", f"- BFR : {eur(i['bfr'])} [créances - dettes d'exploitation et compte courant]", f"- Trésorerie nette : {eur(i['tresorerie_nette'])}"] return "\n".join(L) def cmd_sources(data, args): L = [f"Données générées le {data['genere_le']} depuis /mnt/gestion/BILAN.", data["societe"]["exercice"], ""] for c in data["controles"]: an = str(c["exercice"]) e = data["exercices"][an] etat = [] if c["resultat_officiel"] is not None: etat.append(f"liasse/PV {eur(c['resultat_officiel'])} {'OK' if c['ok_officiel'] else 'ÉCART'}") if c["resultat_repris_n_plus_1"] is not None: etat.append(f"repris en {int(an) + 1} : {eur(c['resultat_repris_n_plus_1'])}" f" {'OK' if c['ok_report'] else 'ÉCART'}") L.append(f"{an} : résultat {eur(c['resultat_calcule'])} — {'; '.join(etat) or 'pas de contrôle externe'}") L.append(f" source : {e['source']}") if "note" in e: L.append(f" remarque : {e['note']}") return "\n".join(L) COMMANDES = {"liste": cmd_liste, "resume": cmd_resume, "compare": cmd_compare, "evolution": cmd_evolution, "charges": cmd_charges, "compte": cmd_compte, "ratios": cmd_ratios, "sources": cmd_sources} def main(argv): as_json = "--json" in argv argv = [a for a in argv if a != "--json"] if not argv or argv[0] in ("-h", "--help", "aide"): print(__doc__.strip()) return 0 cmd, args = argv[0].lower(), argv[1:] if cmd not in COMMANDES: sortir(f"Commande « {cmd} » inconnue. Commandes : {', '.join(COMMANDES)}.") data = charger() if as_json: ans = [a for a in args if a.isdigit()] or list(data["exercices"]) out = {a: {"indicateurs": indicateurs(data["exercices"][a]["comptes"]), "ratios": {k: v for k, (v, _) in ratios(indicateurs(data["exercices"][a]["comptes"])).items()}} for a in ans if a in data["exercices"]} print(json.dumps(out, ensure_ascii=False, indent=1)) return 0 print(COMMANDES[cmd](data, args)) return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))