[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:
@@ -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
|
||||
Reference in New Issue
Block a user