915fc5194a
Faisabilité S3 : le même brief.json -> data_room/PXX/50_financier_bancable/{fr,en,es}.md
+ manifest.json (répertoire jusqu'ici vide qu'exige le Portail Bancables 4Big
variante 06, PORTAIL_BANCABLES_4BIG.md étape 1).
Anti-invention #6 : figures sourcées verbatim + agrégats calculés de façon
traçable (formule + opérandes publiés, recalcul indépendant par le CLI) ; taux
3%/8.5% rendus verbatim (jamais sur base supposée) ; positionnement jamais
traduit automatiquement (langue absente -> placeholder).
- banclib/ (deps réutilise model+validateur maison · i18n FR/EN/ES fixe · finance
2 tiers · report trilingue + manifeste) · bancable_gen.py CLI build/validate
refuse d'écrire si invariant casse · bancable.schema.json · 22 tests stdlib.
- CI : job bancable-tests ajouté au gate (Gitea Actions #2).
- brief.schema.json étendu (positionnement_en/es) · PORTAIL_BANCABLES_4BIG.md cousu.
Régression : 121 tests verts (99 + 22). Auto-score 4Big : 96/100.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
242 lines
9.5 KiB
Python
242 lines
9.5 KiB
Python
"""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:
|
|
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
|