[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,94 @@
|
||||
# Daily Report · 2026-07-30 · Claude Code DTP Worker (session 10)
|
||||
|
||||
**Session** : `20260730_045701`
|
||||
|
||||
## Tâche exécutée
|
||||
**Sprint 3 · Faisabilité — Générateur de dossier financier bancable trilingue
|
||||
FR/EN/ES** (roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` §Sprint 3 :
|
||||
« `40_llm_outputs/` + rapports bancables FR/EN/ES » · **prochaine tâche suggérée**
|
||||
par le daily report session 9 : « Faisabilité S3 »).
|
||||
|
||||
## Contexte / analyse
|
||||
- Relu `CLAUDE.md`, `ROADMAP_8_WEEKS_OR_LESS.md`, `PORTAIL_BANCABLES_4BIG.md`,
|
||||
daily report session 9, le générateur Faisabilité 4 volets (`renderer.py`,
|
||||
`model.py`, `scorer.py`, `faisabilite_gen.py`, `brief.schema.json`), le
|
||||
validateur maison Publiciste, le patron CI.
|
||||
- État : volet RBAC **clos** en-repo (4 générateurs + run-book). Faisabilité S2
|
||||
produit `data_room/PXX/` (4 volets + score 4Big) mais laissait le répertoire
|
||||
**`50_financier_bancable/` VIDE** (`.gitkeep`).
|
||||
- **Priorité évidente** : le **Portail Bancables 4Big** (variante 06 retenue par
|
||||
Michel, `PORTAIL_BANCABLES_4BIG.md`) exige à l'**étape 1** de son workflow des
|
||||
dossiers bancables **FR/EN/ES** placés précisément dans `50_financier_bancable/`.
|
||||
Ce répertoire était le dernier trou de la faisabilité auto. 100 % autorable
|
||||
sans VPS/pip/API, même brief que le générateur 4 volets, même patron modulaire.
|
||||
|
||||
## Réalisé — module `05_deliverables_mvp/faisabilite/bancable/`
|
||||
- `banclib/deps.py` — réutilise (workflow #5, zéro duplication) `is_filled` +
|
||||
`CANONICAL` du générateur et le **validateur maison** Publiciste (zéro pip).
|
||||
- `banclib/i18n.py` — dictionnaire de libellés **FR/EN/ES fixes** (traductions de
|
||||
gabarit, jamais du brief) + drapeaux du portail (🇨🇦/🇺🇸/🇩🇴).
|
||||
- `banclib/finance.py` — **deux tiers anti-invention (#6)** :
|
||||
- **sourcé** (verbatim) : coût construction, revenu brut, marge, taux, prix par
|
||||
typologie — champ absent ⇒ `None` (jamais 0 fabriqué) ;
|
||||
- **calculé** (traçable) : Σ unités · valeur catalogue USD/DOP (`Σ quantité ×
|
||||
prix`) · point d'équilibre en unités (`⌈52 % × nb_unités⌉`) — **formule
|
||||
publiée**, opérandes tous sourcés/canoniques, `None` si un opérande manque.
|
||||
- Les taux « 3 % édition » / « 8.5 % marketing » sont rendus **verbatim**
|
||||
(jamais multipliés par une base supposée — base non documentée = hypothèse
|
||||
inventée, refusée).
|
||||
- `banclib/report.py` — rendu Markdown par langue (bannière **CONFIDENTIEL**
|
||||
systématique ; bannière synthétique + `publiable:false` si fixture) + manifeste
|
||||
agrégé machine-lisible.
|
||||
- `bancable_gen.py` — CLI `build` / `validate`. **Refuse d'écrire** si un
|
||||
invariant casse : conformité schéma, 3 langues rendues, bannière CONFIDENTIEL,
|
||||
**recalcul indépendant des 4 agrégats == manifeste** (anti-figure-posée),
|
||||
synthétique ⇒ non publiable, aucun littéral `None` dans le rendu.
|
||||
- `bancable.schema.json` — contrat de sortie draft-07 (sous-ensemble validateur
|
||||
maison · valeurs numériques nullable `["number","null"]`).
|
||||
- `tests/test_bancable.py` — **22 tests `unittest` (stdlib pur)** : finance
|
||||
(sourcé verbatim, arithmétique tracée, `None`≠0), rendu trilingue, positionnement
|
||||
par langue + placeholder si absent, paramètres canoniques, schéma (maison +
|
||||
oracle `jsonschema`), invariants CLI (détection de figure falsifiée), écriture
|
||||
réelle, déterminisme.
|
||||
- **CI** : job `bancable-tests` ajouté au **gate** (`.gitea/workflows/ci.yml`,
|
||||
Gitea Actions uniquement · #2).
|
||||
- **Docs cousues** : `PORTAIL_BANCABLES_4BIG.md` (nouvelle étape 0 → générateur) ;
|
||||
`brief.schema.json` du générateur étendu (`positionnement_en/es`) → contrat
|
||||
d'entrée aligné entre les deux modules.
|
||||
|
||||
## Anti-invention (#6) appliqué
|
||||
- Figures sourcées **reprises telles quelles** ; aucune valeur inventée ni 0
|
||||
fabriqué (champ absent ⇒ placeholder `{{…}}`).
|
||||
- Agrégats calculés **auditables** : formule + opérandes publiés, **recoupés
|
||||
indépendamment** par le CLI (rejette toute figure falsifiée · test dédié).
|
||||
- Aucun montant sur base non documentée (3 %/8.5 % rendus comme taux, pas comme $).
|
||||
- Positionnement **jamais traduit automatiquement** : langue absente ⇒ placeholder.
|
||||
|
||||
## Vérifications effectuées (en-repo, sans toucher au VPS)
|
||||
- **22/22 tests verts** (schéma maison + oracle `jsonschema`). Génération réelle
|
||||
(fixture synthétique) : 3 langues + manifeste · 40 unités · valeur catalogue
|
||||
USD 8,560,000 / DOP 505,040,000 · point d'équilibre **21 unités** (⌈52 % × 40⌉)
|
||||
— toutes formules recoupées ; `publiable=false` (synthétique).
|
||||
- **Gate CI local vert (exit 0)** : `guard_constraints.sh`, `validate_json.sh`
|
||||
(schéma + fixture bancable), `check_docs.sh` (README score présent, 0 lien
|
||||
cassé), YAML `ci.yml` valide.
|
||||
- **Régression** : 23 Publiciste + 10 RBAC + 16 Faisabilité + 11 fixtures + 12
|
||||
userperm + 11 roleprofile + 16 apply_plan + 22 bancable = **121 tests verts**.
|
||||
|
||||
## Non fait (hors périmètre worker · VPS)
|
||||
- Conversion PDF (pandoc/wkhtmltopdf) des `.md` FR/EN/ES + rebuild du Portail
|
||||
Bancables 4Big → agent DevOps/Frontend (contrainte #8).
|
||||
|
||||
## Prochaine tâche suggérée
|
||||
- Faisabilité S3 restant : refactor `oto_agent_faisabilite.py` → batch <1h côté
|
||||
VPS (hors périmètre worker) ; ou packaging du hand-off ERPNext (`fixtures/` de
|
||||
l'app OTO à partir des sorties RBAC + faisabilité).
|
||||
- Frontend/CRM S2 : dépend des layouts LIVE (VPS) → hors périmètre worker.
|
||||
|
||||
---
|
||||
|
||||
**Auto-score 4Big du livrable Générateur bancable trilingue : 96/100.**
|
||||
Réserve −4 : la conversion PDF + le rebuild du portail restent côté VPS (agent
|
||||
DevOps/Frontend, hors périmètre worker · #8). Validé statiquement en-repo (22
|
||||
tests verts + manifeste conforme + recoupement arithmétique des agrégats + gate
|
||||
CI vert · 121 tests de régression au total).
|
||||
@@ -0,0 +1,4 @@
|
||||
# Sorties re-générables à la demande (jamais commitées — data_room construit).
|
||||
out/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,114 @@
|
||||
# Générateur de dossier financier bancable trilingue FR/EN/ES · Sprint 3
|
||||
|
||||
> Livrable **Faisabilité · Sprint 3** — roadmap
|
||||
> [`ROADMAP_8_WEEKS_OR_LESS.md`](../../../04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md)
|
||||
> §Sprint 3 (« `40_llm_outputs/` + rapports bancables FR/EN/ES »).
|
||||
|
||||
Remplit le répertoire `50_financier_bancable/` d'une data_room — jusqu'ici **vide**
|
||||
(`.gitkeep` posé par le générateur 4 volets) — que le **Portail Bancables 4Big**
|
||||
([`PORTAIL_BANCABLES_4BIG.md`](../../../PORTAIL_BANCABLES_4BIG.md), variante 06
|
||||
retenue par Michel) exige à l'**étape 1** de son workflow de déploiement projet
|
||||
(« Placer les PDFs dans `…/50_financier_bancable/` (FR/EN/ES) »).
|
||||
|
||||
## Place dans la chaîne de valeur
|
||||
|
||||
```
|
||||
brief.json ─► faisabilite_gen ─► data_room/PXX/ (4 volets · template v1.0)
|
||||
│
|
||||
bancable_gen ───┤ (ce module · MÊME brief)
|
||||
▼
|
||||
data_room/PXX/50_financier_bancable/{fr,en,es}.md + manifest.json
|
||||
│
|
||||
▼ (VPS · hors périmètre worker · #8)
|
||||
PDFs FR/EN/ES ─► Portail Bancables 4Big (privé · noindex)
|
||||
```
|
||||
|
||||
Le module **ferme le dernier trou** de la faisabilité auto : les 4 volets étaient
|
||||
produits et notés, mais le dossier **financier bancable** (l'artefact que voit le
|
||||
banquier) restait à écrire à la main, en trois langues. Il consomme le **même
|
||||
`brief.json`** que le générateur 4 volets — une seule source, zéro re-saisie.
|
||||
|
||||
## Trois langues imposées
|
||||
|
||||
`PORTAIL_BANCABLES_4BIG.md` : « Boutons langues **FR 🇨🇦 · EN 🇺🇸 · ES 🇩🇴** ». Le
|
||||
générateur produit systématiquement les trois. Les **libellés** de gabarit sont
|
||||
des traductions **fixes** ([`banclib/i18n.py`](banclib/i18n.py)) ; seules les
|
||||
**chaînes libres** (positionnement commercial) proviennent du brief, langue par
|
||||
langue (`commercial.positionnement_{fr,en,es}`). Une langue de positionnement
|
||||
absente reste un placeholder `{{positionnement_en}}` — **jamais traduite
|
||||
automatiquement** (ce serait une invention · #6).
|
||||
|
||||
## Anti-invention (#6) — deux tiers, tous deux traçables
|
||||
|
||||
| Tier | Contenu | Garantie |
|
||||
|---|---|---|
|
||||
| **Sourcé** (verbatim) | coût construction, revenu brut, marge, taux, prix par typologie | Repris tel quel du brief. Champ absent ⇒ placeholder `{{…}}`, **jamais 0 fabriqué**. |
|
||||
| **Calculé** (traçable) | Σ unités · valeur catalogue USD/DOP · point d'équilibre en unités | Chaque valeur **publie sa formule** ; **tous** ses opérandes sont sourcés/canoniques. Opérande manquant ⇒ valeur `null` + champ listé. |
|
||||
|
||||
> **Calcul transparent ≠ invention.** Un agrégat dont la formule et les opérandes
|
||||
> sont publiés est *auditable* — l'invention, c'est un chiffre sans source. Le CLI
|
||||
> **recalcule indépendamment** les 4 agrégats et refuse d'écrire s'ils divergent
|
||||
> du manifeste (`_check_derived_arithmetic`).
|
||||
|
||||
**Aucun montant n'est calculé sur une base non documentée.** Les pourcentages
|
||||
« 3 % édition » et « 8.5 % marketing » (#9) sont rendus comme **taux canoniques**
|
||||
(verbatim), jamais multipliés par une base supposée — la base n'étant pas
|
||||
documentée, la choisir serait une hypothèse inventée. Seuls le point d'équilibre
|
||||
en unités (`52 % × nb_unités`) et les agrégats de catalogue (`Σ quantité × prix`),
|
||||
dont la base est explicite et sourcée, sont calculés.
|
||||
|
||||
## Confidentialité
|
||||
|
||||
Le Portail Bancables est **privé** (`noindex`). Chaque `.md` porte en tête la
|
||||
bannière **CONFIDENTIEL** ; une fixture `synthetique:true` porte en plus
|
||||
l'avertissement « ne jamais publier » (#6) et son manifeste est `publiable:false`.
|
||||
|
||||
## Utilisation
|
||||
|
||||
```bash
|
||||
# Écrit DATA_ROOM/P01/50_financier_bancable/{fr,en,es}.md + manifest.json
|
||||
python3 bancable_gen.py build fixtures/brief_bancable.json -o out
|
||||
|
||||
# Manifeste + invariants sans rien écrire (schéma + recalcul des agrégats)
|
||||
python3 bancable_gen.py validate fixtures/brief_bancable.json
|
||||
|
||||
# Tests (stdlib pur, zéro pip · oracle jsonschema si présent)
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
## Ce qui est généré (`out/` non commité)
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `50_financier_bancable/fr.md` · `en.md` · `es.md` | Dossier bancable print-ready (pré-PDF), une langue. |
|
||||
| `50_financier_bancable/manifest.json` | Vue machine-lisible : figures sourcées + calculées (avec formule), langues, `publiable`, `champs_manquants` — conforme à [`bancable.schema.json`](bancable.schema.json). |
|
||||
|
||||
## Garde-fous (le CLI refuse d'écrire si un invariant casse)
|
||||
|
||||
- Manifeste conforme à `bancable.schema.json` (validateur **maison** Publiciste,
|
||||
zéro pip · oracle `jsonschema` en test si présent).
|
||||
- Exactement **3 langues** `fr/en/es`, chacune rendue, chacune avec la bannière
|
||||
**CONFIDENTIEL**.
|
||||
- **Recalcul indépendant** des 4 agrégats == manifeste (anti-figure-posée).
|
||||
- Une fixture **synthétique** ne peut pas être `publiable` ; aucun littéral
|
||||
`None` Python ne fuit dans un rendu.
|
||||
- `publiable ⇒` trio coût/revenu/marge présent + au moins une typologie tarifée.
|
||||
- Sortie **déterministe** (pas d'horodatage sauf `--generated-at`) → diffable,
|
||||
re-générable bit-à-bit en CI.
|
||||
|
||||
## Vérification en-repo
|
||||
|
||||
- `python3 -m unittest discover -s tests -v` → **22/22 verts** (finance, rendu
|
||||
trilingue, schéma maison + oracle, invariants CLI, déterminisme).
|
||||
- Génération réelle (fixture) : 3 langues + manifeste · 40 unités · valeur
|
||||
catalogue USD 8,560,000 / DOP 505,040,000 · point d'équilibre 21 unités
|
||||
(⌈52 % × 40⌉) — **toutes formules recoupées**.
|
||||
- Job CI dédié `bancable-tests` ajouté au **gate** (`.gitea/workflows/ci.yml`,
|
||||
Gitea Actions uniquement · #2).
|
||||
|
||||
## Auto-score 4Big du livrable : **96/100**
|
||||
|
||||
_Réserve −4_ : la conversion PDF (pandoc/wkhtmltopdf) + le rebuild du Portail
|
||||
Bancables restent côté VPS (agent DevOps/Frontend, hors périmètre worker · #8).
|
||||
Validé statiquement en-repo (22 tests verts + manifeste conforme + recoupement
|
||||
arithmétique des agrégats + gate CI).
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://oto.dtp/schemas/faisabilite/bancable.schema.json",
|
||||
"title": "Manifeste du dossier financier bancable trilingue",
|
||||
"description": "Vue machine-lisible du dossier bancable FR/EN/ES d'un projet. Toute valeur numérique absente est null (jamais 0-inventé · CLAUDE.md #6). Les figures calculées portent leur formule (traçabilité). Sous-ensemble draft-07 supporté par le validateur maison Publiciste (zéro pip).",
|
||||
"type": "object",
|
||||
"required": ["projet", "template_version", "langues", "synthetique", "publiable", "figures_sourcees", "figures_calculees", "typologies_count", "champs_manquants", "fichiers"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"projet": { "type": "string", "pattern": "^P0[1-9]$" },
|
||||
"template_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
|
||||
"langues": {
|
||||
"type": "array",
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "enum": ["fr", "en", "es"] }
|
||||
},
|
||||
"synthetique": { "type": "boolean" },
|
||||
"publiable": { "type": "boolean" },
|
||||
"generated_at": { "type": "string", "format": "date-time" },
|
||||
"figures_sourcees": {
|
||||
"type": "object",
|
||||
"required": ["cout_construction_usd", "revenu_brut_usd", "marge_pct", "taux_conversion"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cout_construction_usd": { "type": ["number", "null"] },
|
||||
"revenu_brut_usd": { "type": ["number", "null"] },
|
||||
"marge_pct": { "type": ["number", "null"] },
|
||||
"taux_conversion": {
|
||||
"type": "object",
|
||||
"required": ["taux", "date", "source"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"taux": { "type": ["number", "null"] },
|
||||
"date": { "type": ["string", "null"] },
|
||||
"source": { "type": ["string", "null"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"figures_calculees": {
|
||||
"type": "array",
|
||||
"minItems": 4,
|
||||
"maxItems": 4,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["cle", "libelle", "formule", "valeur", "unite"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cle": { "type": "string", "enum": ["total_unites", "valeur_catalogue_usd", "valeur_catalogue_dop", "point_equilibre_unites"] },
|
||||
"libelle": { "type": "string", "minLength": 1 },
|
||||
"formule": { "type": "string", "minLength": 1 },
|
||||
"valeur": { "type": ["number", "null"] },
|
||||
"unite": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"typologies_count": { "type": "integer", "minimum": 0 },
|
||||
"champs_manquants": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"fichiers": {
|
||||
"type": "array",
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
"items": { "type": "string", "pattern": "^50_financier_bancable/(fr|en|es)\\.md$" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Générateur de dossier financier bancable trilingue FR/EN/ES · Sprint 3.
|
||||
|
||||
Livrable **Faisabilité · Sprint 3** (roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md`
|
||||
§Sprint 3 · « 40_llm_outputs/ + rapports bancables FR/EN/ES »). Remplit le
|
||||
répertoire `50_financier_bancable/` d'une data_room — jusqu'ici vide (`.gitkeep`)
|
||||
— que le **Portail Bancables 4Big** (`PORTAIL_BANCABLES_4BIG.md`, variante 06
|
||||
retenue par Michel) exige à l'étape 1 de son workflow de déploiement projet.
|
||||
|
||||
Chaîne de valeur :
|
||||
|
||||
brief.json ─► faisabilite_gen ─► data_room/PXX/ (4 volets, template v1.0)
|
||||
│
|
||||
bancable_gen ───┤ (ce module)
|
||||
▼
|
||||
data_room/PXX/50_financier_bancable/{fr,en,es}.md + manifest.json
|
||||
│
|
||||
▼ (VPS · hors périmètre worker)
|
||||
PDFs FR/EN/ES ─► Portail Bancables 4Big (privé · noindex)
|
||||
|
||||
Anti-invention (#6) : figures sourcées reprises verbatim ; agrégats calculés de
|
||||
façon TRAÇABLE (formule + opérandes sourcés publiés) ; aucun montant sur base non
|
||||
documentée ; tout champ absent reste placeholder `{{…}}` (jamais 0 fabriqué).
|
||||
|
||||
⚠ Ce worker n'écrit JAMAIS sur le VPS (#8). Les .md sont produits en local ; la
|
||||
conversion PDF + le rebuild du portail restent côté serveur.
|
||||
|
||||
Sous-commandes :
|
||||
build BRIEF.json -o DATA_ROOM → DATA_ROOM/PXX/50_financier_bancable/*
|
||||
validate BRIEF.json → manifeste + invariants (sans écrire)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
from banclib import deps, finance, i18n, report # noqa: E402
|
||||
|
||||
|
||||
def _eprint(*args) -> None:
|
||||
print(*args, file=sys.stderr)
|
||||
|
||||
|
||||
def _load_schema() -> dict:
|
||||
with open(os.path.join(_HERE, "bancable.schema.json"), encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def _validate_projet(brief: dict) -> None:
|
||||
code = brief.get("projet")
|
||||
if not (isinstance(code, str) and len(code) == 3 and code[:2] == "P0"
|
||||
and code[2] in "123456789"):
|
||||
raise ValueError(f"brief.projet invalide (attendu P01..P09) : {code!r}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Invariants — le CLI REFUSE d'écrire si l'un casse (honnêteté #6).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def check_invariants(brief: dict, manifest: dict,
|
||||
files: dict[str, str]) -> list[str]:
|
||||
motifs: list[str] = []
|
||||
|
||||
# (a) manifeste conforme au schéma (validateur maison).
|
||||
errs = deps.validate(manifest, _load_schema())
|
||||
motifs += [f"manifest: {e}" for e in errs]
|
||||
|
||||
# (b) exactement les 3 langues canoniques, chacune rendue.
|
||||
if manifest["langues"] != ["fr", "en", "es"]:
|
||||
motifs.append(f"langues != [fr,en,es] : {manifest['langues']}")
|
||||
for lang in i18n.LANGS:
|
||||
rel = f"50_financier_bancable/{lang}.md"
|
||||
if rel not in files:
|
||||
motifs.append(f"fichier langue manquant : {rel}")
|
||||
|
||||
# (c) chaque .md porte la bannière CONFIDENTIEL ; les synthétiques portent
|
||||
# aussi l'avertissement « ne jamais publier » (#6) et sont non publiables.
|
||||
for lang in i18n.LANGS:
|
||||
md = files.get(f"50_financier_bancable/{lang}.md", "")
|
||||
if "CONFIDENTIEL" not in md and "CONFIDENTIAL" not in md \
|
||||
and "CONFIDENCIAL" not in md:
|
||||
motifs.append(f"{lang}.md : bannière CONFIDENTIEL absente")
|
||||
if brief.get("synthetique"):
|
||||
if "SYNTH" not in md.upper():
|
||||
motifs.append(f"{lang}.md : avertissement synthétique absent")
|
||||
# Garde-fou : aucun `None` Python ne doit fuiter dans le rendu.
|
||||
if " None " in md or "| None " in md or ": None\n" in md:
|
||||
motifs.append(f"{lang}.md : littéral None dans le rendu")
|
||||
if brief.get("synthetique") and manifest["publiable"]:
|
||||
motifs.append("synthétique mais publiable=true (#6)")
|
||||
|
||||
# (d) figures calculées : recoupement anti-invention. Chaque valeur non nulle
|
||||
# doit se RECALCULER à partir des opérandes sourcés (formule publiée).
|
||||
motifs += _check_derived_arithmetic(brief, manifest)
|
||||
|
||||
# (e) cohérence publiable ↔ champs manquants : un dossier publiable ne peut pas
|
||||
# manquer le trio coût/revenu/marge.
|
||||
if manifest["publiable"]:
|
||||
fs = manifest["figures_sourcees"]
|
||||
if fs["cout_construction_usd"] is None or fs["revenu_brut_usd"] is None \
|
||||
or fs["marge_pct"] is None:
|
||||
motifs.append("publiable=true mais cœur financier incomplet")
|
||||
|
||||
return motifs
|
||||
|
||||
|
||||
def _check_derived_arithmetic(brief: dict, manifest: dict) -> list[str]:
|
||||
"""Reproduit indépendamment les 4 agrégats et compare au manifeste.
|
||||
|
||||
Prouve qu'aucune valeur calculée n'a été « posée » : elle DOIT découler des
|
||||
typologies sourcées et du taux canonique 52 %.
|
||||
"""
|
||||
motifs: list[str] = []
|
||||
rows = finance.typologies(brief)
|
||||
figs = {f["cle"]: f["valeur"] for f in manifest["figures_calculees"]}
|
||||
|
||||
qtes = [r["quantite"] for r in rows]
|
||||
all_q = bool(rows) and all(q is not None for q in qtes)
|
||||
|
||||
exp_total = sum(qtes) if all_q else None
|
||||
exp_usd = (sum(q * r["prix_usd"] for q, r in zip(qtes, rows))
|
||||
if all_q and all(r["prix_usd"] is not None for r in rows) else None)
|
||||
exp_dop = (sum(q * r["prix_dop"] for q, r in zip(qtes, rows))
|
||||
if all_q and all(r["prix_dop"] is not None for r in rows) else None)
|
||||
exp_pe = (float(math.ceil(0.52 * exp_total))
|
||||
if exp_total is not None else None)
|
||||
|
||||
for cle, exp in (("total_unites", exp_total),
|
||||
("valeur_catalogue_usd", exp_usd),
|
||||
("valeur_catalogue_dop", exp_dop),
|
||||
("point_equilibre_unites", exp_pe)):
|
||||
got = figs.get(cle)
|
||||
if exp is None and got is not None:
|
||||
motifs.append(f"figure {cle} calculée ({got}) alors qu'un opérande manque")
|
||||
elif exp is not None and got != exp:
|
||||
motifs.append(f"figure {cle} : manifeste {got} != recalcul {exp}")
|
||||
return motifs
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
def cmd_build(ns: argparse.Namespace) -> int:
|
||||
with open(ns.brief, encoding="utf-8") as fh:
|
||||
brief = json.load(fh)
|
||||
_validate_projet(brief)
|
||||
code = brief["projet"]
|
||||
|
||||
files = report.render_all(brief, generated_at=ns.generated_at)
|
||||
manifest = json.loads(files["50_financier_bancable/manifest.json"])
|
||||
|
||||
motifs = check_invariants(brief, manifest, files)
|
||||
if motifs:
|
||||
_eprint(f"❌ {code} : invariants cassés — RIEN écrit :")
|
||||
for m in motifs:
|
||||
_eprint(" ·", m)
|
||||
return 1
|
||||
|
||||
dest_dir = os.path.join(ns.out, code, "50_financier_bancable")
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
for rel, content in files.items():
|
||||
with open(os.path.join(ns.out, code, rel), "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
|
||||
_eprint(f"✅ {code} → {dest_dir}")
|
||||
_eprint(f" langues={manifest['langues']} · publiable={manifest['publiable']} "
|
||||
f"· synthétique={manifest['synthetique']}")
|
||||
_eprint(f" typologies={manifest['typologies_count']} · "
|
||||
f"champs 🔴 manquants={len(manifest['champs_manquants'])}")
|
||||
for f in manifest["figures_calculees"]:
|
||||
_eprint(f" · {f['cle']} = {f['valeur']} [{f['formule']}]")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_validate(ns: argparse.Namespace) -> int:
|
||||
with open(ns.brief, encoding="utf-8") as fh:
|
||||
brief = json.load(fh)
|
||||
_validate_projet(brief)
|
||||
code = brief["projet"]
|
||||
|
||||
files = report.render_all(brief, generated_at=ns.generated_at)
|
||||
manifest = json.loads(files["50_financier_bancable/manifest.json"])
|
||||
motifs = check_invariants(brief, manifest, files)
|
||||
|
||||
print(json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
if motifs:
|
||||
_eprint(f"❌ {code} : {len(motifs)} invariant(s) cassé(s) :")
|
||||
for m in motifs:
|
||||
_eprint(" ·", m)
|
||||
return 1
|
||||
_eprint(f"✅ {code} : manifeste conforme + invariants OK "
|
||||
f"(publiable={manifest['publiable']}).")
|
||||
return 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
def build_argparser() -> argparse.ArgumentParser:
|
||||
ap = argparse.ArgumentParser(prog="bancable_gen",
|
||||
description=__doc__.splitlines()[0])
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p = sub.add_parser("build", help="brief.json → 50_financier_bancable/{fr,en,es}.md")
|
||||
p.add_argument("brief")
|
||||
p.add_argument("-o", "--out", required=True, help="dossier data_room de sortie")
|
||||
p.add_argument("--generated-at", dest="generated_at", default=None)
|
||||
p.set_defaults(func=cmd_build)
|
||||
|
||||
p = sub.add_parser("validate", help="brief.json → manifeste + invariants (sans écrire)")
|
||||
p.add_argument("brief")
|
||||
p.add_argument("--generated-at", dest="generated_at", default=None)
|
||||
p.set_defaults(func=cmd_validate)
|
||||
return ap
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ns = build_argparser().parse_args(argv)
|
||||
return ns.func(ns)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"projet": "P01",
|
||||
"nom": "Résidence Fixture Bancable",
|
||||
"synthetique": true,
|
||||
"sources": [
|
||||
"SYNTHÉTIQUE — archive fictive dossier_bancable_2026.pdf",
|
||||
"SYNTHÉTIQUE — taux BCRD fictif 2026-07-01"
|
||||
],
|
||||
"architecture": {
|
||||
"taux_conversion": {
|
||||
"taux": 59.0,
|
||||
"date": "2026-07-01",
|
||||
"source": "SYNTHÉTIQUE — BCRD fictif"
|
||||
},
|
||||
"typologies": [
|
||||
{
|
||||
"nom": "Studio",
|
||||
"quantite": 12,
|
||||
"surface_interieure_m2": 45,
|
||||
"surface_terrasse_m2": 10,
|
||||
"surface_totale_m2": 55,
|
||||
"prix_usd": 150000,
|
||||
"prix_dop": 8850000
|
||||
},
|
||||
{
|
||||
"nom": "1 Chambre",
|
||||
"quantite": 20,
|
||||
"surface_interieure_m2": 68,
|
||||
"surface_terrasse_m2": 15,
|
||||
"surface_totale_m2": 83,
|
||||
"prix_usd": 210000,
|
||||
"prix_dop": 12390000
|
||||
},
|
||||
{
|
||||
"nom": "2 Chambres",
|
||||
"quantite": 8,
|
||||
"surface_interieure_m2": 95,
|
||||
"surface_terrasse_m2": 25,
|
||||
"surface_totale_m2": 120,
|
||||
"prix_usd": 320000,
|
||||
"prix_dop": 18880000
|
||||
}
|
||||
]
|
||||
},
|
||||
"ingenierie": {
|
||||
"cout_construction_usd": 9000000,
|
||||
"revenu_brut_usd": 14200000,
|
||||
"marge_pct": 28,
|
||||
"regime_fiscal": "CONFOTUR + Fideicomiso"
|
||||
},
|
||||
"commercial": {
|
||||
"positionnement_fr": "Une adresse balnéaire confidentielle où l'architecture contemporaine dialogue avec la nature tropicale, sous régime CONFOTUR.",
|
||||
"positionnement_en": "A confidential beachfront address where contemporary architecture meets tropical nature, under the CONFOTUR regime.",
|
||||
"positionnement_es": "Una dirección balnearia confidencial donde la arquitectura contemporánea dialoga con la naturaleza tropical, bajo el régimen CONFOTUR."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Tests du générateur de dossier bancable trilingue (stdlib `unittest`, zéro pip).
|
||||
|
||||
Couvre : rendu FR/EN/ES, bannière CONFIDENTIEL, anti-invention (#6 — placeholders,
|
||||
zéro chiffre fabriqué), calculs traçables recoupés indépendamment, cohérence du
|
||||
manifeste avec le schéma (validateur maison + oracle `jsonschema` si présent),
|
||||
publiabilité, déterminisme, et refus d'écrire sur invariant cassé.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_MOD = os.path.normpath(os.path.join(_HERE, "..")) # bancable/
|
||||
_PUB = os.path.normpath(os.path.join(_MOD, "..", "..", "publiciste"))
|
||||
|
||||
sys.path.insert(0, _PUB)
|
||||
sys.path.insert(0, _MOD)
|
||||
|
||||
from banclib import finance, i18n, report # noqa: E402
|
||||
|
||||
|
||||
def _load_cli():
|
||||
"""Charge bancable_gen.py comme module isolé."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"bancable_gen", os.path.join(_MOD, "bancable_gen.py")
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _brief():
|
||||
with open(os.path.join(_MOD, "fixtures", "brief_bancable.json"),
|
||||
encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def _schema():
|
||||
with open(os.path.join(_MOD, "bancable.schema.json"), encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
class TestFinance(unittest.TestCase):
|
||||
def test_sourced_verbatim(self):
|
||||
src = finance.sourced(_brief())
|
||||
self.assertEqual(src["cout_construction_usd"], 9000000)
|
||||
self.assertEqual(src["revenu_brut_usd"], 14200000)
|
||||
self.assertEqual(src["marge_pct"], 28)
|
||||
self.assertEqual(src["taux_conversion"]["taux"], 59.0)
|
||||
|
||||
def test_derived_arithmetic_traceable(self):
|
||||
figs = {f["cle"]: f for f in finance.derived(_brief())}
|
||||
# 12 + 20 + 8 = 40
|
||||
self.assertEqual(figs["total_unites"]["valeur"], 40)
|
||||
# 12*150000 + 20*210000 + 8*320000 = 1.8M + 4.2M + 2.56M = 8.56M
|
||||
self.assertEqual(figs["valeur_catalogue_usd"]["valeur"], 8560000)
|
||||
# 12*8.85M + 20*12.39M + 8*18.88M = 106.2M + 247.8M + 151.04M = 505.04M
|
||||
self.assertEqual(figs["valeur_catalogue_dop"]["valeur"], 505040000)
|
||||
# ceil(0.52 * 40) = ceil(20.8) = 21
|
||||
self.assertEqual(figs["point_equilibre_unites"]["valeur"], 21)
|
||||
|
||||
def test_derived_carry_formula(self):
|
||||
for f in finance.derived(_brief()):
|
||||
self.assertTrue(f["formule"])
|
||||
self.assertNotIn("None", f["formule"])
|
||||
|
||||
def test_missing_operand_yields_none_not_zero(self):
|
||||
b = _brief()
|
||||
del b["ingenierie"]["cout_construction_usd"]
|
||||
b["architecture"]["typologies"][0].pop("prix_usd")
|
||||
src = finance.sourced(b)
|
||||
self.assertIsNone(src["cout_construction_usd"]) # jamais 0
|
||||
figs = {f["cle"]: f for f in finance.derived(b)}
|
||||
# un prix_usd manquant ⇒ valeur catalogue USD non calculable
|
||||
self.assertIsNone(figs["valeur_catalogue_usd"]["valeur"])
|
||||
# mais les quantités restent complètes ⇒ total_unites calculable
|
||||
self.assertEqual(figs["total_unites"]["valeur"], 40)
|
||||
self.assertIn("ingenierie.cout_construction_usd",
|
||||
finance.missing_fields(b))
|
||||
|
||||
|
||||
class TestReport(unittest.TestCase):
|
||||
def test_three_languages_rendered(self):
|
||||
files = report.render_all(_brief())
|
||||
for lang in ("fr", "en", "es"):
|
||||
self.assertIn(f"50_financier_bancable/{lang}.md", files)
|
||||
|
||||
def test_confidential_banner_every_language(self):
|
||||
for lang in i18n.LANGS:
|
||||
md = report.render_report(_brief(), lang)
|
||||
up = md.upper()
|
||||
self.assertTrue("CONFIDENTIEL" in up or "CONFIDENTIAL" in up
|
||||
or "CONFIDENCIAL" in up)
|
||||
|
||||
def test_synthetic_banner_and_not_publishable(self):
|
||||
b = _brief()
|
||||
self.assertTrue(b["synthetique"])
|
||||
m = report.build_manifest(b)
|
||||
self.assertFalse(m["publiable"])
|
||||
for lang in i18n.LANGS:
|
||||
self.assertIn("SYNTH", report.render_report(b, lang).upper())
|
||||
|
||||
def test_positioning_per_language_from_brief(self):
|
||||
b = _brief()
|
||||
self.assertIn("beachfront", report.render_report(b, "en"))
|
||||
self.assertIn("balnearia", report.render_report(b, "es"))
|
||||
self.assertIn("balnéaire", report.render_report(b, "fr"))
|
||||
|
||||
def test_missing_positioning_becomes_placeholder(self):
|
||||
b = _brief()
|
||||
del b["commercial"]["positionnement_en"]
|
||||
md = report.render_report(b, "en")
|
||||
self.assertIn("{{positionnement_en}}", md)
|
||||
# les autres langues restent intactes
|
||||
self.assertNotIn("{{positionnement_fr}}", report.render_report(b, "fr"))
|
||||
|
||||
def test_no_python_none_leaks_in_render(self):
|
||||
b = _brief()
|
||||
del b["ingenierie"]["cout_construction_usd"]
|
||||
for lang in i18n.LANGS:
|
||||
md = report.render_report(b, lang)
|
||||
self.assertNotIn(" None ", md)
|
||||
self.assertNotIn("| None ", md)
|
||||
|
||||
def test_canonical_params_present(self):
|
||||
md = report.render_report(_brief(), "fr")
|
||||
for marker in ("3 %", "8.5 %", "52 %", "USD + DOP", "Cardnet", "Letter US"):
|
||||
self.assertIn(marker, md)
|
||||
|
||||
def test_computed_subtotal_traced_in_table(self):
|
||||
md = report.render_report(_brief(), "fr")
|
||||
# sous-total Studio = 12 × 150,000 = 1,800,000
|
||||
self.assertIn("USD 1,800,000", md)
|
||||
|
||||
def test_determinism(self):
|
||||
b = _brief()
|
||||
self.assertEqual(report.render_all(b), report.render_all(b))
|
||||
|
||||
|
||||
class TestManifestSchema(unittest.TestCase):
|
||||
def test_manifest_matches_home_validator(self):
|
||||
from lib import validator as pub_validator # noqa: PLC0415
|
||||
m = report.build_manifest(_brief())
|
||||
errs = pub_validator.validate(m, _schema())
|
||||
self.assertEqual(errs, [], f"erreurs schéma maison : {errs}")
|
||||
|
||||
def test_manifest_matches_jsonschema_oracle_if_present(self):
|
||||
try:
|
||||
import jsonschema # noqa: PLC0415
|
||||
except ImportError:
|
||||
self.skipTest("jsonschema non installé (oracle optionnel)")
|
||||
jsonschema.validate(report.build_manifest(_brief()), _schema())
|
||||
|
||||
def test_manifest_figures_count(self):
|
||||
m = report.build_manifest(_brief())
|
||||
self.assertEqual(len(m["figures_calculees"]), 4)
|
||||
self.assertEqual(m["langues"], ["fr", "en", "es"])
|
||||
self.assertEqual(m["typologies_count"], 3)
|
||||
|
||||
def test_generated_at_optional(self):
|
||||
m0 = report.build_manifest(_brief())
|
||||
self.assertNotIn("generated_at", m0)
|
||||
m1 = report.build_manifest(_brief(), generated_at="2026-07-30T04:57:01Z")
|
||||
self.assertEqual(m1["generated_at"], "2026-07-30T04:57:01Z")
|
||||
from lib import validator as pub_validator # noqa: PLC0415
|
||||
self.assertEqual(pub_validator.validate(m1, _schema()), [])
|
||||
|
||||
|
||||
class TestCLIInvariants(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.cli = _load_cli()
|
||||
|
||||
def test_invariants_pass_on_fixture(self):
|
||||
b = _brief()
|
||||
files = report.render_all(b)
|
||||
manifest = json.loads(files["50_financier_bancable/manifest.json"])
|
||||
self.assertEqual(self.cli.check_invariants(b, manifest, files), [])
|
||||
|
||||
def test_invariant_detects_forged_figure(self):
|
||||
b = _brief()
|
||||
files = report.render_all(b)
|
||||
manifest = json.loads(files["50_financier_bancable/manifest.json"])
|
||||
# Falsifie une valeur calculée → l'invariant de recalcul doit la rattraper.
|
||||
for f in manifest["figures_calculees"]:
|
||||
if f["cle"] == "valeur_catalogue_usd":
|
||||
f["valeur"] = 9999999
|
||||
motifs = self.cli.check_invariants(b, manifest, files)
|
||||
self.assertTrue(any("valeur_catalogue_usd" in m for m in motifs))
|
||||
|
||||
def test_invariant_detects_synthetic_marked_publishable(self):
|
||||
b = _brief()
|
||||
files = report.render_all(b)
|
||||
manifest = json.loads(files["50_financier_bancable/manifest.json"])
|
||||
manifest["publiable"] = True # incohérent avec synthétique
|
||||
motifs = self.cli.check_invariants(b, manifest, files)
|
||||
self.assertTrue(any("synth" in m.lower() for m in motifs))
|
||||
|
||||
def test_build_writes_files(self):
|
||||
import tempfile
|
||||
self.cli = _load_cli()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
rc = self.cli.main(["build", os.path.join(_MOD, "fixtures",
|
||||
"brief_bancable.json"),
|
||||
"-o", tmp])
|
||||
self.assertEqual(rc, 0)
|
||||
base = os.path.join(tmp, "P01", "50_financier_bancable")
|
||||
for name in ("fr.md", "en.md", "es.md", "manifest.json"):
|
||||
self.assertTrue(os.path.exists(os.path.join(base, name)), name)
|
||||
|
||||
def test_publishable_real_brief(self):
|
||||
# Un brief NON synthétique et complet est publiable.
|
||||
b = _brief()
|
||||
b["synthetique"] = False
|
||||
b["sources"] = ["archive réelle P01"]
|
||||
m = report.build_manifest(b)
|
||||
self.assertTrue(m["publiable"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -86,8 +86,11 @@
|
||||
},
|
||||
"commercial": {
|
||||
"type": "object",
|
||||
"description": "Chaînes libres commerciales, langue par langue. Le générateur 4 volets consomme le FR (positionnement §40_llm_outputs) ; le générateur bancable consomme FR/EN/ES pour la section Positionnement du dossier bancable. Une langue absente reste un placeholder (jamais traduite automatiquement · #6).",
|
||||
"properties": {
|
||||
"positionnement_fr": { "type": "string" }
|
||||
"positionnement_fr": { "type": "string" },
|
||||
"positionnement_en": { "type": "string" },
|
||||
"positionnement_es": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"rendus": {
|
||||
|
||||
Reference in New Issue
Block a user