[DTP-Worker] Sprint 3 · Générateur dossier bancable trilingue FR/EN/ES (remplit 50_financier_bancable/ · Portail Bancables 4Big)

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>
This commit is contained in:
Claude Code DTP Worker
2026-07-30 05:08:02 +00:00
parent 75a3b0a471
commit 915fc5194a
15 changed files with 1458 additions and 3 deletions
@@ -0,0 +1,241 @@
"""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