feat(sites) : brique fondatrice du module Sites (ticket 1/4)

Module Sites optionnel et desactivable via config/modules.php.
Entite Site (nom unique, ville, CP FR, couleur hex, adresse),
repository + impl Doctrine, migration racine (namespace DoctrineMigrations
conforme exception CLAUDE.md), fixtures idempotentes (Chatellerault,
Saint-Jean, Pommevic), permissions RBAC sites.view/sites.manage.
Tests unitaires + validation via KernelTestCase (UniqueEntity, regex
hex et CP, NotBlank, Length).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 15:45:33 +02:00
parent 6b4868b261
commit 8590e3e850
11 changed files with 829 additions and 0 deletions

View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace App\Module\Sites\Domain\Entity;
use App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Site physique (usine / etablissement) appartenant a l'instance Coltura.
*
* Brique fondatrice du module Sites : cette entite n'est pas exposee par
* une ressource API Platform dans ce ticket (ticket 1/4). Elle sert de socle
* de donnees aux tickets suivants (rattachement utilisateurs, affichage
* navbar, etc.). Aucune dependance dure depuis le module Core : la table
* est creee meme si le module est desactive (voir migration dediee).
*/
#[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, maxMessage: 'Le nom du site ne peut pas depasser {{ limit }} caracteres.')]
private string $name;
#[ORM\Column(length: 100)]
#[Assert\NotBlank(message: 'La ville du site est requise.')]
#[Assert\Length(max: 100, maxMessage: 'La ville ne peut pas depasser {{ limit }} caracteres.')]
private string $city;
// Colonne mappee sur le snake_case PostgreSQL (convention projet : noms de
// colonnes en minuscules dans le SQL brut). Le format est contraint au
// code postal francais strict : 5 chiffres numeriques.
#[ORM\Column(name: 'postal_code', length: 10)]
#[Assert\NotBlank(message: 'Le code postal est requis.')]
#[Assert\Length(max: 10, maxMessage: 'Le code postal ne peut pas depasser {{ limit }} caracteres.')]
#[Assert\Regex(
pattern: '/^\d{5}$/',
message: 'Le code postal doit etre compose de 5 chiffres (format FR).',
)]
private string $postalCode;
// Couleur d'identification visuelle du site au format hex #RRGGBB (7 chars
// incluant le diese). Utilisee plus tard par la navbar (ticket 3) pour
// distinguer les sites d'un coup d'oeil.
#[ORM\Column(length: 7)]
#[Assert\NotBlank(message: 'La couleur est requise.')]
#[Assert\Regex(
pattern: '/^#[0-9A-Fa-f]{6}$/',
message: 'La couleur doit etre un code hex de 7 caracteres au format #RRGGBB.',
)]
private string $color;
// Champ TEXT volontaire : l'adresse complete peut courir sur plusieurs
// lignes (voie + complement + mention particuliere). Borne haute a 500
// caracteres : une adresse francaise complete tient tres largement dans
// cette enveloppe, et la limite applicative protege contre les payloads
// anormalement volumineux envoyes par un client (garde DoS basique).
#[ORM\Column(name: 'full_address', type: Types::TEXT)]
#[Assert\NotBlank(message: 'L\'adresse complete est requise.')]
#[Assert\Length(max: 500, maxMessage: 'L\'adresse complete ne peut pas depasser {{ limit }} caracteres.')]
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;
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;
}
/**
* Callback Doctrine : a chaque update en base on rafraichit updatedAt.
* Ne pas toucher a createdAt ici (immutable apres creation).
*/
#[ORM\PreUpdate]
public function onPreUpdate(): void
{
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getCity(): string
{
return $this->city;
}
public function setCity(string $city): static
{
$this->city = $city;
return $this;
}
public function getPostalCode(): string
{
return $this->postalCode;
}
public function setPostalCode(string $postalCode): static
{
$this->postalCode = $postalCode;
return $this;
}
public function getColor(): string
{
return $this->color;
}
public function setColor(string $color): static
{
$this->color = $color;
return $this;
}
public function getFullAddress(): string
{
return $this->fullAddress;
}
public function setFullAddress(string $fullAddress): static
{
$this->fullAddress = $fullAddress;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
}