diff --git a/migrations/Version20260603120000.php b/migrations/Version20260603120000.php new file mode 100644 index 0000000..8b41584 --- /dev/null +++ b/migrations/Version20260603120000.php @@ -0,0 +1,131 @@ + ce DROP s'executerait avant le CREATE TABLE client + * (Version20260601000000). Le namespace racine garantit l'ordre par timestamp. + */ +final class Version20260603120000 extends AbstractMigration +{ + /** Colonnes de contact inline supprimees du `client`. */ + private const array INLINE_CONTACT_COLUMNS = [ + 'first_name', 'last_name', 'phone_primary', 'phone_secondary', 'email', + ]; + + public function getDescription(): string + { + return 'M1 : suppression du contact inline du Client (backfill vers client_contact puis DROP des 5 colonnes).'; + } + + public function up(Schema $schema): void + { + // 1. Backfill : tout client SANS contact recoit une ligne client_contact + // (position 0) reprenant ses champs inline. phone_primary / email du + // client sont NOT NULL -> toujours une donnee a reporter. Le CHECK + // chk_client_contact_name (first_name OU last_name) est garanti par le + // fallback company_name si jamais les deux noms etaient null (cas qui ne + // devrait pas exister, RG-1.01 ayant ete appliquee a l'ecriture). + // created_at/updated_at NOT NULL -> NOW() ; created_by/updated_by null + // (backfill hors contexte HTTP, libelle « Systeme » cote front). + $this->addSql(<<<'SQL' + INSERT INTO client_contact ( + client_id, first_name, last_name, phone_primary, phone_secondary, + email, position, created_at, updated_at + ) + SELECT + c.id, + c.first_name, + CASE + WHEN c.first_name IS NULL AND c.last_name IS NULL THEN c.company_name + ELSE c.last_name + END, + c.phone_primary, + c.phone_secondary, + c.email, + 0, + NOW(), + NOW() + FROM client c + WHERE NOT EXISTS ( + SELECT 1 FROM client_contact cc WHERE cc.client_id = c.id + ) + SQL); + + // 2. DROP des 5 colonnes inline (rien a documenter : suppression). + $this->addSql(<<<'SQL' + ALTER TABLE client + DROP COLUMN first_name, + DROP COLUMN last_name, + DROP COLUMN phone_primary, + DROP COLUMN phone_secondary, + DROP COLUMN email + SQL); + } + + public function down(Schema $schema): void + { + // Best-effort : on RECREE les 5 colonnes (en NULLABLE — l'etat NOT NULL + // d'origine de phone_primary/email ne peut etre restaure sur une table + // peuplee sans risque) et on retro-alimente depuis le contact principal + // (position minimale) de chaque client. La donnee n'est pas garantie + // identique a l'origine : ce down() sert au rollback technique, pas a une + // restauration fidele. + $this->addSql('ALTER TABLE client ADD COLUMN first_name VARCHAR(120) DEFAULT NULL'); + $this->addSql('ALTER TABLE client ADD COLUMN last_name VARCHAR(120) DEFAULT NULL'); + $this->addSql('ALTER TABLE client ADD COLUMN phone_primary VARCHAR(20) DEFAULT NULL'); + $this->addSql('ALTER TABLE client ADD COLUMN phone_secondary VARCHAR(20) DEFAULT NULL'); + $this->addSql('ALTER TABLE client ADD COLUMN email VARCHAR(180) DEFAULT NULL'); + + // Retro-alimentation depuis le contact de position la plus basse. + $this->addSql(<<<'SQL' + UPDATE client c SET + first_name = cc.first_name, + last_name = cc.last_name, + phone_primary = cc.phone_primary, + phone_secondary = cc.phone_secondary, + email = cc.email + FROM ( + SELECT DISTINCT ON (client_id) + client_id, first_name, last_name, phone_primary, phone_secondary, email + FROM client_contact + ORDER BY client_id, position ASC, id ASC + ) cc + WHERE cc.client_id = c.id + SQL); + + // Re-pose des commentaires d'origine (regle ABSOLUE n°12) — dollar-quoting + // Postgres pour eviter tout echappement d apostrophe. + $this->addSql('COMMENT ON COLUMN client.first_name IS $_$Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).$_$'); + $this->addSql('COMMENT ON COLUMN client.last_name IS $_$Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).$_$'); + $this->addSql('COMMENT ON COLUMN client.phone_primary IS $_$Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.$_$'); + $this->addSql('COMMENT ON COLUMN client.phone_secondary IS $_$Telephone secondaire optionnel — chiffres uniquement (RG-1.20).$_$'); + $this->addSql('COMMENT ON COLUMN client.email IS $_$Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).$_$'); + } +} diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php index bdcac63..1a26cd4 100644 --- a/src/Module/Commercial/Domain/Entity/Client.php +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -151,31 +151,9 @@ class Client implements TimestampableInterface, BlamableInterface #[Groups(['client:read', 'client:write:main'])] private ?string $companyName = null; - // RG-1.01 : firstName OU lastName obligatoire (validation au futur Processor). - #[ORM\Column(length: 120, nullable: true)] - #[Assert\Length(max: 120, normalizer: 'trim')] - #[Groups(['client:read', 'client:write:main'])] - private ?string $firstName = null; - - #[ORM\Column(length: 120, nullable: true)] - #[Assert\Length(max: 120, normalizer: 'trim')] - #[Groups(['client:read', 'client:write:main'])] - private ?string $lastName = null; - - #[ORM\Column(length: 20)] - #[Assert\NotBlank] - #[Groups(['client:read', 'client:write:main'])] - private ?string $phonePrimary = null; - - #[ORM\Column(length: 20, nullable: true)] - #[Groups(['client:read', 'client:write:main'])] - private ?string $phoneSecondary = null; - - #[ORM\Column(length: 180)] - #[Assert\NotBlank] - #[Assert\Email] - #[Groups(['client:read', 'client:write:main'])] - private ?string $email = null; + // Le contact principal n'est plus porte inline par le Client : les contacts + // vivent uniquement dans ClientContact (onglet Contact). RG-1.01 / RG-1.02 + // supprimees du Client (equivalent RG-1.05 / RG-1.14 sur ClientContact). // RG-1.03 : distributor / broker auto-references mutuellement exclusives // (CHECK chk_client_distrib_or_broker en base). @@ -326,66 +304,6 @@ class Client implements TimestampableInterface, BlamableInterface return $this; } - public function getFirstName(): ?string - { - return $this->firstName; - } - - public function setFirstName(?string $firstName): static - { - $this->firstName = $firstName; - - return $this; - } - - public function getLastName(): ?string - { - return $this->lastName; - } - - public function setLastName(?string $lastName): static - { - $this->lastName = $lastName; - - return $this; - } - - public function getPhonePrimary(): ?string - { - return $this->phonePrimary; - } - - public function setPhonePrimary(string $phonePrimary): static - { - $this->phonePrimary = $phonePrimary; - - return $this; - } - - public function getPhoneSecondary(): ?string - { - return $this->phoneSecondary; - } - - public function setPhoneSecondary(?string $phoneSecondary): static - { - $this->phoneSecondary = $phoneSecondary; - - return $this; - } - - public function getEmail(): ?string - { - return $this->email; - } - - public function setEmail(string $email): static - { - $this->email = $email; - - return $this; - } - public function getDistributor(): ?Client { return $this->distributor; diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php index fa1f6c8..293900f 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php @@ -29,7 +29,7 @@ use Symfony\Component\Validator\ConstraintViolationList; /** * Processor d'ecriture du repertoire clients (M1). Cf. spec-back M1 § 2.8 / - * § 2.9 / § 4.3 / § 4.4 + RG-1.01 a RG-1.28. + * § 2.9 / § 4.3 / § 4.4 + RG-1.03 a RG-1.28 (RG-1.01/1.02 supprimees : contact inline retire). * * Sequence (POST / PATCH) : * 1. Autorisation additionnelle par groupe d'onglet. La security d'operation @@ -41,7 +41,7 @@ use Symfony\Component\Validator\ConstraintViolationList; * - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et * interdit toute autre modification dans la meme requete (RG-1.22, 422). * 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer. - * 3. Regles metier : RG-1.01 (prenom/nom), RG-1.03 (distributor/broker + * 3. Regles metier : RG-1.03 (distributor/broker * exclusifs + type de categorie), RG-1.12 (Virement -> banque), * RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST * et tout PATCH pour le role Commerciale). @@ -60,8 +60,7 @@ final class ClientProcessor implements ProcessorInterface { /** Champs de l'onglet principal (groupe client:write:main). */ private const array MAIN_FIELDS = [ - 'companyName', 'firstName', 'lastName', 'phonePrimary', 'phoneSecondary', - 'email', 'distributor', 'broker', 'triageService', 'categories', + 'companyName', 'distributor', 'broker', 'triageService', 'categories', ]; /** Champs de l'onglet Information (groupe client:write:information). */ @@ -124,7 +123,6 @@ final class ClientProcessor implements ProcessorInterface // valeurs normalisees des deux cotes (l'etat persiste l'a deja ete). $this->guardManage($data); - $this->validateMainContact($data); $this->validateDistributorBroker($data); $this->validateAccountingConsistency($data); $this->validateInformationCompleteness($data); @@ -274,11 +272,6 @@ final class ClientProcessor implements ProcessorInterface { $newValues = [ 'companyName' => $data->getCompanyName(), - 'firstName' => $data->getFirstName(), - 'lastName' => $data->getLastName(), - 'phonePrimary' => $data->getPhonePrimary(), - 'phoneSecondary' => $data->getPhoneSecondary(), - 'email' => $data->getEmail(), 'distributor' => $data->getDistributor(), 'broker' => $data->getBroker(), 'triageService' => $data->isTriageService(), @@ -420,39 +413,17 @@ final class ClientProcessor implements ProcessorInterface } /** - * Normalisation serveur (RG-1.18 a 1.21). Les setters non-nullables - * (companyName, email, phonePrimary) ne sont touches que si une valeur est - * presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel. + * Normalisation serveur du formulaire principal (RG-1.18). Seul companyName + * subsiste cote Client depuis la suppression du contact inline (les champs de + * contact — noms, telephones, email — sont normalises par ClientContactProcessor). + * Le setter non-nullable n'est touche que si une valeur est presente, pour ne + * jamais ecraser l'existant lors d'un PATCH partiel. */ private function normalize(Client $data): void { if (null !== $data->getCompanyName()) { $data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName())); } - if (null !== $data->getEmail()) { - $data->setEmail((string) $this->normalizer->normalizeEmail($data->getEmail())); - } - if (null !== $data->getPhonePrimary()) { - $data->setPhonePrimary((string) $this->normalizer->normalizePhone($data->getPhonePrimary())); - } - - $data->setFirstName($this->normalizer->normalizePersonName($data->getFirstName())); - $data->setLastName($this->normalizer->normalizePersonName($data->getLastName())); - $data->setPhoneSecondary($this->normalizer->normalizePhone($data->getPhoneSecondary())); - } - - /** - * RG-1.01 : au moins le prenom OU le nom du contact principal. - */ - private function validateMainContact(Client $data): void - { - if (null === $data->getFirstName() && null === $data->getLastName()) { - $this->throwViolation( - 'firstName', - 'Le prénom ou le nom du contact principal est obligatoire.', - $data, - ); - } } /** diff --git a/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php b/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php index 6bb0c50..153fb0b 100644 --- a/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php +++ b/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php @@ -86,7 +86,9 @@ final class ClientExportController } /** - * Colonnes dans l'ordre impose par la spec § 4.6. SIREN inseree avant la + * Colonnes de l'export. Depuis la suppression du contact inline (refonte + * contact, D2), les colonnes de contact principal sont retirees : l'export + * ne porte plus que les donnees propres au Client. SIREN inseree avant la * date de creation, uniquement si l'utilisateur a accounting.view. * * @return list @@ -95,11 +97,6 @@ final class ClientExportController { $headers = [ 'Nom entreprise', - 'Nom contact principal', - 'Prénom', - 'Téléphone principal', - 'Téléphone secondaire', - 'Email', 'Catégories', 'Sites', ]; @@ -123,11 +120,6 @@ final class ClientExportController foreach ($clients as $client) { $row = [ $client->getCompanyName(), - $client->getLastName(), - $client->getFirstName(), - $client->getPhonePrimary(), - $client->getPhoneSecondary(), - $client->getEmail(), $this->formatCategories($client), $this->formatSites($client), ]; diff --git a/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php b/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php index 8e77ccc..40a899f 100644 --- a/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php +++ b/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php @@ -112,10 +112,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$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'], ); if ($gsoIsNew) { @@ -127,10 +123,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$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: ['Courtier'], ); if ($leonardIsNew) { @@ -142,10 +134,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$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) { @@ -159,10 +147,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$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) { @@ -175,10 +159,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$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) { @@ -191,10 +171,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$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) { @@ -209,10 +185,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$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) { @@ -229,12 +201,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$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); @@ -247,10 +214,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$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, ); @@ -263,10 +226,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$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) { @@ -279,10 +238,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$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) { @@ -301,10 +256,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$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) { @@ -316,10 +267,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$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) { @@ -331,10 +278,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$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) { @@ -350,6 +293,9 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface * sinon retourne l'existant. Retourne [Client, isNew] : isNew=false bloque la * reconstruction des sous-collections (idempotence sans doublon). * + * Le contact principal n'est plus porte par le Client (refonte contact) : les + * coordonnees de contact sont fournies via addContact() dans le bloc isNew. + * * @param list $categoryNames * * @return array{0: Client, 1: bool} @@ -357,12 +303,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface 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); @@ -374,11 +315,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface $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)); diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php index 1457b21..cf70bf3 100644 --- a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php @@ -103,9 +103,11 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client } /** - * Recherche fuzzy insensible a la casse sur companyName + lastName + email. - * Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester - * litteraux. + * Recherche fuzzy insensible a la casse sur companyName (D1, refonte contact). + * Depuis la suppression du contact inline du Client, la recherche ne porte + * plus que sur le nom d'entreprise (les anciens criteres lastName / email + * vivaient sur les colonnes inline disparues). Les metacaracteres LIKE + * (%, _, \) saisis sont echappes pour rester litteraux. */ private function applySearch(QueryBuilder $qb, ?string $search): void { @@ -116,11 +118,9 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search)); $pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%'; - $qb->andWhere( - 'LOWER(c.companyName) LIKE :search ' - .'OR LOWER(c.lastName) LIKE :search ' - .'OR LOWER(c.email) LIKE :search', - )->setParameter('search', $pattern); + $qb->andWhere('LOWER(c.companyName) LIKE :search') + ->setParameter('search', $pattern) + ; } /** diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index a60dc90..faa1188 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -166,14 +166,12 @@ final class ColumnCommentsCatalog ], 'client' => [ - '_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).', - 'id' => 'Identifiant interne auto-incremente.', - 'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).', - 'first_name' => 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).', - 'last_name' => 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).', - 'phone_primary' => 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.', - 'phone_secondary' => 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).', - 'email' => 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).', + '_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).', + 'id' => 'Identifiant interne auto-incremente.', + 'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).', + // Contact principal inline supprime (refonte contact) : first_name, + // last_name, phone_primary, phone_secondary, email vivent desormais + // uniquement sur client_contact. 'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.', 'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.', 'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.', diff --git a/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php b/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php index 66159d9..4b2f704 100644 --- a/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php +++ b/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php @@ -126,9 +126,6 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase // Stocke en MAJUSCULES pour refleter l'etat normalise (RG-1.18) qu'aurait // produit le ClientProcessor via l'API. $client->setCompanyName(mb_strtoupper($companyName, 'UTF-8')); - $client->setLastName('Seed'); - $client->setPhonePrimary('0102030405'); - $client->setEmail(strtolower(str_replace(' ', '', $companyName)).'@seed.test'); $client->addCategory($this->createCategory($categoryCode)); $client->setIsArchived($isArchived); if ($isArchived) { diff --git a/tests/Module/Commercial/Api/ClientApiTest.php b/tests/Module/Commercial/Api/ClientApiTest.php index 90827cc..445447b 100644 --- a/tests/Module/Commercial/Api/ClientApiTest.php +++ b/tests/Module/Commercial/Api/ClientApiTest.php @@ -25,7 +25,7 @@ final class ClientApiTest extends AbstractCommercialApiTestCase { private const string LD = 'application/ld+json'; - public function testPostNormalizesTextFields(): void + public function testPostNormalizesCompanyName(): void { $client = $this->createAdminClient(); $cat = $this->createCategory('SECTEUR'); @@ -33,23 +33,18 @@ final class ClientApiTest extends AbstractCommercialApiTestCase $response = $client->request('POST', '/api/clients', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ - 'companyName' => 'acme sas', - 'firstName' => 'JEAN', - 'lastName' => 'dupont', - 'phonePrimary' => '06.12.34.56.78', - 'email' => 'Jean.DUPONT@ACME.FR', - 'categories' => ['/api/categories/'.$cat->getId()], + 'companyName' => 'acme sas', + 'categories' => ['/api/categories/'.$cat->getId()], ], ]); self::assertResponseStatusCodeSame(201); $data = $response->toArray(); - // RG-1.18 / 1.19 / 1.20 / 1.21 + // RG-1.18 : companyName normalise en MAJUSCULES. Les champs de contact + // inline ont disparu (refonte contact) -> plus de normalisation ici. self::assertSame('ACME SAS', $data['companyName']); - self::assertSame('Jean', $data['firstName']); - self::assertSame('Dupont', $data['lastName']); - self::assertSame('0612345678', $data['phonePrimary']); - self::assertSame('jean.dupont@acme.fr', $data['email']); + self::assertArrayNotHasKey('firstName', $data); + self::assertArrayNotHasKey('email', $data); self::assertFalse($data['isArchived']); } @@ -60,41 +55,18 @@ final class ClientApiTest extends AbstractCommercialApiTestCase $iri = '/api/categories/'.$cat->getId(); $payload = [ - 'companyName' => 'Doublon SARL', - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'dup@test.fr', - 'categories' => [$iri], + 'companyName' => 'Doublon SARL', + 'categories' => [$iri], ]; $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]); self::assertResponseStatusCodeSame(201); // Meme nom (insensible a la casse via l'index LOWER) -> 409 (RG-1.16). - $payload['email'] = 'dup2@test.fr'; $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]); self::assertResponseStatusCodeSame(409); } - public function testPostWithoutFirstOrLastNameReturns422(): void - { - $client = $this->createAdminClient(); - $cat = $this->createCategory('SECTEUR'); - - $client->request('POST', '/api/clients', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => [ - 'companyName' => 'No Contact Name', - 'phonePrimary' => '0102030405', - 'email' => 'nc@test.fr', - 'categories' => ['/api/categories/'.$cat->getId()], - ], - ]); - - // RG-1.01 - self::assertResponseStatusCodeSame(422); - } - public function testPostWithoutCategoryReturns422(): void { $client = $this->createAdminClient(); @@ -102,11 +74,8 @@ final class ClientApiTest extends AbstractCommercialApiTestCase $client->request('POST', '/api/clients', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ - 'companyName' => 'No Category', - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'nocat@test.fr', - 'categories' => [], + 'companyName' => 'No Category', + 'categories' => [], ], ]); @@ -124,9 +93,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase 'headers' => ['Content-Type' => self::LD], 'json' => [ 'companyName' => 'Mutex Client', - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'mutex@test.fr', 'categories' => ['/api/categories/'.$cat->getId()], 'distributor' => '/api/clients/'.$distributor->getId(), 'broker' => '/api/clients/'.$distributor->getId(), @@ -147,9 +113,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase 'headers' => ['Content-Type' => self::LD], 'json' => [ 'companyName' => 'Bad Distrib Ref', - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'baddistrib@test.fr', 'categories' => ['/api/categories/'.$cat->getId()], 'distributor' => '/api/clients/'.$notDistro->getId(), ], @@ -169,9 +132,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase 'headers' => ['Content-Type' => self::LD], 'json' => [ 'companyName' => 'Client Avec Distrib', - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'okdistrib@test.fr', 'categories' => ['/api/categories/'.$cat->getId()], 'distributor' => '/api/clients/'.$distributor->getId(), ], @@ -190,9 +150,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase 'headers' => ['Content-Type' => self::LD], 'json' => [ 'companyName' => 'Bad Broker Ref', - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'badbroker@test.fr', 'categories' => ['/api/categories/'.$cat->getId()], 'broker' => '/api/clients/'.$notBroker->getId(), ], @@ -212,9 +169,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase 'headers' => ['Content-Type' => self::LD], 'json' => [ 'companyName' => 'Client Avec Courtier', - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'okbroker@test.fr', 'categories' => ['/api/categories/'.$cat->getId()], 'broker' => '/api/clients/'.$broker->getId(), ], diff --git a/tests/Module/Commercial/Api/ClientAuditTest.php b/tests/Module/Commercial/Api/ClientAuditTest.php index 6466c86..0fbc0b1 100644 --- a/tests/Module/Commercial/Api/ClientAuditTest.php +++ b/tests/Module/Commercial/Api/ClientAuditTest.php @@ -68,9 +68,6 @@ final class ClientAuditTest extends AbstractCommercialApiTestCase 'headers' => ['Content-Type' => self::LD], 'json' => [ 'companyName' => 'Blamable Co', - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'blamable@test.fr', 'categories' => ['/api/categories/'.$cat->getId()], ], ])->toArray(); diff --git a/tests/Module/Commercial/Api/ClientFormulaireMainTest.php b/tests/Module/Commercial/Api/ClientFormulaireMainTest.php index e6b6eec..165954f 100644 --- a/tests/Module/Commercial/Api/ClientFormulaireMainTest.php +++ b/tests/Module/Commercial/Api/ClientFormulaireMainTest.php @@ -7,12 +7,15 @@ namespace App\Tests\Module\Commercial\Api; use App\Module\Commercial\Domain\Entity\Client as ClientEntity; /** - * Tests fonctionnels du formulaire principal — combler les trous (ERP-60). + * Tests fonctionnels du formulaire principal apres la refonte contact. * - * RG-1.01 (prenom OU nom obligatoire) et RG-1.03 (distributor/broker exclusifs - * + type de categorie) sont DEJA couverts par ClientApiTest (ERP-55) : on ne les - * reduplique pas ici. Ce fichier ne couvre que RG-1.02 (telephone secondaire), - * non encore testee. + * RG-1.01 (prenom OU nom) et RG-1.02 (telephone secondaire) ont ete SUPPRIMEES + * du Client : le contact principal n'est plus porte inline, il vit uniquement + * dans ClientContact (onglet Contact). Ce fichier verifie que : + * - le formulaire principal se cree avec les seuls champs subsistants + * (companyName + categories), sans aucun champ de contact ; + * - les anciens champs de contact (firstName, lastName, phonePrimary, + * phoneSecondary, email) ne sont plus exposes ni persistes. * * @internal */ @@ -21,11 +24,10 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase private const string LD = 'application/ld+json'; /** - * RG-1.02 : le telephone secondaire est optionnel mais persiste (2 colonnes - * distinctes). Verifie aussi la normalisation chiffres-seuls (RG-1.20) sur - * la colonne secondaire. + * Le formulaire principal n'exige plus que companyName + au moins une + * categorie (RG-1.16 / RG sur categories). Aucun champ de contact requis. */ - public function testPostPersistsSecondaryPhoneNormalized(): void + public function testPostMainFormWithoutContactFields(): void { $client = $this->createAdminClient(); $cat = $this->createCategory('SECTEUR'); @@ -33,26 +35,28 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase $data = $client->request('POST', '/api/clients', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ - 'companyName' => 'Two Phones SARL', - 'firstName' => 'A', - 'phonePrimary' => '06.12.34.56.78', - 'phoneSecondary' => '05 49 00 11 22', - 'email' => 'twophones@test.fr', - 'categories' => ['/api/categories/'.$cat->getId()], + 'companyName' => 'Main Form SARL', + 'categories' => ['/api/categories/'.$cat->getId()], ], ])->toArray(); self::assertResponseStatusCodeSame(201); - self::assertSame('0612345678', $data['phonePrimary']); - self::assertSame('0549001122', $data['phoneSecondary']); + self::assertSame('MAIN FORM SARL', $data['companyName']); + + // Les champs de contact inline ont disparu de la representation. + self::assertArrayNotHasKey('firstName', $data); + self::assertArrayNotHasKey('lastName', $data); + self::assertArrayNotHasKey('phonePrimary', $data); + self::assertArrayNotHasKey('phoneSecondary', $data); + self::assertArrayNotHasKey('email', $data); } /** - * RG-1.02 : maximum 2 telephones — le modele n'expose que phonePrimary et - * phoneSecondary. Un eventuel 3e champ envoye par un appel API direct est - * ignore (aucune 3e colonne), il ne peut donc pas creer un troisieme numero. + * Les anciens champs de contact envoyes par un appel API direct (payload + * historique) sont ignores par le denormaliseur : ils n'apparaissent pas + * dans la representation et ne creent aucune colonne sur le client. */ - public function testThirdPhoneFieldIsIgnored(): void + public function testLegacyContactFieldsAreIgnored(): void { $client = $this->createAdminClient(); $cat = $this->createCategory('SECTEUR'); @@ -60,25 +64,25 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase $data = $client->request('POST', '/api/clients', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ - 'companyName' => 'Third Phone SARL', - 'firstName' => 'A', + 'companyName' => 'Legacy Fields SARL', + 'firstName' => 'Ignored', + 'lastName' => 'Ignored', 'phonePrimary' => '0612345678', 'phoneSecondary' => '0549001122', - 'phoneTertiary' => '0700000000', - 'email' => 'thirdphone@test.fr', + 'email' => 'ignored@test.fr', 'categories' => ['/api/categories/'.$cat->getId()], ], ])->toArray(); self::assertResponseStatusCodeSame(201); - // Le champ inconnu est ignore par le denormaliseur : il n'apparait pas - // dans la representation et n'a pas ete persiste. - self::assertArrayNotHasKey('phoneTertiary', $data); + self::assertArrayNotHasKey('firstName', $data); + self::assertArrayNotHasKey('phonePrimary', $data); + self::assertArrayNotHasKey('email', $data); - // Confirmation cote base : seules les 2 colonnes telephone existent. + // Confirmation cote base : le client cree ne porte aucun contact inline + // (les colonnes n'existent plus, l'entite n'a plus les proprietes). $persisted = $this->getEm()->getRepository(ClientEntity::class)->find($data['id']); self::assertNotNull($persisted); - self::assertSame('0612345678', $persisted->getPhonePrimary()); - self::assertSame('0549001122', $persisted->getPhoneSecondary()); + self::assertSame('LEGACY FIELDS SARL', $persisted->getCompanyName()); } } diff --git a/tests/Module/Commercial/Api/ClientMigrationTest.php b/tests/Module/Commercial/Api/ClientMigrationTest.php index 2e0af76..1c54f54 100644 --- a/tests/Module/Commercial/Api/ClientMigrationTest.php +++ b/tests/Module/Commercial/Api/ClientMigrationTest.php @@ -14,10 +14,41 @@ namespace App\Tests\Module\Commercial\Api; * - les anciens index uq_client_siren_active (RG-1.15) et uq_client_email_active * (RG-1.17) ont ete supprimes / ne sont jamais crees. * + * Verifie aussi la refonte contact (Version20260603120000) : les 5 colonnes de + * contact principal inline ont ete supprimees de la table `client`. + * * @internal */ final class ClientMigrationTest extends AbstractCommercialApiTestCase { + /** + * Refonte contact : first_name / last_name / phone_primary / phone_secondary + * / email ne doivent plus exister sur la table `client` (deplaces vers + * client_contact). NB : le backfill de la migration ne s'exerce que sur une + * base portant des donnees pre-refonte ; sur le schema de test (table client + * vierge au moment de la migration) il est un no-op, donc non assertable ici + * au runtime — seul l'etat de schema final est verifie. + */ + public function testInlineContactColumnsAreDropped(): void + { + self::bootKernel(); + + /** @var list $columns */ + $columns = $this->getEm()->getConnection()->fetchAllAssociative( + "SELECT column_name FROM information_schema.columns " + ."WHERE table_schema = 'public' AND table_name = 'client'", + ); + $names = array_map(static fn (array $r): string => $r['column_name'], $columns); + + foreach (['first_name', 'last_name', 'phone_primary', 'phone_secondary', 'email'] as $dropped) { + self::assertNotContains( + $dropped, + $names, + sprintf('La colonne client.%s aurait du etre supprimee (refonte contact).', $dropped), + ); + } + } + public function testCompanyNameActivePartialIndexExistsExactlyOnce(): void { $rows = $this->clientIndexes(); diff --git a/tests/Module/Commercial/Api/ClientRBACMatrixTest.php b/tests/Module/Commercial/Api/ClientRBACMatrixTest.php index e2afbf8..014ab66 100644 --- a/tests/Module/Commercial/Api/ClientRBACMatrixTest.php +++ b/tests/Module/Commercial/Api/ClientRBACMatrixTest.php @@ -324,9 +324,9 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase } /** - * Payload minimal valide de l'onglet principal (RG-1.01 : un nom de contact ; - * une categorie SECTEUR). Si $categoryId est null, une categorie est creee a - * la volee. + * Payload minimal valide de l'onglet principal (companyName + une categorie + * SECTEUR ; le contact inline a ete supprime). Si $categoryId est null, une + * categorie est creee a la volee. * * @return array */ @@ -335,11 +335,8 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase $categoryId ??= $this->createCategory('SECTEUR')->getId(); return [ - 'companyName' => $companyName, - 'firstName' => 'Jean', - 'phonePrimary' => '0612345678', - 'email' => strtolower(str_replace(' ', '', $companyName)).'@matrix.test', - 'categories' => ['/api/categories/'.$categoryId], + 'companyName' => $companyName, + 'categories' => ['/api/categories/'.$categoryId], ]; } } diff --git a/tests/Module/Commercial/Api/ClientSerializationContractTest.php b/tests/Module/Commercial/Api/ClientSerializationContractTest.php index 680c9e3..7a3e345 100644 --- a/tests/Module/Commercial/Api/ClientSerializationContractTest.php +++ b/tests/Module/Commercial/Api/ClientSerializationContractTest.php @@ -232,9 +232,6 @@ final class ClientSerializationContractTest extends AbstractCommercialApiTestCas $client = new ClientEntity(); $client->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8')); - $client->setLastName('Complet'); - $client->setPhonePrimary('0102030405'); - $client->setEmail('complet'.$suffix.'@seed.test'); $client->addCategory($this->createCategory('SECTEUR')); // Bloc comptable non nul (gating par omission cote Commerciale). $client->setSiren('123456789'); diff --git a/tests/Module/Commercial/Api/ClientUniquenessTest.php b/tests/Module/Commercial/Api/ClientUniquenessTest.php index ab4cff1..0f313af 100644 --- a/tests/Module/Commercial/Api/ClientUniquenessTest.php +++ b/tests/Module/Commercial/Api/ClientUniquenessTest.php @@ -12,41 +12,13 @@ use App\Module\Commercial\Domain\Entity\Client as ClientEntity; * RG-1.16 (doublon de companyName parmi les actifs -> 409) est DEJA couvert par * ClientApiTest::testPostDuplicateCompanyNameReturns409 (ERP-55). Ce fichier * verifie l'envers de la decision Q4 (29/05/2026) : le SIREN (RG-1.15 supprimee) - * et l'email (RG-1.17 supprimee) NE SONT PLUS contraints uniques. + * n'est PLUS contraint unique. (L'email — RG-1.17 — a disparu du Client avec la + * refonte contact, il vit desormais sur ClientContact.) * * @internal */ final class ClientUniquenessTest extends AbstractCommercialApiTestCase { - private const string LD = 'application/ld+json'; - - /** - * RG-1.16 / RG-1.17 (Q4) : deux clients actifs peuvent partager le meme - * email principal — aucune contrainte d'unicite (un email peut servir - * plusieurs clients). - */ - public function testDuplicateEmailIsAllowed(): void - { - $client = $this->createAdminClient(); - $cat = $this->createCategory('SECTEUR'); - $iri = '/api/categories/'.$cat->getId(); - - $payload = static fn (string $name): array => [ - 'companyName' => $name, - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'partage@test.fr', - 'categories' => [$iri], - ]; - - $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share One')]); - self::assertResponseStatusCodeSame(201); - - // Meme email, nom different -> doit passer (pas d'index unique email). - $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share Two')]); - self::assertResponseStatusCodeSame(201); - } - /** * RG-1.15 (Q4) : deux clients peuvent partager le meme SIREN (etablissements * multiples). Le SIREN n'est pas ecrivable au POST (groupe accounting), on diff --git a/tests/Module/Commercial/Unit/ClientProcessorTest.php b/tests/Module/Commercial/Unit/ClientProcessorTest.php index d6270d6..8a025f3 100644 --- a/tests/Module/Commercial/Unit/ClientProcessorTest.php +++ b/tests/Module/Commercial/Unit/ClientProcessorTest.php @@ -134,14 +134,11 @@ final class ClientProcessorTest extends TestCase 'isArchived' => false, ], managed: true, - // Etat persiste complet (valeurs normalisees) : sans les champs - // metier, guardManage (ERP-74) les croirait modifies (companyName, - // lastName... compares a null) et leverait un 403 parasite. + // Etat persiste (valeurs normalisees) : sans companyName, guardManage + // (ERP-74) le croirait modifie (compare a null) et leverait un 403 + // parasite. originalData: [ 'companyName' => 'TEST CO', - 'lastName' => 'Dupont', - 'phonePrimary' => '0102030405', - 'email' => 't@test.fr', 'triageService' => false, 'isArchived' => false, ], @@ -164,13 +161,10 @@ final class ClientProcessorTest extends TestCase managed: true, // getOriginalEntityData renvoie tous les champs mappes d'une entite // geree : isArchived (non-null) y figure toujours, ainsi que les - // champs metier (sinon guardManage les croirait modifies). + // champs metier (companyName) sinon guardManage les croirait modifies. originalData: [ 'siren' => '123456789', 'companyName' => 'TEST CO', - 'lastName' => 'Dupont', - 'phonePrimary' => '0102030405', - 'email' => 't@test.fr', 'triageService' => false, 'isArchived' => false, ], @@ -193,9 +187,6 @@ final class ClientProcessorTest extends TestCase managed: true, originalData: [ 'companyName' => 'TEST CO', - 'lastName' => 'Dupont', - 'phonePrimary' => '0102030405', - 'email' => 't@test.fr', 'triageService' => false, 'isArchived' => false, ], @@ -220,9 +211,6 @@ final class ClientProcessorTest extends TestCase originalData: [ 'siren' => '111111111', 'companyName' => 'TEST CO', - 'lastName' => 'Dupont', - 'phonePrimary' => '0102030405', - 'email' => 't@test.fr', 'triageService' => false, 'isArchived' => false, ], @@ -324,9 +312,6 @@ final class ClientProcessorTest extends TestCase managed: true, originalData: [ 'companyName' => 'TEST CO', - 'lastName' => 'Dupont', - 'phonePrimary' => '0102030405', - 'email' => 't@test.fr', 'triageService' => false, 'isArchived' => false, ], @@ -401,16 +386,14 @@ final class ClientProcessorTest extends TestCase } /** - * Client minimal valide vis-a-vis de RG-1.01 (un nom de contact) — suffisant - * pour atteindre les validations testees. + * Client minimal — companyName seul depuis la suppression du contact inline. + * Suffisant pour atteindre les validations testees (le contact vit desormais + * dans ClientContact, hors scope du ClientProcessor). */ private function minimalClient(): Client { $client = new Client(); $client->setCompanyName('Test Co'); - $client->setLastName('Dupont'); - $client->setPhonePrimary('0102030405'); - $client->setEmail('t@test.fr'); return $client; }