Merge develop into feature/ERP-62-page-repertoire-clients
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m55s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m18s

Résolution du conflit de contrat de sérialisation (Client.php) après merge
du fix #45 (ERP-80/81/82/83) dans develop :

- GetCollection : ajout category:read + site:read + accesseur getSites()
  (delta nécessaire à ERP-62, absent du contrat mergé) — un seul endroit.
- Get : client_rib:read NON réintroduit (fix sécu #45 conservé : contenu RIB
  gaté par client:read:accounting, plus de fuite IBAN/BIC).
- getSites() conservé en sus du gating RIB de develop.
- Repository : fetch-joins categories/addresses/sites conservés (anti N+1 liste).
This commit is contained in:
Matthieu
2026-06-02 12:06:44 +02:00
5 changed files with 332 additions and 14 deletions
+17 -5
View File
@@ -68,15 +68,21 @@ use Symfony\Component\Validator\Constraints as Assert;
),
new Get(
security: "is_granted('commercial.clients.view')",
// Detail : client + sous-collections embarquees. Le groupe
// client:read:accounting est ajoute par le context builder selon la
// permission, donc absent ici volontairement.
// Detail : client + sous-collections embarquees.
// - client:read:accounting est ajoute par le context builder selon la
// permission (gate les scalaires comptables ET les RIB embarques),
// donc absent ici volontairement.
// - client_rib:read N'EST PLUS dans le contexte : le contenu des RIB
// embarques est desormais porte par client:read:accounting (gate),
// ce qui retire la fuite IBAN/BIC vers les users sans accounting.view.
// - category:read et site:read sont indispensables pour embarquer le
// code/libelle des categories et des sites (sinon stub IRI nu) :
// Category.code/name vivent sous category:read, Site.name sous site:read.
normalizationContext: ['groups' => [
'client:read',
'client:item:read',
'client_contact:read',
'client_address:read',
'client_rib:read',
'category:read',
'site:read',
'default:read',
@@ -682,8 +688,14 @@ class Client implements TimestampableInterface, BlamableInterface
return array_values($sites);
}
// Embed gate sur le groupe COMPTABLE (et non client:item:read comme contacts/
// adresses) : client:read:accounting n'est ajoute au contexte que si l'user a
// accounting.view (ClientReadGroupContextBuilder). Resultat : la cle `ribs` est
// TOTALEMENT ABSENTE du detail pour un user sans accounting.view (ex. Commerciale),
// au meme titre que les scalaires comptables — corrige la fuite de RIB ou la
// Commerciale recevait IBAN/BIC en clair.
/** @return Collection<int, ClientRib> */
#[Groups(['client:item:read'])]
#[Groups(['client:read:accounting'])]
public function getRibs(): Collection
{
return $this->ribs;
@@ -22,6 +22,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -104,16 +105,23 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Client $client = null;
// Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH/POST).
// Le groupe de LECTURE est porte par le getter isProspect()/isDelivery()/
// isBilling() avec SerializedName : sans cela, Symfony strip le prefixe "is"
// des getters booleens et exposerait les cles "prospect"/"delivery"/"billing"
// — en pratique le #[Groups] etant sur la propriete `isX` et le getter
// derivant l'attribut `x`, la cle etait totalement DROPPEE du JSON (meme bug
// que Client::isArchived). Pattern corrige : Groups + SerializedName sur le getter.
#[ORM\Column(name: 'is_prospect', options: ['default' => false])]
#[Groups(['client_address:read', 'client_address:write'])]
#[Groups(['client_address:write'])]
private bool $isProspect = false;
#[ORM\Column(name: 'is_delivery', options: ['default' => false])]
#[Groups(['client_address:read', 'client_address:write'])]
#[Groups(['client_address:write'])]
private bool $isDelivery = false;
#[ORM\Column(name: 'is_billing', options: ['default' => false])]
#[Groups(['client_address:read', 'client_address:write'])]
#[Groups(['client_address:write'])]
private bool $isBilling = false;
#[ORM\Column(length: 80, options: ['default' => 'France'])]
@@ -276,6 +284,12 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
return $this;
}
// Groupe de lecture + nom serialise explicite (cf. note sur la propriete) :
// sans SerializedName, Symfony exposerait la cle "prospect" (strip du prefixe
// "is" sur les getters) et, le groupe etant declare sur la propriete `isProspect`,
// droppait silencieusement la cle du JSON.
#[Groups(['client_address:read'])]
#[SerializedName('isProspect')]
public function isProspect(): bool
{
return $this->isProspect;
@@ -288,6 +302,8 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
return $this;
}
#[Groups(['client_address:read'])]
#[SerializedName('isDelivery')]
public function isDelivery(): bool
{
return $this->isDelivery;
@@ -300,6 +316,8 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
return $this;
}
#[Groups(['client_address:read'])]
#[SerializedName('isBilling')]
public function isBilling(): bool
{
return $this->isBilling;
@@ -79,10 +79,17 @@ class ClientRib implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
// Double groupe de lecture :
// - `client_rib:read` : sous-ressource autonome GET /api/client_ribs/{id}
// (deja securisee par commercial.clients.accounting.view).
// - `client:read:accounting` : embed des RIB sous le detail Client, ajoute
// DYNAMIQUEMENT par ClientReadGroupContextBuilder uniquement si l'user a
// accounting.view. Ce double marquage gate les RIB embarques au meme titre
// que les scalaires comptables (RG : la Commerciale ne voit aucun RIB).
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client_rib:read'])]
#[Groups(['client_rib:read', 'client:read:accounting'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'ribs')]
@@ -92,23 +99,23 @@ class ClientRib implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 120)]
#[Assert\NotBlank]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client_rib:read', 'client_rib:write'])]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
private ?string $label = null;
#[ORM\Column(length: 20)]
#[Assert\NotBlank]
#[Assert\Bic]
#[Groups(['client_rib:read', 'client_rib:write'])]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
private ?string $bic = null;
#[ORM\Column(length: 34)]
#[Assert\NotBlank]
#[Assert\Iban]
#[Groups(['client_rib:read', 'client_rib:write'])]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
private ?string $iban = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['client_rib:read', 'client_rib:write'])]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
private int $position = 0;
public function getId(): ?int