From daa8224b8b2e26fd90c52f71597d5e0e877e1070 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 16 Jun 2026 10:18:10 +0200 Subject: [PATCH] feat(transport) : sous-ressource contacts transporteur (ERP-160) --- .../Domain/Entity/CarrierContact.php | 111 ++++++++- .../Processor/CarrierContactProcessor.php | 235 ++++++++++++++++++ .../Transport/Api/CarrierContactApiTest.php | 179 +++++++++++++ 3 files changed, 519 insertions(+), 6 deletions(-) create mode 100644 src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierContactProcessor.php create mode 100644 tests/Module/Transport/Api/CarrierContactApiTest.php diff --git a/src/Module/Transport/Domain/Entity/CarrierContact.php b/src/Module/Transport/Domain/Entity/CarrierContact.php index 1f5e8fb..93a507a 100644 --- a/src/Module/Transport/Domain/Entity/CarrierContact.php +++ b/src/Module/Transport/Domain/Entity/CarrierContact.php @@ -4,21 +4,80 @@ declare(strict_types=1); namespace App\Module\Transport\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierContactProcessor; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Validator\Constraints as Assert; /** * Contact d'un transporteur (1:n) — onglet Contact (M4). Jumeau de * SupplierContact (M2) : au moins un champ rempli (RG-4.08, garanti par le * CHECK chk_carrier_contact_filled + le Processor), max 2 telephones. * - * WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read` - * (embarquees au detail). Les sous-ressources d'ecriture arrivent au WT7. + * Lecture : proprietes en `carrier:item:read` (embarquees au detail du + * transporteur). Ecriture : groupe `carrier:write:contacts`. + * + * Sous-ressource API (ERP-160, spec § 4.5) — jumelle de SupplierContact (M2) : + * - POST /api/carriers/{carrierId}/contacts : creation rattachee au + * transporteur parent (Link toProperty 'carrier'), security + * transport.carriers.manage. + * - PATCH / DELETE /api/carrier_contacts/{id} : security + * transport.carriers.manage. + * - GET /api/carrier_contacts/{id} : lecture unitaire (security view) — la + * lecture courante reste via le parent. Pas de GET collection autonome. + * Tout passe par le CarrierContactProcessor (rattachement parent + RG-4.08 + + * RG-4.13). + * + * Telephones (RG-4.08, max 2) : le contrat d'ecriture expose un tableau virtuel + * `phones` (liste dynamique cote front « x1, +1 possible, max 2 ») mappe par le + * Processor vers `phonePrimary` / `phoneSecondary` (un 3e numero -> 422). Les + * deux colonnes scalaires restent en lecture seule (embarquees au detail). + * + * Audite (#[Auditable]) + Timestampable / Blamable. */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('transport.carriers.view')", + normalizationContext: ['groups' => ['carrier:item:read', 'default:read']], + ), + new Post( + uriTemplate: '/carriers/{carrierId}/contacts', + uriVariables: [ + 'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'), + ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT CarrierContact ... WHERE carrier = :id) + // et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par CarrierContactProcessor::linkParent (404 si absent). + read: false, + security: "is_granted('transport.carriers.manage')", + normalizationContext: ['groups' => ['carrier:item:read', 'default:read']], + denormalizationContext: ['groups' => ['carrier:write:contacts']], + processor: CarrierContactProcessor::class, + ), + new Patch( + security: "is_granted('transport.carriers.manage')", + normalizationContext: ['groups' => ['carrier:item:read', 'default:read']], + denormalizationContext: ['groups' => ['carrier:write:contacts']], + processor: CarrierContactProcessor::class, + ), + new Delete( + security: "is_granted('transport.carriers.manage')", + processor: CarrierContactProcessor::class, + ), + ], +)] #[ORM\Entity] #[ORM\Table(name: 'carrier_contact')] #[ORM\Index(name: 'idx_carrier_contact_carrier', columns: ['carrier_id'])] @@ -39,18 +98,27 @@ class CarrierContact implements TimestampableInterface, BlamableInterface #[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] private ?Carrier $carrier = null; + // RG-4.08 : aucun champ obligatoire isolement (≥ 1 champ rempli, garde + // Processor + CHECK BDD). Les colonnes restent nullable au niveau ORM. #[ORM\Column(name: 'first_name', length: 120, nullable: true)] - #[Groups(['carrier:item:read'])] + #[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['carrier:item:read', 'carrier:write:contacts'])] private ?string $firstName = null; #[ORM\Column(name: 'last_name', length: 120, nullable: true)] - #[Groups(['carrier:item:read'])] + #[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['carrier:item:read', 'carrier:write:contacts'])] private ?string $lastName = null; #[ORM\Column(name: 'job_title', length: 120, nullable: true)] - #[Groups(['carrier:item:read'])] + #[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['carrier:item:read', 'carrier:write:contacts'])] private ?string $jobTitle = null; + // Telephones en LECTURE seule : alimentes en ecriture via le tableau virtuel + // `phones` (mappe par le CarrierContactProcessor). Pas de groupe write -> pas + // de saisie directe (et donc exemptes du miroir Assert\Length, le Processor + // borne deja la longueur). #[ORM\Column(name: 'phone_primary', length: 20, nullable: true)] #[Groups(['carrier:item:read'])] private ?string $phonePrimary = null; @@ -60,9 +128,22 @@ class CarrierContact implements TimestampableInterface, BlamableInterface private ?string $phoneSecondary = null; #[ORM\Column(length: 180, nullable: true)] - #[Groups(['carrier:item:read'])] + #[Assert\Email(message: 'L\'adresse email n\'est pas valide.')] + #[Assert\Length(max: 180, maxMessage: 'L\'email ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['carrier:item:read', 'carrier:write:contacts'])] private ?string $email = null; + /** + * Telephones en ecriture (RG-4.08, max 2), NON persiste : le + * CarrierContactProcessor normalise chaque numero (RG-4.13) puis le mappe vers + * phonePrimary / phoneSecondary. null = non fourni (PATCH partiel : on ne + * touche pas aux telephones existants). Un 3e numero -> 422 sur `phones`. + * + * @var null|list + */ + #[Groups(['carrier:write:contacts'])] + private ?array $phones = null; + #[ORM\Column(options: ['default' => 0])] private int $position = 0; @@ -155,6 +236,24 @@ class CarrierContact implements TimestampableInterface, BlamableInterface return $this; } + /** + * @return null|list + */ + public function getPhones(): ?array + { + return $this->phones; + } + + /** + * @param null|list $phones + */ + public function setPhones(?array $phones): static + { + $this->phones = $phones; + + return $this; + } + public function getPosition(): int { return $this->position; diff --git a/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierContactProcessor.php b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierContactProcessor.php new file mode 100644 index 0000000..6291feb --- /dev/null +++ b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierContactProcessor.php @@ -0,0 +1,235 @@ + phonePrimary/phoneSecondary + * (max 2, chiffres uniquement), puis garde RG-4.08 (≥ 1 champ) avant + * persistance. + * - DELETE : aucune regle metier specifique (suppression physique directe). + * + * RG-4.08 vit ICI (double du CHECK BDD) pour transformer une violation SQL (500 + * generique) en 422 propre rattachee au champ `firstName` (mapping inline + * ERP-101). Le « max 2 telephones » est rattache au champ `phones` : seul + * point de saisie des numeros (les colonnes phonePrimary/phoneSecondary sont en + * lecture seule). + * + * La security d'operation (transport.carriers.manage) est appliquee par API + * Platform en amont, de meme que la validation Symfony des contraintes d'attribut + * (Assert\Email, Assert\Length...). + * + * @implements ProcessorInterface + */ +final class CarrierContactProcessor implements ProcessorInterface +{ + /** RG-4.08 : nombre maximal de telephones par contact. */ + private const int MAX_PHONES = 2; + + /** Longueur max d'un telephone normalise (colonne VARCHAR(20)). */ + private const int PHONE_MAX_LENGTH = 20; + + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly CarrierFieldNormalizer $normalizer, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof CarrierContact) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + $this->normalize($data); + $this->applyPhones($data); + $this->validateAtLeastOneField($data); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache le contact au transporteur parent de la sous-ressource POST + * (/carriers/{carrierId}/contacts) : la relation n'est pas peuplee + * automatiquement par le Link sur une ecriture. Sur PATCH (entite existante), + * le transporteur est deja present -> no-op. + */ + private function linkParent(CarrierContact $contact, array $uriVariables): void + { + if (null !== $contact->getCarrier()) { + return; + } + + $carrierId = $uriVariables['carrierId'] ?? null; + if (null === $carrierId) { + return; + } + + $carrier = $carrierId instanceof Carrier + ? $carrierId + : $this->em->getRepository(Carrier::class)->find($carrierId); + + // read:false sur le POST : sans stade lecture, un parent introuvable n'est + // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la + // contrainte carrier_id NOT NULL). + if (!$carrier instanceof Carrier) { + throw new NotFoundHttpException('Transporteur introuvable.'); + } + + $contact->setCarrier($carrier); + } + + /** + * Normalisation serveur RG-4.13 des champs texte. Toutes les methodes du + * normalizer sont null-safe : une chaine vide apres trim devient null (donc la + * garde RG-4.08 detecte bien « champ non rempli »). Les telephones sont + * traites a part (applyPhones). + */ + private function normalize(CarrierContact $contact): void + { + $contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName())); + $contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName())); + $contact->setJobTitle($this->blankToNull($contact->getJobTitle())); + $contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail())); + } + + /** + * Mappe le tableau d'ecriture `phones` (max 2, RG-4.08) vers phonePrimary / + * phoneSecondary apres normalisation RG-4.13 (chiffres uniquement). Les + * numeros vides (sans chiffre) sont ecartes. null = champ non fourni (PATCH + * partiel) -> on ne touche pas aux telephones existants. Un 3e numero + * exploitable, ou un numero trop long (> colonne VARCHAR(20)), -> 422 sur + * `phones`. + */ + private function applyPhones(CarrierContact $contact): void + { + $phones = $contact->getPhones(); + if (null === $phones) { + return; + } + + $normalized = []; + foreach ($phones as $phone) { + $digits = $this->normalizer->normalizePhone(is_string($phone) ? $phone : null); + if (null !== $digits) { + $normalized[] = $digits; + } + } + + $violations = new ConstraintViolationList(); + if (self::MAX_PHONES < count($normalized)) { + $violations->add(new ConstraintViolation( + 'Un contact ne peut comporter plus de deux téléphones.', + null, + [], + $contact, + 'phones', + $phones, + )); + } + foreach ($normalized as $digits) { + if (self::PHONE_MAX_LENGTH < mb_strlen($digits)) { + $violations->add(new ConstraintViolation( + 'Un numéro de téléphone ne peut dépasser '.self::PHONE_MAX_LENGTH.' caractères.', + null, + [], + $contact, + 'phones', + $phones, + )); + + break; + } + } + + if (0 < $violations->count()) { + throw new ValidationException($violations); + } + + $contact->setPhonePrimary($normalized[0] ?? null); + $contact->setPhoneSecondary($normalized[1] ?? null); + // Nettoie le champ virtuel (non persiste, mais evite toute fuite ulterieure). + $contact->setPhones(null); + } + + /** + * RG-4.08 : un bloc Contact est valide des qu'au moins 1 champ est rempli + * (firstName, lastName, jobTitle, phonePrimary ou email — meme perimetre que + * le CHECK BDD chk_carrier_contact_filled, qui exclut phoneSecondary). Double + * garde : leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL. + * Joue apres normalisation + mapping telephones, donc les chaines vides sont + * deja ramenees a null. + */ + private function validateAtLeastOneField(CarrierContact $contact): void + { + if ( + null === $contact->getFirstName() + && null === $contact->getLastName() + && null === $contact->getJobTitle() + && null === $contact->getPhonePrimary() + && null === $contact->getEmail() + ) { + $violations = new ConstraintViolationList(); + $violations->add(new ConstraintViolation( + 'Renseignez au moins un champ pour le contact.', + null, + [], + $contact, + 'firstName', + null, + )); + + throw new ValidationException($violations); + } + } + + /** + * Trim + chaine vide -> null (la fonction n'est pas normalisee en casse, + * contrairement aux noms de personne). Garantit que RG-4.08 detecte un champ + * « non rempli » meme si le client envoie une chaine vide. + */ + private function blankToNull(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : $value; + } +} diff --git a/tests/Module/Transport/Api/CarrierContactApiTest.php b/tests/Module/Transport/Api/CarrierContactApiTest.php new file mode 100644 index 0000000..189ef45 --- /dev/null +++ b/tests/Module/Transport/Api/CarrierContactApiTest.php @@ -0,0 +1,179 @@ + 422 (au moins 1 champ requis) ; + * - RG-4.08 : 1 seul champ rempli -> 201 ; + * - RG-4.08 : 3 telephones (tableau `phones`) -> 422 (max 2) ; + * - mapping `phones[]` -> phonePrimary / phoneSecondary + normalisation (RG-4.13) ; + * - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul). + * + * @internal + */ +final class CarrierContactApiTest extends AbstractCarrierApiTestCase +{ + private const string PWD = RbacDemoFixtures::DEMO_PASSWORD; + + protected function setUp(): void + { + parent::setUp(); + + // Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin + // qu'en recette), requis pour les tests de permission (bureau/commerciale). + self::bootKernel(); + $application = new Application(self::$kernel); + $application->setAutoExit(false); + $exit = $application->run( + new ArrayInput([ + 'command' => 'app:seed-rbac', + '--with-demo-users' => true, + '--password' => self::PWD, + ]), + new NullOutput(), + ); + self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).'); + + self::ensureKernelShutdown(); + } + + public function testEmptyContactReturns422(): void + { + // RG-4.08 : aucun champ rempli -> 422 (garde Processor, double du CHECK BDD). + $carrier = $this->seedCarrier('Contact Vide'); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [], + ]); + self::assertResponseStatusCodeSame(422); + } + + public function testSingleFieldContactIsCreated(): void + { + // RG-4.08 : un seul champ suffit a valider le bloc. + $carrier = $this->seedCarrier('Contact Mono'); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['lastName' => 'martin'], + ]); + self::assertResponseStatusCodeSame(201); + + // RG-4.13 : nom capitalise serveur. + self::assertJsonContains(['lastName' => 'Martin']); + } + + public function testThirdPhoneReturns422(): void + { + // RG-4.08 : max 2 telephones. Le contrat d'ecriture accepte un tableau + // `phones` (liste dynamique cote front « x1, +1 possible, max 2 ») ; un 3e + // numero -> 422 rattachee au champ `phones`. + $carrier = $this->seedCarrier('Contact Trois Tel'); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'firstName' => 'Jean', + 'phones' => ['0611111111', '0622222222', '0633333333'], + ], + ]); + self::assertResponseStatusCodeSame(422); + } + + public function testPhonesAreMappedAndNormalized(): void + { + // Mapping `phones[0]` -> phonePrimary, `phones[1]` -> phoneSecondary + + // normalisation RG-4.13 (chiffres uniquement). + $carrier = $this->seedCarrier('Contact Deux Tel'); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'lastName' => 'Dupont', + 'phones' => ['06.11.11.11.11', '06 22 22 22 22'], + ], + ]); + self::assertResponseStatusCodeSame(201); + self::assertJsonContains([ + 'phonePrimary' => '0611111111', + 'phoneSecondary' => '0622222222', + ]); + } + + public function testPatchAndDeleteSucceedWithManage(): void + { + $contact = $this->seedContact('Patch Delete'); + $client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2) + + // PATCH (manage) -> 200 + $client->request('PATCH', '/api/carrier_contacts/'.$contact->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['jobTitle' => 'Directeur'], + ]); + self::assertResponseStatusCodeSame(200); + + // DELETE (manage) -> 204 + $client->request('DELETE', '/api/carrier_contacts/'.$contact->getId()); + self::assertResponseStatusCodeSame(204); + } + + public function testWriteForbiddenWithoutManage(): void + { + $contact = $this->seedContact('Forbidden'); + $carrier = $contact->getCarrier(); + self::assertNotNull($carrier); + $client = $this->authenticatedClient('commerciale', self::PWD); // view seul + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['lastName' => 'Bernard'], + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('PATCH', '/api/carrier_contacts/'.$contact->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['jobTitle' => 'Chef'], + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('DELETE', '/api/carrier_contacts/'.$contact->getId()); + self::assertResponseStatusCodeSame(403); + } + + /** + * Seede un transporteur + un contact rattache (pour les tests PATCH/DELETE). + */ + private function seedContact(string $name): CarrierContact + { + $em = $this->getEm(); + $carrier = $this->seedCarrier($name); + + $contact = new CarrierContact(); + $contact->setCarrier($carrier); + $contact->setLastName('Martin'); + $contact->setPhonePrimary('0612345678'); + $carrier->addContact($contact); + $em->persist($contact); + $em->flush(); + + return $contact; + } +}