fix(technique) : RG-3.04 prenom ou nom + completude onglet comptabilite (alignement M1/M2)

This commit is contained in:
2026-06-15 11:19:52 +02:00
parent 954f35abc2
commit 09cff93660
7 changed files with 268 additions and 44 deletions
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* RG-3.04 (correctif) — aligne la regle de validite d'un contact prestataire sur
* le M1/M2 : au moins le PRENOM OU le NOM (et non plus « un champ quelconque parmi
* prenom/nom/fonction/telephone/email »). Remplace le CHECK chk_provider_contact_name
* et met a jour les commentaires de colonnes. La garde applicative
* (ProviderContactProcessor::validateName) est alignee dans le meme commit.
*
* Placee au namespace racine DoctrineMigrations (et non en modulaire Technique) :
* elle ALTERE une table creee par une migration racine (Version20260612100000) ;
* le tri par version au sein du meme namespace garantit qu'elle joue APRES l'init
* (cf. CLAUDE.md regle 11 — le tri cross-namespace casserait l'ordre sur base vide).
*/
final class Version20260615120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'RG-3.04 : contact prestataire valide si prenom OU nom (alignement M1/M2) — CHECK chk_provider_contact_name.';
}
public function up(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)');
$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).$_$');
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Technique\Domain\Entity\Provider;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier (spec-front M3 § Onglet Comptabilite — jumeau de
* SupplierAccountingCompletenessValidator M2) : a la soumission complete de
* l'onglet Comptabilite, les six champs scalaires obligatoires doivent etre
* renseignes (SIREN, Numero de compte, Mode de TVA, N de TVA, Delai de reglement,
* Type de reglement). La banque reste conditionnelle (RG-3.07) et les RIB aussi
* (RG-3.08) : ils ne sont pas couverts ici (Assert\Callback sur l'entite Provider
* — validatePaymentTypeConsistency).
*
* Parti pris (miroir M1/M2) : colonnes nullable en base + validateur contextuel,
* plutot qu'un Assert\NotBlank sur l'entite (qui casserait le POST de l'onglet
* principal, lequel n'envoie aucun champ comptable).
*
* Invoque par le ProviderProcessor uniquement quand le payload porte les six
* champs (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
* coherence avec les violations Symfony rendues par API Platform (mapping inline
* front via useFormErrors, ERP-101).
*/
final class ProviderAccountingCompletenessValidator
{
public function validate(Provider $provider): void
{
// Map champ -> 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);
}
}
@@ -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 / * RG-3.04 : un bloc Contact exige au moins le prenom OU le nom (aligne sur le
* nom / fonction / telephone principal / email est renseigne (double garde avec * M1/M2 — un contact se materialise par son nom ; fonction / telephone / email
* le CHECK BDD chk_provider_contact_name — leve une 422 propre rattachee au * seuls ne suffisent pas). Double garde avec le CHECK BDD chk_provider_contact_name
* champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les * — leve une 422 propre rattachee au champ `firstName` plutot qu'une 500 SQL.
* chaines vides (y compris une fonction ou un phone_secondary vides) sont deja * Joue apres normalisation (les chaines vides sont deja ramenees a null).
* ramenees a null et ne suffisent pas a valider le bloc.
*/ */
private function validateName(ProviderContact $contact): void private function validateName(ProviderContact $contact): void
{ {
if (null === $contact->getFirstName() if (null === $contact->getFirstName() && null === $contact->getLastName()) {
&& null === $contact->getLastName()
&& null === $contact->getJobTitle()
&& null === $contact->getPhonePrimary()
&& null === $contact->getEmail()) {
$violations = new ConstraintViolationList(); $violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation( $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, null,
[], [],
$contact, $contact,
@@ -9,6 +9,7 @@ use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException; use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User; use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer; use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Application\Validator\ProviderAccountingCompletenessValidator;
use App\Module\Technique\Domain\Entity\Provider; use App\Module\Technique\Domain\Entity\Provider;
use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\SiteInterface;
use DateTimeImmutable; use DateTimeImmutable;
@@ -75,6 +76,15 @@ final class ProviderProcessor implements ProcessorInterface
'paymentType', 'bank', '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). */ /** Champ d'archivage (groupe provider:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived'; private const string ARCHIVE_FIELD = 'isArchived';
@@ -102,6 +112,7 @@ final class ProviderProcessor implements ProcessorInterface
private readonly Security $security, private readonly Security $security,
private readonly RequestStack $requestStack, private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly ProviderAccountingCompletenessValidator $accountingValidator,
) {} ) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed 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). // deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data); $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 { try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) { } catch (UniqueConstraintViolationException $e) {
@@ -496,6 +511,21 @@ final class ProviderProcessor implements ProcessorInterface
* *
* @return list<string> * @return list<string>
*/ */
/**
* 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 private function payloadKeys(): array
{ {
$request = $this->requestStack->getCurrentRequest(); $request = $this->requestStack->getCurrentRequest();
@@ -395,12 +395,12 @@ final class ColumnCommentsCatalog
], ],
'provider_contact' => [ '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.', 'id' => 'Identifiant interne auto-incremente.',
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.', '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).', '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). Au moins un champ du contact requis (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).', '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_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).',
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).', 'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).',
'email' => 'Email du contact (lowercase serveur).', 'email' => 'Email du contact (lowercase serveur).',
@@ -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 * 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 * Comptabilite (groupe provider:write:accounting). On asserte le code HTTP et le
* propertyPath de la violation (consommable par extractApiViolations cote front, * propertyPath de la violation (consommable par extractApiViolations cote front,
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), sans le bloc « completude de * ERP-101). Jumeau de SupplierAccountingApiTest (M2), completude de l'onglet
* l'onglet » : le prestataire est minimal et n'impose pas les six scalaires * INCLUSE : a la validation complete de l'onglet, les six scalaires comptables
* comptables (spec M3 § 3.1). * sont obligatoires (spec-front M3 § Onglet Comptabilite — aligne M1/M2).
* *
* @internal * @internal
*/ */
@@ -81,5 +81,58 @@ final class ProviderAccountingValidationTest extends AbstractProviderApiTestCase
self::assertResponseStatusCodeSame(200); 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. // violationsByPath() : helper mutualise dans AbstractProviderApiTestCase.
} }
@@ -9,7 +9,7 @@ use App\Module\Technique\Domain\Entity\Provider;
/** /**
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire * 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 * (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), * 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`), * 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 * 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 * RG-3.04 (aligne M1/M2) : un bloc Contact exige le prenom OU le nom. Une
* prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici * Fonction seule (sans nom ni prenom) ne suffit plus -> 422 rattachee a firstName.
* seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201.
*/ */
public function testPostContactWithOnlyJobTitleReturns201(): void public function testPostContactWithOnlyJobTitleReturns422(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedProvider('Contact JobTitle Only'); $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', [ $response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['jobTitle' => ' '], 'json' => ['jobTitle' => 'Directeur'],
]); ]);
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false))); 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 public function testPostContactOnMissingProviderReturns404(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();