From 052a39092bfb9e2379fb5e49b1b5da71f595c046 Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Wed, 3 Jun 2026 09:09:37 +0000 Subject: [PATCH] =?UTF-8?q?fix(audit)=20:=20libell=C3=A9s=20i18n=20des=20t?= =?UTF-8?q?ypes=20d'entit=C3=A9=20+=20garde-fou=20(ERP-99)=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Contexte Le filtre « Type d'entité » de l'audit-log est dynamique (`GET /audit-log-entity-types`). Toute entité `#[Auditable]` dont la clé i18n manquait s'affichait en **type technique brut** (ex: `commercial.Client`), le rendu retombant **silencieusement** sur le fallback. ## Décisions (cœur du ticket ERP-99) - **Schéma de clé** : flat `audit.entity._` (inchangé, zéro régression). - **Emplacement** : centralisé dans `frontend/i18n/locales/fr.json` (migration per-module = ticket infra i18n dédié). - **Source de vérité** : `entity_type` = `strtolower(module).Entity` (confirmé dans `AuditListener::formatEntityType`). ## Changements - **Complétude** : ajout des clés `audit.entity.*` manquantes (catalog + commercial) → 9 entités `#[Auditable]` couvertes. - **Convention** : `.claude/rules/backend.md` § Audit — ajouter sa clé de libellé audit fait partie de la définition de fini d'une entité auditée. - **Garde-fou** : `tests/Architecture/AuditableEntitiesHaveI18nLabelTest` scanne les entités `#[Auditable]` et échoue si une clé `audit.entity.*` manque ou est vide (rend le manque bloquant en CI). ## Vérifications - Suite PHPUnit complète : **465 tests OK** (1604 assertions). - Garde-fou : vert (9 entités) + test négatif confirmé rouge (clé retirée → échec actionnable). - JSON `fr.json` valide, php-cs-fixer OK. --------- Co-authored-by: admin malio Co-authored-by: Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/48 Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- .claude/rules/backend.md | 18 ++ frontend/i18n/locales/fr.json | 13 +- .../AuditableEntitiesHaveI18nLabelTest.php | 167 ++++++++++++++++++ 3 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 tests/Architecture/AuditableEntitiesHaveI18nLabelTest.php diff --git a/.claude/rules/backend.md b/.claude/rules/backend.md index 4079df4..758731f 100644 --- a/.claude/rules/backend.md +++ b/.claude/rules/backend.md @@ -98,6 +98,24 @@ Format obligatoire : `module.resource[.subresource].action` en snake_case. - Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire - Spec complete : @doc/audit-log.md +### Libelle i18n du type d'entite (obligatoire avec `#[Auditable]`) + +**Toute entite `#[Auditable]` doit avoir son libelle FR dans le bloc `audit.entity` de `frontend/i18n/locales/fr.json`.** C'est la contrepartie i18n de l'attribut : sans elle, le filtre « Type d'entite » de l'audit-log affiche le type technique brut (ex: `commercial.Client`) au lieu d'un libelle lisible. + +Pourquoi : le filtre est dynamique (`GET /audit-log-entity-types` renvoie les `entity_type` distincts presents en base) ; des qu'un module audite une entite, son type y apparait. Le front (`formatEntityType`, `audit-log.vue`) construit la cle `audit.entity._` et, faute de traduction, **retombe silencieusement** sur le type brut. + +Derivation de la cle (emplacement centralise + schema flat — decision ERP-99) : + +| FQCN entite | `entity_type` (back) | Cle i18n (flat) | +|---|---|---| +| `App\Module\Commercial\Domain\Entity\Client` | `commercial.Client` | `commercial_client` | +| `App\Module\Commercial\Domain\Entity\ClientAddress` | `commercial.ClientAddress` | `commercial_clientaddress` | +| `App\Module\Catalog\Domain\Entity\Category` | `catalog.Category` | `catalog_category` | + +Regle : `strtolower(module)` + `_` + `strtolower(Entity)`. Ajouter sa cle de libelle audit fait partie de la **definition de fini** d'une entite metier auditee. + +**Garde-fou** : `tests/Architecture/AuditableEntitiesHaveI18nLabelTest` scanne les entites `#[Auditable]` et echoue si une seule n'a pas sa cle `audit.entity.*`. Conclusion : creer une entite `#[Auditable]` sans son libelle i18n casse `make test`. + ## Timestampable + Blamable (obligatoire pour entites metier) Toute **nouvelle** entite metier sous `src/Module/*/Domain/Entity/` doit porter les 4 colonnes `created_at` / `updated_at` / `created_by` / `updated_by`, remplies automatiquement. Trois lignes a ajouter a l'entite : diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index c21d4ab..8a2b0b0 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -222,10 +222,15 @@ "delete": "Suppression" }, "entity": { - "core_user": "Utilisateur", - "core_role": "Rôle", - "core_permission": "Permission", - "sites_site": "Site" + "core_user": "Utilisateur", + "core_role": "Rôle", + "core_permission": "Permission", + "sites_site": "Site", + "catalog_category": "Catégorie", + "commercial_client": "Client", + "commercial_clientaddress": "Adresse client", + "commercial_clientcontact": "Contact client", + "commercial_clientrib": "RIB client" }, "empty": "Aucune activité enregistrée", "no_results": "Aucun résultat pour ces filtres", diff --git a/tests/Architecture/AuditableEntitiesHaveI18nLabelTest.php b/tests/Architecture/AuditableEntitiesHaveI18nLabelTest.php new file mode 100644 index 0000000..98732da --- /dev/null +++ b/tests/Architecture/AuditableEntitiesHaveI18nLabelTest.php @@ -0,0 +1,167 @@ +_` et, faute de traduction, retombe + * SILENCIEUSEMENT sur le type technique brut (ex: `commercial.Client`). Le + * manque passe donc inapercu jusqu'a observation dans l'UI. + * + * Ce test rend le manque BLOQUANT (meme esprit que ColumnsHaveSqlCommentTest) : + * il scanne les entites `#[Auditable]` sous `src/Module//Domain/Entity/`, + * derive la cle attendue comme le fait le front, et echoue si elle est absente + * du `fr.json`. + * + * Derivation de la cle (miroir exact de AuditListener::formatEntityType + de + * formatEntityType cote front) : + * FQCN `App\Module\Commercial\Domain\Entity\ClientAddress` + * -> entity_type `commercial.ClientAddress` (module en minuscules, Entity intacte) + * -> cle i18n `commercial_clientaddress` (tout en minuscules, `.` -> `_`) + * + * @internal + */ +final class AuditableEntitiesHaveI18nLabelTest extends TestCase +{ + /** + * Chemin du fichier de traductions FR du shell. Source unique des libelles + * d'entite audit (decision ERP-99 : emplacement centralise, schema flat). + */ + private const LOCALE_FILE = __DIR__.'/../../frontend/i18n/locales/fr.json'; + + public function testEveryAuditableEntityHasAnI18nLabel(): void + { + $labels = $this->loadAuditEntityLabels(); + + $finder = new Finder() + ->files() + ->in(__DIR__.'/../../src/Module') + ->path('Domain/Entity') + ->name('*.php') + ; + + // Garde : si le scan ne trouve rien, le chemin est casse — le test + // deviendrait un faux positif vert. On verifie qu'il a du grain a moudre. + self::assertNotEmpty(iterator_to_array($finder), 'Aucune entite scannee : chemin src/Module invalide ?'); + + $checked = 0; + foreach ($finder as $file) { + $fqcn = $this->extractFqcn($file->getRealPath()); + if (null === $fqcn) { + continue; + } + + $reflection = new ReflectionClass($fqcn); + // On ne s'interesse qu'aux entites reellement auditees. + if ($reflection->isAbstract() || [] === $reflection->getAttributes(Auditable::class)) { + continue; + } + + $key = $this->deriveI18nKey($fqcn); + self::assertNotNull( + $key, + sprintf('Entite %s hors structure modulaire attendue (App\Module\\Domain\Entity\).', $fqcn), + ); + + self::assertArrayHasKey( + $key, + $labels, + sprintf( + 'L\'entite auditable %s n\'a pas de libelle i18n. Ajouter "%s" dans le bloc ' + .'`audit.entity` de frontend/i18n/locales/fr.json (sinon le filtre audit-log ' + .'affiche le type technique brut). Cf. ERP-99 + .claude/rules/backend.md § Audit.', + $fqcn, + $key, + ), + ); + self::assertNotSame('', trim($labels[$key]), sprintf('Le libelle audit "%s" est vide.', $key)); + + ++$checked; + } + + // Garde : au moins une entite auditable doit avoir ete verifiee, sinon + // la detection de l'attribut est cassee (faux positif vert). + self::assertGreaterThan(0, $checked, 'Aucune entite #[Auditable] detectee : detection d\'attribut cassee ?'); + } + + /** + * Charge le bloc `audit.entity` du fr.json sous forme de map cle -> libelle. + * + * @return array + */ + private function loadAuditEntityLabels(): array + { + $raw = file_get_contents(self::LOCALE_FILE); + self::assertIsString($raw, sprintf('Fichier de locale introuvable : %s', self::LOCALE_FILE)); + + /** @var array $json */ + $json = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); + + $entity = $json['audit']['entity'] ?? null; + self::assertIsArray($entity, 'Bloc `audit.entity` absent ou invalide dans fr.json.'); + + $labels = []; + foreach ($entity as $key => $value) { + if (is_string($key) && is_string($value)) { + $labels[$key] = $value; + } + } + + return $labels; + } + + /** + * Derive la cle i18n `_` depuis le FQCN, en miroir de + * AuditListener::formatEntityType (module en minuscules) suivi de + * l'aplatissement front (tout en minuscules, `.` -> `_`). + * + * Retourne null si le FQCN ne respecte pas la structure modulaire. + */ + private function deriveI18nKey(string $fqcn): ?string + { + if (1 !== preg_match('#^App\\\Module\\\(?[^\\\]+)\\\.+\\\(?[^\\\]+)$#', $fqcn, $m)) { + return null; + } + + return strtolower($m['module']).'_'.strtolower($m['entity']); + } + + /** + * Extrait le FQCN (namespace + classe) d'un fichier PHP par lecture du + * source, sans charger le fichier. + */ + private function extractFqcn(string $path): ?string + { + $source = file_get_contents($path); + if (false === $source) { + return null; + } + + if ( + 1 !== preg_match('/^namespace\s+([^;]+);/m', $source, $nsMatch) + || 1 !== preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $source, $classMatch) + ) { + return null; + } + + return trim($nsMatch[1]).'\\'.$classMatch[1]; + } +}