Files
Coltura/docs/sites/ticket-01-spec.md
tristan 6cf5ef4cfc
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Module sites (#8)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #8
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-20 15:31:58 +00:00

27 KiB

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<timestamp>.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\SiteRepositoryInterfaceApp\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 (postalCodepostal_code, fullAddressfull_address, createdAtcreated_at, updatedAtupdated_at).
  • Le nom de table site n'est pas un mot reserve PostgreSQL : pas de backtick necessaire.

Entité Site

#[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

# 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<timestamp>.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 :

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 :

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

#[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

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-<uniqid>), 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

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 DoDmake test-db-setup && make test (doit passer 148/148), verifier que designer SitesModule ne casse rien.
  11. CS fixermake php-cs-fixer-allow-risky FILES="src/Module/Sites tests/Module/Sites migrations/Version<timestamp>.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).