[ERP-76 + ERP-68] Validations d'adresse client (RG → 422) + fixtures démo Catalog/Commercial (#41)
Auto Tag Develop / tag (push) Successful in 11s

Stack de 2 tickets sur une branche (squash sur `develop`).

## ERP-76 (#500) — Validations d'adresse Client → 422

Les règles d'intégrité de l'onglet Adresse étaient soit non implémentées (RG-1.29), soit rejetées en 500 par les CHECK Postgres (RG-1.06/07/08/11). Elles sont désormais portées par des `Assert\Callback` applicatifs sur `ClientAddress`, qui remontent une **422 Hydra avant la base** ; les CHECK BDD restent en filet de sécurité.

- `validateProspectExclusivity` — `isProspect` exclusif de `isDelivery`/`isBilling` (RG-1.06/07/08).
- `validateBillingEmailPresence` — `billingEmail` obligatoire ssi `isBilling` (RG-1.11).
- `validateCategoryTypes` — refuse une catégorie de type DISTRIBUTEUR/COURTIER sur une adresse (RG-1.29, violation `categories`), via `CategoryInterface` (règle n°1 respectée).

Tests `ClientAddressTest` durcis (≥400 → **422 explicite**) + 4 cas RG-1.29. Cahier de test M1 mis à jour.

## ERP-68 (#486) — Fixtures démo Catalog + Commercial (dev only)

- `CategoryFixtures` (Catalog) : 12 catégories sur les 4 types.
- `ClientFixtures` (Commercial) : 14 clients couvrant les cas RG (dépendant distributeur/courtier RG-1.03, LCR + 2 RIB RG-1.13, Chèque sans RIB, multi-adresses Prospect/Livraison/Facturation RG-1.06/07/08/11, prospect seul, 3 contacts + tél. secondaire RG-1.05/1.02, archivé RG-1.22, onglet Information complet, multi-catégories M2M).

Résolution inter-modules via les seuls contrats Shared (`CategoryInterface`, `SiteProviderInterface`). Valeurs brutes normalisées par `ClientFieldNormalizer`. Données conformes aux CHECK BDD **et** aux validators ERP-76. Idempotentes (lookup `companyName`/`name`). **Garde-fou** : les deux fixtures sont no-op en environnement `test` (la base de test reste un socle minimal ; pas de pollution des comptages ni des cleanups FK).

## Bonus — idempotence fixtures

`AppFixtures` (admin/alice/bob) rendu idempotent via lookup par username : `doctrine:fixtures:load --append` est désormais rejouable sans erreur sur tout le jeu de fixtures.

## Vérifications

- `make test` : **436/436 vert** (0 échec/erreur).
- `make php-cs-fixer-allow-risky` OK.
- `make db-reset` charge sans erreur ; 2 runs `--append` consécutifs = idempotent (0 doublon ; 7 users / 14 clients / 12 catégories stables).
- `admin/admin` intact.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #41
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #41.
This commit is contained in:
2026-06-01 22:14:23 +00:00
committed by admin malio
parent 865180e648
commit a52e3bec34
6 changed files with 1005 additions and 57 deletions
@@ -23,19 +23,23 @@ 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, CHECK BDD). Un email de facturation est obligatoire ssi
* isBilling (RG-1.11, CHECK BDD). Au moins un site doit etre rattache
* (RG-1.10, Assert\Count).
* (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 cote validation (RG-1.29, hors ERP-57)
* — limitees aux types SECTEUR/AUTRE (RG-1.29, validateCategoryTypes, ERP-76)
*
* Audite (#[Auditable]) + Timestampable/Blamable.
*
@@ -83,6 +87,9 @@ 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]
@@ -130,7 +137,7 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $streetComplement = null;
// RG-1.11 : obligatoire ssi isBilling (CHECK BDD + futur Processor).
// RG-1.11 : obligatoire ssi isBilling (validateBillingEmailPresence + CHECK BDD).
#[ORM\Column(length: 180, nullable: true)]
#[Assert\Email]
#[Groups(['client_address:read', 'client_address:write'])]
@@ -158,7 +165,7 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:read', 'client_address:write'])]
private Collection $contacts;
// RG-1.29 : categories de type SECTEUR/AUTRE uniquement (filtre au Processor).
// 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')]
@@ -174,6 +181,79 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
$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 : 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;