[DTP-Worker] Sprint 8 · buffer · CRM/Commissions : gate d'IDENTITÉ des RÔLES PAYÉS — le « à quel rôle » du barème vendeurs — appartenance SINGLETON au portail ventes (séparation des pouvoirs · invariant #5) double-ancrée contrat rbac_50_roles.json ⨯ littéral README ⨯ portails_business · nom Frappe erpnext_role_name RÉSOLU == contrat (invariant #6, jamais figé en dur) · role_id ∈ contrat (jamais fantôme) · MANIFEST.roles_rbac_utilises recoupé — recomputés de commission_plan.json + contrat + MANIFEST byte-gatés

This commit is contained in:
Claude Code DTP Worker
2026-08-01 15:44:53 +00:00
parent 290bd2ec02
commit 28e5e2145b
3 changed files with 180 additions and 1 deletions
+99
View File
@@ -3009,6 +3009,105 @@ if cta_ev is not None:
bad(f"CRM commissions anti-invention · MANIFEST.note_taux ne mentionne plus "
f"`null` alors que tous les taux le sont → note périmée")
# ============================================================================
# CRM/Commissions · IDENTITÉ des RÔLES PAYÉS — appartenance au portail `ventes`
# + nom Frappe RÉSOLU (invariants #5/#6 du module · le « à quel rôle » du livrable).
# ----------------------------------------------------------------------------
# Le README pose la question fondatrice « quel évènement paie, à quel RÔLE, sur
# quel montant » (README:8-9) et promet en §Cross-cohérence (README:51-53) que
# chaque `role_id` commissionné « doit être résolu depuis rbac_50_roles.json …
# ET appartenir au portail `ventes` » (invariant CLI #5), son `erpnext_role_name`
# restant « cohérent avec RBAC » (invariant #6 · le « nom Frappe résolu » du
# README:36). C'est une SÉPARATION DES POUVOIRS : on ne commissionne QUE des rôles
# du portail ventes, jamais un rôle compta/construction/direction. Les blocs
# commissions ci-dessus gatent les états soumis, les champs de base et la promesse
# anti-taux — mais RIEN ne confronte cette appartenance-portail ni la résolution du
# nom Frappe à l'artefact. Dérive #6 la plus grave : (a) commissionner un rôle d'un
# AUTRE portail (payer un fiscaliste compta → la prose « portail `ventes` » devient
# un MENSONGE, pouvoirs mélangés) ; (b) un `role_id` FANTÔME absent du contrat (rôle
# inventé, l'invention même que #6 interdit) ; (c) un `erpnext_role_name` figé en dur
# qui dérive quand le contrat renomme le rôle (nom Frappe périmé → `Has Role` importé
# sur un rôle INEXISTANT côté VPS). Aucune suite tests/ (FONCTIONS de résolution/
# calcul, jamais la prose vs l'artefact) ne l'attrape ; ce n'est ni un lien, ni un
# terme interdit, ni un `out/` → invisible à check_docs/guard_constraints/artifacts.
# On RECOMPUTE depuis les artefacts byte-gatés (plan + contrat + MANIFEST · zéro dup
# du contrat du générateur #6) : chaque role_id du plan ∈ contrat, portail == un
# SINGLETON == le portail nommé au README == un portail_business, nom Frappe ==
# contrat, et MANIFEST.roles_rbac_utilises recoupe l'ensemble distinct réellement payé.
# ============================================================================
try:
cr_ev = load("crm/commissions/out/commission_plan.json")["evenements"]
_cr_rbac = load("rbac/rbac_50_roles.json")
cr_by = {r["id"]: r for r in _cr_rbac["roles"]}
cr_pbiz = set(_cr_rbac["portails_business"])
cr_man = load("crm/commissions/out/MANIFEST.json")
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"CRM commissions rôles · artefact illisible/inattendu : {e}"); cr_ev = None
if cr_ev is not None:
cr_ids = {e["role_id"] for e in cr_ev}
if not cr_ids:
bad("CRM commissions rôles · plan sans role_id → barème suspect")
else:
# (a) chaque role_id commissionné se RÉSOUT dans le contrat (jamais fantôme)
cr_phantom = sorted(i for i in cr_ids if i not in cr_by)
if cr_phantom:
bad(f"CRM commissions rôles · role_id(s) {cr_phantom} ABSENT(s) de "
f"rbac_50_roles.json → rôle INVENTÉ (invariant #5 · #6)")
else:
good(f"CRM commissions rôles · les {len(cr_ids)} role_id payés se résolvent "
f"tous dans le contrat RBAC")
# (b) nom Frappe RÉSOLU : plan.erpnext_role_name == contrat (invariant #6)
cr_badname = sorted(e["role_id"] for e in cr_ev
if e.get("erpnext_role_name")
!= cr_by[e["role_id"]]["erpnext_role_name"])
if cr_badname:
bad(f"CRM commissions rôles · erpnext_role_name du plan ≠ contrat pour "
f"{cr_badname} → nom Frappe périmé/en dur (invariant #6) → régénérer")
else:
good("CRM commissions rôles · erpnext_role_name == contrat pour chaque "
"évènement (nom Frappe résolu · invariant #6)")
# (c) appartenance-portail : SINGLETON == portail nommé au README == business
cr_ports = {cr_by[i]["portail"] for i in cr_ids}
if len(cr_ports) != 1:
bad(f"CRM commissions rôles · rôles payés répartis sur PLUSIEURS portails "
f"{sorted(cr_ports)} → séparation des pouvoirs rompue (invariant #5 : "
f"seul le portail ventes commissionne)")
else:
cr_port = next(iter(cr_ports))
if cr_port not in cr_pbiz:
bad(f"CRM commissions rôles · portail des rôles payés `{cr_port}` ∉ "
f"portails_business {sorted(cr_pbiz)} → portail fantôme")
else:
cr_rd = read_norm(CM_RD)
mrp = re.search(r"appartenir au portail `([a-z-]+)`", cr_rd or "")
if mrp is None:
bad(f"CRM commissions rôles · {CM_RD} — claim « appartenir au portail "
f"`…` » INTROUVABLE (attendu `{cr_port}`)")
elif mrp.group(1) != cr_port:
bad(f"CRM commissions rôles · {CM_RD} — dit portail `{mrp.group(1)}` "
f"MAIS les rôles payés sont du portail `{cr_port}` → régénérer le README")
else:
good(f"CRM commissions rôles · tous les rôles payés ∈ portail "
f"`{cr_port}` == README == portail_business (pouvoirs séparés)")
# (d) MANIFEST.roles_rbac_utilises recoupe l'ensemble distinct payé
cr_mru_pairs = {(r.get("role_id"), r.get("erpnext_role_name"))
for r in cr_man.get("roles_rbac_utilises", [])}
cr_ev_pairs = {(e["role_id"], cr_by[e["role_id"]]["erpnext_role_name"])
for e in cr_ev if e["role_id"] in cr_by}
if cr_mru_pairs != cr_ev_pairs:
miss = sorted(cr_ev_pairs - cr_mru_pairs)
extra = sorted(cr_mru_pairs - cr_ev_pairs)
bad(f"CRM commissions rôles · MANIFEST.roles_rbac_utilises ≠ rôles réellement "
f"payés" + (f" · absents={miss}" if miss else "")
+ (f" · en trop={extra}" if extra else "") + " → MANIFEST périmé")
elif cr_man.get("counts", {}).get("roles") != len(cr_ids):
bad(f"CRM commissions rôles · MANIFEST.counts.roles="
f"{cr_man.get('counts', {}).get('roles')} ≠ {len(cr_ids)} rôles distincts "
f"payés → MANIFEST périmé")
else:
good(f"CRM commissions rôles · MANIFEST.roles_rbac_utilises == {len(cr_ids)} "
f"rôles distincts payés (counts.roles cohérent)")
# ============================================================================
# RBAC/fixtures_gen · le CATALOGUE des DocTypes `custom` à créer sur le VPS et la
# SÉPARATION DES POUVOIRS (le rôle unique porteur de `set_user_permissions`) —