# Ticket #01 — 1/4 — Brique fondatrice du module Sites (Backend) ## 1. Objectif Ce ticket livre la couche de donnees du module optionnel Sites. Il cree le bounded context, declare le module a Symfony, enregistre ses permissions RBAC, installe la table `site` en base et seed trois etablissements de demonstration utilises par les tickets suivants. Le resultat attendu est un socle de persistance activable par tenant via `config/modules.php`, sans UI, sans API publique, sans couplage au module Core, et sur lequel les tickets 2/3/4 pourront greffer : rattachement utilisateurs, selecteur de site dans la navbar, administration CRUD. ## 2. Périmètre ### IN - Creer le module `/home/m-tristan/workspace/Coltura/src/Module/Sites/SitesModule.php` avec `ID = 'sites'`, `LABEL = 'Sites'`, `REQUIRED = false`, et une methode statique `permissions()` declarant les deux codes RBAC `sites.view` et `sites.manage`. - Creer l'entite Doctrine `Site` avec `id`, `name` (unique), `city`, `postalCode`, `color`, `fullAddress`, `createdAt`, `updatedAt` et les contraintes de validation applicatives associees (NotBlank, Length, Regex hex `#RRGGBB`, Regex CP FR `^\d{5}$`, UniqueEntity). - Creer l'interface `SiteRepositoryInterface` et son implementation Doctrine `DoctrineSiteRepository`, avec un contrat CRUD complet (`findById`, `findByName`, `findAllOrderedByName`, `save`, `remove`) en anticipation du ticket 2. - Creer une migration Doctrine creant la table `site` avec son index unique `uniq_site_name`. La migration est placee dans `/home/m-tristan/workspace/Coltura/migrations/` au namespace racine `DoctrineMigrations` conformement a l'exception documentee dans `CLAUDE.md` (bug de tri alphabetique des migrations multi-namespaces dans Doctrine Migrations 3.x). - Creer `SitesFixtures` creant trois sites de demonstration : `Chatellerault` (`#056CF2`), `Saint-Jean` (`#10B981`), `Pommevic` (`#F59E0B`). Fixtures idempotentes via lookup par nom lorsque le purger Doctrine est desactive. - Enregistrer `SitesModule::class` dans `/home/m-tristan/workspace/Coltura/config/modules.php` pour l'activer par defaut. - Declarer le mapping Doctrine du module dans `/home/m-tristan/workspace/Coltura/config/packages/doctrine.yaml` (inconditionnel, le mapping reste charge meme si le module est retire de `modules.php`). - Enregistrer l'alias service `SiteRepositoryInterface → DoctrineSiteRepository` dans `/home/m-tristan/workspace/Coltura/config/services.yaml`. - Ajouter deux suites de tests PHPUnit : - `SiteTest` (pure `TestCase`) pour le comportement de l'entite (constructeur, getters/setters, lifecycle `PreUpdate`). - `SiteValidationTest` (`KernelTestCase`) pour la validation complete : regex hex, regex CP FR, NotBlank, Length, UniqueEntity via Doctrine. ### OUT - Ticket `#02` : relation `User ↔ Site` (FK ou ManyToMany selon decision UX), expose les sites de l'utilisateur courant via `/api/me` et propage l'autorisation au niveau des ressources decoupees par site. - Ticket `#03` : integration dans la navbar Coltura (selecteur de site actif, persistance du choix cote front, consommation du flux issu du ticket 2). - Ticket `#04` : ecran d'administration CRUD des sites (page admin/sites, DataTable, drawer creation/edition, modale suppression, API Platform `Site` resource avec voters RBAC). - Gestion des soft-deletes sur `Site` : non introduite dans ce ticket. - Rattachement historique ou audit trail des modifications : hors scope. ## 3. Fichiers à créer ### Domaine — Entité - `/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Entity/Site.php` : entite Doctrine porteuse des attributs metier (nom unique, ville, code postal FR, couleur hex, adresse complete multi-ligne) et des timestamps auto-maintenus via lifecycle callbacks. ### Domaine — Repository - `/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Repository/SiteRepositoryInterface.php` : contrat d'acces domaine a l'entite Site (CRUD applicatif ; l'acces API Platform du ticket 4 utilisera le provider Doctrine par defaut). ### Infrastructure — Doctrine - `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php` : implementation Doctrine de `SiteRepositoryInterface` basee sur `ServiceEntityRepository`. ### Infrastructure — Migration - `/home/m-tristan/workspace/Coltura/migrations/Version.php` : migration racine (namespace `DoctrineMigrations`) qui cree la table `site` et son index unique. Emplacement racine et non modulaire, cf. exception documentee dans `CLAUDE.md` (bug Doctrine 3.x sur le tri alphabetique des migrations multi-namespaces). ### Infrastructure — DataFixtures - `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php` : fixture Doctrine seedant les 3 sites de demonstration. Ne declare pas de `DependentFixtureInterface` (aucune dependance a AppFixtures dans ce ticket). ### Module — Declaration - `/home/m-tristan/workspace/Coltura/src/Module/Sites/SitesModule.php` : marker class du module avec `ID`, `LABEL`, `REQUIRED` et `permissions()`. Meme pattern que `CoreModule`. ### Tests - `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Domain/Entity/SiteTest.php` : tests unitaires purs (`TestCase`) couvrant constructeur, getters, setters et lifecycle `PreUpdate`. - `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Domain/Entity/SiteValidationTest.php` : tests de validation (`KernelTestCase`) couvrant regex hex, regex CP FR, NotBlank, Length sur tous les champs, et `UniqueEntity` via la DB de test. ## 4. Fichiers à modifier - `/home/m-tristan/workspace/Coltura/config/modules.php` : ajouter `App\Module\Sites\SitesModule::class` dans le tableau de retour. Le module est actif par defaut. Le commenter suffit a le desactiver sans autre intervention (les permissions deviendront orphelines a la prochaine sync mais la table reste). - `/home/m-tristan/workspace/Coltura/config/packages/doctrine.yaml` : ajouter une mapping `Sites:` alignee sur le pattern du module `Core:`. Le mapping est inconditionnel : il reste declare meme si `SitesModule::class` est retire de `modules.php`. Le commentaire doit etre explicite sur cette decoupe (activation fonctionnelle via `modules.php`, structure DB via la mapping Doctrine). - `/home/m-tristan/workspace/Coltura/config/services.yaml` : ajouter l'alias `App\Module\Sites\Domain\Repository\SiteRepositoryInterface` → `App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository`. Pattern aligne sur les trois aliases Core existants. ## 5. Schéma cible — mapping Doctrine Comme pour le ticket RBAC (ticket-343), le schema est decrit par les attributs Doctrine plutot que par le SQL brut. Le fichier de migration contient le SQL final (section 6). ### Conventions respectées - `declare(strict_types=1)` en tete de tous les fichiers PHP. - Identifiants de classe et proprietes en anglais, commentaires en francais (cf. `CLAUDE.md`). - PostgreSQL : noms de colonnes en snake_case minuscules, Doctrine les deduit des proprietes camelCase (`postalCode` → `postal_code`, `fullAddress` → `full_address`, `createdAt` → `created_at`, `updatedAt` → `updated_at`). - Le nom de table `site` n'est pas un mot reserve PostgreSQL : pas de backtick necessaire. ### Entité `Site` ```php #[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)] #[ORM\Table(name: 'site')] #[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])] #[ORM\HasLifecycleCallbacks] #[UniqueEntity(fields: ['name'], message: 'Un site avec ce nom existe deja.')] class Site { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 100)] #[Assert\NotBlank(message: 'Le nom du site est requis.')] #[Assert\Length(max: 100, ...)] private string $name; #[ORM\Column(length: 100)] #[Assert\NotBlank(message: 'La ville du site est requise.')] #[Assert\Length(max: 100, ...)] private string $city; #[ORM\Column(name: 'postal_code', length: 10)] #[Assert\NotBlank(message: 'Le code postal est requis.')] #[Assert\Length(max: 10, ...)] #[Assert\Regex(pattern: '/^\d{5}$/', message: '...')] private string $postalCode; #[ORM\Column(length: 7)] #[Assert\NotBlank(message: 'La couleur est requise.')] #[Assert\Regex(pattern: '/^#[0-9A-Fa-f]{6}$/', message: '...')] private string $color; #[ORM\Column(name: 'full_address', type: Types::TEXT)] #[Assert\NotBlank(message: 'L\'adresse complete est requise.')] #[Assert\Length(max: 500, ...)] private string $fullAddress; #[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)] private DateTimeImmutable $createdAt; #[ORM\Column(name: 'updated_at', type: Types::DATETIME_IMMUTABLE)] private DateTimeImmutable $updatedAt; } ``` Contraintes fonctionnelles : - `name` est unique en base (`uniq_site_name`) et porte egalement la contrainte applicative `UniqueEntity` pour que le validator remonte une violation lisible avant d'atteindre la violation DB. - `color` est contraint par regex a un code hex strict de 7 caracteres `#RRGGBB`, majuscules ou minuscules. La colonne `VARCHAR(7)` est dimensionnee au plus juste car la regex est exhaustive. - `postalCode` est contraint a 5 chiffres exacts via regex (format FR). La colonne `VARCHAR(10)` est volontairement plus large que la regex pour laisser marge si le projet etend plus tard la regex a d'autres formats (UK, PT, ...). Choix assume : evite une migration DDL au ticket suivant, cout DB negligeable sur un champ court. - `fullAddress` est de type `TEXT` (PostgreSQL) pour permettre une adresse multi-ligne, mais borne cote applicatif a 500 caracteres via `Assert\Length(max: 500)` comme garde DoS basique (une adresse FR complete tient largement dans cette enveloppe). - `createdAt` est seede dans le constructeur et **ne change plus jamais** apres persistance. - `updatedAt` est seede dans le constructeur a la meme valeur que `createdAt`, puis refresh a chaque update via le callback `#[ORM\PreUpdate]`. ### Mapping Doctrine — `doctrine.yaml` ```yaml # Mapping inconditionnelle du module Sites : la structure DB existe meme # si SitesModule::class est retire de config/modules.php. L'activation # fonctionnelle (ex: exposition des permissions, futurs endpoints API) # passe exclusivement par config/modules.php. Sites: type: attribute is_bundle: false dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity' prefix: 'App\Module\Sites\Domain\Entity' alias: Sites ``` ## 6. Plan de migration Doctrine La migration est placee dans `/home/m-tristan/workspace/Coltura/migrations/Version.php` au namespace racine `DoctrineMigrations`, conformement a l'exception documentee dans `CLAUDE.md`. Tant que le bug de tri alphabetique des `MigrationsComparator` multi-namespaces n'est pas resolu (via un comparator custom ou un upgrade Doctrine), toute migration d'initialisation (creation de table sur base vide) reste au namespace racine. ### `up()` — ordre des instructions 1. Creer la table `site` avec toutes les colonnes NOT NULL : - `id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL` - `name VARCHAR(100) NOT NULL` - `city VARCHAR(100) NOT NULL` - `postal_code VARCHAR(10) NOT NULL` - `color VARCHAR(7) NOT NULL` - `full_address TEXT NOT NULL` - `created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL` - `updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL` - `PRIMARY KEY (id)` 2. Creer l'index unique `uniq_site_name` sur `site(name)` pour garantir l'invariant metier "un site porte un nom unique" au niveau DB. Le validator applicatif `UniqueEntity` s'appuie dessus en lecture avant qu'une tentative d'insertion concurrente ne remonte la violation DB. ### `down()` — rollback 1. `DROP TABLE site`. Aucune FK n'existe depuis ou vers cette table dans ce ticket ; le rollback est donc trivial et safe. ### Precision timestamp PostgreSQL `TIMESTAMP(0) WITHOUT TIME ZONE` stocke a la seconde pres. Les DateTimeImmutable PHP portent une precision microseconde mais perdent cette precision au round-trip DB. Les tests unitaires de lifecycle doivent en tenir compte (cf. section 10 — usage de reflection plutot qu'un `sleep`). ## 7. Intégration avec sync-permissions Le ticket ne modifie pas `SyncPermissionsCommand`. Il exploite l'algorithme existant (cf. ticket-343 section 7) en declarant `SitesModule::permissions()` dans un format strictement conforme au contrat attendu par la commande : ```php public static function permissions(): array { return [ ['code' => 'sites.view', 'label' => 'Voir les sites'], ['code' => 'sites.manage', 'label' => 'Gerer les sites (creer, editer, supprimer)'], ]; } ``` Regles de validation appliquees par `SyncPermissionsCommand` : - Chaque entree doit contenir exactement les cles `code` et `label`. - Le prefixe du code doit correspondre a `SitesModule::ID . '.'`, soit `sites.`. - Ni `code` ni `label` ne peuvent etre une chaine vide. Comportement a attendre : - Apres `php bin/console app:sync-permissions`, les deux lignes `sites.view` et `sites.manage` sont presentes dans la table `permission` avec `module = 'sites'` et `orphan = false`. - Si `SitesModule::class` est retire de `config/modules.php` et la commande relancee, les deux lignes sont marquees `orphan = true` (non supprimees, pour preserver les assignations). Reactiver le module les remet a `orphan = false`. - La cle `module` n'est **pas** presente dans le payload : elle est auto-injectee par la commande depuis `SitesModule::ID`. ### Granularité des permissions `sites.manage` est une permission **composite** couvrant creation, edition et suppression. Ce choix reste simple pour un ticket fondateur, mais le ticket 4 (administration CRUD) devra arbitrer si une granularite plus fine (`sites.create`, `sites.edit`, `sites.delete`) est necessaire pour les besoins UX. Si oui, la migration de permissions se fera naturellement via la commande de sync : ajouter les trois codes dans `permissions()`, retirer `sites.manage` → la sync marque l'ancien orphelin sans casser les roles deja existants. ## 8. Méthodes clés détaillées ### `Site::__construct` Le constructeur prend les cinq champs metier obligatoires et positionne les deux timestamps a la meme valeur : ```php public function __construct( string $name, string $city, string $postalCode, string $color, string $fullAddress, ) { $this->name = $name; $this->city = $city; $this->postalCode = $postalCode; $this->color = $color; $this->fullAddress = $fullAddress; $now = new DateTimeImmutable(); $this->createdAt = $now; $this->updatedAt = $now; } ``` Justification : - Tous les champs sont passes au constructeur pour forcer l'invariant "un Site instancie est toujours complet". L'alternative (setters post-new) autoriserait des etats transitoires invalides. - `createdAt` et `updatedAt` partagent la meme valeur a l'instanciation, ce qui garantit `updated_at >= created_at` au niveau base. Le premier appel a `onPreUpdate()` fera avancer uniquement `updatedAt`. ### `Site::onPreUpdate` ```php #[ORM\PreUpdate] public function onPreUpdate(): void { $this->updatedAt = new DateTimeImmutable(); } ``` Justification : - Callback Doctrine declenche **uniquement** quand Doctrine detecte au moins un changement sur l'entite en session de persistance. Pas de risque de tick silencieux sur un find pur. - `createdAt` n'est volontairement jamais touche ici : il est immuable apres persistance. - Pas de `#[ORM\PrePersist]` : le constructeur gere deja l'initialisation, inutile de dupliquer la logique dans un callback qui pourrait etre appele a vide. ### `SitesFixtures::ensureSite` ```php private function ensureSite( ObjectManager $manager, string $name, string $city, string $postalCode, string $color, string $fullAddress, ): Site { $site = $this->siteRepository->findByName($name); if (null === $site) { $site = new Site($name, $city, $postalCode, $color, $fullAddress); $manager->persist($site); return $site; } $site->setCity($city); $site->setPostalCode($postalCode); $site->setColor($color); $site->setFullAddress($fullAddress); return $site; } ``` Contrat honnete sur l'idempotence (cf. docblock en tete de fixture) : - **Supporte** : lookup par nom avec purger Doctrine actif (cas nominal de `doctrine:fixtures:load`). - **Supporte** : lookup par nom hors purger si la fixture est rejouee telle quelle sur une base deja seedee → les autres champs sont re-alignes sur les valeurs de reference. - **Non supporte** : chargement cumulatif apres qu'une autre fixture ait `persist` (sans `flush`) des Site dans la meme session → `findByName` via `findOneBy` n'inspecte pas l'unit-of-work et peut creer un doublon. - **Non supporte** : renommage d'un site dans la fixture → le lookup par `name` rate, un nouveau site est cree, l'ancien reste en base si le purger est desactive. ## 9. Fixtures Sites Trois sites de demonstration, avec des couleurs distinctes suffisamment contrastees pour un futur affichage visuel (ticket 3 — navbar) : | Nom | Ville | CP | Couleur | Commentaire | |-----|-------|-----|---------|-------------| | Chatellerault | Chatellerault | 86100 | `#056CF2` | Couleur imposee par le ticket (bleu Coltura). | | Saint-Jean | Saint-Jean-de-Sauves | 86330 | `#10B981` | Vert emeraude (contraste avec le bleu). | | Pommevic | Pommevic | 82400 | `#F59E0B` | Ambre (troisieme teinte nettement distincte). | Les adresses completes sont des chaines multi-lignes (voie + CP/ville), cas nominal d'exploitation du type `TEXT` sur `full_address`. ### Ordre d'execution global des fixtures `SitesFixtures` est une `Fixture` sans dependance : elle peut s'executer dans n'importe quel ordre relatif aux autres fixtures Core (`AppFixtures`). Aucune FK inter-modules dans ce ticket. Le ticket 2 introduira probablement une relation `User ↔ Site` ; `SitesFixtures` devra alors etre declare comme dependance de `AppFixtures` (ou inversement, selon la direction de la FK) via `DependentFixtureInterface::getDependencies()`. ## 10. Plan de tests PHPUnit Deux suites separees, motivation : - `SiteTest` reste en `TestCase` pur (pas de kernel) pour tester le comportement mecanique de l'entite — rapide, zero dependance DB. - `SiteValidationTest` utilise `KernelTestCase` pour avoir acces au validator applicatif, **indispensable** pour tester `UniqueEntity` dont le validator est backed par Doctrine et necessite donc un `ManagerRegistry` reel. ### `SiteTest` — tests unitaires purs 1. `testConstructorInitialState` : verifie que le constructeur positionne correctement les 5 champs metier et les deux timestamps (`DateTimeImmutable`). 2. `testCreatedAtAndUpdatedAtAreInitiallyEqual` : verifie l'invariant "a l'instanciation, `createdAt == updatedAt`". 3. `testOnPreUpdateAdvancesUpdatedAtOnly` : utilise `Reflection` pour forcer `updatedAt` a une valeur anterieure (`-1 hour`), appelle `onPreUpdate()`, et verifie que `updatedAt` avance strictement mais que `createdAt` reste immuable. - **Justification reflection** : eviter un `sleep/usleep` flaky en CI et lent. 4. `testSettersMutateFields` : verifie que les setters publics modifient correctement les champs metier. ### `SiteValidationTest` — tests d'integration validator Bootstrap : `self::bootKernel()` dans `setUp()`, recuperation de `ValidatorInterface` et `EntityManagerInterface` depuis le container. Tests de validation scalaire (via `DataProvider` PHPUnit 12+, attribut `#[DataProvider]`) : 1. `testValidSitePassesValidation` : un Site correct passe sans violation. 2. `testColorMustBeHexRrggbb` / `testValidColorsAreAccepted` : jeu de donnees invalide (`red`, `#FFF`, `FFFFFF`, `rgb(...)`, `#1234567`, `#12345G`, `""`) vs valide (`#ABCDEF`, `#abcdef`, `#0a1B2c`, `#000000`, `#FFFFFF`). 3. `testPostalCodeMustMatchFrFormat` / `testValidPostalCodesAreAccepted` : jeu de donnees invalide (`1234`, `123456`, `8610A`, `86-100`, `""`, `86 100`) vs valide (`86100`, `75001`, `97100`, `20000`). 4. `testBlankNameIsRejected`, `testBlankCityIsRejected`, `testBlankFullAddressIsRejected` : `NotBlank` sur chaque champ obligatoire. 5. `testNameLongerThan100CharsIsRejected`, `testCityLongerThan100CharsIsRejected` : `Length(max: 100)`. Test d'unicite : 6. `testDuplicateNameIsRejected` : **auto-suffisant** — persiste lui-meme un site porteur d'un nom unique (`Test-Duplicate-`), flush, tente de valider un second Site avec le meme nom, verifie qu'au moins une violation porte `UniqueEntity::NOT_UNIQUE_ERROR` sur la property `name`, puis supprime le site en `finally`. - **Justification** : pas de dependance aux fixtures (robustesse, pas de couplage sur `Chatellerault`). Assertion precise sur le `code` de violation + `propertyPath`, pas sur le message (resistant aux traductions). ### Pattern `finally` pour cleanup ```php try { $duplicate = new Site($name, ...); $violations = $this->validator->validate($duplicate); // assertions... } finally { $this->em->remove($original); $this->em->flush(); } ``` Garantit le cleanup meme si une assertion rate, sans dependre d'une transaction globale de test. ## 11. Risques et points d'attention ### Risque 1 — Mapping Doctrine inconditionnel Le mapping `Sites:` est declare dans `doctrine.yaml` sans dependance a `config/modules.php`. Consequence : retirer `SitesModule::class` de `modules.php` ne desactive **pas** le mapping Doctrine ni la table `site`. Decision assumee et alignee avec le traitement du module `Core` : - La structure DB est "toujours la" (migrations jouees inconditionnellement). - L'activation fonctionnelle (exposition des permissions, futurs endpoints) passe exclusivement par `modules.php`. Cela doit etre **explicite dans `doctrine.yaml`** via un commentaire en tete du bloc `Sites:` pour eviter qu'un futur reviewer n'interprete le mapping comme un oubli. ### Risque 2 — Migration racine vs migration modulaire La migration est placee dans `migrations/` et non dans `src/Module/Sites/Infrastructure/Doctrine/Migrations/`. C'est une exception documentee dans `CLAUDE.md` et dans le docblock de la migration elle-meme, motivee par un bug de tri alphabetique des `MigrationsComparator` en Doctrine Migrations 3.x lorsque plusieurs `migrations_paths` sont declares. Consequence pour les tickets futurs : - Tant que le bug n'est pas resolu, **toute nouvelle migration d'initialisation** (creation de table sur base vide) continuera d'aller au namespace racine. - Les migrations applicatives (ajout de colonne, backfill) qui supposent un schema deja en place peuvent vivre dans le namespace modulaire, comme prevu. - Une fois le bug resolu (comparator custom ou upgrade Doctrine), migrer les fichiers vers le namespace modulaire sera un simple `git mv` + ajustement du namespace PHP. ### Risque 3 — Idempotence des fixtures non cumulative Le docblock de `SitesFixtures` declare explicitement les cas d'idempotence supportes et non supportes (cf. section 8). Ne pas promettre une robustesse que le pattern ne tient pas : si un futur ticket introduit une fixture persistant des Site **avant** `SitesFixtures` sans flush intermediaire, un doublon peut apparaitre. Le contrat ecrit permet au reviewer de ce futur ticket de reagir. ### Risque 4 — Regex couleur non normalisee La regex `/^#[0-9A-Fa-f]{6}$/` accepte majuscules et minuscules. Les fixtures utilisent des majuscules ; si l'UI du ticket 4 permet de saisir en minuscules, deux couleurs "visuellement identiques" pourront coexister en base avec casse differente, cassant toute comparaison naive (`$a->color === $b->color`). A decider au ticket 4 : normaliser en uppercase a la persistance, ou assumer le stockage tel quel et normaliser uniquement a la comparaison. ### Risque 5 — Precision timestamp PostgreSQL TIMESTAMP(0) PostgreSQL `TIMESTAMP(0)` ecrete a la seconde pres. Deux updates espaces de moins d'une seconde produisent le meme `updated_at` en base. Pas un probleme pour les cas d'usage metier de ce ticket (edition manuelle), mais a garder en tete si un ticket futur introduit un `updatedAt` comme cle de tri ou de detection de version optimiste. ## 12. Ordre d'exécution recommandé 1. **Exploration** — Lire le module Core (`CoreModule.php`, `User.php`, `Role.php`) pour aligner le style. 2. **Module declaration** — Creer `SitesModule.php` avec `permissions()`. 3. **Entite** — Creer `Site.php` avec tous les attributs Doctrine et contraintes de validation. 4. **Repository** — Creer `SiteRepositoryInterface.php` puis `DoctrineSiteRepository.php`. 5. **Configuration** — Enregistrer le mapping dans `doctrine.yaml`, l'alias dans `services.yaml`, le module dans `modules.php`. 6. **Migration** — Generer le fichier de migration (manuellement ou via `doctrine:migrations:diff` puis ajuster), jouer `make migration-migrate`. 7. **Fixtures** — Creer `SitesFixtures.php`, jouer `make fixtures` puis `make sync-permissions`. 8. **Tests unitaires** — Ecrire `SiteTest.php` (TestCase pur). 9. **Tests validation** — Ecrire `SiteValidationTest.php` (KernelTestCase). 10. **Validation DoD** — `make test-db-setup && make test` (doit passer 148/148), verifier que designer SitesModule ne casse rien. 11. **CS fixer** — `make php-cs-fixer-allow-risky FILES="src/Module/Sites tests/Module/Sites migrations/Version.php config/..."`. ## 13. Critères d'acceptation (DoD) - [ ] `SitesModule.php` existe et declare exactement 2 permissions (`sites.view`, `sites.manage`) prefixees `sites.` conformement au format attendu par `SyncPermissionsCommand`. - [ ] `SitesModule::class` est enregistre dans `config/modules.php` et active par defaut. - [ ] Entite `Site` creee avec tous les champs, contraintes de validation (`NotBlank`, `Length`, `Regex hex`, `Regex CP FR`, `UniqueEntity`) et timestamps auto. - [ ] `SiteRepositoryInterface` expose au minimum `findById`, `findByName`, `findAllOrderedByName`, `save`, `remove` ; `DoctrineSiteRepository` l'implemente. - [ ] La migration existe dans `migrations/` (namespace `DoctrineMigrations`), cree la table `site` et l'index unique `uniq_site_name`, est jouable via `make migration-migrate`. - [ ] `SitesFixtures` cree les 3 sites avec couleurs distinctes et docblock honnete sur son idempotence. - [ ] `make fixtures` charge les 3 sites sans erreur et est rejouable apres purge. - [ ] Apres `app:sync-permissions`, la table `permission` contient `sites.view` et `sites.manage` avec `module = 'sites'` et `orphan = false`. - [ ] Le mapping `Sites:` est declare dans `doctrine.yaml` avec un commentaire explicite sur son caractere inconditionnel. - [ ] L'alias `SiteRepositoryInterface → DoctrineSiteRepository` est declare dans `services.yaml`. - [ ] `make test` passe 148/148 tests avec `SitesModule::class` active. - [ ] `make test` passe 148/148 tests avec `SitesModule::class` commente dans `config/modules.php`. - [ ] `make php-cs-fixer-allow-risky` ne signale aucune correction sur les fichiers du ticket. - [ ] Aucun import direct depuis `src/Module/Core/...` vers `src/Module/Sites/...` ni l'inverse (independance des bounded contexts).