diff --git a/migrations/Version20260615120000.php b/migrations/Version20260615120000.php new file mode 100644 index 0000000..381206b --- /dev/null +++ b/migrations/Version20260615120000.php @@ -0,0 +1,50 @@ +addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name'); + $this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)'); + + $this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).$_$'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name'); + $this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL)'); + + $this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$'); + } +} diff --git a/src/Module/Technique/Application/Validator/ProviderAccountingCompletenessValidator.php b/src/Module/Technique/Application/Validator/ProviderAccountingCompletenessValidator.php new file mode 100644 index 0000000..11d39da --- /dev/null +++ b/src/Module/Technique/Application/Validator/ProviderAccountingCompletenessValidator.php @@ -0,0 +1,79 @@ + valeur courante des champs obligatoires de l'onglet. + $fields = [ + 'siren' => $provider->getSiren(), + 'accountNumber' => $provider->getAccountNumber(), + 'tvaMode' => $provider->getTvaMode(), + 'nTva' => $provider->getNTva(), + 'paymentDelay' => $provider->getPaymentDelay(), + 'paymentType' => $provider->getPaymentType(), + ]; + + $violations = new ConstraintViolationList(); + + foreach ($fields as $property => $value) { + if ($this->isMissing($value)) { + $violations->add(new ConstraintViolation( + 'Ce champ est obligatoire.', + null, + [], + $provider, + $property, + $value, + )); + } + } + + if (count($violations) > 0) { + throw new ValidationException($violations); + } + } + + /** + * Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les + * references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que + * lorsqu'elles valent null. + */ + private function isMissing(mixed $value): bool + { + if (null === $value) { + return true; + } + + return is_string($value) && '' === trim($value); + } +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php index 21ee45c..10674f6 100644 --- a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php @@ -119,23 +119,18 @@ final class ProviderContactProcessor implements ProcessorInterface } /** - * RG-3.04 : un bloc Contact est valide des qu'au moins un champ parmi prenom / - * nom / fonction / telephone principal / email est renseigne (double garde avec - * le CHECK BDD chk_provider_contact_name — leve une 422 propre rattachee au - * champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les - * chaines vides (y compris une fonction ou un phone_secondary vides) sont deja - * ramenees a null et ne suffisent pas a valider le bloc. + * RG-3.04 : un bloc Contact exige au moins le prenom OU le nom (aligne sur le + * M1/M2 — un contact se materialise par son nom ; fonction / telephone / email + * seuls ne suffisent pas). Double garde avec le CHECK BDD chk_provider_contact_name + * — leve une 422 propre rattachee au champ `firstName` plutot qu'une 500 SQL. + * Joue apres normalisation (les chaines vides sont deja ramenees a null). */ private function validateName(ProviderContact $contact): void { - if (null === $contact->getFirstName() - && null === $contact->getLastName() - && null === $contact->getJobTitle() - && null === $contact->getPhonePrimary() - && null === $contact->getEmail()) { + if (null === $contact->getFirstName() && null === $contact->getLastName()) { $violations = new ConstraintViolationList(); $violations->add(new ConstraintViolation( - 'Au moins un champ du contact est obligatoire (nom, prénom, fonction, téléphone ou email).', + 'Le prénom ou le nom du contact est obligatoire.', null, [], $contact, diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php index 0552d13..8df9e9b 100644 --- a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php @@ -9,6 +9,7 @@ use ApiPlatform\State\ProcessorInterface; use ApiPlatform\Validator\Exception\ValidationException; use App\Module\Core\Domain\Entity\User; use App\Module\Technique\Application\Service\ProviderFieldNormalizer; +use App\Module\Technique\Application\Validator\ProviderAccountingCompletenessValidator; use App\Module\Technique\Domain\Entity\Provider; use App\Shared\Domain\Contract\SiteInterface; use DateTimeImmutable; @@ -75,6 +76,15 @@ final class ProviderProcessor implements ProcessorInterface 'paymentType', 'bank', ]; + /** + * Champs comptables obligatoires a la validation complete de l'onglet + * (spec-front M3 § Onglet Comptabilite — miroir M1/M2). bank est exclu : + * conditionnel (RG-3.07). + */ + private const array ACCOUNTING_REQUIRED_FIELDS = [ + 'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType', + ]; + /** Champ d'archivage (groupe provider:write:archive). */ private const string ARCHIVE_FIELD = 'isArchived'; @@ -102,6 +112,7 @@ final class ProviderProcessor implements ProcessorInterface private readonly Security $security, private readonly RequestStack $requestStack, private readonly EntityManagerInterface $em, + private readonly ProviderAccountingCompletenessValidator $accountingValidator, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed @@ -128,6 +139,10 @@ final class ProviderProcessor implements ProcessorInterface // deux cotes (l'etat persiste l'a deja ete). $this->guardManage($data); + // Completude de l'onglet Comptabilite (apres normalize : les chaines vides + // sont deja ramenees a null). Joue uniquement sur une soumission d'onglet. + $this->validateAccountingCompleteness($data); + try { return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } catch (UniqueConstraintViolationException $e) { @@ -496,6 +511,21 @@ final class ProviderProcessor implements ProcessorInterface * * @return list */ + /** + * Completude de l'onglet Comptabilite (miroir SupplierProcessor) : ne se + * declenche que si TOUS les champs requis sont presents dans le payload + * (= soumission d'onglet, pas un PATCH partiel cible). Delegue au validateur + * qui leve une 422 listant chaque champ manquant (mapping inline ERP-101). + */ + private function validateAccountingCompleteness(Provider $data): void + { + if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) { + return; + } + + $this->accountingValidator->validate($data); + } + private function payloadKeys(): array { $request = $this->requestStack->getCurrentRequest(); diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index 13f5770..a6f6dee 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -395,12 +395,12 @@ final class ColumnCommentsCatalog ], 'provider_contact' => [ - '_table' => 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).', + '_table' => 'Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).', 'id' => 'Identifiant interne auto-incremente.', 'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.', - 'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).', - 'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).', - 'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).', + 'first_name' => 'Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).', + 'last_name' => 'Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).', + 'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).', 'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).', 'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).', 'email' => 'Email du contact (lowercase serveur).', diff --git a/tests/Module/Technique/Api/ProviderAccountingValidationTest.php b/tests/Module/Technique/Api/ProviderAccountingValidationTest.php index aecbd2f..fde7d3e 100644 --- a/tests/Module/Technique/Api/ProviderAccountingValidationTest.php +++ b/tests/Module/Technique/Api/ProviderAccountingValidationTest.php @@ -9,9 +9,9 @@ namespace App\Tests\Module\Technique\Api; * de l'entite Provider (M3, RG-3.07 / RG-3.08), via le PATCH de l'onglet * Comptabilite (groupe provider:write:accounting). On asserte le code HTTP et le * propertyPath de la violation (consommable par extractApiViolations cote front, - * ERP-101). Jumeau de SupplierAccountingApiTest (M2), sans le bloc « completude de - * l'onglet » : le prestataire est minimal et n'impose pas les six scalaires - * comptables (spec M3 § 3.1). + * ERP-101). Jumeau de SupplierAccountingApiTest (M2), completude de l'onglet + * INCLUSE : a la validation complete de l'onglet, les six scalaires comptables + * sont obligatoires (spec-front M3 § Onglet Comptabilite — aligne M1/M2). * * @internal */ @@ -81,5 +81,58 @@ final class ProviderAccountingValidationTest extends AbstractProviderApiTestCase self::assertResponseStatusCodeSame(200); } + // === Completude de l'onglet Comptabilite (six scalaires obligatoires) === + + /** + * spec-front M3 § Onglet Comptabilite : a la validation COMPLETE de l'onglet + * (les six champs requis presents dans le payload), chacun vide doit renvoyer + * une 422 sur son propre propertyPath (mapping inline front, ERP-101). Miroir + * M1/M2 (ProviderAccountingCompletenessValidator). + */ + public function testIncompleteAccountingTabReturns422OnEachField(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Accounting Incomplete'); + + $response = $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD], + 'json' => [ + 'siren' => null, + 'accountNumber' => null, + 'tvaMode' => null, + 'nTva' => null, + 'paymentDelay' => null, + 'paymentType' => null, + ], + ]); + + self::assertResponseStatusCodeSame(422); + $paths = $this->violationsByPath($response->toArray(false)); + self::assertArrayHasKey('siren', $paths); + self::assertArrayHasKey('accountNumber', $paths); + self::assertArrayHasKey('tvaMode', $paths); + self::assertArrayHasKey('nTva', $paths); + self::assertArrayHasKey('paymentDelay', $paths); + self::assertArrayHasKey('paymentType', $paths); + } + + /** + * Un PATCH ciblant un sous-ensemble de champs comptables n'est PAS une + * validation d'onglet : la completude ne se declenche pas (edition ponctuelle + * preservee, cf. validateAccountingCompleteness). + */ + public function testPartialAccountingPatchSkipsCompleteness(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Accounting Partial'); + + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['nTva' => 'FR12345678901'], + ]); + + self::assertResponseStatusCodeSame(200); + } + // violationsByPath() : helper mutualise dans AbstractProviderApiTestCase. } diff --git a/tests/Module/Technique/Api/ProviderSubResourceApiTest.php b/tests/Module/Technique/Api/ProviderSubResourceApiTest.php index fc252bb..1063fa9 100644 --- a/tests/Module/Technique/Api/ProviderSubResourceApiTest.php +++ b/tests/Module/Technique/Api/ProviderSubResourceApiTest.php @@ -9,7 +9,7 @@ use App\Module\Technique\Domain\Entity\Provider; /** * Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire * (M3, spec § 4.5 — ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04 - * (au moins un champ parmi prenom/nom/fonction/telephone/email), RG-3.05 (>= 1 site sur + * (au moins le prenom OU le nom — aligne M1/M2), RG-3.05 (>= 1 site sur * l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse), * le cloisonnement d'ecriture des sites de l'adresse (§ 2.13 -> 422 sur `sites`), * RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de @@ -53,43 +53,60 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase } /** - * RG-3.04 : un bloc Contact est valide des qu'AU MOINS UN champ est rempli parmi - * prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici - * seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201. + * RG-3.04 (aligne M1/M2) : un bloc Contact exige le prenom OU le nom. Une + * Fonction seule (sans nom ni prenom) ne suffit plus -> 422 rattachee a firstName. */ - public function testPostContactWithOnlyJobTitleReturns201(): void + public function testPostContactWithOnlyJobTitleReturns422(): void { $client = $this->createAdminClient(); $seed = $this->seedProvider('Contact JobTitle Only'); - $data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [ - 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], - 'json' => ['jobTitle' => 'Directeur'], - ])->toArray(); - - self::assertResponseStatusCodeSame(201); - self::assertSame('Directeur', $data['jobTitle']); - } - - /** - * RG-3.04 : un bloc Contact TOTALEMENT vide (aucun champ du CHECK - * chk_provider_contact_name) est rejete avant la base -> 422 rattachee a - * firstName. Une Fonction vide (apres normalisation) ne suffit pas a valider. - */ - public function testPostContactCompletelyEmptyReturns422OnFirstNamePath(): void - { - $client = $this->createAdminClient(); - $seed = $this->seedProvider('Contact No Field'); - $response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], - 'json' => ['jobTitle' => ' '], + 'json' => ['jobTitle' => 'Directeur'], ]); self::assertResponseStatusCodeSame(422); self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false))); } + /** + * RG-3.04 : un bloc Contact sans prenom NI nom (meme avec d'autres champs ou + * apres normalisation des chaines vides) est rejete avant la base -> 422 + * rattachee a firstName (double garde CHECK chk_provider_contact_name). + */ + public function testPostContactWithoutNameReturns422OnFirstNamePath(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Contact No Name'); + + $response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + // Email + telephone fournis mais ni prenom ni nom -> invalide (RG-3.04). + 'json' => ['email' => 'contact@acme.fr', 'phonePrimary' => '0612345678'], + ]); + + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false))); + } + + /** + * RG-3.04 : le prenom SEUL (sans nom) suffit a valider le contact -> 201. + */ + public function testPostContactWithOnlyFirstNameReturns201(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Contact FirstName Only'); + + $data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => ['firstName' => 'Jean'], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('Jean', $data['firstName']); + } + public function testPostContactOnMissingProviderReturns404(): void { $client = $this->createAdminClient();