1888b70623
Auto Tag Develop / tag (push) Successful in 11s
## Contexte
ERP-102 — Découvert pendant ERP-64. Connecté avec un rôle **métier** (bureau / compta / commerciale), `GET /api/categories` et `GET /api/sites` renvoient **403**, alors que `/tva_modes`, `/payment_delays`, `/payment_types`, `/banks` renvoient 200.
Conséquences : page **Création client** inutilisable (le `Promise.all` rejetait → **tous** les selects vides) et **filtres Catégories/Sites vides** au répertoire.
## Cause
La `security` des `GetCollection`/`Get` de `Category` et `Site` exigeait `catalog.categories.view` / `sites.view` — permissions d'**administration** du Catalogue / des Sites. Or ces référentiels sont **transverses** : tout rôle qui gère un tiers doit pouvoir les lire.
## Correctif back — Option C (permission de lecture-référentiel dédiée)
Choix d'archi retenu parmi les 3 du ticket :
- **Pourquoi pas A** (`... or is_granted('commercial.clients.view')`) : coupler `Category`/`Site` à une permission **Commercial** viole l'esprit de la règle ABSOLUE n°1 et ne scale pas (M2 Fournisseurs devrait rajouter un OR).
- **Pourquoi pas B** (donner `.view` aux rôles métier) : `.view` = accès admin → items sidebar admin Catégories/Sites exposés à une commerciale.
- **C** : nouvelle permission `catalog.categories.read_ref` / `sites.read_ref`, distincte de `.view` (pas d'item sidebar) et de `.manage`. Chaque permission appartient à **son** module → isolement inter-module préservé, **réutilisable tel quel par M2 Fournisseurs**. C'est la « permission référentiel lisible » que le ticket pointe lui-même.
Détail :
- `CatalogModule` / `SitesModule` : déclaration des deux permissions `read_ref`.
- `Category` / `Site` : security lecture (liste + item) = `view OR read_ref`.
- `RbacSeeder` (matrice § 2.7) : `read_ref` attaché à bureau / compta / commerciale ; usine reste sans accès.
## Durcissement front (résilience — requis dans tous les cas)
`useClientReferentials.loadCommon` : `Promise.all` → **`Promise.allSettled`** avec affectation isolée par référentiel. L'échec d'un endpoint ne vide plus que **son** select, plus la totalité du formulaire.
## Tests (TDD)
- `ClientRBACMatrixTest::testBusinessRolesCanReadCategoriesAndSitesReferentials` — bureau/compta/commerciale listent `/categories` et `/sites` (200), usine reste 403.
- `SitesModuleTest` — set de permissions porté à 4 codes.
- `useClientReferentials.spec` (Vitest) — un référentiel en échec ne vide que son select.
## Vérifications
- `make test` (back) : **467/467** ✓
- `make nuxt-test` (front) : **131/131** ✓
- `make php-cs-fixer` : conforme ✓
## Note miroirs RBAC
`config/sidebar.php` / `personas.ts` / `SeedE2ECommand.php` **non touchés** : `read_ref` n'ajoute aucun item sidebar, le persona E2E `user-full` lit déjà via `.view`, et aucun persona ne modélise un rôle métier seul. Pas de nouveau test E2E (règle n°7 : bug attrapé avant prod). La source de vérité de la matrice (`RbacSeeder`) est mise à jour et couverte par `ClientRBACMatrixTest`.
Closes ERP-102.
---------
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #53
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
335 lines
11 KiB
PHP
335 lines
11 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\Attribute\Auditable;
|
|
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 Starseed.
|
|
*
|
|
* 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: [
|
|
// Lecture (liste + item) : permission d'administration `sites.view` OU
|
|
// permission de lecture-referentiel transverse `sites.read_ref` (ERP-102).
|
|
// Le referentiel sites alimente les selects d'adresse des modules Tiers :
|
|
// tout role qui gere des tiers doit pouvoir le lire sans porter l'acces
|
|
// admin des Sites.
|
|
new GetCollection(
|
|
normalizationContext: ['groups' => ['site:read']],
|
|
security: "is_granted('sites.view') or is_granted('sites.read_ref')",
|
|
),
|
|
new Get(
|
|
normalizationContext: ['groups' => ['site:read']],
|
|
security: "is_granted('sites.view') or is_granted('sites.read_ref')",
|
|
),
|
|
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')]
|
|
#[Auditable]
|
|
#[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;
|
|
}
|
|
}
|