cb8069b83c
Explore very-thorough sur crm/dossier_vente · crm/financement_bancaire · faisabilite/bancable · frontend/chat_otoia · pie/manifest · qa/audit_4big · mobile/app_config · fiscal/ecf_dgii → 0 défaut réel. Les 4 candidats remontés sont tous NON-défauts (verify-non-defects #6) : 3× `:g` sur domaine BORNÉ (nombre d'unités / pourcentages 0-100 → exponentiel + arrondi-6-chiffres hors domaine, et `:g` nettoie le bruit flottant) + 1× HTML chat_otoia à entrée canonique contrôlée. Preuve robuste : report.py `_money` réserve sciemment `,.0f`/`,.2f` aux montants non bornés → les `:g` restants sont un choix d'ingénierie correct, pas un oubli. Fait nouveau : le taux commissions `:g` (déjà statué non-défaut en 080104) a RÉCIDIVÉ de flag ce cycle, et bancable ×2 fraîchement flaggés → la classe gaspille un cycle d'audit à chaque passage. `grep ':g}'` prod = exactement 3 sites. Passe unique : note de justification in-situ à chacun (domaine borné, 2 pièges hors domaine, montants → `,.0f`, « ne pas corriger »). Convertit le faux-positif récurrent en « déjà revu, sûr ». 0 fix de code (défauts inexistants ; corriger `:g`→`:f` réintroduirait du bruit flottant = régression) · 0 gate (#5) · 0 artefact reconstruit (bancable sans out/ ; _rate_label runtime non sérialisé) · matrice inchangée 633/616/17 · run_ci 33/0/0 · NFC-clean · 0 code moteur V18 (bloqué D-06) · 0 commande VPS. M crm/commissions/commlib/finance.py M faisabilite/bancable/banclib/finance.py M faisabilite/bancable/banclib/report.py M 05_activity_log/2026-08-12.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
192 lines
7.9 KiB
Python
192 lines
7.9 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))
|
||
# `:g` sûr ici : pe_brut = 0.52 × NOMBRE d'unités (domaine borné à quelques
|
||
# centaines en immobilier réel) — jamais ≥ 1e6 (pas d'exponentiel) ni > 6
|
||
# chiffres significatifs, les deux pièges de `:g`. En prime `:g` nettoie le
|
||
# bruit flottant du produit (0.52 × 173 = 89.96000…1 → « 89.96 »). Les
|
||
# MONTANTS non bornés, eux, passent par _money (`,.0f`), jamais par `:g`.
|
||
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
|