Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a668a8eb28 | |||
| a52e3bec34 |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.61'
|
app.version: '0.1.62'
|
||||||
|
|||||||
@@ -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.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.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.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.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.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.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.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 |
|
| 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.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.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.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
|
## Couvertures transverses
|
||||||
|
|
||||||
@@ -66,14 +66,15 @@ merge de la stack.
|
|||||||
|
|
||||||
## Gaps & suivi
|
## Gaps & suivi
|
||||||
|
|
||||||
- **RG-1.29 (validation écriture)** : refuser une catégorie de type
|
- ~~**RG-1.29 (validation écriture)**~~ — **résolu en ERP-76**. La validation
|
||||||
`DISTRIBUTEUR`/`COURTIER` sur une `ClientAddress` (→ 422, violation
|
d'écriture refuse désormais une catégorie de type `DISTRIBUTEUR`/`COURTIER` sur
|
||||||
`categories`) n'est pas implémenté au M1. La spec § 8.1 ne le liste pas comme
|
une `ClientAddress` (→ 422, violation `categories`) via l'Assert\Callback
|
||||||
cas de test back ; le filtrage de lecture est front-driven. **Suggestion** :
|
`ClientAddress::validateCategoryTypes`. Le filtrage de lecture reste
|
||||||
ouvrir un follow-up (durcissement `ClientAddressProcessor`) ou l'intégrer à
|
front-driven (SearchFilter). Couvert par `ClientAddressTest`.
|
||||||
ERP-74. Aucune invention de feature dans ERP-60 (ticket test-only).
|
- ~~**Violations CHECK → statut HTTP**~~ — **résolu en ERP-76**. Les règles
|
||||||
- **Violations CHECK → statut HTTP** : les CHECK d'adresse (RG-1.06/07/08/11)
|
d'adresse RG-1.06/07/08/11 sont désormais rejetées en **422** par des
|
||||||
sont aujourd'hui rejetées par la base (statut ≥ 400) mais sans mapping fin
|
Assert\Callback applicatifs (`validateProspectExclusivity` /
|
||||||
vers 422 (pas d'`exception_to_status` ni de listener DBAL→HTTP). Les tests
|
`validateBillingEmailPresence`) qui s'exécutent AVANT la base. Les CHECK
|
||||||
ERP-60 assertent donc le **rejet** (≥ 400). Un mapping explicite vers 422
|
Postgres (`chk_client_address_prospect_exclusive` /
|
||||||
serait une amélioration UX d'API (follow-up possible).
|
`chk_client_address_billing_email`) restent en filet de sécurité. Les tests
|
||||||
|
`ClientAddressTest` assertent maintenant le 422 explicite (et non plus ≥ 400).
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Infrastructure\DataFixtures;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\Category;
|
||||||
|
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||||
|
use App\Module\Catalog\Domain\Repository\CategoryTypeRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixtures dev/test du module Catalog : ~12 categories de demonstration reparties
|
||||||
|
* sur les 4 types metier (DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE). Alimente le
|
||||||
|
* repertoire clients (ClientFixtures, module Commercial) avec des donnees
|
||||||
|
* realistes couvrant les categorisations RG-1.03 (DISTRIBUTEUR/COURTIER) et
|
||||||
|
* RG-1.29 (SECTEUR/AUTRE sur adresse).
|
||||||
|
*
|
||||||
|
* Depend de CategoryTypeFixtures : les 4 CategoryType doivent etre seedes avant
|
||||||
|
* de pouvoir y rattacher des Category.
|
||||||
|
*
|
||||||
|
* Idempotence : lookup par (name, categoryType) parmi les categories non
|
||||||
|
* supprimees (deletedAt null), coherent avec l'index unique partiel
|
||||||
|
* uq_category_name_type_active (LOWER(name), category_type_id WHERE deleted_at
|
||||||
|
* IS NULL). 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.
|
||||||
|
*
|
||||||
|
* 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<string, list<string>>
|
||||||
|
*/
|
||||||
|
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<int, class-string>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,19 +23,23 @@ use Doctrine\Common\Collections\Collection;
|
|||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
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
|
* Adresse d'un client (1:n) — onglet Adresse. Une adresse de prospection
|
||||||
* (isProspect) est exclusive d'une adresse de livraison/facturation
|
* (isProspect) est exclusive d'une adresse de livraison/facturation
|
||||||
* (RG-1.06/07/08, CHECK BDD). Un email de facturation est obligatoire ssi
|
* (RG-1.06/07/08). Un email de facturation est obligatoire ssi isBilling
|
||||||
* isBilling (RG-1.11, CHECK BDD). Au moins un site doit etre rattache
|
* (RG-1.11). Au moins un site doit etre rattache (RG-1.10, Assert\Count). Ces
|
||||||
* (RG-1.10, Assert\Count).
|
* 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 :
|
* Relations M2M :
|
||||||
* - sites : SiteInterface (module Sites) via resolve_target_entities
|
* - sites : SiteInterface (module Sites) via resolve_target_entities
|
||||||
* - contacts : ClientContact (meme module)
|
* - contacts : ClientContact (meme module)
|
||||||
* - categories : CategoryInterface (module Catalog) via resolve_target_entities
|
* - 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.
|
* Audite (#[Auditable]) + Timestampable/Blamable.
|
||||||
*
|
*
|
||||||
@@ -83,6 +87,9 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
|||||||
{
|
{
|
||||||
use TimestampableBlamableTrait;
|
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\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
@@ -130,7 +137,7 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['client_address:read', 'client_address:write'])]
|
#[Groups(['client_address:read', 'client_address:write'])]
|
||||||
private ?string $streetComplement = null;
|
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)]
|
#[ORM\Column(length: 180, nullable: true)]
|
||||||
#[Assert\Email]
|
#[Assert\Email]
|
||||||
#[Groups(['client_address:read', 'client_address:write'])]
|
#[Groups(['client_address:read', 'client_address:write'])]
|
||||||
@@ -158,7 +165,7 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['client_address:read', 'client_address:write'])]
|
#[Groups(['client_address:read', 'client_address:write'])]
|
||||||
private Collection $contacts;
|
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> */
|
/** @var Collection<int, CategoryInterface> */
|
||||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||||
#[ORM\JoinTable(name: 'client_address_category')]
|
#[ORM\JoinTable(name: 'client_address_category')]
|
||||||
@@ -174,6 +181,79 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
|||||||
$this->categories = 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 : 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
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
|
|||||||
@@ -0,0 +1,555 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Commercial\Infrastructure\DataFixtures;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Infrastructure\DataFixtures\CategoryFixtures;
|
||||||
|
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
|
||||||
|
use App\Module\Commercial\Domain\Entity\Bank;
|
||||||
|
use App\Module\Commercial\Domain\Entity\Client;
|
||||||
|
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||||
|
use App\Module\Commercial\Domain\Entity\ClientContact;
|
||||||
|
use App\Module\Commercial\Domain\Entity\ClientRib;
|
||||||
|
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||||
|
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
|
||||||
|
use App\Shared\Domain\Contract\CategoryInterface;
|
||||||
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
|
use App\Shared\Domain\Contract\SiteProviderInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixtures dev/test du module Commercial : ~14 clients de demonstration couvrant
|
||||||
|
* l'ensemble des cas metier RG-1.xx du repertoire clients (M1) :
|
||||||
|
* - client basique ; dependant distributeur / courtier (RG-1.03) ;
|
||||||
|
* - reglement LCR avec 2 RIB (RG-1.13) ; reglement Cheque sans RIB ;
|
||||||
|
* - multi-adresses Prospect / Livraison / Facturation (RG-1.06/07/08/11) ;
|
||||||
|
* - prospect seul ; 3 contacts dont un avec telephone secondaire (RG-1.05/1.02) ;
|
||||||
|
* - client archive (RG-1.22) ; onglet Information complet ; multi-categories M2M.
|
||||||
|
*
|
||||||
|
* Resolution inter-modules conforme a la regle n°1 (pas d'import direct) :
|
||||||
|
* - categories resolues via le contrat Shared CategoryInterface
|
||||||
|
* (resolve_target_entities -> 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<int, class-string>
|
||||||
|
*/
|
||||||
|
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<string> $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<string> $siteNames au moins un site (RG-1.10)
|
||||||
|
* @param list<string> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
|||||||
* systeme de maniere idempotente avant de rattacher les utilisateurs, afin
|
* systeme de maniere idempotente avant de rattacher les utilisateurs, afin
|
||||||
* que le workflow "make db-reset && make fixtures" reste one-shot.
|
* 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,
|
* Dependance explicite a SitesFixtures (ticket 2) : les 3 sites Chatellerault,
|
||||||
* Saint-Jean et Pommevic doivent etre presents en base avant d'etre rattaches
|
* Saint-Jean et Pommevic doivent etre presents en base avant d'etre rattaches
|
||||||
* aux users. L'inversion volontaire de l'ordre (AppFixtures ← SitesFixtures)
|
* 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');
|
$saintJean = $this->requireSite('Saint-Jean');
|
||||||
$pommevic = $this->requireSite('Pommevic');
|
$pommevic = $this->requireSite('Pommevic');
|
||||||
|
|
||||||
$admin = new User();
|
$admin = $this->ensureUser($manager, 'admin');
|
||||||
$admin->setUsername('admin');
|
|
||||||
$admin->setIsAdmin(true);
|
$admin->setIsAdmin(true);
|
||||||
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
|
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
|
||||||
$admin->addRbacRole($adminRole);
|
$admin->addRbacRole($adminRole);
|
||||||
@@ -87,8 +91,7 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
|
|||||||
$admin->setCurrentSite($chatellerault);
|
$admin->setCurrentSite($chatellerault);
|
||||||
$manager->persist($admin);
|
$manager->persist($admin);
|
||||||
|
|
||||||
$alice = new User();
|
$alice = $this->ensureUser($manager, 'alice');
|
||||||
$alice->setUsername('alice');
|
|
||||||
$alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice'));
|
$alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice'));
|
||||||
$alice->addRbacRole($userRole);
|
$alice->addRbacRole($userRole);
|
||||||
// Alice : un seul site, site courant = ce site.
|
// Alice : un seul site, site courant = ce site.
|
||||||
@@ -96,8 +99,7 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
|
|||||||
$alice->setCurrentSite($chatellerault);
|
$alice->setCurrentSite($chatellerault);
|
||||||
$manager->persist($alice);
|
$manager->persist($alice);
|
||||||
|
|
||||||
$bob = new User();
|
$bob = $this->ensureUser($manager, 'bob');
|
||||||
$bob->setUsername('bob');
|
|
||||||
$bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob'));
|
$bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob'));
|
||||||
$bob->addRbacRole($userRole);
|
$bob->addRbacRole($userRole);
|
||||||
// Bob : site different de Alice, pour prouver le filtrage par site
|
// Bob : site different de Alice, pour prouver le filtrage par site
|
||||||
@@ -135,6 +137,27 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
|
|||||||
return $role;
|
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
|
private function requireSite(string $name): SiteInterface
|
||||||
{
|
{
|
||||||
$site = $this->siteProvider->findByName($name);
|
$site = $this->siteProvider->findByName($name);
|
||||||
|
|||||||
@@ -7,27 +7,22 @@ namespace App\Tests\Module\Commercial\Api;
|
|||||||
use App\Module\Sites\Domain\Entity\Site;
|
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
|
* 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
|
* ClientSubResourceApiTest (ERP-57) et ne sont pas reduplique ici. Ce fichier
|
||||||
* cible les contraintes CHECK BDD non encore testees :
|
* cible :
|
||||||
* - RG-1.06 / RG-1.07 / RG-1.08 : `chk_client_address_prospect_exclusive`
|
* - RG-1.06 / RG-1.07 / RG-1.08 : exclusivite is_prospect vs
|
||||||
* (is_prospect exclusif de is_delivery / is_billing) ;
|
* is_delivery / is_billing ;
|
||||||
* - RG-1.11 : `chk_client_address_billing_email` (billing_email obligatoire
|
* - RG-1.11 : billing_email obligatoire ssi is_billing ;
|
||||||
* 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
|
* Depuis ERP-76, ces regles sont portees par des Assert\Callback sur l'entite
|
||||||
* regle Processor au M1). On verifie donc que la combinaison invalide est
|
* ClientAddress (mirror applicatif des CHECK Postgres) : la combinaison invalide
|
||||||
* REJETEE par le serveur (statut >= 400), sans coupler le test au code exact :
|
* est donc rejetee en 422 AVANT la base, et non plus par une violation CHECK
|
||||||
* une violation CHECK non mappee remonte aujourd'hui en erreur serveur ; un
|
* remontant en 500. Les CHECK BDD restent en filet de securite (non testes ici,
|
||||||
* mapping fin vers 422 serait une amelioration ulterieure (hors perimetre
|
* inatteignables tant que les validators applicatifs passent en premier).
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* @internal
|
* @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
|
* 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
|
public function testProspectAddressCannotBeDelivery(): void
|
||||||
{
|
{
|
||||||
@@ -45,7 +41,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
|||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$seed = $this->seedClient('Prospect Delivery');
|
$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],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
'isProspect' => true,
|
'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
|
* 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
|
* adresse de facturation -> 422. On fournit billingEmail pour que la seule
|
||||||
* seule violation possible soit l'exclusivite prospect/billing.
|
* violation possible soit l'exclusivite prospect/billing.
|
||||||
*/
|
*/
|
||||||
public function testProspectAddressCannotBeBilling(): void
|
public function testProspectAddressCannotBeBilling(): void
|
||||||
{
|
{
|
||||||
@@ -71,7 +67,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
|||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$seed = $this->seedClient('Prospect Billing');
|
$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],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
'isProspect' => true,
|
'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
|
* RG-1.11 : une adresse de facturation exige un billingEmail -> 422.
|
||||||
* (CHECK chk_client_address_billing_email).
|
|
||||||
*/
|
*/
|
||||||
public function testBillingAddressRequiresBillingEmail(): void
|
public function testBillingAddressRequiresBillingEmail(): void
|
||||||
{
|
{
|
||||||
@@ -97,7 +92,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
|||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$seed = $this->seedClient('Billing No Email');
|
$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],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
'isBilling' => true,
|
'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
|
* RG-1.11 (sens inverse) : une adresse NON facturable ne peut pas porter un
|
||||||
* billingEmail (meme CHECK).
|
* billingEmail -> 422.
|
||||||
*/
|
*/
|
||||||
public function testNonBillingAddressRejectsBillingEmail(): void
|
public function testNonBillingAddressRejectsBillingEmail(): void
|
||||||
{
|
{
|
||||||
@@ -121,7 +143,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
|||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$seed = $this->seedClient('Non Billing With Email');
|
$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],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
'isBilling' => false,
|
'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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user