Category) ; * - sites resolus via le contrat Shared SiteProviderInterface. * * Normalisation : les valeurs sont fournies BRUTES (casse libre, telephones * formates) et normalisees par ClientFieldNormalizer avant persist, exactement * comme le ferait le ClientProcessor via l'API (companyName UPPERCASE, * first/last Capitalize, telephones chiffres seuls, emails lowercase). * * Distributeur / courtier auto-references (RG-1.03) : les tiers referencables * (GSO distributeur, Cabinet Leonard courtier) sont crees AVANT les clients qui * les referencent ; un unique flush en fin de load ordonne correctement les * inserts auto-references. * * Idempotence : lookup par companyName normalise (coherent avec l'index unique * partiel uq_client_company_name_active). Un client deja present n'est pas * reconstruit (ses sous-collections ne sont pas redupliquees). Rejouable sans * doublon meme si le purger Doctrine est desactive. * * Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by * restent null (« Systeme » cote front), c'est attendu. Les donnees respectent * les CHECK BDD ET les validators applicatifs (exclusivite Prospect, billingEmail * ssi facturation, aucune categorie de code DISTRIBUTEUR/COURTIER sur une adresse * — RG-1.29, ERP-78). * * Depend de CategoryFixtures (categories), SitesFixtures (sites) et * CommercialReferentialFixtures (referentiels comptables Bank / PaymentType). * * Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, * la fixture ne charge rien : les tests seedent et nettoient leurs propres * clients et comptent sur une table `client` vierge — y injecter 14 clients de * demo casserait les comptages de liste et les cleanups. Meme garde-fou que * CategoryFixtures. */ class ClientFixtures extends Fixture implements DependentFixtureInterface { /** Cache des categories resolues par nom (evite des requetes repetees). */ private array $categoryCache = []; /** Cache des sites resolus par nom. */ private array $siteCache = []; /** ObjectManager courant, capture en debut de load (resolution categories). */ private ObjectManager $manager; public function __construct( private readonly ClientFieldNormalizer $normalizer, private readonly SiteProviderInterface $siteProvider, #[Autowire('%kernel.environment%')] private readonly string $environment, ) {} /** * @return array */ public function getDependencies(): array { return [ CategoryFixtures::class, SitesFixtures::class, CommercialReferentialFixtures::class, ]; } public function load(ObjectManager $manager): void { // Donnees de demo : dev uniquement. En test, on laisse la table vierge. if ('test' === $this->environment) { return; } $this->manager = $manager; // === Tiers referencables (RG-1.03) : crees en premier === // Distributeur reference par d'autres clients. [$gso, $gsoIsNew] = $this->ensureClient( $manager, companyName: 'Distrib Grand Sud-Ouest', firstName: 'Paul', lastName: 'Garnier', phonePrimary: '05 56 10 20 30', email: 'contact@distrib-gso.fr', categoryNames: ['Distributeur'], ); if ($gsoIsNew) { $this->addContact($gso, 'Paul', 'Garnier', 'Directeur commercial', '05 56 10 20 30', null, 'paul.garnier@distrib-gso.fr'); $this->addAddress($gso, ['Pommevic'], '82400', 'Pommevic', '1 Av. Jean Duquesne', isDelivery: true, categoryNames: ['Transport/Logistique']); } // Courtier reference par d'autres clients. [$leonard, $leonardIsNew] = $this->ensureClient( $manager, companyName: 'Cabinet Léonard Assurances', firstName: 'Sophie', lastName: 'Léonard', phonePrimary: '05 49 11 22 33', email: 'contact@cabinet-leonard.fr', categoryNames: ['Courtier'], ); if ($leonardIsNew) { $this->addContact($leonard, 'Sophie', 'Léonard', 'Gérante', '05 49 11 22 33', null, 'sophie.leonard@cabinet-leonard.fr'); $this->addAddress($leonard, ['Chatellerault'], '86100', 'Châtellerault', '5 rue des Courtiers', isBilling: true, billingEmail: 'Factures@Cabinet-Leonard.FR'); } // === Client basique === [$dubois, $isNew] = $this->ensureClient( $manager, companyName: 'Menuiserie Dubois', firstName: 'Jean', lastName: 'Dubois', phonePrimary: '05 49 00 00 01', email: 'contact@menuiserie-dubois.fr', categoryNames: ['BTP'], ); if ($isNew) { $dubois->setPaymentType($this->paymentType($manager, 'VIREMENT')); $dubois->setBank($this->bank($manager, 'SG')); $this->addContact($dubois, 'Jean', 'Dubois', 'Gérant', '05 49 00 00 01', null, 'jean.dubois@menuiserie-dubois.fr'); $this->addAddress($dubois, ['Chatellerault'], '86100', 'Châtellerault', '12 rue de l\'Atelier', isDelivery: true, categoryNames: ['BTP']); } // === Dependant d'un distributeur (RG-1.03) === [$garage, $isNew] = $this->ensureClient( $manager, companyName: 'Garage Martin', firstName: 'Luc', lastName: 'Martin', phonePrimary: '05 56 44 55 66', email: 'accueil@garage-martin.fr', categoryNames: ['Services'], ); if ($isNew) { $garage->setDistributor($gso); $this->addContact($garage, 'Luc', 'Martin', 'Gérant', '05 56 44 55 66', null, 'luc.martin@garage-martin.fr'); $this->addAddress($garage, ['Pommevic'], '82400', 'Pommevic', '8 route de Moissac', isDelivery: true); } // === Dependant d'un courtier (RG-1.03) === [$boulangerie, $isNew] = $this->ensureClient( $manager, companyName: 'Boulangerie Lemoine', firstName: 'Marie', lastName: 'Lemoine', phonePrimary: '05 49 77 88 99', email: 'bonjour@boulangerie-lemoine.fr', categoryNames: ['Agro-alimentaire'], ); if ($isNew) { $boulangerie->setBroker($leonard); $this->addContact($boulangerie, 'Marie', 'Lemoine', 'Gérante', '05 49 77 88 99', null, 'marie.lemoine@boulangerie-lemoine.fr'); $this->addAddress($boulangerie, ['Chatellerault'], '86100', 'Châtellerault', '3 place du Marché', isDelivery: true); } // === Reglement LCR avec 2 RIB (RG-1.13) === [$transports, $isNew] = $this->ensureClient( $manager, companyName: 'Transports Rapides', firstName: null, lastName: 'Bernard', phonePrimary: '05 56 12 13 14', email: 'exploitation@transports-rapides.fr', categoryNames: ['Transport/Logistique'], ); if ($isNew) { $transports->setPaymentType($this->paymentType($manager, 'LCR')); $this->addContact($transports, null, 'Bernard', 'Responsable exploitation', '05 56 12 13 14', null, 'expl@transports-rapides.fr'); $this->addAddress($transports, ['Saint-Jean'], '17400', 'Fontenet', '2 zone industrielle', isDelivery: true, categoryNames: ['Transport/Logistique']); $this->addRib($transports, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0); $this->addRib($transports, 'Compte secondaire', 'SOGEFRPPXXX', 'FR7630006000011234567890189', 1); } // === Multi-adresses Prospect / Livraison / Facturation (RG-1.06/07/08/11) === [$industries, $isNew] = $this->ensureClient( $manager, companyName: 'Industries Vertes', firstName: 'Claire', lastName: 'Moreau', phonePrimary: '05 49 21 22 23', email: 'contact@industries-vertes.fr', categoryNames: ['Industrie'], ); if ($isNew) { $this->addContact($industries, 'Claire', 'Moreau', 'Directrice', '05 49 21 22 23', null, 'claire.moreau@industries-vertes.fr'); // Prospect : exclusif de livraison/facturation (sans billingEmail). $this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '1 avenue de la Prospection', isProspect: true, position: 0); // Livraison. $this->addAddress($industries, ['Saint-Jean'], '17400', 'Fontenet', '4 rue de la Livraison', isDelivery: true, categoryNames: ['Industrie'], position: 1); // Facturation : billingEmail obligatoire. $this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '7 boulevard des Factures', isBilling: true, billingEmail: 'Compta@Industries-Vertes.FR', position: 2); } // === 3 contacts dont un avec telephone secondaire (RG-1.05/1.02) === [$agro, $isNew] = $this->ensureClient( $manager, companyName: 'Agro Distribution Sud', firstName: 'Thomas', lastName: 'Petit', phonePrimary: '05 56 31 32 33', email: 'contact@agro-sud.fr', categoryNames: ['Agro-alimentaire'], phoneSecondary: '06 01 02 03 04', ); if ($isNew) { $this->addContact($agro, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@agro-sud.fr', 0); $this->addContact($agro, 'Julie', 'Roux', 'Assistante commerciale', '05 56 31 32 34', null, 'julie.roux@agro-sud.fr', 1); $this->addContact($agro, 'Marc', 'Girard', 'Logistique', '05 56 31 32 35', null, 'marc.girard@agro-sud.fr', 2); $this->addAddress($agro, ['Pommevic'], '82400', 'Pommevic', '10 rue des Producteurs', isDelivery: true); } // === Client archive (RG-1.22) === [$ancienne, $isNew] = $this->ensureClient( $manager, companyName: 'Ancienne Société Oubliée', firstName: null, lastName: 'Durand', phonePrimary: '05 49 99 99 99', email: 'contact@ancienne-societe.fr', categoryNames: ['Association'], isArchived: true, ); if ($isNew) { $this->addContact($ancienne, null, 'Durand', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancienne-societe.fr'); $this->addAddress($ancienne, ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée', isDelivery: true); } // === Reglement Cheque sans RIB === [$services, $isNew] = $this->ensureClient( $manager, companyName: 'Services Pro Conseil', firstName: 'Nadia', lastName: 'Benali', phonePrimary: '05 49 41 42 43', email: 'contact@services-pro.fr', categoryNames: ['Services'], ); if ($isNew) { $services->setPaymentType($this->paymentType($manager, 'CHEQUE')); $this->addContact($services, 'Nadia', 'Benali', 'Consultante', '05 49 41 42 43', null, 'nadia.benali@services-pro.fr'); $this->addAddress($services, ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Conseil', isDelivery: true); } // === Onglet Information complet (RG-1.04) === [$holding, $isNew] = $this->ensureClient( $manager, companyName: 'Holding Premium Invest', firstName: 'Antoine', lastName: 'Lefèvre', phonePrimary: '05 56 51 52 53', email: 'direction@holding-premium.fr', categoryNames: ['Industrie'], ); if ($isNew) { $holding->setDescription('Holding industrielle diversifiée, présente sur le Grand Sud-Ouest.'); $holding->setCompetitors('Groupe Atlantique, Sud Industries'); $holding->setFoundedAt(new DateTimeImmutable('2005-03-15')); $holding->setEmployeesCount(240); $holding->setRevenueAmount('18500000.00'); $holding->setDirectorName('Antoine Lefèvre'); $holding->setProfitAmount('1250000.00'); $this->addContact($holding, 'Antoine', 'Lefèvre', 'PDG', '05 56 51 52 53', null, 'antoine.lefevre@holding-premium.fr'); $this->addAddress($holding, ['Pommevic'], '82400', 'Pommevic', '1 allée des Investisseurs', isDelivery: true, categoryNames: ['Industrie']); } // === Multi-categories M2M === [$conglo, $isNew] = $this->ensureClient( $manager, companyName: 'Conglomérat Multi Activités', firstName: 'Hélène', lastName: 'Faure', phonePrimary: '05 49 61 62 63', email: 'contact@conglomerat-multi.fr', categoryNames: ['BTP', 'Industrie', 'Services'], ); if ($isNew) { $this->addContact($conglo, 'Hélène', 'Faure', 'Directrice générale', '05 49 61 62 63', null, 'helene.faure@conglomerat-multi.fr'); $this->addAddress($conglo, ['Chatellerault', 'Saint-Jean'], '86100', 'Châtellerault', '20 rue des Activités', isDelivery: true, categoryNames: ['BTP', 'Services']); } // === Prospect seul === [$prospect, $isNew] = $this->ensureClient( $manager, companyName: 'Prospect Futur Client', firstName: 'Olivier', lastName: 'Renard', phonePrimary: '05 56 71 72 73', email: 'olivier.renard@prospect-futur.fr', categoryNames: ['BTP'], ); if ($isNew) { $this->addContact($prospect, 'Olivier', 'Renard', 'Responsable projet', '05 56 71 72 73', null, 'olivier.renard@prospect-futur.fr'); $this->addAddress($prospect, ['Chatellerault'], '86100', 'Châtellerault', '30 rue de la Découverte', isProspect: true); } // === Categorie AUTRE === [$association, $isNew] = $this->ensureClient( $manager, companyName: 'Association des Riverains', firstName: null, lastName: 'Caron', phonePrimary: '05 49 81 82 83', email: 'contact@asso-riverains.fr', categoryNames: ['Association'], ); if ($isNew) { $this->addContact($association, null, 'Caron', 'Président', '05 49 81 82 83', null, 'president@asso-riverains.fr'); $this->addAddress($association, ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village', isDelivery: true, categoryNames: ['Association']); } $manager->flush(); } /** * Cree un client (base normalisee + categories) s'il n'existe pas encore, * sinon retourne l'existant. Retourne [Client, isNew] : isNew=false bloque la * reconstruction des sous-collections (idempotence sans doublon). * * @param list $categoryNames * * @return array{0: Client, 1: bool} */ private function ensureClient( ObjectManager $manager, string $companyName, ?string $firstName, ?string $lastName, string $phonePrimary, string $email, array $categoryNames, ?string $phoneSecondary = null, bool $isArchived = false, ): array { $normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName); $existing = $manager->getRepository(Client::class)->findOneBy(['companyName' => $normalizedName]); if ($existing instanceof Client) { return [$existing, false]; } $client = new Client(); $client->setCompanyName($normalizedName); $client->setFirstName($this->normalizer->normalizePersonName($firstName)); $client->setLastName($this->normalizer->normalizePersonName($lastName)); $client->setPhonePrimary((string) $this->normalizer->normalizePhone($phonePrimary)); $client->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary)); $client->setEmail((string) $this->normalizer->normalizeEmail($email)); foreach ($categoryNames as $categoryName) { $client->addCategory($this->category($manager, $categoryName)); } if ($isArchived) { $client->setIsArchived(true); $client->setArchivedAt(new DateTimeImmutable()); } $manager->persist($client); return [$client, true]; } /** * Ajoute un contact normalise au client (cascade persist via Client.contacts). * Au moins lastName est toujours fourni (RG-1.05, chk_client_contact_name). */ private function addContact( Client $client, ?string $firstName, ?string $lastName, ?string $jobTitle, ?string $phonePrimary, ?string $phoneSecondary, ?string $email, int $position = 0, ): void { $contact = new ClientContact(); $contact->setFirstName($this->normalizer->normalizePersonName($firstName)); $contact->setLastName($this->normalizer->normalizePersonName($lastName)); $contact->setJobTitle($jobTitle); $contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary)); $contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary)); $contact->setEmail($this->normalizer->normalizeEmail($email)); $contact->setPosition($position); $client->addContact($contact); } /** * Ajoute une adresse au client (cascade persist via Client.addresses). Les * donnees respectent les validators : exclusivite Prospect, billingEmail ssi * facturation, aucune categorie de code DISTRIBUTEUR/COURTIER (RG-1.29). * * @param list $siteNames au moins un site (RG-1.10) * @param list $categoryNames categories hors DISTRIBUTEUR/COURTIER (RG-1.29) */ private function addAddress( Client $client, array $siteNames, string $postalCode, string $city, string $street, bool $isProspect = false, bool $isDelivery = false, bool $isBilling = false, ?string $billingEmail = null, array $categoryNames = [], int $position = 0, ): void { $address = new ClientAddress(); $address->setIsProspect($isProspect); $address->setIsDelivery($isDelivery); $address->setIsBilling($isBilling); $address->setBillingEmail($this->normalizer->normalizeEmail($billingEmail)); $address->setPostalCode($postalCode); $address->setCity($city); $address->setStreet($street); $address->setPosition($position); foreach ($siteNames as $siteName) { $address->addSite($this->site($siteName)); } foreach ($categoryNames as $categoryName) { $address->addCategory($this->category($this->manager, $categoryName)); } $client->addAddress($address); } /** * Ajoute un RIB au client (cascade persist via Client.ribs). IBAN/BIC valides * (Assert\Iban/Bic non rejouee sur persist direct mais donnees coherentes). */ private function addRib(Client $client, string $label, string $bic, string $iban, int $position = 0): void { $rib = new ClientRib(); $rib->setLabel($label); $rib->setBic($bic); $rib->setIban($iban); $rib->setPosition($position); $client->addRib($rib); } /** * Resout une categorie par son nom via le contrat Shared CategoryInterface * (resolve_target_entities -> Category), sans importer le module Catalog * (regle n°1). Mise en cache par nom. */ private function category(ObjectManager $manager, string $name): CategoryInterface { if (isset($this->categoryCache[$name])) { return $this->categoryCache[$name]; } $category = $manager->getRepository(CategoryInterface::class)->findOneBy([ 'name' => $name, 'deletedAt' => null, ]); if (!$category instanceof CategoryInterface) { throw new RuntimeException(sprintf( 'Categorie "%s" introuvable : CategoryFixtures doit tourner avant ClientFixtures.', $name, )); } return $this->categoryCache[$name] = $category; } /** * Resout un site par son nom via le contrat Shared SiteProviderInterface, * sans importer le module Sites (regle n°1). Mise en cache par nom. */ private function site(string $name): SiteInterface { if (isset($this->siteCache[$name])) { return $this->siteCache[$name]; } $site = $this->siteProvider->findByName($name); if (!$site instanceof SiteInterface) { throw new RuntimeException(sprintf( 'Site "%s" introuvable : SitesFixtures doit tourner avant ClientFixtures.', $name, )); } return $this->siteCache[$name] = $site; } private function paymentType(ObjectManager $manager, string $code): PaymentType { $type = $manager->getRepository(PaymentType::class)->findOneBy(['code' => $code]); if (!$type instanceof PaymentType) { throw new RuntimeException(sprintf( 'PaymentType "%s" introuvable : CommercialReferentialFixtures doit tourner avant ClientFixtures.', $code, )); } return $type; } private function bank(ObjectManager $manager, string $code): Bank { $bank = $manager->getRepository(Bank::class)->findOneBy(['code' => $code]); if (!$bank instanceof Bank) { throw new RuntimeException(sprintf( 'Bank "%s" introuvable : CommercialReferentialFixtures doit tourner avant ClientFixtures.', $code, )); } return $bank; } }