fix(audit) : libellés i18n des types d'entité + garde-fou (ERP-99) (#48)
Auto Tag Develop / tag (push) Successful in 8s
Auto Tag Develop / tag (push) Successful in 8s
## 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.<module>_<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 <malio@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #48 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #48.
This commit is contained in:
@@ -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
|
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire
|
||||||
- Spec complete : @doc/audit-log.md
|
- 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.<module>_<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)
|
## 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 :
|
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 :
|
||||||
|
|||||||
@@ -225,7 +225,12 @@
|
|||||||
"core_user": "Utilisateur",
|
"core_user": "Utilisateur",
|
||||||
"core_role": "Rôle",
|
"core_role": "Rôle",
|
||||||
"core_permission": "Permission",
|
"core_permission": "Permission",
|
||||||
"sites_site": "Site"
|
"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",
|
"empty": "Aucune activité enregistrée",
|
||||||
"no_results": "Aucun résultat pour ces filtres",
|
"no_results": "Aucun résultat pour ces filtres",
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Architecture;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
use Symfony\Component\Finder\Finder;
|
||||||
|
|
||||||
|
use function is_string;
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
use const JSON_THROW_ON_ERROR;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garde-fou architecture : toute entite `#[Auditable]` doit avoir son libelle
|
||||||
|
* i18n dans le bloc `audit.entity` du `fr.json` du shell.
|
||||||
|
*
|
||||||
|
* Pourquoi : le filtre « Type d'entite » de l'audit-log est dynamique
|
||||||
|
* (`GET /audit-log-entity-types` renvoie les `entity_type` distincts presents
|
||||||
|
* en base). Des qu'un module audite une entite, un nouveau type apparait. Le
|
||||||
|
* rendu front (`formatEntityType`, audit-log.vue) construit la cle
|
||||||
|
* `audit.entity.<module>_<entity>` 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/<m>/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\<M>\Domain\Entity\<E>).', $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<string, string>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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 `<module>_<entity>` 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\\\(?<module>[^\\\]+)\\\.+\\\(?<entity>[^\\\]+)$#', $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];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user