"""Rendu Markdown du dossier bancable (une langue) + manifeste agrégé. Le rendu place toujours en tête la bannière CONFIDENTIEL (le Portail Bancables est privé · `PORTAIL_BANCABLES_4BIG.md`) et, pour une fixture synthétique, la bannière « ne jamais publier » (#6). Toute figure absente est rendue en placeholder `{{…}}` (jamais un chiffre inventé). """ from __future__ import annotations from typing import Any, Optional from . import deps, finance, i18n CANONICAL = deps.CANONICAL # --------------------------------------------------------------------------- # # Helpers de rendu de valeurs. # --------------------------------------------------------------------------- # def _money(devise: str, n: Optional[float], placeholder: str) -> str: """« USD 250,000 » (format Letter US · milliers séparés). Placeholder si None.""" if n is None: return "{{" + placeholder + "}}" entier = f"{n:,.0f}" if n == int(n) else f"{n:,.2f}" return f"{devise} {entier}" def _int(n: Optional[float], placeholder: str) -> str: if n is None: return "{{" + placeholder + "}}" return f"{int(n):,}" if n == int(n) else f"{n:,.2f}" def _pct(n: Optional[float], placeholder: str) -> str: # `:g` sûr : un pourcentage (marge, taux) est borné (0-100), donc jamais en # exponentiel (< 1e6) ni > 6 chiffres significatifs — les deux pièges de `:g` # sont hors domaine ; `:g` nettoie en prime le bruit flottant. Les MONTANTS # passent par _money (`,.0f`/`,.2f`), jamais par `:g`. Ne pas « corriger ». if n is None: return "{{" + placeholder + "}}" return f"{n:g} %" def _txt(value: Any, placeholder: str) -> str: if value is None: return "{{" + placeholder + "}}" return str(value).strip() # --------------------------------------------------------------------------- # # Rendu d'une langue. # --------------------------------------------------------------------------- # def render_report(brief: dict, lang: str) -> str: """Retourne le Markdown du dossier bancable pour `lang` ∈ {fr,en,es}.""" L = lambda k: i18n.t(lang, k) # noqa: E731 — raccourci local lisible code = brief["projet"] nom = _txt(brief.get("nom"), "nom") src = finance.sourced(brief) rows = finance.typologies(brief) figs = {f["cle"]: f for f in finance.derived(brief)} manquants = finance.missing_fields(brief) parts: list[str] = [] # En-tête + bannières. parts.append(f"# {nom} · {code}\n") parts.append(f"**{L('doc_title')}** · {i18n.LANG_FLAG[lang]} " f"{i18n.LANG_NOM[lang]}\n") parts.append(f"> {L('confidential')}\n") if brief.get("synthetique"): parts.append(f"> {L('synthetic')}\n") # 1. Positionnement (chaîne libre du brief, langue par langue). com = brief.get("commercial") or {} positionnement = com.get(f"positionnement_{lang}") pos_txt = (str(positionnement).strip() if deps.is_filled(positionnement) else "{{positionnement_" + lang + "}}") parts.append(f"## {L('sec_positioning')}\n\n{pos_txt}\n") # 2. Données financières sourcées (verbatim). parts.append(f"## {L('sec_sourced')}\n") parts.append( f"- **{L('lbl_cout_construction')}** : " f"{_money('USD', src['cout_construction_usd'], 'cout_construction')}\n" f"- **{L('lbl_revenu_brut')}** : " f"{_money('USD', src['revenu_brut_usd'], 'revenu_brut')}\n" f"- **{L('lbl_marge')}** : {_pct(src['marge_pct'], 'marge')}\n" f"- **{L('lbl_regime_fiscal')}** : " f"{_txt(src['regime_fiscal'], 'regime_fiscal')}\n" ) taux = src["taux_conversion"] taux_val = _int(taux["taux"], "taux") parts.append( f"- **{L('lbl_taux')}** : {taux_val} " f"({_txt(taux['date'], 'taux_date')} · {L('note_taux_source')} " f"{_txt(taux['source'], 'taux_source')})\n" ) # 3. Grille tarifaire par typologie (sourcé + sous-total tracé). parts.append(f"## {L('sec_typologies')}\n") header = (f"| {L('th_typologie')} | {L('th_quantite')} | {L('th_prix_usd')} " f"| {L('th_prix_dop')} | {L('th_soustotal')} |\n" "|---|---|---|---|---|\n") body = "" if rows: for i, r in enumerate(rows): body += ( f"| {_txt(r['nom'], f'typo_{i+1}_nom')} " f"| {_int(r['quantite'], f'typo_{i+1}_qte')} " f"| {_money('USD', r['prix_usd'], f'typo_{i+1}_usd')} " f"| {_money('DOP', r['prix_dop'], f'typo_{i+1}_dop')} " f"| {_money('USD', r['sous_total_usd'], f'typo_{i+1}_st')} |\n" ) else: body = ("| {{typo_1_nom}} | {{typo_1_qte}} | {{typo_1_usd}} " "| {{typo_1_dop}} | {{typo_1_st}} |\n") parts.append(header + body + "\n") # 4. Agrégats & emplois — calculs traçables (formule affichée). parts.append(f"## {L('sec_derived')}\n") tu = figs["total_unites"] vcu = figs["valeur_catalogue_usd"] vcd = figs["valeur_catalogue_dop"] pe = figs["point_equilibre_unites"] parts.append( f"- **{L('lbl_total_unites')}** : {_int(tu['valeur'], 'total_unites')} " f"_({L('note_calcule')} : {tu['formule']})_\n" f"- **{L('lbl_valeur_catalogue_usd')}** : " f"{_money('USD', vcu['valeur'], 'valeur_catalogue_usd')} " f"_({L('note_calcule')} : {vcu['formule']})_\n" f"- **{L('lbl_valeur_catalogue_dop')}** : " f"{_money('DOP', vcd['valeur'], 'valeur_catalogue_dop')} " f"_({L('note_calcule')} : {vcd['formule']})_\n" ) pe_val = (f"{_int(pe['valeur'], 'point_equilibre_unites')} " f"{L('word_units')} ({L('word_rounded')})" if pe["valeur"] is not None else "{{point_equilibre_unites}}") parts.append( f"- **{L('lbl_point_equilibre_unites')}** : {pe_val} " f"_({L('note_calcule')} : {pe['formule']})_\n" ) # 5. Paramètres canoniques OTO (verbatim · #9/#10 · base non multipliée). parts.append(f"## {L('sec_canonical')}\n") parts.append( f"- **{L('lbl_edition')}** : {CANONICAL['frais_edition_pct']} " f"({L('note_fixe')} · {L('word_base')})\n" f"- **{L('lbl_marketing')}** : {CANONICAL['marketing_pct']} " f"({L('note_fixe')} · {L('word_base')})\n" f"- **{L('lbl_point_equilibre_pct')}** : " f"{CANONICAL['point_equilibre_pct']} ({L('note_fixe')})\n" f"- **{L('lbl_devises')}** : {CANONICAL['devises']}\n" f"- **{L('lbl_paiement')}** : {CANONICAL['paiement']}\n" f"- **{L('lbl_format_doc')}** : {CANONICAL['format_doc']}\n" ) # Champs manquants (transparence anti-invention). if manquants: parts.append(f"## {L('note_missing')}\n") parts.append("".join(f"- {m}\n" for m in manquants)) parts.append(f"\n---\n_{L('note_generated')} {code}._\n") return "\n".join(parts) # --------------------------------------------------------------------------- # # Manifeste agrégé (une seule vue machine-lisible des 3 langues). # --------------------------------------------------------------------------- # def _publiable(brief: dict, manquants: list[str]) -> bool: """Publiable ssi : non synthétique ET cœur financier sourcé complet. On n'exige PAS zéro champ manquant (un taux daté peut manquer sans bloquer), mais le trio coût/revenu/marge + au moins une typologie tarifée est requis — sans quoi le dossier n'a pas de substance bancable. """ if brief.get("synthetique"): return False src = finance.sourced(brief) if src["cout_construction_usd"] is None or src["revenu_brut_usd"] is None \ or src["marge_pct"] is None: return False rows = finance.typologies(brief) tarifs_ok = any( r["quantite"] is not None and r["prix_usd"] is not None for r in rows ) return bool(rows) and tarifs_ok def build_manifest(brief: dict, generated_at: Optional[str] = None) -> dict: """Manifeste agrégé (contrat bancable.schema.json).""" code = brief["projet"] src = finance.sourced(brief) manquants = finance.missing_fields(brief) figs = finance.derived(brief) manifest: dict[str, Any] = { "projet": code, "template_version": deps.TEMPLATE_VERSION, "langues": list(i18n.LANGS), "synthetique": bool(brief.get("synthetique")), "publiable": _publiable(brief, manquants), "figures_sourcees": { "cout_construction_usd": src["cout_construction_usd"], "revenu_brut_usd": src["revenu_brut_usd"], "marge_pct": src["marge_pct"], "taux_conversion": { "taux": src["taux_conversion"]["taux"], "date": src["taux_conversion"]["date"], "source": src["taux_conversion"]["source"], }, }, "figures_calculees": [ { "cle": f["cle"], "libelle": i18n.t("fr", f"lbl_{f['cle']}"), "formule": f["formule"], "valeur": f["valeur"], "unite": f["unite"], } for f in figs ], "typologies_count": len(finance.typologies(brief)), "champs_manquants": manquants, "fichiers": [f"50_financier_bancable/{lang}.md" for lang in i18n.LANGS], } if generated_at is not None: manifest["generated_at"] = generated_at return manifest def render_all(brief: dict, generated_at: Optional[str] = None) -> dict[str, str]: """Retourne {chemin_relatif: contenu} pour les 3 langues + le manifeste.""" import json files: dict[str, str] = {} for lang in i18n.LANGS: files[f"50_financier_bancable/{lang}.md"] = render_report(brief, lang) files["50_financier_bancable/manifest.json"] = json.dumps( build_manifest(brief, generated_at), ensure_ascii=False, indent=2 ) + "\n" return files