fa20482393
Mirror applicatif des CHECK Postgres d'adresse via Assert\Callback sur ClientAddress, joue avant la base pour remonter une 422 Hydra au lieu d'une 500 DBAL, et durcit RG-1.29 (categorie d'adresse limitee a SECTEUR/AUTRE) : - validateProspectExclusivity : isProspect exclusif de isDelivery/isBilling (RG-1.06/07/08, mirror chk_client_address_prospect_exclusive). - validateBillingEmailPresence : billingEmail obligatoire ssi isBilling (RG-1.11, mirror chk_client_address_billing_email). - validateCategoryTypes : refuse une categorie DISTRIBUTEUR/COURTIER sur une adresse (RG-1.29, violation 'categories'), via CategoryInterface. Les CHECK BDD restent en filet de securite. Tests ClientAddressTest durcis de >= 400 vers 422 explicite + 4 cas RG-1.29. Cahier de test M1 mis a jour.
450 lines
14 KiB
PHP
450 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Module\Commercial\Domain\Entity;
|
|
|
|
use ApiPlatform\Metadata\ApiResource;
|
|
use ApiPlatform\Metadata\Delete;
|
|
use ApiPlatform\Metadata\Get;
|
|
use ApiPlatform\Metadata\Link;
|
|
use ApiPlatform\Metadata\Patch;
|
|
use ApiPlatform\Metadata\Post;
|
|
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientAddressProcessor;
|
|
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepository;
|
|
use App\Shared\Domain\Attribute\Auditable;
|
|
use App\Shared\Domain\Contract\BlamableInterface;
|
|
use App\Shared\Domain\Contract\CategoryInterface;
|
|
use App\Shared\Domain\Contract\SiteInterface;
|
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
|
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
|
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\Validator\Constraints as Assert;
|
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|
|
|
/**
|
|
* Adresse d'un client (1:n) — onglet Adresse. Une adresse de prospection
|
|
* (isProspect) est exclusive d'une adresse de livraison/facturation
|
|
* (RG-1.06/07/08). Un email de facturation est obligatoire ssi isBilling
|
|
* (RG-1.11). Au moins un site doit etre rattache (RG-1.10, Assert\Count). Ces
|
|
* regles sont portees par des Assert\Callback (cf. validateProspectExclusivity /
|
|
* validateBillingEmailPresence, ERP-76) qui remontent une 422 avant la base ;
|
|
* les CHECK Postgres (chk_client_address_prospect_exclusive /
|
|
* chk_client_address_billing_email) restent en filet de securite.
|
|
*
|
|
* Relations M2M :
|
|
* - sites : SiteInterface (module Sites) via resolve_target_entities
|
|
* - contacts : ClientContact (meme module)
|
|
* - categories : CategoryInterface (module Catalog) via resolve_target_entities
|
|
* — limitees aux types SECTEUR/AUTRE (RG-1.29, validateCategoryTypes, ERP-76)
|
|
*
|
|
* Audite (#[Auditable]) + Timestampable/Blamable.
|
|
*
|
|
* Sous-ressource API (ERP-57, spec § 4.5) :
|
|
* - POST /api/clients/{clientId}/addresses : creation rattachee au client parent
|
|
* (Link toProperty 'client'), security commercial.clients.manage.
|
|
* - PATCH / DELETE /api/client_addresses/{id} : security commercial.clients.manage.
|
|
* - GET /api/client_addresses/{id} : lecture unitaire (security view) — la
|
|
* lecture courante reste via le parent. Pas de GET collection autonome.
|
|
* Tout passe par le ClientAddressProcessor (normalisation RG-1.21 billingEmail).
|
|
*/
|
|
#[ApiResource(
|
|
operations: [
|
|
new Get(
|
|
security: "is_granted('commercial.clients.view')",
|
|
normalizationContext: ['groups' => ['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 : seuls ces types de categorie qualifient une adresse physique. */
|
|
private const array ALLOWED_CATEGORY_TYPES = ['SECTEUR', 'AUTRE'];
|
|
|
|
#[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;
|
|
|
|
#[ORM\Column(name: 'is_prospect', options: ['default' => false])]
|
|
#[Groups(['client_address:read', 'client_address:write'])]
|
|
private bool $isProspect = false;
|
|
|
|
#[ORM\Column(name: 'is_delivery', options: ['default' => false])]
|
|
#[Groups(['client_address:read', 'client_address:write'])]
|
|
private bool $isDelivery = false;
|
|
|
|
#[ORM\Column(name: 'is_billing', options: ['default' => false])]
|
|
#[Groups(['client_address:read', '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<int, SiteInterface> */
|
|
#[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<int, ClientContact> */
|
|
#[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 type SECTEUR/AUTRE uniquement (validateCategoryTypes).
|
|
/** @var Collection<int, CategoryInterface> */
|
|
#[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.
|
|
*/
|
|
#[Assert\Callback]
|
|
public function validateBillingEmailPresence(ExecutionContextInterface $context): void
|
|
{
|
|
if ($this->isBilling && null === $this->billingEmail) {
|
|
$context->buildViolation('L\'email de facturation est obligatoire pour une adresse de facturation.')
|
|
->atPath('billingEmail')
|
|
->addViolation()
|
|
;
|
|
}
|
|
|
|
if (!$this->isBilling && null !== $this->billingEmail) {
|
|
$context->buildViolation('L\'email de facturation n\'est autorisé que sur une adresse de facturation.')
|
|
->atPath('billingEmail')
|
|
->addViolation()
|
|
;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* RG-1.29 : seules les categories de type SECTEUR / AUTRE qualifient une
|
|
* adresse physique. Les types DISTRIBUTEUR / COURTIER decrivent une relation
|
|
* entre clients (RG-1.03) et n'ont pas de sens sur une adresse -> 422 avec
|
|
* violation sur le champ `categories`. S'appuie sur
|
|
* CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog).
|
|
*/
|
|
#[Assert\Callback]
|
|
public function validateCategoryTypes(ExecutionContextInterface $context): void
|
|
{
|
|
foreach ($this->categories as $category) {
|
|
if ($category instanceof CategoryInterface
|
|
&& !in_array($category->getCategoryTypeCode(), self::ALLOWED_CATEGORY_TYPES, 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;
|
|
}
|
|
|
|
public function isProspect(): bool
|
|
{
|
|
return $this->isProspect;
|
|
}
|
|
|
|
public function setIsProspect(bool $isProspect): static
|
|
{
|
|
$this->isProspect = $isProspect;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function isDelivery(): bool
|
|
{
|
|
return $this->isDelivery;
|
|
}
|
|
|
|
public function setIsDelivery(bool $isDelivery): static
|
|
{
|
|
$this->isDelivery = $isDelivery;
|
|
|
|
return $this;
|
|
}
|
|
|
|
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<int, SiteInterface> */
|
|
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<int, ClientContact> */
|
|
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<int, CategoryInterface> */
|
|
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;
|
|
}
|
|
}
|