[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,4 @@
# Sorties re-générables à la demande (jamais commitées — data_room construit).
out/
**/__pycache__/
*.pyc
@@ -0,0 +1,114 @@
# Générateur de dossier financier bancable trilingue FR/EN/ES · Sprint 3
> Livrable **Faisabilité · Sprint 3** — roadmap
> [`ROADMAP_8_WEEKS_OR_LESS.md`](../../../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` posé par le générateur 4 volets) — que le **Portail Bancables 4Big**
([`PORTAIL_BANCABLES_4BIG.md`](../../../PORTAIL_BANCABLES_4BIG.md), variante 06
retenue par Michel) exige à l'**étape 1** de son workflow de déploiement projet
(« Placer les PDFs dans `…/50_financier_bancable/` (FR/EN/ES) »).
## Place dans la chaîne de valeur
```
brief.json ─► faisabilite_gen ─► data_room/PXX/ (4 volets · template v1.0)
bancable_gen ───┤ (ce module · MÊME brief)
data_room/PXX/50_financier_bancable/{fr,en,es}.md + manifest.json
▼ (VPS · hors périmètre worker · #8)
PDFs FR/EN/ES ─► Portail Bancables 4Big (privé · noindex)
```
Le module **ferme le dernier trou** de la faisabilité auto : les 4 volets étaient
produits et notés, mais le dossier **financier bancable** (l'artefact que voit le
banquier) restait à écrire à la main, en trois langues. Il consomme le **même
`brief.json`** que le générateur 4 volets — une seule source, zéro re-saisie.
## Trois langues imposées
`PORTAIL_BANCABLES_4BIG.md` : « Boutons langues **FR 🇨🇦 · EN 🇺🇸 · ES 🇩🇴** ». Le
générateur produit systématiquement les trois. Les **libellés** de gabarit sont
des traductions **fixes** ([`banclib/i18n.py`](banclib/i18n.py)) ; seules les
**chaînes libres** (positionnement commercial) proviennent du brief, langue par
langue (`commercial.positionnement_{fr,en,es}`). Une langue de positionnement
absente reste un placeholder `{{positionnement_en}}` — **jamais traduite
automatiquement** (ce serait une invention · #6).
## Anti-invention (#6) — deux tiers, tous deux traçables
| Tier | Contenu | Garantie |
|---|---|---|
| **Sourcé** (verbatim) | coût construction, revenu brut, marge, taux, prix par typologie | Repris tel quel du brief. Champ absent ⇒ placeholder `{{…}}`, **jamais 0 fabriqué**. |
| **Calculé** (traçable) | Σ unités · valeur catalogue USD/DOP · point d'équilibre en unités | Chaque valeur **publie sa formule** ; **tous** ses opérandes sont sourcés/canoniques. Opérande manquant ⇒ valeur `null` + champ listé. |
> **Calcul transparent ≠ invention.** Un agrégat dont la formule et les opérandes
> sont publiés est *auditable* — l'invention, c'est un chiffre sans source. Le CLI
> **recalcule indépendamment** les 4 agrégats et refuse d'écrire s'ils divergent
> du manifeste (`_check_derived_arithmetic`).
**Aucun montant n'est calculé sur une base non documentée.** Les pourcentages
« 3 % édition » et « 8.5 % marketing » (#9) sont rendus comme **taux canoniques**
(verbatim), jamais multipliés par une base supposée — la base n'étant pas
documentée, la choisir serait une hypothèse inventée. Seuls le point d'équilibre
en unités (`52 % × nb_unités`) et les agrégats de catalogue (`Σ quantité × prix`),
dont la base est explicite et sourcée, sont calculés.
## Confidentialité
Le Portail Bancables est **privé** (`noindex`). Chaque `.md` porte en tête la
bannière **CONFIDENTIEL** ; une fixture `synthetique:true` porte en plus
l'avertissement « ne jamais publier » (#6) et son manifeste est `publiable:false`.
## Utilisation
```bash
# Écrit DATA_ROOM/P01/50_financier_bancable/{fr,en,es}.md + manifest.json
python3 bancable_gen.py build fixtures/brief_bancable.json -o out
# Manifeste + invariants sans rien écrire (schéma + recalcul des agrégats)
python3 bancable_gen.py validate fixtures/brief_bancable.json
# Tests (stdlib pur, zéro pip · oracle jsonschema si présent)
python3 -m unittest discover -s tests -v
```
## Ce qui est généré (`out/` non commité)
| Fichier | Rôle |
|---|---|
| `50_financier_bancable/fr.md` · `en.md` · `es.md` | Dossier bancable print-ready (pré-PDF), une langue. |
| `50_financier_bancable/manifest.json` | Vue machine-lisible : figures sourcées + calculées (avec formule), langues, `publiable`, `champs_manquants` — conforme à [`bancable.schema.json`](bancable.schema.json). |
## Garde-fous (le CLI refuse d'écrire si un invariant casse)
- Manifeste conforme à `bancable.schema.json` (validateur **maison** Publiciste,
zéro pip · oracle `jsonschema` en test si présent).
- Exactement **3 langues** `fr/en/es`, chacune rendue, chacune avec la bannière
**CONFIDENTIEL**.
- **Recalcul indépendant** des 4 agrégats == manifeste (anti-figure-posée).
- Une fixture **synthétique** ne peut pas être `publiable` ; aucun littéral
`None` Python ne fuit dans un rendu.
- `publiable ⇒` trio coût/revenu/marge présent + au moins une typologie tarifée.
- Sortie **déterministe** (pas d'horodatage sauf `--generated-at`) → diffable,
re-générable bit-à-bit en CI.
## Vérification en-repo
- `python3 -m unittest discover -s tests -v`**22/22 verts** (finance, rendu
trilingue, schéma maison + oracle, invariants CLI, déterminisme).
- Génération réelle (fixture) : 3 langues + manifeste · 40 unités · valeur
catalogue USD 8,560,000 / DOP 505,040,000 · point d'équilibre 21 unités
(⌈52 % × 40⌉) — **toutes formules recoupées**.
- Job CI dédié `bancable-tests` ajouté au **gate** (`.gitea/workflows/ci.yml`,
Gitea Actions uniquement · #2).
## Auto-score 4Big du livrable : **96/100**
_Réserve 4_ : la conversion PDF (pandoc/wkhtmltopdf) + le rebuild du Portail
Bancables restent côté VPS (agent DevOps/Frontend, hors périmètre worker · #8).
Validé statiquement en-repo (22 tests verts + manifeste conforme + recoupement
arithmétique des agrégats + gate CI).
@@ -0,0 +1,71 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://oto.dtp/schemas/faisabilite/bancable.schema.json",
"title": "Manifeste du dossier financier bancable trilingue",
"description": "Vue machine-lisible du dossier bancable FR/EN/ES d'un projet. Toute valeur numérique absente est null (jamais 0-inventé · CLAUDE.md #6). Les figures calculées portent leur formule (traçabilité). Sous-ensemble draft-07 supporté par le validateur maison Publiciste (zéro pip).",
"type": "object",
"required": ["projet", "template_version", "langues", "synthetique", "publiable", "figures_sourcees", "figures_calculees", "typologies_count", "champs_manquants", "fichiers"],
"additionalProperties": false,
"properties": {
"projet": { "type": "string", "pattern": "^P0[1-9]$" },
"template_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
"langues": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"uniqueItems": true,
"items": { "type": "string", "enum": ["fr", "en", "es"] }
},
"synthetique": { "type": "boolean" },
"publiable": { "type": "boolean" },
"generated_at": { "type": "string", "format": "date-time" },
"figures_sourcees": {
"type": "object",
"required": ["cout_construction_usd", "revenu_brut_usd", "marge_pct", "taux_conversion"],
"additionalProperties": false,
"properties": {
"cout_construction_usd": { "type": ["number", "null"] },
"revenu_brut_usd": { "type": ["number", "null"] },
"marge_pct": { "type": ["number", "null"] },
"taux_conversion": {
"type": "object",
"required": ["taux", "date", "source"],
"additionalProperties": false,
"properties": {
"taux": { "type": ["number", "null"] },
"date": { "type": ["string", "null"] },
"source": { "type": ["string", "null"] }
}
}
}
},
"figures_calculees": {
"type": "array",
"minItems": 4,
"maxItems": 4,
"items": {
"type": "object",
"required": ["cle", "libelle", "formule", "valeur", "unite"],
"additionalProperties": false,
"properties": {
"cle": { "type": "string", "enum": ["total_unites", "valeur_catalogue_usd", "valeur_catalogue_dop", "point_equilibre_unites"] },
"libelle": { "type": "string", "minLength": 1 },
"formule": { "type": "string", "minLength": 1 },
"valeur": { "type": ["number", "null"] },
"unite": { "type": "string" }
}
}
},
"typologies_count": { "type": "integer", "minimum": 0 },
"champs_manquants": {
"type": "array",
"items": { "type": "string" }
},
"fichiers": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": { "type": "string", "pattern": "^50_financier_bancable/(fr|en|es)\\.md$" }
}
}
}
@@ -0,0 +1,225 @@
#!/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())
@@ -0,0 +1,10 @@
"""Librairie interne du générateur de dossier financier bancable trilingue.
Modules :
- `deps` : réutilisation (workflow #5) du modèle Faisabilité + validateur
maison Publiciste (zéro pip · zéro duplication).
- `i18n` : dictionnaire de libellés FR/EN/ES (fixe, sourcé CLAUDE.md).
- `finance` : extraction des figures financières du brief (sourcées verbatim +
agrégats calculés de façon traçable · anti-invention #6).
- `report` : rendu Markdown par langue + construction du manifeste agrégé.
"""
@@ -0,0 +1,40 @@
"""Réutilisation des briques déjà livrées (workflow #5 · zéro duplication).
Le dossier bancable dérive du MÊME brief que le générateur Faisabilité 4 volets
et doit rester cohérent avec lui :
- `is_filled` : même notion de « champ réellement rempli » que le générateur
(un placeholder / « non défini » / 0-inventé n'est pas rempli).
- `CANONICAL` : les paramètres canoniques CLAUDE.md #9/#10 (3 % / 8.5 % / 52 % ·
USD+DOP · Cardnet · Letter US) — jamais issus du brief.
- `validate` : le validateur JSON-Schema maison du Publiciste (draft-07,
sous-ensemble), pour valider le manifeste SANS installation pip
(le gate CI Gitea Actions tourne sans réseau · CLAUDE.md #2).
On importe par manipulation de `sys.path` (comme `faisabilite_gen.py`) plutôt que
de dupliquer ces valeurs — une seule source de vérité pour tout le mandat.
"""
from __future__ import annotations
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
# faisabilite/bancable/banclib → générateur (genlib) : ../../generator
_GEN = os.path.normpath(os.path.join(_HERE, "..", "..", "generator"))
# faisabilite/bancable/banclib → Publiciste (lib) : ../../../publiciste
_PUB = os.path.normpath(os.path.join(_HERE, "..", "..", "..", "publiciste"))
for _p in (_GEN, _PUB):
if _p not in sys.path:
sys.path.insert(0, _p)
from genlib import model # type: ignore # noqa: E402
from lib import validator # type: ignore # noqa: E402
is_filled = model.is_filled
CANONICAL = model.CANONICAL
TEMPLATE_VERSION = model.TEMPLATE_VERSION
validate = validator.validate
__all__ = ["is_filled", "CANONICAL", "TEMPLATE_VERSION", "validate"]
@@ -0,0 +1,186 @@
"""Extraction des figures financières du brief pour le dossier bancable.
DEUX TIERS, tous deux anti-invention (CLAUDE.md #6) :
SOURCÉ (verbatim) — figures reprises telles quelles du brief, aucun calcul :
coût de construction, revenu brut, marge, taux de conversion, prix par
typologie. Un champ absent reste `None` → placeholder (jamais 0-inventé).
CALCULÉ (traçable) — agrégats dont CHAQUE opérande provient du brief ou d'un
paramètre canonique CLAUDE.md, et dont la FORMULE est publiée avec la
valeur. Un calcul transparent, reproductible et entièrement sourcé n'est
PAS une invention : c'est de la modélisation financière auditable. Si un
seul opérande manque, la figure reste `None` (formule affichée, valeur en
placeholder) et le champ est listé dans `champs_manquants`.
Aucun montant n'est calculé sur une BASE non documentée. En particulier les
pourcentages « 3 % édition » et « 8.5 % marketing » (#9) sont rendus comme des
TAUX canoniques (verbatim), jamais multipliés par une base supposée — la base
n'est pas documentée, la choisir serait une hypothèse inventée. Seul le point
d'équilibre en unités (52 % × nombre d'unités) et les agrégats de catalogue
(Σ quantité × prix) sont calculés : leur base est explicite et sourcée.
"""
from __future__ import annotations
import math
from typing import Any, Optional
from . import deps
is_filled = deps.is_filled
CANONICAL = deps.CANONICAL
def _num(value: Any) -> Optional[float]:
"""Valeur numérique réelle, ou None si absente/placeholder/non numérique."""
if not is_filled(value):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _pct(canonical_str: str) -> Optional[float]:
"""« 52 % » → 0.52. Source unique : le paramètre canonique (#9), jamais codé
en dur ici — évite toute dérive entre le taux affiché et le taux calculé."""
txt = canonical_str.replace("%", "").replace(",", ".").strip()
try:
return float(txt) / 100.0
except ValueError:
return None
# --------------------------------------------------------------------------- #
# Tier SOURCÉ — verbatim.
# --------------------------------------------------------------------------- #
def sourced(brief: dict) -> dict:
"""Figures financières reprises TELLES QUELLES du brief (aucun calcul)."""
ing = brief.get("ingenierie") or {}
taux = (brief.get("architecture") or {}).get("taux_conversion") or {}
return {
"cout_construction_usd": _num(ing.get("cout_construction_usd")),
"revenu_brut_usd": _num(ing.get("revenu_brut_usd")),
"marge_pct": _num(ing.get("marge_pct")),
"regime_fiscal": (ing.get("regime_fiscal")
if is_filled(ing.get("regime_fiscal")) else None),
"taux_conversion": {
"taux": _num(taux.get("taux")),
"date": taux.get("date") if is_filled(taux.get("date")) else None,
"source": taux.get("source") if is_filled(taux.get("source")) else None,
},
}
def typologies(brief: dict) -> list[dict]:
"""Lignes tarifaires sourcées + sous-total catalogue calculé par ligne.
`sous_total_usd` = quantité × prix_usd (calcul traçable, opérandes affichés).
None si l'un des deux manque — jamais 0-inventé.
"""
typos = (brief.get("architecture") or {}).get("typologies") or []
rows: list[dict] = []
for t in typos:
qte = _num(t.get("quantite"))
pu = _num(t.get("prix_usd"))
pd = _num(t.get("prix_dop"))
sous_total = qte * pu if (qte is not None and pu is not None) else None
rows.append({
"nom": t.get("nom") if is_filled(t.get("nom")) else None,
"quantite": qte,
"prix_usd": pu,
"prix_dop": pd,
"sous_total_usd": sous_total,
})
return rows
# --------------------------------------------------------------------------- #
# Tier CALCULÉ — traçable (formule + opérandes sourcés).
# --------------------------------------------------------------------------- #
def _fig(cle: str, valeur: Optional[float], formule: str,
unite: str = "") -> dict:
return {"cle": cle, "valeur": valeur, "formule": formule, "unite": unite}
def derived(brief: dict) -> list[dict]:
"""Agrégats calculés de façon traçable. Chaque figure porte sa formule.
- total_unites = Σ quantité (typologies)
- valeur_catalogue_usd = Σ (quantité × prix_usd)
- valeur_catalogue_dop = Σ (quantité × prix_dop)
- point_equilibre_unites= ⌈52 % × total_unites⌉ (52 % = canonique #9)
Chaque valeur reste None (opérande manquant) → placeholder + champ manquant.
"""
rows = typologies(brief)
qtes = [r["quantite"] for r in rows]
prix_usd = [r["prix_usd"] for r in rows]
prix_dop = [r["prix_dop"] for r in rows]
total_unites = (sum(qtes) if rows and all(q is not None for q in qtes)
else None)
val_usd = (sum(q * p for q, p in zip(qtes, prix_usd))
if rows and all(q is not None for q in qtes)
and all(p is not None for p in prix_usd) else None)
val_dop = (sum(q * p for q, p in zip(qtes, prix_dop))
if rows and all(q is not None for q in qtes)
and all(p is not None for p in prix_dop) else None)
pe_pct = _pct(CANONICAL["point_equilibre_pct"])
if total_unites is not None and pe_pct is not None:
pe_brut = pe_pct * total_unites
pe_unites = float(math.ceil(pe_brut))
pe_formule = (f"{CANONICAL['point_equilibre_pct']} × {int(total_unites)} = "
f"{pe_brut:g}")
else:
pe_unites = None
pe_formule = f"{CANONICAL['point_equilibre_pct']} × {{total_unites}}"
return [
_fig("total_unites", total_unites, "Σ quantité (typologies)", "units"),
_fig("valeur_catalogue_usd", val_usd, "Σ (quantité × prix_usd)", "USD"),
_fig("valeur_catalogue_dop", val_dop, "Σ (quantité × prix_dop)", "DOP"),
_fig("point_equilibre_unites", pe_unites, pe_formule, "units"),
]
# --------------------------------------------------------------------------- #
# Champs financiers 🔴 manquants (anti-invention — pas de 0 fabriqué).
# --------------------------------------------------------------------------- #
_REQUIRED_SOURCED = [
("cout_construction_usd", "ingenierie.cout_construction_usd"),
("revenu_brut_usd", "ingenierie.revenu_brut_usd"),
("marge_pct", "ingenierie.marge_pct"),
]
def missing_fields(brief: dict) -> list[str]:
"""Champs financiers 🔴 absents (sourcés + agrégats non calculables)."""
src = sourced(brief)
manquants = [label for key, label in _REQUIRED_SOURCED if src[key] is None]
rows = typologies(brief)
if not rows:
manquants.append("architecture.typologies (aucune typologie tarifée)")
else:
for i, r in enumerate(rows):
for col in ("nom", "quantite", "prix_usd", "prix_dop"):
if r[col] is None:
manquants.append(f"architecture.typologies[{i}].{col}")
taux = src["taux_conversion"]
if taux["taux"] is None or taux["date"] is None or taux["source"] is None:
manquants.append("architecture.taux_conversion.{taux,date,source}")
# Dédoublonnage en préservant l'ordre.
seen: set[str] = set()
out: list[str] = []
for m in manquants:
if m not in seen:
seen.add(m)
out.append(m)
return out
@@ -0,0 +1,163 @@
"""Libellés FR/EN/ES du dossier financier bancable (fixes · sourcés CLAUDE.md).
Les trois langues du dossier bancable sont IMPOSÉES par le Portail Bancables 4Big
(`PORTAIL_BANCABLES_4BIG.md` : « Boutons langues FR 🇨🇦 · EN 🇺🇸 · ES 🇩🇴 »).
Chaque colonne de ce dictionnaire est une traduction FIXE de libellés de gabarit :
aucun libellé ne provient du brief, donc aucune donnée projet n'est traduite « à
la volée » (ce qui serait une invention #6). Les seules chaînes libres du dossier
(positionnement commercial) proviennent du brief, langue par langue, et restent en
placeholder si absentes — jamais traduites automatiquement.
"""
from __future__ import annotations
# Ordre canonique des langues (drapeaux = ceux du Portail Bancables 4Big).
LANGS = ["fr", "en", "es"]
LANG_FLAG = {"fr": "🇨🇦", "en": "🇺🇸", "es": "🇩🇴"}
LANG_NOM = {"fr": "Français", "en": "English", "es": "Español"}
LABELS: dict[str, dict[str, str]] = {
"fr": {
"doc_title": "Dossier financier bancable",
"confidential": (
"⚠️ CONFIDENTIEL — diffusion restreinte. Document destiné aux "
"personnes ressource (banquiers, notaires, investisseurs, CPA/UAF, "
"ministères). Ne pas indexer, ne pas rediffuser."
),
"synthetic": (
"⚠️ FIXTURE SYNTHÉTIQUE — données fictives de test. NE JAMAIS "
"publier ni transmettre (CLAUDE.md #6)."
),
"sec_positioning": "1. Positionnement",
"sec_sourced": "2. Données financières sourcées",
"sec_typologies": "3. Grille tarifaire par typologie",
"sec_derived": "4. Agrégats & emplois — calculs traçables",
"sec_canonical": "5. Paramètres canoniques OTO",
"lbl_cout_construction": "Coût de construction total",
"lbl_revenu_brut": "Revenu brut de vente projeté",
"lbl_marge": "Marge projetée",
"lbl_taux": "Taux de conversion USD↔DOP",
"lbl_regime_fiscal": "Régime fiscal",
"lbl_total_unites": "Total d'unités (Σ quantités typologies)",
"lbl_valeur_catalogue_usd": "Valeur catalogue du programme (USD)",
"lbl_valeur_catalogue_dop": "Valeur catalogue du programme (DOP)",
"lbl_point_equilibre_unites": "Point d'équilibre pré-vente (en unités)",
"lbl_edition": "Frais d'édition documentaire",
"lbl_marketing": "Budget marketing",
"lbl_point_equilibre_pct": "Point d'équilibre pré-vente",
"lbl_devises": "Devises",
"lbl_paiement": "Moyen de paiement",
"lbl_format_doc": "Format des documents",
"th_typologie": "Typologie",
"th_quantite": "Nb unités",
"th_prix_usd": "Prix « à partir de » (USD)",
"th_prix_dop": "Prix (DOP)",
"th_soustotal": "Sous-total catalogue (USD)",
"note_calcule": "calculé",
"note_fixe": "fixe",
"note_missing": "Champs 🔴 manquants (non inventés · #6)",
"note_taux_source": "source",
"note_generated": "Projet",
"word_units": "unités",
"word_rounded": "arrondi supérieur",
"word_base": "base à confirmer (non documentée · #9)",
"word_none": "à confirmer (faisabilité en cours)",
},
"en": {
"doc_title": "Bankable financial dossier",
"confidential": (
"⚠️ CONFIDENTIAL — restricted distribution. Intended for resource "
"persons (bankers, notaries, investors, CPA/UAF, ministries). Do not "
"index, do not redistribute."
),
"synthetic": (
"⚠️ SYNTHETIC FIXTURE — fictional test data. NEVER publish or share "
"(CLAUDE.md #6)."
),
"sec_positioning": "1. Positioning",
"sec_sourced": "2. Sourced financial data",
"sec_typologies": "3. Price grid by unit type",
"sec_derived": "4. Aggregates & uses of funds — traceable computations",
"sec_canonical": "5. OTO canonical parameters",
"lbl_cout_construction": "Total construction cost",
"lbl_revenu_brut": "Projected gross sales revenue",
"lbl_marge": "Projected margin",
"lbl_taux": "USD↔DOP conversion rate",
"lbl_regime_fiscal": "Tax regime",
"lbl_total_unites": "Total units (Σ unit-type quantities)",
"lbl_valeur_catalogue_usd": "Programme catalogue value (USD)",
"lbl_valeur_catalogue_dop": "Programme catalogue value (DOP)",
"lbl_point_equilibre_unites": "Pre-sale break-even (in units)",
"lbl_edition": "Document edition fee",
"lbl_marketing": "Marketing budget",
"lbl_point_equilibre_pct": "Pre-sale break-even",
"lbl_devises": "Currencies",
"lbl_paiement": "Payment method",
"lbl_format_doc": "Document format",
"th_typologie": "Unit type",
"th_quantite": "Units",
"th_prix_usd": "Price “from” (USD)",
"th_prix_dop": "Price (DOP)",
"th_soustotal": "Catalogue subtotal (USD)",
"note_calcule": "computed",
"note_fixe": "fixed",
"note_missing": "Missing 🔴 fields (never invented · #6)",
"note_taux_source": "source",
"note_generated": "Project",
"word_units": "units",
"word_rounded": "rounded up",
"word_base": "base to be confirmed (undocumented · #9)",
"word_none": "to be confirmed (feasibility in progress)",
},
"es": {
"doc_title": "Expediente financiero bancable",
"confidential": (
"⚠️ CONFIDENCIAL — distribución restringida. Destinado a personas "
"recurso (banqueros, notarios, inversionistas, CPA/UAF, ministerios). "
"No indexar, no redistribuir."
),
"synthetic": (
"⚠️ DATOS SINTÉTICOS — datos ficticios de prueba. NUNCA publicar ni "
"compartir (CLAUDE.md #6)."
),
"sec_positioning": "1. Posicionamiento",
"sec_sourced": "2. Datos financieros con fuente",
"sec_typologies": "3. Tabla de precios por tipología",
"sec_derived": "4. Agregados y usos — cálculos trazables",
"sec_canonical": "5. Parámetros canónicos OTO",
"lbl_cout_construction": "Costo total de construcción",
"lbl_revenu_brut": "Ingreso bruto de ventas proyectado",
"lbl_marge": "Margen proyectado",
"lbl_taux": "Tasa de conversión USD↔DOP",
"lbl_regime_fiscal": "Régimen fiscal",
"lbl_total_unites": "Total de unidades (Σ cantidades por tipología)",
"lbl_valeur_catalogue_usd": "Valor de catálogo del programa (USD)",
"lbl_valeur_catalogue_dop": "Valor de catálogo del programa (DOP)",
"lbl_point_equilibre_unites": "Punto de equilibrio de preventa (en unidades)",
"lbl_edition": "Cargo de edición documental",
"lbl_marketing": "Presupuesto de marketing",
"lbl_point_equilibre_pct": "Punto de equilibrio de preventa",
"lbl_devises": "Monedas",
"lbl_paiement": "Medio de pago",
"lbl_format_doc": "Formato de documentos",
"th_typologie": "Tipología",
"th_quantite": "Unidades",
"th_prix_usd": "Precio «desde» (USD)",
"th_prix_dop": "Precio (DOP)",
"th_soustotal": "Subtotal catálogo (USD)",
"note_calcule": "calculado",
"note_fixe": "fijo",
"note_missing": "Campos 🔴 faltantes (nunca inventados · #6)",
"note_taux_source": "fuente",
"note_generated": "Proyecto",
"word_units": "unidades",
"word_rounded": "redondeo superior",
"word_base": "base por confirmar (no documentada · #9)",
"word_none": "por confirmar (factibilidad en curso)",
},
}
def t(lang: str, key: str) -> str:
"""Libellé traduit. Lève KeyError si la clé n'existe pas (bug de gabarit)."""
return LABELS[lang][key]
@@ -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
@@ -0,0 +1,56 @@
{
"projet": "P01",
"nom": "Résidence Fixture Bancable",
"synthetique": true,
"sources": [
"SYNTHÉTIQUE — archive fictive dossier_bancable_2026.pdf",
"SYNTHÉTIQUE — taux BCRD fictif 2026-07-01"
],
"architecture": {
"taux_conversion": {
"taux": 59.0,
"date": "2026-07-01",
"source": "SYNTHÉTIQUE — BCRD fictif"
},
"typologies": [
{
"nom": "Studio",
"quantite": 12,
"surface_interieure_m2": 45,
"surface_terrasse_m2": 10,
"surface_totale_m2": 55,
"prix_usd": 150000,
"prix_dop": 8850000
},
{
"nom": "1 Chambre",
"quantite": 20,
"surface_interieure_m2": 68,
"surface_terrasse_m2": 15,
"surface_totale_m2": 83,
"prix_usd": 210000,
"prix_dop": 12390000
},
{
"nom": "2 Chambres",
"quantite": 8,
"surface_interieure_m2": 95,
"surface_terrasse_m2": 25,
"surface_totale_m2": 120,
"prix_usd": 320000,
"prix_dop": 18880000
}
]
},
"ingenierie": {
"cout_construction_usd": 9000000,
"revenu_brut_usd": 14200000,
"marge_pct": 28,
"regime_fiscal": "CONFOTUR + Fideicomiso"
},
"commercial": {
"positionnement_fr": "Une adresse balnéaire confidentielle où l'architecture contemporaine dialogue avec la nature tropicale, sous régime CONFOTUR.",
"positionnement_en": "A confidential beachfront address where contemporary architecture meets tropical nature, under the CONFOTUR regime.",
"positionnement_es": "Una dirección balnearia confidencial donde la arquitectura contemporánea dialoga con la naturaleza tropical, bajo el régimen CONFOTUR."
}
}
@@ -0,0 +1,224 @@
"""Tests du générateur de dossier bancable trilingue (stdlib `unittest`, zéro pip).
Couvre : rendu FR/EN/ES, bannière CONFIDENTIEL, anti-invention (#6 — placeholders,
zéro chiffre fabriqué), calculs traçables recoupés indépendamment, cohérence du
manifeste avec le schéma (validateur maison + oracle `jsonschema` si présent),
publiabilité, déterminisme, et refus d'écrire sur invariant cassé.
"""
import copy
import importlib.util
import json
import os
import sys
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
_MOD = os.path.normpath(os.path.join(_HERE, "..")) # bancable/
_PUB = os.path.normpath(os.path.join(_MOD, "..", "..", "publiciste"))
sys.path.insert(0, _PUB)
sys.path.insert(0, _MOD)
from banclib import finance, i18n, report # noqa: E402
def _load_cli():
"""Charge bancable_gen.py comme module isolé."""
spec = importlib.util.spec_from_file_location(
"bancable_gen", os.path.join(_MOD, "bancable_gen.py")
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _brief():
with open(os.path.join(_MOD, "fixtures", "brief_bancable.json"),
encoding="utf-8") as fh:
return json.load(fh)
def _schema():
with open(os.path.join(_MOD, "bancable.schema.json"), encoding="utf-8") as fh:
return json.load(fh)
class TestFinance(unittest.TestCase):
def test_sourced_verbatim(self):
src = finance.sourced(_brief())
self.assertEqual(src["cout_construction_usd"], 9000000)
self.assertEqual(src["revenu_brut_usd"], 14200000)
self.assertEqual(src["marge_pct"], 28)
self.assertEqual(src["taux_conversion"]["taux"], 59.0)
def test_derived_arithmetic_traceable(self):
figs = {f["cle"]: f for f in finance.derived(_brief())}
# 12 + 20 + 8 = 40
self.assertEqual(figs["total_unites"]["valeur"], 40)
# 12*150000 + 20*210000 + 8*320000 = 1.8M + 4.2M + 2.56M = 8.56M
self.assertEqual(figs["valeur_catalogue_usd"]["valeur"], 8560000)
# 12*8.85M + 20*12.39M + 8*18.88M = 106.2M + 247.8M + 151.04M = 505.04M
self.assertEqual(figs["valeur_catalogue_dop"]["valeur"], 505040000)
# ceil(0.52 * 40) = ceil(20.8) = 21
self.assertEqual(figs["point_equilibre_unites"]["valeur"], 21)
def test_derived_carry_formula(self):
for f in finance.derived(_brief()):
self.assertTrue(f["formule"])
self.assertNotIn("None", f["formule"])
def test_missing_operand_yields_none_not_zero(self):
b = _brief()
del b["ingenierie"]["cout_construction_usd"]
b["architecture"]["typologies"][0].pop("prix_usd")
src = finance.sourced(b)
self.assertIsNone(src["cout_construction_usd"]) # jamais 0
figs = {f["cle"]: f for f in finance.derived(b)}
# un prix_usd manquant ⇒ valeur catalogue USD non calculable
self.assertIsNone(figs["valeur_catalogue_usd"]["valeur"])
# mais les quantités restent complètes ⇒ total_unites calculable
self.assertEqual(figs["total_unites"]["valeur"], 40)
self.assertIn("ingenierie.cout_construction_usd",
finance.missing_fields(b))
class TestReport(unittest.TestCase):
def test_three_languages_rendered(self):
files = report.render_all(_brief())
for lang in ("fr", "en", "es"):
self.assertIn(f"50_financier_bancable/{lang}.md", files)
def test_confidential_banner_every_language(self):
for lang in i18n.LANGS:
md = report.render_report(_brief(), lang)
up = md.upper()
self.assertTrue("CONFIDENTIEL" in up or "CONFIDENTIAL" in up
or "CONFIDENCIAL" in up)
def test_synthetic_banner_and_not_publishable(self):
b = _brief()
self.assertTrue(b["synthetique"])
m = report.build_manifest(b)
self.assertFalse(m["publiable"])
for lang in i18n.LANGS:
self.assertIn("SYNTH", report.render_report(b, lang).upper())
def test_positioning_per_language_from_brief(self):
b = _brief()
self.assertIn("beachfront", report.render_report(b, "en"))
self.assertIn("balnearia", report.render_report(b, "es"))
self.assertIn("balnéaire", report.render_report(b, "fr"))
def test_missing_positioning_becomes_placeholder(self):
b = _brief()
del b["commercial"]["positionnement_en"]
md = report.render_report(b, "en")
self.assertIn("{{positionnement_en}}", md)
# les autres langues restent intactes
self.assertNotIn("{{positionnement_fr}}", report.render_report(b, "fr"))
def test_no_python_none_leaks_in_render(self):
b = _brief()
del b["ingenierie"]["cout_construction_usd"]
for lang in i18n.LANGS:
md = report.render_report(b, lang)
self.assertNotIn(" None ", md)
self.assertNotIn("| None ", md)
def test_canonical_params_present(self):
md = report.render_report(_brief(), "fr")
for marker in ("3 %", "8.5 %", "52 %", "USD + DOP", "Cardnet", "Letter US"):
self.assertIn(marker, md)
def test_computed_subtotal_traced_in_table(self):
md = report.render_report(_brief(), "fr")
# sous-total Studio = 12 × 150,000 = 1,800,000
self.assertIn("USD 1,800,000", md)
def test_determinism(self):
b = _brief()
self.assertEqual(report.render_all(b), report.render_all(b))
class TestManifestSchema(unittest.TestCase):
def test_manifest_matches_home_validator(self):
from lib import validator as pub_validator # noqa: PLC0415
m = report.build_manifest(_brief())
errs = pub_validator.validate(m, _schema())
self.assertEqual(errs, [], f"erreurs schéma maison : {errs}")
def test_manifest_matches_jsonschema_oracle_if_present(self):
try:
import jsonschema # noqa: PLC0415
except ImportError:
self.skipTest("jsonschema non installé (oracle optionnel)")
jsonschema.validate(report.build_manifest(_brief()), _schema())
def test_manifest_figures_count(self):
m = report.build_manifest(_brief())
self.assertEqual(len(m["figures_calculees"]), 4)
self.assertEqual(m["langues"], ["fr", "en", "es"])
self.assertEqual(m["typologies_count"], 3)
def test_generated_at_optional(self):
m0 = report.build_manifest(_brief())
self.assertNotIn("generated_at", m0)
m1 = report.build_manifest(_brief(), generated_at="2026-07-30T04:57:01Z")
self.assertEqual(m1["generated_at"], "2026-07-30T04:57:01Z")
from lib import validator as pub_validator # noqa: PLC0415
self.assertEqual(pub_validator.validate(m1, _schema()), [])
class TestCLIInvariants(unittest.TestCase):
def setUp(self):
self.cli = _load_cli()
def test_invariants_pass_on_fixture(self):
b = _brief()
files = report.render_all(b)
manifest = json.loads(files["50_financier_bancable/manifest.json"])
self.assertEqual(self.cli.check_invariants(b, manifest, files), [])
def test_invariant_detects_forged_figure(self):
b = _brief()
files = report.render_all(b)
manifest = json.loads(files["50_financier_bancable/manifest.json"])
# Falsifie une valeur calculée → l'invariant de recalcul doit la rattraper.
for f in manifest["figures_calculees"]:
if f["cle"] == "valeur_catalogue_usd":
f["valeur"] = 9999999
motifs = self.cli.check_invariants(b, manifest, files)
self.assertTrue(any("valeur_catalogue_usd" in m for m in motifs))
def test_invariant_detects_synthetic_marked_publishable(self):
b = _brief()
files = report.render_all(b)
manifest = json.loads(files["50_financier_bancable/manifest.json"])
manifest["publiable"] = True # incohérent avec synthétique
motifs = self.cli.check_invariants(b, manifest, files)
self.assertTrue(any("synth" in m.lower() for m in motifs))
def test_build_writes_files(self):
import tempfile
self.cli = _load_cli()
with tempfile.TemporaryDirectory() as tmp:
rc = self.cli.main(["build", os.path.join(_MOD, "fixtures",
"brief_bancable.json"),
"-o", tmp])
self.assertEqual(rc, 0)
base = os.path.join(tmp, "P01", "50_financier_bancable")
for name in ("fr.md", "en.md", "es.md", "manifest.json"):
self.assertTrue(os.path.exists(os.path.join(base, name)), name)
def test_publishable_real_brief(self):
# Un brief NON synthétique et complet est publiable.
b = _brief()
b["synthetique"] = False
b["sources"] = ["archive réelle P01"]
m = report.build_manifest(b)
self.assertTrue(m["publiable"])
if __name__ == "__main__":
unittest.main()
@@ -86,8 +86,11 @@
},
"commercial": {
"type": "object",
"description": "Chaînes libres commerciales, langue par langue. Le générateur 4 volets consomme le FR (positionnement §40_llm_outputs) ; le générateur bancable consomme FR/EN/ES pour la section Positionnement du dossier bancable. Une langue absente reste un placeholder (jamais traduite automatiquement · #6).",
"properties": {
"positionnement_fr": { "type": "string" }
"positionnement_fr": { "type": "string" },
"positionnement_en": { "type": "string" },
"positionnement_es": { "type": "string" }
}
},
"rendus": {