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