diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php new file mode 100644 index 0000000..11f5658 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php @@ -0,0 +1,139 @@ + created_by / updated_by + * restent null (« Systeme » cote front), c'est attendu. + * + * Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, + * la fixture ne charge rien : les tests seedent et nettoient leurs propres + * categories (prefixe dedie) et comptent sur une table `category` vierge — y + * injecter 12 categories de demo casserait comptages et cleanups FK + * (client_category). Cf. ClientFixtures (meme garde-fou). + */ +class CategoryFixtures extends Fixture implements DependentFixtureInterface +{ + /** + * Source unique des categories de demonstration : code de type metier => + * liste de noms. Les noms sont stockes tels quels (l'unicite est + * case-insensitive cote index). + * + * @var array> + */ + private const CATEGORIES = [ + 'SECTEUR' => [ + 'BTP', + 'Industrie', + 'Agro-alimentaire', + 'Transport/Logistique', + 'Services', + ], + 'DISTRIBUTEUR' => [ + 'Distributeur Grand Sud-Ouest', + 'Distributeur National Premium', + 'Grossiste régional', + ], + 'COURTIER' => [ + 'Cabinet de courtage Léonard', + 'Cabinet de courtage Bernard', + ], + 'AUTRE' => [ + 'Indépendant', + 'Association', + ], + ]; + + public function __construct( + private readonly CategoryTypeRepositoryInterface $categoryTypeRepository, + #[Autowire('%kernel.environment%')] + private readonly string $environment, + ) {} + + /** + * @return array + */ + public function getDependencies(): array + { + return [CategoryTypeFixtures::class]; + } + + public function load(ObjectManager $manager): void + { + // Donnees de demo : dev uniquement. En test, on laisse la table vierge. + if ('test' === $this->environment) { + return; + } + + // Index des types metier par code (CategoryTypeFixtures les a seedes). + $typesByCode = []; + foreach ($this->categoryTypeRepository->findAllOrderedByLabel() as $type) { + $typesByCode[$type->getCode()] = $type; + } + + foreach (self::CATEGORIES as $typeCode => $names) { + $type = $typesByCode[$typeCode] ?? null; + if (!$type instanceof CategoryType) { + // Misconfiguration : CategoryTypeFixtures n'a pas tourne avant. + throw new RuntimeException(sprintf( + 'CategoryTypeFixtures doit avoir seede le type "%s" avant CategoryFixtures.', + $typeCode, + )); + } + + foreach ($names as $name) { + $this->ensureCategory($manager, $name, $type); + } + } + + $manager->flush(); + } + + /** + * Cree la categorie (name, type) si elle n'existe pas encore parmi les + * categories actives, sinon la laisse en place. Lookup aligne sur l'index + * unique partiel (nom + type, hors soft-deleted). + */ + private function ensureCategory(ObjectManager $manager, string $name, CategoryType $type): void + { + $existing = $manager->getRepository(Category::class)->findOneBy([ + 'name' => $name, + 'categoryType' => $type, + 'deletedAt' => null, + ]); + + if (null !== $existing) { + return; + } + + $category = new Category(); + $category->setName($name); + $category->setCategoryType($type); + $manager->persist($category); + } +} diff --git a/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php b/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php new file mode 100644 index 0000000..d0facdb --- /dev/null +++ b/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php @@ -0,0 +1,555 @@ + 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 ERP-76 (exclusivite Prospect, + * billingEmail ssi facturation, aucune categorie DISTRIBUTEUR/COURTIER sur une + * adresse). + * + * 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 Grand Sud-Ouest'], + ); + 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: ['Cabinet de courtage Léonard'], + ); + 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 ERP-76 : exclusivite Prospect, + * billingEmail ssi facturation, categories limitees a SECTEUR/AUTRE. + * + * @param list $siteNames au moins un site (RG-1.10) + * @param list $categoryNames categories SECTEUR/AUTRE uniquement (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; + } +}