From a52e3bec3424fdf065703c904a8196c927d6dd2b Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Mon, 1 Jun 2026 22:14:23 +0000 Subject: [PATCH] =?UTF-8?q?[ERP-76=20+=20ERP-68]=20Validations=20d'adresse?= =?UTF-8?q?=20client=20(RG=20=E2=86=92=20422)=20+=20fixtures=20d=C3=A9mo?= =?UTF-8?q?=20Catalog/Commercial=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/41 Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- docs/specs/M1-clients/cahier-test-back-M1.md | 29 +- .../DataFixtures/CategoryFixtures.php | 139 +++++ .../Domain/Entity/ClientAddress.php | 92 ++- .../DataFixtures/ClientFixtures.php | 555 ++++++++++++++++++ .../DataFixtures/AppFixtures.php | 35 +- .../Commercial/Api/ClientAddressTest.php | 212 ++++++- 6 files changed, 1005 insertions(+), 57 deletions(-) create mode 100644 src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php create mode 100644 src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php diff --git a/docs/specs/M1-clients/cahier-test-back-M1.md b/docs/specs/M1-clients/cahier-test-back-M1.md index 95afbc9..be9e2cc 100644 --- a/docs/specs/M1-clients/cahier-test-back-M1.md +++ b/docs/specs/M1-clients/cahier-test-back-M1.md @@ -23,10 +23,10 @@ merge de la stack. | RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 | | RG-1.04 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** | | RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 | -| RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation (CHECK) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | **ERP-60** | +| RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation → 422 (Assert\Callback + CHECK filet) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | ERP-60 / **ERP-76** | | RG-1.09 | Code postal `^[0-9]{4,5}$` → 422 | `ClientSubResourceApiTest::testPostAddressWithInvalidPostalCodeReturns422` | ERP-57 | | RG-1.10 | ≥ 1 site sur adresse → 422 | `ClientSubResourceApiTest::testPostAddressWithoutSiteReturns422` | ERP-57 | -| RG-1.11 | billingEmail obligatoire ssi isBilling (CHECK) | `ClientAddressTest::testBillingAddressRequiresBillingEmail` ; `::testNonBillingAddressRejectsBillingEmail` | **ERP-60** | +| RG-1.11 | billingEmail obligatoire ssi isBilling → 422 (Assert\Callback + CHECK filet) | `ClientAddressTest::testBillingAddressRequiresBillingEmail` ; `::testNonBillingAddressRejectsBillingEmail` | ERP-60 / **ERP-76** | | RG-1.12 | Virement → banque obligatoire → 422 | `ClientProcessorTest::testVirementWithoutBankIsUnprocessable` ; `::testVirementWithBankPasses` (unit) | ERP-55 | | RG-1.13 | LCR → ≥ 1 RIB ; DELETE dernier RIB en LCR → 409 | `ClientProcessorTest::testLcrWithoutRibIsUnprocessable` / `::testLcrWithRibPasses` (unit) ; `ClientSubResourceApiTest::testDeleteLastRibUnderLcrReturns409` / `::testDeleteRibNonLcrReturns204` | ERP-55 / ERP-57 | | RG-1.14 | ≥ 1 bloc Contact pour finaliser l'onglet | **Front-driven (pas de state machine back).** Back voisin : `ClientSubResourceApiTest::testDeleteLastContactReturns409` | ERP-57 | @@ -44,7 +44,7 @@ merge de la stack. | RG-1.26 | Tri par défaut companyName ASC | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 | | RG-1.27 | Timestampable/Blamable : created* figés, updated* mis à jour | `ClientAuditTest::testCreatedFrozenAndUpdatedByReflectsModifier` | **ERP-60** | | RG-1.28 | PATCH multi-groupes sans permission → 403 strict (tout le payload) | `ClientProcessorTest::testStrictMixWithAccountingFieldIsForbidden` / `::testAccountingFieldWithoutPermissionIsForbidden` (unit) ; **`ClientPatchStrictTest::testMixedGroupsPatchWithoutAccountingPermissionIsForbidden`** (fonctionnel) | ERP-55 / **ERP-60** | -| RG-1.29 | Catégorie d'adresse limitée aux types SECTEUR/AUTRE | **Filtrage LECTURE = front-driven** (SearchFilter `GET /api/categories?categoryType.code[]=…`). **Validation ÉCRITURE (POST/PATPH catégorie DISTRIBUTEUR/COURTIER → 422) NON IMPLÉMENTÉE côté back au M1** (absente du `ClientAddressProcessor` et de la liste § 8.1). → voir « Gaps & suivi » | — (gap) | +| RG-1.29 | Catégorie d'adresse limitée aux types SECTEUR/AUTRE | **Filtrage LECTURE = front-driven** (SearchFilter `GET /api/categories?categoryType.code[]=…`). **Validation ÉCRITURE** : `ClientAddress::validateCategoryTypes` (Assert\Callback) rejette une catégorie DISTRIBUTEUR/COURTIER en 422 (violation `categories`). Tests : `ClientAddressTest::testAddressRejectsDistributorCategory` / `::testAddressRejectsBrokerCategory` / `::testAddressAcceptsSectorCategory` / `::testAddressAcceptsOtherCategory` | **ERP-76** | ## Couvertures transverses @@ -66,14 +66,15 @@ merge de la stack. ## Gaps & suivi -- **RG-1.29 (validation écriture)** : refuser une catégorie de type - `DISTRIBUTEUR`/`COURTIER` sur une `ClientAddress` (→ 422, violation - `categories`) n'est pas implémenté au M1. La spec § 8.1 ne le liste pas comme - cas de test back ; le filtrage de lecture est front-driven. **Suggestion** : - ouvrir un follow-up (durcissement `ClientAddressProcessor`) ou l'intégrer à - ERP-74. Aucune invention de feature dans ERP-60 (ticket test-only). -- **Violations CHECK → statut HTTP** : les CHECK d'adresse (RG-1.06/07/08/11) - sont aujourd'hui rejetées par la base (statut ≥ 400) mais sans mapping fin - vers 422 (pas d'`exception_to_status` ni de listener DBAL→HTTP). Les tests - ERP-60 assertent donc le **rejet** (≥ 400). Un mapping explicite vers 422 - serait une amélioration UX d'API (follow-up possible). +- ~~**RG-1.29 (validation écriture)**~~ — **résolu en ERP-76**. La validation + d'écriture refuse désormais une catégorie de type `DISTRIBUTEUR`/`COURTIER` sur + une `ClientAddress` (→ 422, violation `categories`) via l'Assert\Callback + `ClientAddress::validateCategoryTypes`. Le filtrage de lecture reste + front-driven (SearchFilter). Couvert par `ClientAddressTest`. +- ~~**Violations CHECK → statut HTTP**~~ — **résolu en ERP-76**. Les règles + d'adresse RG-1.06/07/08/11 sont désormais rejetées en **422** par des + Assert\Callback applicatifs (`validateProspectExclusivity` / + `validateBillingEmailPresence`) qui s'exécutent AVANT la base. Les CHECK + Postgres (`chk_client_address_prospect_exclusive` / + `chk_client_address_billing_email`) restent en filet de sécurité. Les tests + `ClientAddressTest` assertent maintenant le 422 explicite (et non plus ≥ 400). diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php new file mode 100644 index 0000000..11f5658 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php @@ -0,0 +1,139 @@ + created_by / updated_by + * restent null (« Systeme » cote front), c'est attendu. + * + * Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, + * la fixture ne charge rien : les tests seedent et nettoient leurs propres + * categories (prefixe dedie) et comptent sur une table `category` vierge — y + * injecter 12 categories de demo casserait comptages et cleanups FK + * (client_category). Cf. ClientFixtures (meme garde-fou). + */ +class CategoryFixtures extends Fixture implements DependentFixtureInterface +{ + /** + * Source unique des categories de demonstration : code de type metier => + * liste de noms. Les noms sont stockes tels quels (l'unicite est + * case-insensitive cote index). + * + * @var array> + */ + private const CATEGORIES = [ + 'SECTEUR' => [ + 'BTP', + 'Industrie', + 'Agro-alimentaire', + 'Transport/Logistique', + 'Services', + ], + 'DISTRIBUTEUR' => [ + 'Distributeur Grand Sud-Ouest', + 'Distributeur National Premium', + 'Grossiste régional', + ], + 'COURTIER' => [ + 'Cabinet de courtage Léonard', + 'Cabinet de courtage Bernard', + ], + 'AUTRE' => [ + 'Indépendant', + 'Association', + ], + ]; + + public function __construct( + private readonly CategoryTypeRepositoryInterface $categoryTypeRepository, + #[Autowire('%kernel.environment%')] + private readonly string $environment, + ) {} + + /** + * @return array + */ + public function getDependencies(): array + { + return [CategoryTypeFixtures::class]; + } + + public function load(ObjectManager $manager): void + { + // Donnees de demo : dev uniquement. En test, on laisse la table vierge. + if ('test' === $this->environment) { + return; + } + + // Index des types metier par code (CategoryTypeFixtures les a seedes). + $typesByCode = []; + foreach ($this->categoryTypeRepository->findAllOrderedByLabel() as $type) { + $typesByCode[$type->getCode()] = $type; + } + + foreach (self::CATEGORIES as $typeCode => $names) { + $type = $typesByCode[$typeCode] ?? null; + if (!$type instanceof CategoryType) { + // Misconfiguration : CategoryTypeFixtures n'a pas tourne avant. + throw new RuntimeException(sprintf( + 'CategoryTypeFixtures doit avoir seede le type "%s" avant CategoryFixtures.', + $typeCode, + )); + } + + foreach ($names as $name) { + $this->ensureCategory($manager, $name, $type); + } + } + + $manager->flush(); + } + + /** + * Cree la categorie (name, type) si elle n'existe pas encore parmi les + * categories actives, sinon la laisse en place. Lookup aligne sur l'index + * unique partiel (nom + type, hors soft-deleted). + */ + private function ensureCategory(ObjectManager $manager, string $name, CategoryType $type): void + { + $existing = $manager->getRepository(Category::class)->findOneBy([ + 'name' => $name, + 'categoryType' => $type, + 'deletedAt' => null, + ]); + + if (null !== $existing) { + return; + } + + $category = new Category(); + $category->setName($name); + $category->setCategoryType($type); + $manager->persist($category); + } +} diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php index ca2eac8..148a0ac 100644 --- a/src/Module/Commercial/Domain/Entity/ClientAddress.php +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -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 */ #[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; diff --git a/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php b/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php new file mode 100644 index 0000000..d0facdb --- /dev/null +++ b/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php @@ -0,0 +1,555 @@ + Category) ; + * - sites resolus via le contrat Shared SiteProviderInterface. + * + * Normalisation : les valeurs sont fournies BRUTES (casse libre, telephones + * formates) et normalisees par ClientFieldNormalizer avant persist, exactement + * comme le ferait le ClientProcessor via l'API (companyName UPPERCASE, + * first/last Capitalize, telephones chiffres seuls, emails lowercase). + * + * Distributeur / courtier auto-references (RG-1.03) : les tiers referencables + * (GSO distributeur, Cabinet Leonard courtier) sont crees AVANT les clients qui + * les referencent ; un unique flush en fin de load ordonne correctement les + * inserts auto-references. + * + * Idempotence : lookup par companyName normalise (coherent avec l'index unique + * partiel uq_client_company_name_active). Un client deja present n'est pas + * reconstruit (ses sous-collections ne sont pas redupliquees). Rejouable sans + * doublon meme si le purger Doctrine est desactive. + * + * Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by + * restent null (« Systeme » cote front), c'est attendu. Les donnees respectent + * les CHECK BDD ET les validators applicatifs ERP-76 (exclusivite Prospect, + * billingEmail ssi facturation, aucune categorie DISTRIBUTEUR/COURTIER sur une + * adresse). + * + * Depend de CategoryFixtures (categories), SitesFixtures (sites) et + * CommercialReferentialFixtures (referentiels comptables Bank / PaymentType). + * + * Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, + * la fixture ne charge rien : les tests seedent et nettoient leurs propres + * clients et comptent sur une table `client` vierge — y injecter 14 clients de + * demo casserait les comptages de liste et les cleanups. Meme garde-fou que + * CategoryFixtures. + */ +class ClientFixtures extends Fixture implements DependentFixtureInterface +{ + /** Cache des categories resolues par nom (evite des requetes repetees). */ + private array $categoryCache = []; + + /** Cache des sites resolus par nom. */ + private array $siteCache = []; + + /** ObjectManager courant, capture en debut de load (resolution categories). */ + private ObjectManager $manager; + + public function __construct( + private readonly ClientFieldNormalizer $normalizer, + private readonly SiteProviderInterface $siteProvider, + #[Autowire('%kernel.environment%')] + private readonly string $environment, + ) {} + + /** + * @return array + */ + public function getDependencies(): array + { + return [ + CategoryFixtures::class, + SitesFixtures::class, + CommercialReferentialFixtures::class, + ]; + } + + public function load(ObjectManager $manager): void + { + // Donnees de demo : dev uniquement. En test, on laisse la table vierge. + if ('test' === $this->environment) { + return; + } + + $this->manager = $manager; + + // === Tiers referencables (RG-1.03) : crees en premier === + + // Distributeur reference par d'autres clients. + [$gso, $gsoIsNew] = $this->ensureClient( + $manager, + companyName: 'Distrib Grand Sud-Ouest', + firstName: 'Paul', + lastName: 'Garnier', + phonePrimary: '05 56 10 20 30', + email: 'contact@distrib-gso.fr', + categoryNames: ['Distributeur Grand Sud-Ouest'], + ); + if ($gsoIsNew) { + $this->addContact($gso, 'Paul', 'Garnier', 'Directeur commercial', '05 56 10 20 30', null, 'paul.garnier@distrib-gso.fr'); + $this->addAddress($gso, ['Pommevic'], '82400', 'Pommevic', '1 Av. Jean Duquesne', isDelivery: true, categoryNames: ['Transport/Logistique']); + } + + // Courtier reference par d'autres clients. + [$leonard, $leonardIsNew] = $this->ensureClient( + $manager, + companyName: 'Cabinet Léonard Assurances', + firstName: 'Sophie', + lastName: 'Léonard', + phonePrimary: '05 49 11 22 33', + email: 'contact@cabinet-leonard.fr', + categoryNames: ['Cabinet de courtage Léonard'], + ); + if ($leonardIsNew) { + $this->addContact($leonard, 'Sophie', 'Léonard', 'Gérante', '05 49 11 22 33', null, 'sophie.leonard@cabinet-leonard.fr'); + $this->addAddress($leonard, ['Chatellerault'], '86100', 'Châtellerault', '5 rue des Courtiers', isBilling: true, billingEmail: 'Factures@Cabinet-Leonard.FR'); + } + + // === Client basique === + [$dubois, $isNew] = $this->ensureClient( + $manager, + companyName: 'Menuiserie Dubois', + firstName: 'Jean', + lastName: 'Dubois', + phonePrimary: '05 49 00 00 01', + email: 'contact@menuiserie-dubois.fr', + categoryNames: ['BTP'], + ); + if ($isNew) { + $dubois->setPaymentType($this->paymentType($manager, 'VIREMENT')); + $dubois->setBank($this->bank($manager, 'SG')); + $this->addContact($dubois, 'Jean', 'Dubois', 'Gérant', '05 49 00 00 01', null, 'jean.dubois@menuiserie-dubois.fr'); + $this->addAddress($dubois, ['Chatellerault'], '86100', 'Châtellerault', '12 rue de l\'Atelier', isDelivery: true, categoryNames: ['BTP']); + } + + // === Dependant d'un distributeur (RG-1.03) === + [$garage, $isNew] = $this->ensureClient( + $manager, + companyName: 'Garage Martin', + firstName: 'Luc', + lastName: 'Martin', + phonePrimary: '05 56 44 55 66', + email: 'accueil@garage-martin.fr', + categoryNames: ['Services'], + ); + if ($isNew) { + $garage->setDistributor($gso); + $this->addContact($garage, 'Luc', 'Martin', 'Gérant', '05 56 44 55 66', null, 'luc.martin@garage-martin.fr'); + $this->addAddress($garage, ['Pommevic'], '82400', 'Pommevic', '8 route de Moissac', isDelivery: true); + } + + // === Dependant d'un courtier (RG-1.03) === + [$boulangerie, $isNew] = $this->ensureClient( + $manager, + companyName: 'Boulangerie Lemoine', + firstName: 'Marie', + lastName: 'Lemoine', + phonePrimary: '05 49 77 88 99', + email: 'bonjour@boulangerie-lemoine.fr', + categoryNames: ['Agro-alimentaire'], + ); + if ($isNew) { + $boulangerie->setBroker($leonard); + $this->addContact($boulangerie, 'Marie', 'Lemoine', 'Gérante', '05 49 77 88 99', null, 'marie.lemoine@boulangerie-lemoine.fr'); + $this->addAddress($boulangerie, ['Chatellerault'], '86100', 'Châtellerault', '3 place du Marché', isDelivery: true); + } + + // === Reglement LCR avec 2 RIB (RG-1.13) === + [$transports, $isNew] = $this->ensureClient( + $manager, + companyName: 'Transports Rapides', + firstName: null, + lastName: 'Bernard', + phonePrimary: '05 56 12 13 14', + email: 'exploitation@transports-rapides.fr', + categoryNames: ['Transport/Logistique'], + ); + if ($isNew) { + $transports->setPaymentType($this->paymentType($manager, 'LCR')); + $this->addContact($transports, null, 'Bernard', 'Responsable exploitation', '05 56 12 13 14', null, 'expl@transports-rapides.fr'); + $this->addAddress($transports, ['Saint-Jean'], '17400', 'Fontenet', '2 zone industrielle', isDelivery: true, categoryNames: ['Transport/Logistique']); + $this->addRib($transports, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0); + $this->addRib($transports, 'Compte secondaire', 'SOGEFRPPXXX', 'FR7630006000011234567890189', 1); + } + + // === Multi-adresses Prospect / Livraison / Facturation (RG-1.06/07/08/11) === + [$industries, $isNew] = $this->ensureClient( + $manager, + companyName: 'Industries Vertes', + firstName: 'Claire', + lastName: 'Moreau', + phonePrimary: '05 49 21 22 23', + email: 'contact@industries-vertes.fr', + categoryNames: ['Industrie'], + ); + if ($isNew) { + $this->addContact($industries, 'Claire', 'Moreau', 'Directrice', '05 49 21 22 23', null, 'claire.moreau@industries-vertes.fr'); + // Prospect : exclusif de livraison/facturation (sans billingEmail). + $this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '1 avenue de la Prospection', isProspect: true, position: 0); + // Livraison. + $this->addAddress($industries, ['Saint-Jean'], '17400', 'Fontenet', '4 rue de la Livraison', isDelivery: true, categoryNames: ['Industrie'], position: 1); + // Facturation : billingEmail obligatoire. + $this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '7 boulevard des Factures', isBilling: true, billingEmail: 'Compta@Industries-Vertes.FR', position: 2); + } + + // === 3 contacts dont un avec telephone secondaire (RG-1.05/1.02) === + [$agro, $isNew] = $this->ensureClient( + $manager, + companyName: 'Agro Distribution Sud', + firstName: 'Thomas', + lastName: 'Petit', + phonePrimary: '05 56 31 32 33', + email: 'contact@agro-sud.fr', + categoryNames: ['Agro-alimentaire'], + phoneSecondary: '06 01 02 03 04', + ); + if ($isNew) { + $this->addContact($agro, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@agro-sud.fr', 0); + $this->addContact($agro, 'Julie', 'Roux', 'Assistante commerciale', '05 56 31 32 34', null, 'julie.roux@agro-sud.fr', 1); + $this->addContact($agro, 'Marc', 'Girard', 'Logistique', '05 56 31 32 35', null, 'marc.girard@agro-sud.fr', 2); + $this->addAddress($agro, ['Pommevic'], '82400', 'Pommevic', '10 rue des Producteurs', isDelivery: true); + } + + // === Client archive (RG-1.22) === + [$ancienne, $isNew] = $this->ensureClient( + $manager, + companyName: 'Ancienne Société Oubliée', + firstName: null, + lastName: 'Durand', + phonePrimary: '05 49 99 99 99', + email: 'contact@ancienne-societe.fr', + categoryNames: ['Association'], + isArchived: true, + ); + if ($isNew) { + $this->addContact($ancienne, null, 'Durand', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancienne-societe.fr'); + $this->addAddress($ancienne, ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée', isDelivery: true); + } + + // === Reglement Cheque sans RIB === + [$services, $isNew] = $this->ensureClient( + $manager, + companyName: 'Services Pro Conseil', + firstName: 'Nadia', + lastName: 'Benali', + phonePrimary: '05 49 41 42 43', + email: 'contact@services-pro.fr', + categoryNames: ['Services'], + ); + if ($isNew) { + $services->setPaymentType($this->paymentType($manager, 'CHEQUE')); + $this->addContact($services, 'Nadia', 'Benali', 'Consultante', '05 49 41 42 43', null, 'nadia.benali@services-pro.fr'); + $this->addAddress($services, ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Conseil', isDelivery: true); + } + + // === Onglet Information complet (RG-1.04) === + [$holding, $isNew] = $this->ensureClient( + $manager, + companyName: 'Holding Premium Invest', + firstName: 'Antoine', + lastName: 'Lefèvre', + phonePrimary: '05 56 51 52 53', + email: 'direction@holding-premium.fr', + categoryNames: ['Industrie'], + ); + if ($isNew) { + $holding->setDescription('Holding industrielle diversifiée, présente sur le Grand Sud-Ouest.'); + $holding->setCompetitors('Groupe Atlantique, Sud Industries'); + $holding->setFoundedAt(new DateTimeImmutable('2005-03-15')); + $holding->setEmployeesCount(240); + $holding->setRevenueAmount('18500000.00'); + $holding->setDirectorName('Antoine Lefèvre'); + $holding->setProfitAmount('1250000.00'); + $this->addContact($holding, 'Antoine', 'Lefèvre', 'PDG', '05 56 51 52 53', null, 'antoine.lefevre@holding-premium.fr'); + $this->addAddress($holding, ['Pommevic'], '82400', 'Pommevic', '1 allée des Investisseurs', isDelivery: true, categoryNames: ['Industrie']); + } + + // === Multi-categories M2M === + [$conglo, $isNew] = $this->ensureClient( + $manager, + companyName: 'Conglomérat Multi Activités', + firstName: 'Hélène', + lastName: 'Faure', + phonePrimary: '05 49 61 62 63', + email: 'contact@conglomerat-multi.fr', + categoryNames: ['BTP', 'Industrie', 'Services'], + ); + if ($isNew) { + $this->addContact($conglo, 'Hélène', 'Faure', 'Directrice générale', '05 49 61 62 63', null, 'helene.faure@conglomerat-multi.fr'); + $this->addAddress($conglo, ['Chatellerault', 'Saint-Jean'], '86100', 'Châtellerault', '20 rue des Activités', isDelivery: true, categoryNames: ['BTP', 'Services']); + } + + // === Prospect seul === + [$prospect, $isNew] = $this->ensureClient( + $manager, + companyName: 'Prospect Futur Client', + firstName: 'Olivier', + lastName: 'Renard', + phonePrimary: '05 56 71 72 73', + email: 'olivier.renard@prospect-futur.fr', + categoryNames: ['BTP'], + ); + if ($isNew) { + $this->addContact($prospect, 'Olivier', 'Renard', 'Responsable projet', '05 56 71 72 73', null, 'olivier.renard@prospect-futur.fr'); + $this->addAddress($prospect, ['Chatellerault'], '86100', 'Châtellerault', '30 rue de la Découverte', isProspect: true); + } + + // === Categorie AUTRE === + [$association, $isNew] = $this->ensureClient( + $manager, + companyName: 'Association des Riverains', + firstName: null, + lastName: 'Caron', + phonePrimary: '05 49 81 82 83', + email: 'contact@asso-riverains.fr', + categoryNames: ['Association'], + ); + if ($isNew) { + $this->addContact($association, null, 'Caron', 'Président', '05 49 81 82 83', null, 'president@asso-riverains.fr'); + $this->addAddress($association, ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village', isDelivery: true, categoryNames: ['Association']); + } + + $manager->flush(); + } + + /** + * Cree un client (base normalisee + categories) s'il n'existe pas encore, + * sinon retourne l'existant. Retourne [Client, isNew] : isNew=false bloque la + * reconstruction des sous-collections (idempotence sans doublon). + * + * @param list $categoryNames + * + * @return array{0: Client, 1: bool} + */ + private function ensureClient( + ObjectManager $manager, + string $companyName, + ?string $firstName, + ?string $lastName, + string $phonePrimary, + string $email, + array $categoryNames, + ?string $phoneSecondary = null, + bool $isArchived = false, + ): array { + $normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName); + + $existing = $manager->getRepository(Client::class)->findOneBy(['companyName' => $normalizedName]); + if ($existing instanceof Client) { + return [$existing, false]; + } + + $client = new Client(); + $client->setCompanyName($normalizedName); + $client->setFirstName($this->normalizer->normalizePersonName($firstName)); + $client->setLastName($this->normalizer->normalizePersonName($lastName)); + $client->setPhonePrimary((string) $this->normalizer->normalizePhone($phonePrimary)); + $client->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary)); + $client->setEmail((string) $this->normalizer->normalizeEmail($email)); + + foreach ($categoryNames as $categoryName) { + $client->addCategory($this->category($manager, $categoryName)); + } + + if ($isArchived) { + $client->setIsArchived(true); + $client->setArchivedAt(new DateTimeImmutable()); + } + + $manager->persist($client); + + return [$client, true]; + } + + /** + * Ajoute un contact normalise au client (cascade persist via Client.contacts). + * Au moins lastName est toujours fourni (RG-1.05, chk_client_contact_name). + */ + private function addContact( + Client $client, + ?string $firstName, + ?string $lastName, + ?string $jobTitle, + ?string $phonePrimary, + ?string $phoneSecondary, + ?string $email, + int $position = 0, + ): void { + $contact = new ClientContact(); + $contact->setFirstName($this->normalizer->normalizePersonName($firstName)); + $contact->setLastName($this->normalizer->normalizePersonName($lastName)); + $contact->setJobTitle($jobTitle); + $contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary)); + $contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary)); + $contact->setEmail($this->normalizer->normalizeEmail($email)); + $contact->setPosition($position); + + $client->addContact($contact); + } + + /** + * Ajoute une adresse au client (cascade persist via Client.addresses). Les + * donnees respectent les validators ERP-76 : exclusivite Prospect, + * billingEmail ssi facturation, categories limitees a SECTEUR/AUTRE. + * + * @param list $siteNames au moins un site (RG-1.10) + * @param list $categoryNames categories SECTEUR/AUTRE uniquement (RG-1.29) + */ + private function addAddress( + Client $client, + array $siteNames, + string $postalCode, + string $city, + string $street, + bool $isProspect = false, + bool $isDelivery = false, + bool $isBilling = false, + ?string $billingEmail = null, + array $categoryNames = [], + int $position = 0, + ): void { + $address = new ClientAddress(); + $address->setIsProspect($isProspect); + $address->setIsDelivery($isDelivery); + $address->setIsBilling($isBilling); + $address->setBillingEmail($this->normalizer->normalizeEmail($billingEmail)); + $address->setPostalCode($postalCode); + $address->setCity($city); + $address->setStreet($street); + $address->setPosition($position); + + foreach ($siteNames as $siteName) { + $address->addSite($this->site($siteName)); + } + + foreach ($categoryNames as $categoryName) { + $address->addCategory($this->category($this->manager, $categoryName)); + } + + $client->addAddress($address); + } + + /** + * Ajoute un RIB au client (cascade persist via Client.ribs). IBAN/BIC valides + * (Assert\Iban/Bic non rejouee sur persist direct mais donnees coherentes). + */ + private function addRib(Client $client, string $label, string $bic, string $iban, int $position = 0): void + { + $rib = new ClientRib(); + $rib->setLabel($label); + $rib->setBic($bic); + $rib->setIban($iban); + $rib->setPosition($position); + + $client->addRib($rib); + } + + /** + * Resout une categorie par son nom via le contrat Shared CategoryInterface + * (resolve_target_entities -> Category), sans importer le module Catalog + * (regle n°1). Mise en cache par nom. + */ + private function category(ObjectManager $manager, string $name): CategoryInterface + { + if (isset($this->categoryCache[$name])) { + return $this->categoryCache[$name]; + } + + $category = $manager->getRepository(CategoryInterface::class)->findOneBy([ + 'name' => $name, + 'deletedAt' => null, + ]); + + if (!$category instanceof CategoryInterface) { + throw new RuntimeException(sprintf( + 'Categorie "%s" introuvable : CategoryFixtures doit tourner avant ClientFixtures.', + $name, + )); + } + + return $this->categoryCache[$name] = $category; + } + + /** + * Resout un site par son nom via le contrat Shared SiteProviderInterface, + * sans importer le module Sites (regle n°1). Mise en cache par nom. + */ + private function site(string $name): SiteInterface + { + if (isset($this->siteCache[$name])) { + return $this->siteCache[$name]; + } + + $site = $this->siteProvider->findByName($name); + + if (!$site instanceof SiteInterface) { + throw new RuntimeException(sprintf( + 'Site "%s" introuvable : SitesFixtures doit tourner avant ClientFixtures.', + $name, + )); + } + + return $this->siteCache[$name] = $site; + } + + private function paymentType(ObjectManager $manager, string $code): PaymentType + { + $type = $manager->getRepository(PaymentType::class)->findOneBy(['code' => $code]); + + if (!$type instanceof PaymentType) { + throw new RuntimeException(sprintf( + 'PaymentType "%s" introuvable : CommercialReferentialFixtures doit tourner avant ClientFixtures.', + $code, + )); + } + + return $type; + } + + private function bank(ObjectManager $manager, string $code): Bank + { + $bank = $manager->getRepository(Bank::class)->findOneBy(['code' => $code]); + + if (!$bank instanceof Bank) { + throw new RuntimeException(sprintf( + 'Bank "%s" introuvable : CommercialReferentialFixtures doit tourner avant ClientFixtures.', + $code, + )); + } + + return $bank; + } +} diff --git a/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php b/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php index dbd31f5..46176d7 100644 --- a/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php +++ b/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php @@ -28,6 +28,11 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; * systeme de maniere idempotente avant de rattacher les utilisateurs, afin * que le workflow "make db-reset && make fixtures" reste one-shot. * + * Idempotence complete (y compris `doctrine:fixtures:load --append`, sans + * purge) : roles via ensureSystemRole, utilisateurs via ensureUser (lookup par + * username). Rejouer la fixture ne cree donc aucun doublon ni violation + * d'unicite de username. + * * Dependance explicite a SitesFixtures (ticket 2) : les 3 sites Chatellerault, * Saint-Jean et Pommevic doivent etre presents en base avant d'etre rattaches * aux users. L'inversion volontaire de l'ordre (AppFixtures ← SitesFixtures) @@ -75,8 +80,7 @@ class AppFixtures extends Fixture implements DependentFixtureInterface $saintJean = $this->requireSite('Saint-Jean'); $pommevic = $this->requireSite('Pommevic'); - $admin = new User(); - $admin->setUsername('admin'); + $admin = $this->ensureUser($manager, 'admin'); $admin->setIsAdmin(true); $admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin')); $admin->addRbacRole($adminRole); @@ -87,8 +91,7 @@ class AppFixtures extends Fixture implements DependentFixtureInterface $admin->setCurrentSite($chatellerault); $manager->persist($admin); - $alice = new User(); - $alice->setUsername('alice'); + $alice = $this->ensureUser($manager, 'alice'); $alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice')); $alice->addRbacRole($userRole); // Alice : un seul site, site courant = ce site. @@ -96,8 +99,7 @@ class AppFixtures extends Fixture implements DependentFixtureInterface $alice->setCurrentSite($chatellerault); $manager->persist($alice); - $bob = new User(); - $bob->setUsername('bob'); + $bob = $this->ensureUser($manager, 'bob'); $bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob')); $bob->addRbacRole($userRole); // Bob : site different de Alice, pour prouver le filtrage par site @@ -135,6 +137,27 @@ class AppFixtures extends Fixture implements DependentFixtureInterface return $role; } + /** + * Retourne l'utilisateur correspondant au username, en le creant s'il + * n'existe pas encore. Rend la fixture idempotente y compris en + * `doctrine:fixtures:load --append` (sans purge) : sans ce lookup, recreer + * « admin » / « alice » / « bob » violerait l'unicite de username. Meme + * esprit que ensureSystemRole ci-dessus et RbacDemoFixtures::ensureDemoUsers. + */ + private function ensureUser(ObjectManager $manager, string $username): User + { + $user = $manager->getRepository(User::class)->findOneBy(['username' => $username]); + + if (null !== $user) { + return $user; + } + + $user = new User(); + $user->setUsername($username); + + return $user; + } + private function requireSite(string $name): SiteInterface { $site = $this->siteProvider->findByName($name); diff --git a/tests/Module/Commercial/Api/ClientAddressTest.php b/tests/Module/Commercial/Api/ClientAddressTest.php index cc1578f..11b6be0 100644 --- a/tests/Module/Commercial/Api/ClientAddressTest.php +++ b/tests/Module/Commercial/Api/ClientAddressTest.php @@ -7,27 +7,22 @@ namespace App\Tests\Module\Commercial\Api; use App\Module\Sites\Domain\Entity\Site; /** - * Tests fonctionnels de l'onglet Adresse — combler les trous (ERP-60). + * Tests fonctionnels de l'onglet Adresse. * * RG-1.09 (code postal) et RG-1.10 (>= 1 site) sont DEJA couverts par * ClientSubResourceApiTest (ERP-57) et ne sont pas reduplique ici. Ce fichier - * cible les contraintes CHECK BDD non encore testees : - * - RG-1.06 / RG-1.07 / RG-1.08 : `chk_client_address_prospect_exclusive` - * (is_prospect exclusif de is_delivery / is_billing) ; - * - RG-1.11 : `chk_client_address_billing_email` (billing_email obligatoire - * ssi is_billing). + * cible : + * - RG-1.06 / RG-1.07 / RG-1.08 : exclusivite is_prospect vs + * is_delivery / is_billing ; + * - RG-1.11 : billing_email obligatoire ssi is_billing ; + * - RG-1.29 : seules les categories de type SECTEUR / AUTRE sont autorisees sur + * une adresse (DISTRIBUTEUR / COURTIER -> 422). * - * Note : ces regles sont portees par des CHECK Postgres (pas d'Assert ni de - * regle Processor au M1). On verifie donc que la combinaison invalide est - * REJETEE par le serveur (statut >= 400), sans coupler le test au code exact : - * une violation CHECK non mappee remonte aujourd'hui en erreur serveur ; un - * mapping fin vers 422 serait une amelioration ulterieure (hors perimetre - * ERP-60, test-only). - * - * RG-1.29 (filtrage du type de categorie SECTEUR/AUTRE sur une adresse) n'est - * PAS testee : la validation d'ecriture correspondante n'est pas implementee - * cote back au M1 (et ne figure pas dans la liste § 8.1). Documentee comme gap - * dans le cahier de test #478. + * Depuis ERP-76, ces regles sont portees par des Assert\Callback sur l'entite + * ClientAddress (mirror applicatif des CHECK Postgres) : la combinaison invalide + * est donc rejetee en 422 AVANT la base, et non plus par une violation CHECK + * remontant en 500. Les CHECK BDD restent en filet de securite (non testes ici, + * inatteignables tant que les validators applicatifs passent en premier). * * @internal */ @@ -37,7 +32,8 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase /** * RG-1.06 / RG-1.07 : une adresse de prospection ne peut pas etre une - * adresse de livraison (CHECK chk_client_address_prospect_exclusive). + * adresse de livraison -> 422 (Assert\Callback, mirror du CHECK + * chk_client_address_prospect_exclusive). */ public function testProspectAddressCannotBeDelivery(): void { @@ -45,7 +41,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase $client = $this->createAdminClient(); $seed = $this->seedClient('Prospect Delivery'); - $response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isProspect' => true, @@ -57,13 +53,13 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase ], ]); - self::assertGreaterThanOrEqual(400, $response->getStatusCode()); + self::assertResponseStatusCodeSame(422); } /** * RG-1.06 / RG-1.08 : une adresse de prospection ne peut pas etre une - * adresse de facturation (meme CHECK). On fournit billingEmail pour que la - * seule violation possible soit l'exclusivite prospect/billing. + * adresse de facturation -> 422. On fournit billingEmail pour que la seule + * violation possible soit l'exclusivite prospect/billing. */ public function testProspectAddressCannotBeBilling(): void { @@ -71,7 +67,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase $client = $this->createAdminClient(); $seed = $this->seedClient('Prospect Billing'); - $response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isProspect' => true, @@ -84,12 +80,11 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase ], ]); - self::assertGreaterThanOrEqual(400, $response->getStatusCode()); + self::assertResponseStatusCodeSame(422); } /** - * RG-1.11 : une adresse de facturation exige un billingEmail - * (CHECK chk_client_address_billing_email). + * RG-1.11 : une adresse de facturation exige un billingEmail -> 422. */ public function testBillingAddressRequiresBillingEmail(): void { @@ -97,7 +92,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase $client = $this->createAdminClient(); $seed = $this->seedClient('Billing No Email'); - $response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isBilling' => true, @@ -108,12 +103,39 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase ], ]); - self::assertGreaterThanOrEqual(400, $response->getStatusCode()); + self::assertResponseStatusCodeSame(422); + } + + /** + * RG-1.11 (cas chaine vide) : une adresse de facturation avec un billingEmail + * vide ("") doit etre rejetee en 422, et NON passer la validation pour finir + * en 500 sur le CHECK BDD. Le ClientAddressProcessor normalise "" -> null + * APRES la validation : le callback doit donc traiter "" comme « absent ». + */ + public function testBillingAddressRejectsEmptyBillingEmail(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Billing Empty Email'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'isBilling' => true, + 'billingEmail' => '', + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + ], + ]); + + self::assertResponseStatusCodeSame(422); } /** * RG-1.11 (sens inverse) : une adresse NON facturable ne peut pas porter un - * billingEmail (meme CHECK). + * billingEmail -> 422. */ public function testNonBillingAddressRejectsBillingEmail(): void { @@ -121,7 +143,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase $client = $this->createAdminClient(); $seed = $this->seedClient('Non Billing With Email'); - $response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isBilling' => false, @@ -133,7 +155,135 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase ], ]); - self::assertGreaterThanOrEqual(400, $response->getStatusCode()); + self::assertResponseStatusCodeSame(422); + } + + /** + * RG-1.11 (sens inverse, cas chaine vide) : une adresse NON facturable avec + * un billingEmail vide ("") est ACCEPTEE (201). Le "" equivaut a « pas + * d'email » : il ne doit pas declencher la violation « email interdit hors + * facturation » (sinon un champ simplement vide serait refuse a tort). + */ + public function testNonBillingAddressAcceptsEmptyBillingEmail(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Non Billing Empty Email'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'isBilling' => false, + 'billingEmail' => '', + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + ], + ]); + + self::assertResponseStatusCodeSame(201); + } + + /** + * RG-1.29 : poster une categorie de type DISTRIBUTEUR sur une adresse -> 422 + * avec violation sur le champ `categories`. + */ + public function testAddressRejectsDistributorCategory(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address Distributor Cat'); + $category = $this->createCategory('DISTRIBUTEUR'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$category->getId()], + ], + ]); + + self::assertResponseStatusCodeSame(422); + self::assertStringContainsString( + 'Type de catégorie non autorisé sur une adresse.', + (string) $client->getResponse()->getContent(false), + ); + } + + /** + * RG-1.29 : poster une categorie de type COURTIER sur une adresse -> 422. + */ + public function testAddressRejectsBrokerCategory(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address Broker Cat'); + $category = $this->createCategory('COURTIER'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$category->getId()], + ], + ]); + + self::assertResponseStatusCodeSame(422); + } + + /** + * RG-1.29 : une categorie de type SECTEUR est autorisee sur une adresse. + */ + public function testAddressAcceptsSectorCategory(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address Sector Cat'); + $category = $this->createCategory('SECTEUR'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$category->getId()], + ], + ]); + + self::assertResponseStatusCodeSame(201); + } + + /** + * RG-1.29 : une categorie de type AUTRE est autorisee sur une adresse. + */ + public function testAddressAcceptsOtherCategory(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address Other Cat'); + $category = $this->createCategory('AUTRE'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$category->getId()], + ], + ]); + + self::assertResponseStatusCodeSame(201); } /**