Files
oto-enterprise-os-dtp/05_deliverables_mvp/mobile/app_config/app_config_gen.py
T
Claude Code DTP Worker d0b354f81e
CI / Contraintes NON-NÉGOCIABLES (CLAUDE.md) (push) Has been cancelled
CI / Validation JSON (schémas Faisabilité) (push) Has been cancelled
CI / Qualité documentaire (liens + 4Big) (push) Has been cancelled
CI / Reproductibilité des artefacts out/ (build == commité) (push) Has been cancelled
CI / Fraîcheur matrice de régression (run == commité) (push) Has been cancelled
CI / Intégrité du câblage CI (gate agrège tout · gates statiques verrouillés) (push) Has been cancelled
CI / Intégrité des chiffres du README (valeur == artefact cité · (push) Has been cancelled
CI / Publiciste · parser + schéma + generator (unittest) (push) Has been cancelled
CI / RBAC · 50 rôles + schéma (unittest) (push) Has been cancelled
CI / Faisabilité · générateur 4 volets + round-trip (unittest) (push) Has been cancelled
CI / RBAC · fixtures ERPNext (Role + Custom DocPerm) (push) Has been cancelled
CI / RBAC · plan User Permission (row-level) (push) Has been cancelled
CI / RBAC · Role Profile (bundles par portail) (push) Has been cancelled
CI / RBAC · run-book d'application unifié (agrégat 3 volets) (push) Has been cancelled
CI / Faisabilité · dossier bancable trilingue FR/EN/ES (push) Has been cancelled
CI / CRM · workflow vente ERPNext (lead → CONFOTUR) (push) Has been cancelled
CI / CRM · DocType porteur OTO Dossier Vente (push) Has been cancelled
CI / CRM · barème commissions vendeurs (push) Has been cancelled
CI / Fiscal · e-CF DGII (Compupar) (push) Has been cancelled
CI / Frontend · Workspaces 5 portails rôle (push) Has been cancelled
CI / Legal · DocType CONFOTUR Application (push) Has been cancelled
CI / QA · Audit 5D conformité (push) Has been cancelled
CI / SEO · mots-clés trilingues + schema.org + hreflang (push) Has been cancelled
CI / Chat OTOIA · montage par portail (Custom Block) (push) Has been cancelled
CI / QA · Audit 4Big (95+/100 sur 100% deliverables) (push) Has been cancelled
CI / Démo · Scénarios (run-sheet P07 banquier / P05 client) (push) Has been cancelled
CI / QA · Matrice de régression exhaustive (Sprint 8) (push) Has been cancelled
CI / DevOps · Run-book de déploiement VPS unifié (Sprint 8) (push) Has been cancelled
CI / QA · Matrice d'acceptation / traçabilité MVP (Sprint 8) (push) Has been cancelled
CI / Mobile · config app Expo/EAS (navigation par rôle) (push) Has been cancelled
CI / E2E baseline Playwright (manuel) (push) Has been cancelled
CI / Gate qualité (agrégat) (push) Has been cancelled
[DTP-Worker 20260803_030641] Auto exec · session 20260803_030641
2026-08-03 03:21:42 +00:00

293 lines
12 KiB
Python

#!/usr/bin/env python3
"""Générateur de la config app mobile OTO Enterprise OS (Sprint 5 · Mobile).
Livrable Mobile de la roadmap Sprint 5 (l.56-57 « Rebuild Expo 54 + submit App
Store #32 + Play Store »). Le worker ne BUILD ni ne SUBMIT (VPS/stores · #8) : il
produit la CONFIG versionnable de l'app compagnon, surface mobile des 5 portails
rôle ERPNext.
Transforme (rbac_50_roles.json + mobile_spec.json + portails_spec.json + seo_spec.json)
en :
- app_config.json -> objet Expo `app.config` (thème dark+doré #4, locales seo,
identifiants de store `null` · a_confirmer)
- eas_build.json -> objet `eas.json` (profils build/submit, credentials `null`)
- role_navigation.json -> 1 onglet par portail, gated par la surface RBAC exacte
- store_listing.json -> fiche store FR/EN/ES (sous-titre, descriptions, mots-clés)
dérivée : portails/langues injectés depuis les sources (#6)
- MANIFEST.json -> traçabilité (comptes, résumé par portail, marque, a_confirmer)
Sous-commandes :
build [-o OUT] -> écrit les 4 fichiers ci-dessus
validate [-o OUT] -> (re)génère en mémoire, valide vs mobile.schema.json + invariants
Sortie déterministe (tri stable, aucun horodatage) -> diffable + re-générable.
Anti-invention (#6) : onglets = portails métier ; rôles = surface RBAC EXACTE du
portail (mêmes valeurs que les Has Role des Workspaces) ; identifiants de store/build =
`null` (a_confirmer) — jamais fabriqués ; marque/devises/langues sourcées.
Ce worker n'écrit JAMAIS sur le VPS ni ne soumet aux stores (#8).
"""
from __future__ import annotations
import argparse
import json
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)
from mobilelib import builder, deps # noqa: E402
_DEFAULT_OUT = os.path.join(_HERE, "out")
def _eprint(*args) -> None:
print(*args, file=sys.stderr)
def _write_json(path: str, data) -> None:
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
fh.write("\n")
def _build_bundle() -> dict:
contract = deps.load_contract()
spec = deps.load_spec()
portails_spec = deps.load_portails_spec()
langs, default_lang = deps.load_langs()
return builder.build_bundle(contract, spec, portails_spec, langs, default_lang)
def _resolve_path(root: dict, dotted: str):
"""Descend `root` selon un chemin pointé (ex. 'app_config.expo.ios.bundleIdentifier').
Retourne (trouvé: bool, valeur). `trouvé=False` si un segment n'existe pas.
"""
cur = root
for seg in dotted.split("."):
if not isinstance(cur, dict) or seg not in cur:
return False, None
cur = cur[seg]
return True, cur
def _validate_bundle(bundle: dict) -> list[str]:
errors: list[str] = []
schema = deps.load_json(deps.SCHEMA_PATH)
errors.extend(deps.validate(bundle, schema))
contract = deps.load_contract()
spec = deps.load_spec()
portails_spec = deps.load_portails_spec()
langs, default_lang = deps.load_langs()
app = bundle["app_config"]["expo"]
eas = bundle["eas_build"]
nav = bundle["role_navigation"]
manifest = bundle["manifest"]
b = deps.branding
roles_map = deps.roles_by_portail(contract)
labels = {p["key"]: p["label"] for p in portails_spec["portails"]}
icons = {p["key"]: p["icon"] for p in portails_spec["portails"]}
# Invariant 1 · onglets = portails métier RBAC, dans l'ordre de la spec.
nav_keys = [n["portail"] for n in nav]
if nav_keys != spec["portail_order"]:
errors.append(f"ordre des onglets != portail_order spec: {nav_keys}.")
if set(nav_keys) != set(contract["portails_business"]):
errors.append("onglets != portails_business du contrat RBAC.")
for n in nav:
key = n["portail"]
# Invariant 2 · rôles autorisés = surface RBAC EXACTE du portail (sécurité).
expected_roles = sorted(roles_map.get(key, []))
if n["roles_allowed"] != expected_roles:
errors.append(f"[{key}] roles_allowed != rôles RBAC du portail.")
if not expected_roles:
errors.append(f"[{key}] portail sans rôle (surface RBAC vide ?).")
if n["n_roles"] != len(expected_roles):
errors.append(f"[{key}] n_roles incohérent.")
# Invariant 3 · label + icône = portails_spec (source unique · mise en page).
if n["label"] != labels.get(key):
errors.append(f"[{key}] label != portails_spec.")
if n["icon"] != icons.get(key):
errors.append(f"[{key}] icône != portails_spec.")
# Invariant 4 · route déterministe.
if n["route"] != f"/{key}":
errors.append(f"[{key}] route non déterministe: {n['route']!r}.")
# Invariant 5 · langues = seo_spec (source unique), aucune langue inventée.
if list(langs) != ["fr", "en", "es"] or default_lang != "fr":
errors.append(f"langues seo inattendues: {langs}/{default_lang}.")
if sorted(app["locales"].keys()) != sorted(langs):
errors.append("app_config.locales != langues seo.")
if app["extra"]["defaultLocale"] != default_lang:
errors.append("extra.defaultLocale != langue par défaut seo.")
# Invariant 6 · tokens de marque verbatim (CLAUDE.md #4).
if app["backgroundColor"] != b.COLOR_BG or app["splash"]["backgroundColor"] != b.COLOR_BG:
errors.append("backgroundColor != #4 (#0a0a12).")
if app["primaryColor"] != b.COLOR_ACCENT:
errors.append("primaryColor != #4 (#f0b429).")
if app["userInterfaceStyle"] != "dark":
errors.append("userInterfaceStyle != dark (thème luxury #4).")
brand = manifest["brand"]
if brand["fond"]["valeur"] != b.COLOR_BG or brand["accent"]["valeur"] != b.COLOR_ACCENT:
errors.append("manifest.brand != CLAUDE.md #4.")
# Invariant 7 · devises = USD + DOP (CLAUDE.md #10).
if app["extra"]["devises"] != [b.DEVISE_PRIMAIRE, b.DEVISE_SECONDAIRE]:
errors.append("extra.devises != [USD, DOP] (#10).")
# Invariant 8 · Expo SDK majeur = spec (54) ; orientation portrait.
if app["extra"]["expoSdkMajor"] != spec["app"]["expo_sdk_major"]:
errors.append("extra.expoSdkMajor != spec (Expo 54).")
if app["orientation"] != spec["app"]["orientation"]:
errors.append("orientation != spec.")
# Invariant 9 · ANTI-INVENTION : chaque champ `a_confirmer` reste `null` dans la
# sortie (aucun identifiant de store/build/credential fabriqué · #6/#8).
for entry in spec["a_confirmer"]:
champ = entry["champ"]
found, value = _resolve_path(bundle, champ)
if not found:
errors.append(f"a_confirmer: champ absent du bundle: {champ}.")
elif value is not None:
errors.append(f"a_confirmer: {champ} doit rester null (jamais fabriqué #6/#8).")
if manifest["identifiants_statut"] != "a_confirmer":
errors.append("manifest.identifiants_statut != a_confirmer.")
# Invariant 10 · profils EAS = spec ; credentials submit `null`.
if sorted(eas["build"].keys()) != sorted(spec["eas"]["build_profiles"]):
errors.append("eas.build profils != spec.build_profiles.")
# Invariant 11 · comptes du manifeste cohérents.
c = manifest["counts"]
checks = {
"onglets": len(nav),
"portails": len(nav),
"roles_couverts": sum(n["n_roles"] for n in nav),
"langues": len(langs),
"identifiants_a_confirmer": len(spec["a_confirmer"]),
}
for k, v in checks.items():
if c.get(k) != v:
errors.append(f"counts.{k} incohérent ({c.get(k)} != {v}).")
# Invariant 12 · fiche store : couvre EXACTEMENT les langues seo (aucune inventée).
sl = bundle["store_listing"]
sl_spec = spec["store_listing"]
if sorted(sl["content"].keys()) != sorted(langs):
errors.append("store_listing.content != langues seo (FR/EN/ES).")
if sl["langues"] != list(langs):
errors.append("store_listing.langues != langues seo.")
if sl["limits"] != sl_spec["limits"]:
errors.append("store_listing.limits != spec.store_listing.limits.")
# Invariant 13 · chaque champ de fiche respecte sa limite de caractères store
# (App Store / Play — voir limits_source) et n'est jamais vide.
for lang, fields in sl["content"].items():
for champ, limit in sl_spec["limits"].items():
val = fields.get(champ)
if not val:
errors.append(f"store_listing[{lang}].{champ} vide/absent.")
elif len(val) > limit:
errors.append(
f"store_listing[{lang}].{champ} dépasse la limite store {limit} "
f"caractères (={len(val)})."
)
# Invariant 14 · ANTI-DÉRIVE : l'énumération des portails et les langues injectées
# dans chaque description longue == la surface RÉELLE (labels role_navigation ·
# langues seo) — la copie marketing ne peut pas mentir sur les portails/langues.
portails_str = " · ".join(n["label"] for n in nav)
langues_str = "/".join(lang.upper() for lang in langs)
for lang, fields in sl["content"].items():
full = fields.get("full_description", "")
if portails_str not in full:
errors.append(f"store_listing[{lang}].full_description n'injecte pas les portails réels.")
if langues_str not in full:
errors.append(f"store_listing[{lang}].full_description n'injecte pas les langues réelles.")
if "{" in full or "}" in full:
errors.append(f"store_listing[{lang}].full_description : placeholder non substitué.")
# Invariant 15 · déterminisme : deux builds successifs identiques.
again = _build_bundle()
if json.dumps(again, sort_keys=True, ensure_ascii=False) != json.dumps(bundle, sort_keys=True, ensure_ascii=False):
errors.append("build non déterministe (deux passes divergent).")
return errors
def cmd_build(args: argparse.Namespace) -> int:
bundle = _build_bundle()
errors = _validate_bundle(bundle)
if errors:
_eprint("❌ Bundle invalide — génération refusée (anti-régression) :")
for e in errors:
_eprint(f" - {e}")
return 1
out = os.path.abspath(args.out)
os.makedirs(out, exist_ok=True)
_write_json(os.path.join(out, "app_config.json"), bundle["app_config"])
_write_json(os.path.join(out, "eas_build.json"), bundle["eas_build"])
_write_json(os.path.join(out, "role_navigation.json"), bundle["role_navigation"])
_write_json(os.path.join(out, "store_listing.json"), bundle["store_listing"])
_write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])
c = bundle["manifest"]["counts"]
print(f"✅ Config app mobile générée dans {out}")
print(
f" {c['onglets']} onglets (portails) · {c['roles_couverts']} rôles couverts · "
f"{c['langues']} langues · {c['identifiants_a_confirmer']} identifiants a_confirmer"
)
for m in bundle["manifest"]["portails"]:
print(f" - {m['label']} ({m['icon']}) : {m['nb_roles']} rôles")
return 0
def cmd_validate(args: argparse.Namespace) -> int:
bundle = _build_bundle()
errors = _validate_bundle(bundle)
if errors:
_eprint("❌ Validation KO :")
for e in errors:
_eprint(f" - {e}")
return 1
c = bundle["manifest"]["counts"]
print(
f"✅ Validation OK — {c['onglets']} onglets, {c['roles_couverts']} rôles, "
f"schéma + 15 invariants verts (identifiants a_confirmer · fiche store dérivée)."
)
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
description="Générateur de la config app mobile OTO Enterprise OS (Expo/EAS + navigation par rôle)."
)
sub = p.add_subparsers(dest="cmd", required=True)
pb = sub.add_parser("build", help="génère app_config.json / eas_build.json / role_navigation.json / MANIFEST.json")
pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (défaut: ./out)")
pb.set_defaults(func=cmd_build)
pv = sub.add_parser("validate", help="valide le bundle (schéma + invariants) sans écrire")
pv.add_argument("-o", "--out", default=_DEFAULT_OUT, help="ignoré (compat)")
pv.set_defaults(func=cmd_validate)
args = p.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())