Category) ; * - sites resolus via le contrat Shared SiteProviderInterface. * * Normalisation : les valeurs sont fournies BRUTES (casse libre, telephones * formates) et normalisees par SupplierFieldNormalizer avant persist, exactement * comme le ferait le SupplierProcessor via l'API (companyName UPPERCASE, * first/last Capitalize, telephones chiffres seuls, emails lowercase). * * Coherence gating comptable (RG-2.16) : les scalaires comptables (siren, * tvaMode, paymentType, bank...) et les RIB ne sont visibles qu'avec * accounting.view. Les donnees sont posees pour que les roles SANS cette * permission (ex. Commerciale) ne voient pas de compta — support des tests * ERP-92 et du golden path front. * * Idempotence : lookup par companyName normalise (coherent avec l'index unique * partiel uq_supplier_company_name_active). Un fournisseur 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 (chk_supplier_contact_name : firstName OU lastName ; * chk_supplier_address_type : PROSPECT | DEPART | RENDU) ET la coherence des * validators d'entite (RG-2.07/2.08 : VIREMENT => banque, LCR => >= 1 RIB). * * Depend de CategoryFixtures (categories FOURNISSEUR), SitesFixtures (sites) et * CommercialReferentialFixtures (referentiels comptables — REUTILISES de M1, * aucune nouvelle table). * * Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, * la fixture ne charge rien : les tests seedent et nettoient leurs propres * fournisseurs et comptent sur une table `supplier` vierge — y injecter 13 * fournisseurs de demo casserait les comptages de liste et les cleanups. Meme * garde-fou que ClientFixtures / CategoryFixtures. */ class SupplierFixtures extends Fixture implements DependentFixtureInterface { /** * Type de categorie exige pour un fournisseur et ses adresses (RG-2.10). * Miroir de Supplier::REQUIRED_CATEGORY_TYPE_CODE (non importable — regle n°1). */ private const string SUPPLIER_CATEGORY_TYPE_CODE = 'FOURNISSEUR'; /** 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 SupplierFieldNormalizer $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; // === Fournisseur basique — VIREMENT + banque (RG-2.07), compta complete === [$negoce, $isNew] = $this->ensureSupplier($manager, 'Négoce Métaux Atlantique', ['Négociant']); if ($isNew) { $negoce->setSiren('841611054'); $negoce->setAccountNumber('F0001'); $negoce->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); $negoce->setNTva('FR12841611054'); $negoce->setPaymentDelay($this->paymentDelay($manager, 'J30')); $negoce->setPaymentType($this->paymentType($manager, 'VIREMENT')); $negoce->setBank($this->bank($manager, 'SG')); $this->addContact($negoce, 'Jean', 'Dubois', 'Responsable achats', '05 49 00 00 01', null, 'jean.dubois@negoce-metaux.fr'); $this->addAddress($negoce, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '12 rue de la Ferraille', bennes: 4, triageProvider: true, categoryNames: ['Négociant']); } // === LCR avec 1 RIB (RG-2.08) + 2 contacts === [$coop, $isNew] = $this->ensureSupplier($manager, 'Coopérative Agricole du Sud-Ouest', ['Coopérative']); if ($isNew) { $coop->setSiren('775680459'); $coop->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); $coop->setPaymentDelay($this->paymentDelay($manager, 'J15')); $coop->setPaymentType($this->paymentType($manager, 'LCR')); $this->addContact($coop, 'Sophie', 'Marchand', 'Directrice', '05 56 10 20 30', '06 11 22 33 44', 'sophie.marchand@coop-so.fr', 0); $this->addContact($coop, 'Marc', 'Girard', 'Acheteur', '05 56 10 20 31', null, 'marc.girard@coop-so.fr', 1); $this->addAddress($coop, 'RENDU', ['Pommevic'], '82400', 'Pommevic', '8 route des Cooperateurs', bennes: 2); $this->addRib($coop, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0); } // === Prospect seul (adresse PROSPECT), compta minimale === [$producteur, $isNew] = $this->ensureSupplier($manager, 'Producteur Bio Charente', ['Producteur']); if ($isNew) { $this->addContact($producteur, 'Claire', 'Moreau', 'Gérante', '05 49 21 22 23', null, 'claire.moreau@bio-charente.fr'); $this->addAddress($producteur, 'PROSPECT', ['Saint-Jean'], '17400', 'Fontenet', '1 chemin des Producteurs'); } // === Multi-categories M2M + LCR avec 2 RIB + 3 contacts === [$grossiste, $isNew] = $this->ensureSupplier($manager, 'Grossiste Multi-Métaux', ['Grossiste', 'Négociant']); if ($isNew) { $grossiste->setSiren('552081317'); $grossiste->setAccountNumber('F0004'); $grossiste->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); $grossiste->setNTva('FR45552081317'); $grossiste->setPaymentDelay($this->paymentDelay($manager, 'J30')); $grossiste->setPaymentType($this->paymentType($manager, 'LCR')); $this->addContact($grossiste, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@grossiste-mm.fr', 0); $this->addContact($grossiste, 'Julie', 'Roux', 'Assistante commerciale', '05 56 31 32 34', null, 'julie.roux@grossiste-mm.fr', 1); $this->addContact($grossiste, 'Hélène', 'Faure', 'Logistique', '05 56 31 32 35', null, 'helene.faure@grossiste-mm.fr', 2); $this->addAddress($grossiste, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '20 zone des Activités', streetComplement: 'Bâtiment C', bennes: 6, triageProvider: true, categoryNames: ['Grossiste', 'Négociant']); $this->addRib($grossiste, 'Compte principal', 'BNPAFRPPXXX', 'FR7630006000011234567890189', 0); $this->addRib($grossiste, 'Compte secondaire', 'SOGEFRPPXXX', 'FR7630001007941234567890185', 1); } // === VIREMENT + banque, TVA intracom (importateur), multi-sites sur l'adresse === [$import, $isNew] = $this->ensureSupplier($manager, 'Import Recyclage International', ['Importateur']); if ($isNew) { $import->setSiren('409512012'); $import->setTvaMode($this->tvaMode($manager, 'INTRACOM_VENTES')); $import->setNTva('FR90409512012'); $import->setPaymentDelay($this->paymentDelay($manager, 'J30')); $import->setPaymentType($this->paymentType($manager, 'VIREMENT')); $import->setBank($this->bank($manager, 'CIC')); $this->addContact($import, 'Paul', 'Garnier', 'Import manager', '05 56 44 55 66', null, 'paul.garnier@import-recyclage.fr', 0); $this->addContact($import, null, 'Bernard', 'Douanes', '05 56 44 55 67', null, 'douanes@import-recyclage.fr', 1); $this->addAddress($import, 'RENDU', ['Pommevic', 'Saint-Jean'], '82400', 'Pommevic', '3 quai des Importateurs', bennes: 8); } // === Multi-adresses PROSPECT / DEPART / RENDU (RG-2.09) + VIREMENT/banque === [$ferrailleur, $isNew] = $this->ensureSupplier($manager, 'Ferrailleur Grand Ouest', ['Négociant']); if ($isNew) { $ferrailleur->setSiren('732829320'); $ferrailleur->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); $ferrailleur->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION')); $ferrailleur->setPaymentType($this->paymentType($manager, 'VIREMENT')); $ferrailleur->setBank($this->bank($manager, 'CA')); $this->addContact($ferrailleur, 'Olivier', 'Renard', 'Responsable site', '05 49 61 62 63', null, 'olivier.renard@ferrailleur-go.fr', 0); $this->addContact($ferrailleur, 'Nadia', 'Benali', 'Pesée', '05 49 61 62 64', null, 'nadia.benali@ferrailleur-go.fr', 1); // Prospect (site en cours de demarchage). $this->addAddress($ferrailleur, 'PROSPECT', ['Chatellerault'], '86100', 'Châtellerault', '5 avenue de la Prospection', position: 0); // Depart (collecte) multi-sites avec bennes + triage. $this->addAddress($ferrailleur, 'DEPART', ['Saint-Jean', 'Pommevic'], '17400', 'Fontenet', '4 rue de la Collecte', bennes: 5, triageProvider: true, categoryNames: ['Négociant'], position: 1); // Rendu (livraison). $this->addAddress($ferrailleur, 'RENDU', ['Pommevic'], '82400', 'Pommevic', '7 boulevard du Rendu', bennes: 3, position: 2); } // === Onglet Information complet (dont volumeForecast) + VIREMENT/banque === [$holding, $isNew] = $this->ensureSupplier($manager, 'Holding Recyclage Premium', ['Importateur']); if ($isNew) { $holding->setDescription('Holding de recyclage diversifiée, présente sur le Grand Sud-Ouest.'); $holding->setCompetitors('Groupe Atlantique Recyclage, Sud Métaux'); $holding->setFoundedAt(new DateTimeImmutable('2008-09-01')); $holding->setEmployeesCount(180); $holding->setRevenueAmount('24500000.00'); $holding->setDirectorName('Antoine Lefèvre'); $holding->setProfitAmount('1850000.00'); $holding->setVolumeForecast(120000); $holding->setSiren('318471925'); $holding->setAccountNumber('F0007'); $holding->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); $holding->setNTva('FR33318471925'); $holding->setPaymentDelay($this->paymentDelay($manager, 'J30')); $holding->setPaymentType($this->paymentType($manager, 'VIREMENT')); $holding->setBank($this->bank($manager, 'SG')); $this->addContact($holding, 'Antoine', 'Lefèvre', 'PDG', '05 56 51 52 53', null, 'antoine.lefevre@holding-recyclage.fr'); $this->addAddress($holding, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '1 allée des Investisseurs', bennes: 5, triageProvider: true, categoryNames: ['Importateur']); } // === Coop minimale — contact par le seul nom (RG-2.04), sans compta === [$coopMin, $isNew] = $this->ensureSupplier($manager, 'Coop Métaux Réunis', ['Coopérative']); if ($isNew) { $this->addContact($coopMin, null, 'Caron', 'Président', '05 49 81 82 83', null, 'president@coop-metaux-reunis.fr'); $this->addAddress($coopMin, 'DEPART', ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village'); } // === Reglement CHEQUE (sans banque ni RIB requis) === [$petit, $isNew] = $this->ensureSupplier($manager, 'Petit Négoce Local', ['Négociant']); if ($isNew) { $petit->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); $petit->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION')); $petit->setPaymentType($this->paymentType($manager, 'CHEQUE')); $this->addContact($petit, 'Luc', 'Martin', 'Gérant', '05 56 71 72 73', null, 'luc.martin@petit-negoce.fr'); $this->addAddress($petit, 'RENDU', ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Commerce'); } // === Reglement NON_SOUMISE + adresse multi-sites avec triage === [$recup, $isNew] = $this->ensureSupplier($manager, 'Récupération Métaux Express', ['Grossiste']); if ($isNew) { $recup->setSiren('490212019'); $recup->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); $recup->setPaymentDelay($this->paymentDelay($manager, 'J15')); $recup->setPaymentType($this->paymentType($manager, 'NON_SOUMISE')); $this->addContact($recup, 'Marie', 'Lemoine', 'Responsable', '05 49 77 88 99', null, 'marie.lemoine@recup-express.fr', 0); $this->addContact($recup, 'Pierre', 'Durand', 'Chauffeur', '05 49 77 88 98', null, 'pierre.durand@recup-express.fr', 1); $this->addAddress($recup, 'DEPART', ['Saint-Jean', 'Chatellerault'], '17400', 'Fontenet', '10 zone industrielle', bennes: 7, triageProvider: true, categoryNames: ['Grossiste']); } // === Centre de tri — focus bennes/triage + multi-categories === [$centre, $isNew] = $this->ensureSupplier($manager, 'Centre de Tri Sud', ['Producteur', 'Coopérative']); if ($isNew) { $centre->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION')); $this->addContact($centre, 'Camille', 'Faure', 'Chef de centre', '05 56 91 92 93', null, 'camille.faure@centre-tri-sud.fr'); $this->addAddress($centre, 'DEPART', ['Pommevic'], '82400', 'Pommevic', '2 route du Tri', bennes: 12, triageProvider: true, categoryNames: ['Producteur']); } // === Fournisseur archive #1 (RG-2.17) === [$ancien, $isNew] = $this->ensureSupplier($manager, 'Ancien Fournisseur Fermé', ['Producteur'], isArchived: true); if ($isNew) { $this->addContact($ancien, null, 'Lambert', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancien-fournisseur.fr'); $this->addAddress($ancien, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée'); } // === Fournisseur archive #2 (RG-2.17) === [$disparu, $isNew] = $this->ensureSupplier($manager, 'Négoce Disparu', ['Grossiste'], isArchived: true); if ($isNew) { $this->addContact($disparu, 'Gérard', 'Blanc', 'Ex-gérant', '05 56 00 00 00', null, 'gerard.blanc@negoce-disparu.fr'); $this->addAddress($disparu, 'RENDU', ['Saint-Jean'], '17400', 'Fontenet', '0 impasse Oubliée'); } $manager->flush(); } /** * Cree un fournisseur (base normalisee + categories de type FOURNISSEUR) * s'il n'existe pas encore, sinon retourne l'existant. Retourne * [Supplier, isNew] : isNew=false bloque la reconstruction des * sous-collections (idempotence sans doublon). * * @param list $categoryNames categories de type FOURNISSEUR (RG-2.10) * * @return array{0: Supplier, 1: bool} */ private function ensureSupplier( ObjectManager $manager, string $companyName, array $categoryNames, bool $isArchived = false, ): array { $normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName); $existing = $manager->getRepository(Supplier::class)->findOneBy(['companyName' => $normalizedName]); if ($existing instanceof Supplier) { return [$existing, false]; } $supplier = new Supplier(); $supplier->setCompanyName($normalizedName); foreach ($categoryNames as $categoryName) { $supplier->addCategory($this->category($manager, $categoryName)); } if ($isArchived) { $supplier->setIsArchived(true); $supplier->setArchivedAt(new DateTimeImmutable()); } $manager->persist($supplier); return [$supplier, true]; } /** * Ajoute un contact normalise au fournisseur (cascade persist via * Supplier.contacts). Au moins firstName OU lastName est toujours fourni * (RG-2.04, chk_supplier_contact_name). */ private function addContact( Supplier $supplier, ?string $firstName, ?string $lastName, ?string $jobTitle, ?string $phonePrimary, ?string $phoneSecondary, ?string $email, int $position = 0, ): void { $contact = new SupplierContact(); $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); $supplier->addContact($contact); } /** * Ajoute une adresse au fournisseur (cascade persist via Supplier.addresses). * Le type d'adresse est exclusif (PROSPECT | DEPART | RENDU — RG-2.09, * chk_supplier_address_type) ; au moins un site est rattache (RG-2.06) ; les * categories d'adresse sont de type FOURNISSEUR (RG-2.10). * * @param list $siteNames au moins un site (RG-2.06) * @param list $categoryNames categories de type FOURNISSEUR (RG-2.10) */ private function addAddress( Supplier $supplier, string $addressType, array $siteNames, string $postalCode, string $city, string $street, ?string $streetComplement = null, ?int $bennes = null, bool $triageProvider = false, array $categoryNames = [], int $position = 0, ): void { $address = new SupplierAddress(); $address->setAddressType($addressType); $address->setPostalCode($postalCode); $address->setCity($city); $address->setStreet($street); $address->setStreetComplement($streetComplement); $address->setBennes($bennes); $address->setTriageProvider($triageProvider); $address->setPosition($position); foreach ($siteNames as $siteName) { $address->addSite($this->site($siteName)); } foreach ($categoryNames as $categoryName) { $address->addCategory($this->category($this->manager, $categoryName)); } $supplier->addAddress($address); } /** * Ajoute un RIB au fournisseur (cascade persist via Supplier.ribs). IBAN/BIC * valides (Assert\Iban/Bic non rejouee sur persist direct mais donnees * coherentes pour le golden path / les tests). */ private function addRib(Supplier $supplier, string $label, string $bic, string $iban, int $position = 0): void { $rib = new SupplierRib(); $rib->setLabel($label); $rib->setBic($bic); $rib->setIban($iban); $rib->setPosition($position); $supplier->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]; } // RG-2.10 : on filtre explicitement sur le type FOURNISSEUR. Un lookup par // le seul `name` rattacherait une categorie homonyme d'un autre type (ex. // futur PRESTA) — donc du MAUVAIS type — ce qui violerait « au moins une // categorie de type FOURNISSEUR ». Le filtre type est porte cote PHP // (findBy ne sait pas filtrer une propriete imbriquee categoryType.code). $candidates = $manager->getRepository(CategoryInterface::class)->findBy([ 'name' => $name, 'deletedAt' => null, ]); foreach ($candidates as $candidate) { if ($candidate instanceof CategoryInterface && self::SUPPLIER_CATEGORY_TYPE_CODE === $candidate->getCategoryTypeCode()) { return $this->categoryCache[$name] = $candidate; } } throw new RuntimeException(sprintf( 'Categorie FOURNISSEUR "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.', $name, )); } /** * 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 SupplierFixtures.', $name, )); } return $this->siteCache[$name] = $site; } private function tvaMode(ObjectManager $manager, string $code): TvaMode { $mode = $manager->getRepository(TvaMode::class)->findOneBy(['code' => $code]); if (!$mode instanceof TvaMode) { throw new RuntimeException(sprintf( 'TvaMode "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.', $code, )); } return $mode; } private function paymentDelay(ObjectManager $manager, string $code): PaymentDelay { $delay = $manager->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]); if (!$delay instanceof PaymentDelay) { throw new RuntimeException(sprintf( 'PaymentDelay "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.', $code, )); } return $delay; } 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 SupplierFixtures.', $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 SupplierFixtures.', $code, )); } return $bank; } }