diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7c3403c..7edf963 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -161,10 +161,29 @@ jobs: - name: Tests agrégateur RBAC run: python3 -m unittest discover -s tests -v + # -------------------------------------------------------------------------- + # Générateur de dossier bancable trilingue (Sprint 3 · Faisabilité) : 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). Figures + # sourcées verbatim + agrégats calculés de façon traçable (formule recoupée). + # Valide le manifeste contre bancable.schema.json + invariants (3 langues, + # bannière CONFIDENTIEL, anti-invention, recalcul des figures). stdlib pur. + # -------------------------------------------------------------------------- + bancable-tests: + name: Faisabilité · dossier bancable trilingue FR/EN/ES + runs-on: ubuntu-latest + defaults: + run: + working-directory: 05_deliverables_mvp/faisabilite/bancable + steps: + - uses: actions/checkout@v4 + - name: Tests générateur bancable trilingue + run: python3 -m unittest discover -s tests -v + gate: name: Gate qualité (agrégat) runs-on: ubuntu-latest - needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests] + needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests] steps: - name: Résultat run: echo "✅ Gate qualité 4Big franchi — tous les checks verts." diff --git a/05_deliverables_mvp/daily_reports/2026-07-30-session10.md b/05_deliverables_mvp/daily_reports/2026-07-30-session10.md new file mode 100644 index 0000000..de9eee8 --- /dev/null +++ b/05_deliverables_mvp/daily_reports/2026-07-30-session10.md @@ -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). diff --git a/05_deliverables_mvp/faisabilite/bancable/.gitignore b/05_deliverables_mvp/faisabilite/bancable/.gitignore new file mode 100644 index 0000000..4b5d7ad --- /dev/null +++ b/05_deliverables_mvp/faisabilite/bancable/.gitignore @@ -0,0 +1,4 @@ +# Sorties re-générables à la demande (jamais commitées — data_room construit). +out/ +**/__pycache__/ +*.pyc diff --git a/05_deliverables_mvp/faisabilite/bancable/README.md b/05_deliverables_mvp/faisabilite/bancable/README.md new file mode 100644 index 0000000..05c2481 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/bancable/README.md @@ -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). diff --git a/05_deliverables_mvp/faisabilite/bancable/bancable.schema.json b/05_deliverables_mvp/faisabilite/bancable/bancable.schema.json new file mode 100644 index 0000000..70032f1 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/bancable/bancable.schema.json @@ -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$" } + } + } +} diff --git a/05_deliverables_mvp/faisabilite/bancable/bancable_gen.py b/05_deliverables_mvp/faisabilite/bancable/bancable_gen.py new file mode 100644 index 0000000..9f50356 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/bancable/bancable_gen.py @@ -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()) diff --git a/05_deliverables_mvp/faisabilite/bancable/banclib/__init__.py b/05_deliverables_mvp/faisabilite/bancable/banclib/__init__.py new file mode 100644 index 0000000..6ae5ad4 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/bancable/banclib/__init__.py @@ -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é. +""" diff --git a/05_deliverables_mvp/faisabilite/bancable/banclib/deps.py b/05_deliverables_mvp/faisabilite/bancable/banclib/deps.py new file mode 100644 index 0000000..06629c5 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/bancable/banclib/deps.py @@ -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"] diff --git a/05_deliverables_mvp/faisabilite/bancable/banclib/finance.py b/05_deliverables_mvp/faisabilite/bancable/banclib/finance.py new file mode 100644 index 0000000..3b97312 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/bancable/banclib/finance.py @@ -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 diff --git a/05_deliverables_mvp/faisabilite/bancable/banclib/i18n.py b/05_deliverables_mvp/faisabilite/bancable/banclib/i18n.py new file mode 100644 index 0000000..805a67f --- /dev/null +++ b/05_deliverables_mvp/faisabilite/bancable/banclib/i18n.py @@ -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] diff --git a/05_deliverables_mvp/faisabilite/bancable/banclib/report.py b/05_deliverables_mvp/faisabilite/bancable/banclib/report.py new file mode 100644 index 0000000..9b42e0d --- /dev/null +++ b/05_deliverables_mvp/faisabilite/bancable/banclib/report.py @@ -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 diff --git a/05_deliverables_mvp/faisabilite/bancable/fixtures/brief_bancable.json b/05_deliverables_mvp/faisabilite/bancable/fixtures/brief_bancable.json new file mode 100644 index 0000000..dcd7fb9 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/bancable/fixtures/brief_bancable.json @@ -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." + } +} diff --git a/05_deliverables_mvp/faisabilite/bancable/tests/test_bancable.py b/05_deliverables_mvp/faisabilite/bancable/tests/test_bancable.py new file mode 100644 index 0000000..241f9e2 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/bancable/tests/test_bancable.py @@ -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() diff --git a/05_deliverables_mvp/faisabilite/generator/brief.schema.json b/05_deliverables_mvp/faisabilite/generator/brief.schema.json index d16f520..fa63ca6 100644 --- a/05_deliverables_mvp/faisabilite/generator/brief.schema.json +++ b/05_deliverables_mvp/faisabilite/generator/brief.schema.json @@ -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": { diff --git a/PORTAIL_BANCABLES_4BIG.md b/PORTAIL_BANCABLES_4BIG.md index add663c..1620134 100644 --- a/PORTAIL_BANCABLES_4BIG.md +++ b/PORTAIL_BANCABLES_4BIG.md @@ -20,7 +20,12 @@ Site **PRIVÉ** utilisé pour **partager les dossiers bancables** avec les perso ## Workflow déploiement projets Quand un nouveau projet est prêt (faisabilité complète 4 volets · dossier bancable validé) : -1. Placer les PDFs dans `/opt/oto/data_room/PXX/50_financier_bancable/` (FR/EN/ES) +0. **Générer** les dossiers bancables FR/EN/ES avec + [`05_deliverables_mvp/faisabilite/bancable/`](05_deliverables_mvp/faisabilite/bancable/README.md) + (`python3 bancable_gen.py build brief.json -o data_room`) → produit + `50_financier_bancable/{fr,en,es}.md` (pré-PDF · figures sourcées + agrégats + traçables · anti-invention #6). +1. Convertir en PDFs et les placer dans `/opt/oto/data_room/PXX/50_financier_bancable/` (FR/EN/ES) 2. Rebuild le portail Bancables → régénérer `/opt/oto/portal/feuillets/bancables/index.html` 3. Le nouveau projet apparaît automatiquement dans le portail 4. Michel valide + envoie l'URL aux personnes ressource