All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
| 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>
328 lines
10 KiB
PHP
328 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Module\Sites\Domain\Entity;
|
|
|
|
use ApiPlatform\Metadata\ApiResource;
|
|
use ApiPlatform\Metadata\Delete;
|
|
use ApiPlatform\Metadata\Get;
|
|
use ApiPlatform\Metadata\GetCollection;
|
|
use ApiPlatform\Metadata\Patch;
|
|
use ApiPlatform\Metadata\Post;
|
|
use App\Module\Core\Domain\Entity\User;
|
|
use App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository;
|
|
use App\Shared\Domain\Contract\SiteInterface;
|
|
use DateTimeImmutable;
|
|
use Doctrine\Common\Collections\ArrayCollection;
|
|
use Doctrine\Common\Collections\Collection;
|
|
use Doctrine\DBAL\Types\Types;
|
|
use Doctrine\ORM\Mapping as ORM;
|
|
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
|
use Symfony\Component\Serializer\Attribute\Groups;
|
|
use Symfony\Component\Validator\Constraints as Assert;
|
|
|
|
/**
|
|
* Site physique (usine / etablissement) appartenant a l'instance Coltura.
|
|
*
|
|
* Adresse decomposee en champs structures (rue, complement, CP, ville) pour
|
|
* permettre des recherches/tris fins ulterieurs et eviter les divergences
|
|
* entre champs duplique. La methode `getFullAddress()` fournit la version
|
|
* concatenee multi-lignes pour les usages d'affichage.
|
|
*
|
|
* Expose en API Platform pour l'administration CRUD avec RBAC :
|
|
* - lecture (GET list / item) : requiert la permission `sites.view`
|
|
* - ecriture (POST / PATCH / DELETE) : requiert la permission `sites.manage`
|
|
*
|
|
* Egalement embarque dans la reponse `/api/me` (groupe `me:read`) pour que
|
|
* le frontend connaisse les sites autorises et le site courant de l'user.
|
|
*/
|
|
#[ApiResource(
|
|
operations: [
|
|
new GetCollection(
|
|
normalizationContext: ['groups' => ['site:read']],
|
|
security: "is_granted('sites.view')",
|
|
),
|
|
new Get(
|
|
normalizationContext: ['groups' => ['site:read']],
|
|
security: "is_granted('sites.view')",
|
|
),
|
|
new Post(
|
|
normalizationContext: ['groups' => ['site:read']],
|
|
denormalizationContext: ['groups' => ['site:write']],
|
|
security: "is_granted('sites.manage')",
|
|
),
|
|
new Patch(
|
|
normalizationContext: ['groups' => ['site:read']],
|
|
denormalizationContext: ['groups' => ['site:write']],
|
|
security: "is_granted('sites.manage')",
|
|
),
|
|
new Delete(security: "is_granted('sites.manage')"),
|
|
],
|
|
normalizationContext: ['groups' => ['site:read']],
|
|
denormalizationContext: ['groups' => ['site:write']],
|
|
)]
|
|
#[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 implements SiteInterface
|
|
{
|
|
#[ORM\Id]
|
|
#[ORM\GeneratedValue]
|
|
#[ORM\Column]
|
|
#[Groups(['site:read', 'me:read'])]
|
|
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.')]
|
|
#[Groups(['site:read', 'site:write', 'me:read'])]
|
|
private string $name;
|
|
|
|
// Premiere ligne d'adresse : numero + voie. Requise.
|
|
#[ORM\Column(length: 255)]
|
|
#[Assert\NotBlank(message: 'La rue est requise.')]
|
|
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut pas depasser {{ limit }} caracteres.')]
|
|
#[Groups(['site:read', 'site:write', 'me:read'])]
|
|
private string $street;
|
|
|
|
// Complement d'adresse optionnel : batiment, escalier, BP, etc.
|
|
#[ORM\Column(length: 255, nullable: true)]
|
|
#[Assert\Length(max: 255, maxMessage: 'Le complement ne peut pas depasser {{ limit }} caracteres.')]
|
|
#[Groups(['site:read', 'site:write', 'me:read'])]
|
|
private ?string $complement = null;
|
|
|
|
// 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).',
|
|
)]
|
|
#[Groups(['site:read', 'site:write', 'me:read'])]
|
|
private string $postalCode;
|
|
|
|
#[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.')]
|
|
#[Groups(['site:read', 'site:write', 'me:read'])]
|
|
private string $city;
|
|
|
|
// Couleur d'identification visuelle du site au format hex #RRGGBB (7 chars
|
|
// incluant le diese). Utilisee 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.',
|
|
)]
|
|
#[Groups(['site:read', 'site:write', 'me:read'])]
|
|
private string $color;
|
|
|
|
// createdAt / updatedAt volontairement exclus du groupe `me:read` :
|
|
// le payload `/api/me` doit rester leger, ces metadonnees ne sont utiles
|
|
// qu'a l'admin (exposees uniquement via `site:read` sur /api/sites).
|
|
#[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)]
|
|
#[Groups(['site:read'])]
|
|
private DateTimeImmutable $createdAt;
|
|
|
|
#[ORM\Column(name: 'updated_at', type: Types::DATETIME_IMMUTABLE)]
|
|
#[Groups(['site:read'])]
|
|
private DateTimeImmutable $updatedAt;
|
|
|
|
/**
|
|
* Collection inverse des users rattaches a ce site.
|
|
*
|
|
* Volontairement SANS `#[Groups]` : la collection n'est jamais exposee via
|
|
* l'API pour deux raisons :
|
|
* - eviter une boucle de serialisation infinie User → sites → users → ...
|
|
* si un jour un developpeur ajoute `me:read` ici par megarde ;
|
|
* - l'inverse n'a de valeur qu'en interne (compter les users d'un site,
|
|
* iterer en test de cascade).
|
|
*
|
|
* @var Collection<int, User>
|
|
*/
|
|
#[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'sites')]
|
|
private Collection $users;
|
|
|
|
public function __construct(
|
|
string $name,
|
|
string $street,
|
|
?string $complement,
|
|
string $postalCode,
|
|
string $city,
|
|
string $color,
|
|
) {
|
|
$this->name = $name;
|
|
$this->street = $street;
|
|
$this->complement = $complement;
|
|
$this->postalCode = $postalCode;
|
|
$this->city = $city;
|
|
$this->color = $color;
|
|
$now = new DateTimeImmutable();
|
|
$this->createdAt = $now;
|
|
$this->updatedAt = $now;
|
|
$this->users = new ArrayCollection();
|
|
}
|
|
|
|
/**
|
|
* 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 getStreet(): string
|
|
{
|
|
return $this->street;
|
|
}
|
|
|
|
public function setStreet(string $street): static
|
|
{
|
|
$this->street = $street;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function getComplement(): ?string
|
|
{
|
|
return $this->complement;
|
|
}
|
|
|
|
public function setComplement(?string $complement): static
|
|
{
|
|
$this->complement = $complement;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function getPostalCode(): string
|
|
{
|
|
return $this->postalCode;
|
|
}
|
|
|
|
public function setPostalCode(string $postalCode): static
|
|
{
|
|
$this->postalCode = $postalCode;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function getCity(): string
|
|
{
|
|
return $this->city;
|
|
}
|
|
|
|
public function setCity(string $city): static
|
|
{
|
|
$this->city = $city;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function getColor(): string
|
|
{
|
|
return $this->color;
|
|
}
|
|
|
|
public function setColor(string $color): static
|
|
{
|
|
$this->color = $color;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Adresse complete reconstituee : street, [complement,] {CP} {ville},
|
|
* separes par des sauts de ligne. Methode pure, jamais persistee.
|
|
*
|
|
* Expose en lecture API (groupes site:read + me:read) pour que les
|
|
* consommateurs (frontend, exports PDF) recoivent une adresse prete a
|
|
* afficher sans dupliquer la logique de concatenation cote client.
|
|
*/
|
|
#[Groups(['site:read', 'me:read'])]
|
|
public function getFullAddress(): string
|
|
{
|
|
$lines = [$this->street];
|
|
|
|
if (null !== $this->complement && '' !== trim($this->complement)) {
|
|
$lines[] = $this->complement;
|
|
}
|
|
|
|
$lines[] = sprintf('%s %s', $this->postalCode, $this->city);
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
public function getCreatedAt(): DateTimeImmutable
|
|
{
|
|
return $this->createdAt;
|
|
}
|
|
|
|
public function getUpdatedAt(): DateTimeImmutable
|
|
{
|
|
return $this->updatedAt;
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, User>
|
|
*/
|
|
public function getUsers(): Collection
|
|
{
|
|
return $this->users;
|
|
}
|
|
|
|
/**
|
|
* Synchronise la collection inverse cote Site quand User::addSite est
|
|
* appele. Idempotent. Ne re-appelle pas $user->addSite($this) pour
|
|
* eviter une recursion infinie : User::addSite est le point d'entree
|
|
* unique de la mutation.
|
|
*
|
|
* @internal Appele uniquement par User::addSite()
|
|
*/
|
|
public function addUser(User $user): static
|
|
{
|
|
if (!$this->users->contains($user)) {
|
|
$this->users->add($user);
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @internal Appele uniquement par User::removeSite()
|
|
*/
|
|
public function removeUser(User $user): static
|
|
{
|
|
$this->users->removeElement($user);
|
|
|
|
return $this;
|
|
}
|
|
}
|