#!/usr/bin/env python3 """Générateur de dossier financier bancable trilingue FR/EN/ES · Sprint 3. Livrable **Faisabilité · Sprint 3** (roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` §Sprint 3 · « 40_llm_outputs/ + rapports bancables FR/EN/ES »). Remplit le répertoire `50_financier_bancable/` d'une data_room — jusqu'ici vide (`.gitkeep`) — que le **Portail Bancables 4Big** (`PORTAIL_BANCABLES_4BIG.md`, variante 06 retenue par Michel) exige à l'étape 1 de son workflow de déploiement projet. Chaîne de valeur : brief.json ─► faisabilite_gen ─► data_room/PXX/ (4 volets, template v1.0) │ bancable_gen ───┤ (ce module) ▼ data_room/PXX/50_financier_bancable/{fr,en,es}.md + manifest.json │ ▼ (VPS · hors périmètre worker) PDFs FR/EN/ES ─► Portail Bancables 4Big (privé · noindex) Anti-invention (#6) : figures sourcées reprises verbatim ; agrégats calculés de façon TRAÇABLE (formule + opérandes sourcés publiés) ; aucun montant sur base non documentée ; tout champ absent reste placeholder `{{…}}` (jamais 0 fabriqué). ⚠ Ce worker n'écrit JAMAIS sur le VPS (#8). Les .md sont produits en local ; la conversion PDF + le rebuild du portail restent côté serveur. Sous-commandes : build BRIEF.json -o DATA_ROOM → DATA_ROOM/PXX/50_financier_bancable/* validate BRIEF.json → manifeste + invariants (sans écrire) """ from __future__ import annotations import argparse import json import math import os import sys _HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, _HERE) from banclib import deps, finance, i18n, report # noqa: E402 def _eprint(*args) -> None: print(*args, file=sys.stderr) def _load_schema() -> dict: with open(os.path.join(_HERE, "bancable.schema.json"), encoding="utf-8") as fh: return json.load(fh) def _validate_projet(brief: dict) -> None: code = brief.get("projet") if not (isinstance(code, str) and len(code) == 3 and code[:2] == "P0" and code[2] in "123456789"): raise ValueError(f"brief.projet invalide (attendu P01..P09) : {code!r}") # --------------------------------------------------------------------------- # # Invariants — le CLI REFUSE d'écrire si l'un casse (honnêteté #6). # --------------------------------------------------------------------------- # def check_invariants(brief: dict, manifest: dict, files: dict[str, str]) -> list[str]: motifs: list[str] = [] # (a) manifeste conforme au schéma (validateur maison). errs = deps.validate(manifest, _load_schema()) motifs += [f"manifest: {e}" for e in errs] # (b) exactement les 3 langues canoniques, chacune rendue. if manifest["langues"] != ["fr", "en", "es"]: motifs.append(f"langues != [fr,en,es] : {manifest['langues']}") for lang in i18n.LANGS: rel = f"50_financier_bancable/{lang}.md" if rel not in files: motifs.append(f"fichier langue manquant : {rel}") # (c) chaque .md porte la bannière CONFIDENTIEL ; les synthétiques portent # aussi l'avertissement « ne jamais publier » (#6) et sont non publiables. for lang in i18n.LANGS: md = files.get(f"50_financier_bancable/{lang}.md", "") if "CONFIDENTIEL" not in md and "CONFIDENTIAL" not in md \ and "CONFIDENCIAL" not in md: motifs.append(f"{lang}.md : bannière CONFIDENTIEL absente") if brief.get("synthetique"): if "SYNTH" not in md.upper(): motifs.append(f"{lang}.md : avertissement synthétique absent") # Garde-fou : aucun `None` Python ne doit fuiter dans le rendu. if " None " in md or "| None " in md or ": None\n" in md: motifs.append(f"{lang}.md : littéral None dans le rendu") if brief.get("synthetique") and manifest["publiable"]: motifs.append("synthétique mais publiable=true (#6)") # (d) figures calculées : recoupement anti-invention. Chaque valeur non nulle # doit se RECALCULER à partir des opérandes sourcés (formule publiée). motifs += _check_derived_arithmetic(brief, manifest) # (e) cohérence publiable ↔ champs manquants : un dossier publiable ne peut pas # manquer le trio coût/revenu/marge. if manifest["publiable"]: fs = manifest["figures_sourcees"] if fs["cout_construction_usd"] is None or fs["revenu_brut_usd"] is None \ or fs["marge_pct"] is None: motifs.append("publiable=true mais cœur financier incomplet") return motifs def _check_derived_arithmetic(brief: dict, manifest: dict) -> list[str]: """Reproduit indépendamment les 4 agrégats et compare au manifeste. Prouve qu'aucune valeur calculée n'a été « posée » : elle DOIT découler des typologies sourcées et du taux canonique 52 %. """ motifs: list[str] = [] rows = finance.typologies(brief) figs = {f["cle"]: f["valeur"] for f in manifest["figures_calculees"]} qtes = [r["quantite"] for r in rows] all_q = bool(rows) and all(q is not None for q in qtes) exp_total = sum(qtes) if all_q else None exp_usd = (sum(q * r["prix_usd"] for q, r in zip(qtes, rows)) if all_q and all(r["prix_usd"] is not None for r in rows) else None) exp_dop = (sum(q * r["prix_dop"] for q, r in zip(qtes, rows)) if all_q and all(r["prix_dop"] is not None for r in rows) else None) exp_pe = (float(math.ceil(0.52 * exp_total)) if exp_total is not None else None) for cle, exp in (("total_unites", exp_total), ("valeur_catalogue_usd", exp_usd), ("valeur_catalogue_dop", exp_dop), ("point_equilibre_unites", exp_pe)): got = figs.get(cle) if exp is None and got is not None: motifs.append(f"figure {cle} calculée ({got}) alors qu'un opérande manque") elif exp is not None and got != exp: motifs.append(f"figure {cle} : manifeste {got} != recalcul {exp}") return motifs # --------------------------------------------------------------------------- # def cmd_build(ns: argparse.Namespace) -> int: with open(ns.brief, encoding="utf-8") as fh: brief = json.load(fh) _validate_projet(brief) code = brief["projet"] files = report.render_all(brief, generated_at=ns.generated_at) manifest = json.loads(files["50_financier_bancable/manifest.json"]) motifs = check_invariants(brief, manifest, files) if motifs: _eprint(f"❌ {code} : invariants cassés — RIEN écrit :") for m in motifs: _eprint(" ·", m) return 1 dest_dir = os.path.join(ns.out, code, "50_financier_bancable") os.makedirs(dest_dir, exist_ok=True) for rel, content in files.items(): with open(os.path.join(ns.out, code, rel), "w", encoding="utf-8") as fh: fh.write(content) _eprint(f"✅ {code} → {dest_dir}") _eprint(f" langues={manifest['langues']} · publiable={manifest['publiable']} " f"· synthétique={manifest['synthetique']}") _eprint(f" typologies={manifest['typologies_count']} · " f"champs 🔴 manquants={len(manifest['champs_manquants'])}") for f in manifest["figures_calculees"]: _eprint(f" · {f['cle']} = {f['valeur']} [{f['formule']}]") return 0 def cmd_validate(ns: argparse.Namespace) -> int: with open(ns.brief, encoding="utf-8") as fh: brief = json.load(fh) _validate_projet(brief) code = brief["projet"] files = report.render_all(brief, generated_at=ns.generated_at) manifest = json.loads(files["50_financier_bancable/manifest.json"]) motifs = check_invariants(brief, manifest, files) print(json.dumps(manifest, ensure_ascii=False, indent=2)) if motifs: _eprint(f"❌ {code} : {len(motifs)} invariant(s) cassé(s) :") for m in motifs: _eprint(" ·", m) return 1 _eprint(f"✅ {code} : manifeste conforme + invariants OK " f"(publiable={manifest['publiable']}).") return 0 # --------------------------------------------------------------------------- # def build_argparser() -> argparse.ArgumentParser: ap = argparse.ArgumentParser(prog="bancable_gen", description=__doc__.splitlines()[0]) sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("build", help="brief.json → 50_financier_bancable/{fr,en,es}.md") p.add_argument("brief") p.add_argument("-o", "--out", required=True, help="dossier data_room de sortie") p.add_argument("--generated-at", dest="generated_at", default=None) p.set_defaults(func=cmd_build) p = sub.add_parser("validate", help="brief.json → manifeste + invariants (sans écrire)") p.add_argument("brief") p.add_argument("--generated-at", dest="generated_at", default=None) p.set_defaults(func=cmd_validate) return ap def main(argv: list[str] | None = None) -> int: ns = build_argparser().parse_args(argv) return ns.func(ns) if __name__ == "__main__": raise SystemExit(main())