From c1e74e30ca3404b4fc2419cce66e87114578bb27 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Sun, 7 Jun 2026 11:46:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(commercial)=20:=20fixtures=20Doctrine=20fo?= =?UTF-8?q?urnisseurs=20(=E2=89=8813=20suppliers=20complets=20+=20sous-col?= =?UTF-8?q?lections)=20(ERP-112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DataFixtures/SupplierFixtures.php | 510 ++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 src/Module/Commercial/Infrastructure/DataFixtures/SupplierFixtures.php diff --git a/src/Module/Commercial/Infrastructure/DataFixtures/SupplierFixtures.php b/src/Module/Commercial/Infrastructure/DataFixtures/SupplierFixtures.php new file mode 100644 index 0000000..ac89f22 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/DataFixtures/SupplierFixtures.php @@ -0,0 +1,510 @@ + 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 +{ + /** 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]; + } + + $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 SupplierFixtures.', + $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 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; + } +}