['client_address:read']], ), new Post( uriTemplate: '/clients/{clientId}/addresses', uriVariables: [ 'clientId' => new Link(fromClass: Client::class, toProperty: 'client'), ], security: "is_granted('commercial.clients.manage')", normalizationContext: ['groups' => ['client_address:read']], denormalizationContext: ['groups' => ['client_address:write']], processor: ClientAddressProcessor::class, ), new Patch( security: "is_granted('commercial.clients.manage')", normalizationContext: ['groups' => ['client_address:read']], denormalizationContext: ['groups' => ['client_address:write']], processor: ClientAddressProcessor::class, ), new Delete( security: "is_granted('commercial.clients.manage')", processor: ClientAddressProcessor::class, ), ], )] #[ORM\Entity(repositoryClass: DoctrineClientAddressRepository::class)] #[ORM\Table(name: 'client_address')] #[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])] #[Auditable] class ClientAddress implements TimestampableInterface, BlamableInterface { use TimestampableBlamableTrait; /** * RG-1.29 (ERP-78) : ces codes de categorie decrivent une relation entre * clients (distributeur / courtier) et n'ont pas de sens sur une adresse. * Toute autre categorie du type CLIENT est autorisee. */ private const array FORBIDDEN_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']; #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['client_address:read'])] private ?int $id = null; #[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'addresses')] #[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:write'])] private bool $isProspect = false; #[ORM\Column(name: 'is_delivery', options: ['default' => false])] #[Groups(['client_address:write'])] private bool $isDelivery = false; #[ORM\Column(name: 'is_billing', options: ['default' => false])] #[Groups(['client_address:write'])] private bool $isBilling = false; #[ORM\Column(length: 80, options: ['default' => 'France'])] #[Groups(['client_address:read', 'client_address:write'])] private string $country = 'France'; // RG-1.09 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur). #[ORM\Column(length: 20)] #[Assert\NotBlank] #[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')] #[Groups(['client_address:read', 'client_address:write'])] private ?string $postalCode = null; #[ORM\Column(length: 120)] #[Assert\NotBlank] #[Groups(['client_address:read', 'client_address:write'])] private ?string $city = null; #[ORM\Column(length: 255)] #[Assert\NotBlank] #[Groups(['client_address:read', 'client_address:write'])] private ?string $street = null; #[ORM\Column(length: 255, nullable: true)] #[Groups(['client_address:read', 'client_address:write'])] private ?string $streetComplement = null; // RG-1.11 : obligatoire ssi isBilling (validateBillingEmailPresence + CHECK BDD). #[ORM\Column(length: 180, nullable: true)] #[Assert\Email] #[Groups(['client_address:read', 'client_address:write'])] private ?string $billingEmail = null; #[ORM\Column(options: ['default' => 0])] #[Groups(['client_address:read', 'client_address:write'])] private int $position = 0; // RG-1.10 : au moins un site rattache a chaque adresse. /** @var Collection */ #[ORM\ManyToMany(targetEntity: SiteInterface::class)] #[ORM\JoinTable(name: 'client_address_site')] #[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] #[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')] #[Groups(['client_address:read', 'client_address:write'])] private Collection $sites; /** @var Collection */ #[ORM\ManyToMany(targetEntity: ClientContact::class)] #[ORM\JoinTable(name: 'client_address_contact')] #[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\InverseJoinColumn(name: 'client_contact_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[Groups(['client_address:read', 'client_address:write'])] private Collection $contacts; // RG-1.29 : categories de code DISTRIBUTEUR/COURTIER interdites (validateCategoryCodes). /** @var Collection */ #[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\JoinTable(name: 'client_address_category')] #[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] #[Groups(['client_address:read', 'client_address:write'])] private Collection $categories; public function __construct() { $this->sites = new ArrayCollection(); $this->contacts = new ArrayCollection(); $this->categories = new ArrayCollection(); } /** * RG-1.06 / RG-1.07 / RG-1.08 : une adresse de prospection est exclusive * d'une adresse de livraison ou de facturation. Mirror applicatif (422) du * CHECK chk_client_address_prospect_exclusive, joue avant la base afin de * remonter une violation Hydra plutot qu'une 500 DBAL. */ #[Assert\Callback] public function validateProspectExclusivity(ExecutionContextInterface $context): void { if ($this->isProspect && ($this->isDelivery || $this->isBilling)) { $context->buildViolation('Une adresse de prospection ne peut pas être une adresse de livraison ni de facturation.') ->atPath('isProspect') ->addViolation() ; } } /** * RG-1.11 : l'email de facturation est obligatoire si l'adresse est de * facturation, et interdit sinon. Mirror applicatif (422) du CHECK * chk_client_address_billing_email. * * On raisonne sur la PRESENCE effective de l'email : null ET chaine vide * sont traites comme « absent », car le ClientAddressProcessor normalise une * chaine vide en null APRES la validation (RG-1.21). Sans ce traitement, * billingEmail="" passerait les callbacks (null === "" est faux) puis serait * persiste en null avec is_billing=true -> violation du CHECK -> 500 au lieu * du 422 attendu (et symetriquement, "" sur une adresse non facturable * serait rejete a tort). */ #[Assert\Callback] public function validateBillingEmailPresence(ExecutionContextInterface $context): void { $hasBillingEmail = null !== $this->billingEmail && '' !== trim($this->billingEmail); if ($this->isBilling && !$hasBillingEmail) { $context->buildViolation('L\'email de facturation est obligatoire pour une adresse de facturation.') ->atPath('billingEmail') ->addViolation() ; } if (!$this->isBilling && $hasBillingEmail) { $context->buildViolation('L\'email de facturation n\'est autorisé que sur une adresse de facturation.') ->atPath('billingEmail') ->addViolation() ; } } /** * RG-1.29 (ERP-78) : une adresse interdit les categories de code * DISTRIBUTEUR / COURTIER — elles decrivent une relation entre clients * (RG-1.03) et n'ont pas de sens sur une adresse physique -> 422 avec * violation sur le champ `categories`. Toute autre categorie (type unique * CLIENT) est acceptee. S'appuie sur CategoryInterface::getCode() (pas * d'import du module Catalog — regle ABSOLUE n°1). */ #[Assert\Callback] public function validateCategoryCodes(ExecutionContextInterface $context): void { foreach ($this->categories as $category) { if ($category instanceof CategoryInterface && in_array($category->getCode(), self::FORBIDDEN_CATEGORY_CODES, true)) { $context->buildViolation('Type de catégorie non autorisé sur une adresse.') ->atPath('categories') ->addViolation() ; return; } } } public function getId(): ?int { return $this->id; } public function getClient(): ?Client { return $this->client; } public function setClient(?Client $client): static { $this->client = $client; 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; } public function setIsProspect(bool $isProspect): static { $this->isProspect = $isProspect; return $this; } #[Groups(['client_address:read'])] #[SerializedName('isDelivery')] public function isDelivery(): bool { return $this->isDelivery; } public function setIsDelivery(bool $isDelivery): static { $this->isDelivery = $isDelivery; return $this; } #[Groups(['client_address:read'])] #[SerializedName('isBilling')] public function isBilling(): bool { return $this->isBilling; } public function setIsBilling(bool $isBilling): static { $this->isBilling = $isBilling; return $this; } public function getCountry(): string { return $this->country; } public function setCountry(string $country): static { $this->country = $country; 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 getStreet(): ?string { return $this->street; } public function setStreet(?string $street): static { $this->street = $street; return $this; } public function getStreetComplement(): ?string { return $this->streetComplement; } public function setStreetComplement(?string $streetComplement): static { $this->streetComplement = $streetComplement; return $this; } public function getBillingEmail(): ?string { return $this->billingEmail; } public function setBillingEmail(?string $billingEmail): static { $this->billingEmail = $billingEmail; return $this; } public function getPosition(): int { return $this->position; } public function setPosition(int $position): static { $this->position = $position; return $this; } /** @return Collection */ public function getSites(): Collection { return $this->sites; } public function addSite(SiteInterface $site): static { if (!$this->sites->contains($site)) { $this->sites->add($site); } return $this; } public function removeSite(SiteInterface $site): static { $this->sites->removeElement($site); return $this; } /** @return Collection */ public function getContacts(): Collection { return $this->contacts; } public function addContact(ClientContact $contact): static { if (!$this->contacts->contains($contact)) { $this->contacts->add($contact); } return $this; } public function removeContact(ClientContact $contact): static { $this->contacts->removeElement($contact); return $this; } /** @return Collection */ public function getCategories(): Collection { return $this->categories; } public function addCategory(CategoryInterface $category): static { if (!$this->categories->contains($category)) { $this->categories->add($category); } return $this; } public function removeCategory(CategoryInterface $category): static { $this->categories->removeElement($category); return $this; } }