Files
oto-enterprise-os-dtp/05_deliverables_mvp/faisabilite/bancable/banclib/finance.py
T
Claude Code DTP Worker cb8069b83c [DTP-Worker 20260812_090114] défaut d'abord · chasse DRY sur 8 modules non-audités · durcissement anti-récurrence des 3 sites :g-domaine-borné (commentaires purs · 0 impact artefact)
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>
2026-08-12 09:11:53 +00:00

192 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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