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