From be9204eca7dc82ac1102ec165f69988c48cbc5aa Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 11 Jun 2026 14:51:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(field=5Fsales)=20:=20fondations=20du=20mod?= =?UTF-8?q?ule=20Tourn=C3=A9es=20+=20VisitableInterface=20+=20RBAC=20(ERP-?= =?UTF-8?q?123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Module FieldSales (ID field_sales, REQUIRED false) avec 2 permissions field_sales.tours.view / .manage (scope V0.2, pas de reports.*), active dans config/modules.php. - Contrat partage VisitableInterface (getId/getDisplayName/getVisitableType) implemente par Client (client) et Supplier (supplier) sans import inter-module. Note doctrine.yaml : contrat polymorphe (2 implementations) donc resolu par service via (tier_type, tier_id), pas via resolve_target_entities. - 3 miroirs RBAC alignes : sidebar.php (section Tournées, item /tours, i18n sidebar.field_sales.*), personas.ts et SeedE2ECommand.php (user-full) ; matrice metier RbacSeeder (Commerciale = view+manage, Bureau = view, Compta exclue, Admin bypass). --- config/modules.php | 2 + config/packages/doctrine.yaml | 7 +++ config/sidebar.php | 17 +++++++ frontend/i18n/locales/fr.json | 4 ++ frontend/tests/e2e/_fixtures/personas.ts | 6 +++ .../Commercial/Domain/Entity/Client.php | 21 +++++++- .../Commercial/Domain/Entity/Supplier.php | 21 +++++++- .../Core/Application/Rbac/RbacSeeder.php | 5 ++ .../Infrastructure/Console/SeedE2ECommand.php | 4 ++ src/Module/FieldSales/FieldSalesModule.php | 40 ++++++++++++++++ .../Domain/Contract/VisitableInterface.php | 41 ++++++++++++++++ .../FieldSales/FieldSalesModuleTest.php | 48 +++++++++++++++++++ 12 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 src/Module/FieldSales/FieldSalesModule.php create mode 100644 src/Shared/Domain/Contract/VisitableInterface.php create mode 100644 tests/Module/FieldSales/FieldSalesModuleTest.php diff --git a/config/modules.php b/config/modules.php index c4f8f54..6981f6b 100644 --- a/config/modules.php +++ b/config/modules.php @@ -4,6 +4,7 @@ declare(strict_types=1); use App\Module\Catalog\CatalogModule; use App\Module\Commercial\CommercialModule; use App\Module\Core\CoreModule; +use App\Module\FieldSales\FieldSalesModule; use App\Module\Sites\SitesModule; return [ @@ -11,4 +12,5 @@ return [ CommercialModule::class, SitesModule::class, CatalogModule::class, + FieldSalesModule::class, ]; diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 5aeb29c..f40749a 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -41,6 +41,13 @@ doctrine: # Permet au module Commercial de referencer une Category via le contrat # Shared sans importer la classe concrete du module Catalog (regle n°1). App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category + # NOTE (M6 / VisitableInterface) : VisitableInterface n'apparait PAS ici. + # resolve_target_entities mappe un contrat -> UNE seule classe concrete, + # or ce contrat a plusieurs implementations (Client M1, Supplier M2, et + # Prestataire a venir). FieldSales ne reference donc pas un Tiers via une + # association Doctrine mais via le couple polymorphe (tier_type, tier_id) + # de tour_stop, resolu par un service a partir de getVisitableType() + # (ERP-124). Aucune ligne resolve_target_entities n'est requise/possible. mappings: Core: type: attribute diff --git a/config/sidebar.php b/config/sidebar.php index 9be6d4a..a5d609b 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -61,6 +61,23 @@ return [ ], ], ], + // Section "Tournées" (module field_sales, M6) : planification de tournees + // commerciales terrain. Transverse Clients/Fournisseurs. Masquee si le module + // field_sales est desactivee (cle `module`) ou si l'user n'a pas la + // permission field_sales.tours.view. + [ + 'label' => 'sidebar.field_sales.section', + 'icon' => 'mdi:map-marker-path', + 'items' => [ + [ + 'label' => 'sidebar.field_sales.tours', + 'to' => '/tours', + 'icon' => 'mdi:map-marker-path', + 'module' => 'field_sales', + 'permission' => 'field_sales.tours.view', + ], + ], + ], // Section "Administration" : regroupe toutes les pages de configuration // applicative (RBAC, users, sites, audit log). // diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 855094c..ed29235 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -40,6 +40,10 @@ }, "catalog": { "categories": "Gestion des catégories" + }, + "field_sales": { + "section": "Tournées", + "tours": "Tournées" } }, "dashboard": { diff --git a/frontend/tests/e2e/_fixtures/personas.ts b/frontend/tests/e2e/_fixtures/personas.ts index 73afbd4..d5cd0de 100644 --- a/frontend/tests/e2e/_fixtures/personas.ts +++ b/frontend/tests/e2e/_fixtures/personas.ts @@ -84,6 +84,12 @@ export const personas: Record = { 'commercial.suppliers.accounting.view', 'commercial.suppliers.accounting.manage', 'commercial.suppliers.archive', + // FieldSales — Tournees (M6, ERP-123). Mappe sur le persona "tout", + // pas de nouveau persona (regle ABSOLUE n°7). La section "Tournées" + // n'est pas dans Administration, donc expectedAdminLinks inchange. + // Miroir de SeedE2ECommand.php. + 'field_sales.tours.view', + 'field_sales.tours.manage', ], expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'], }, diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php index c8a033d..b173a36 100644 --- a/src/Module/Commercial/Domain/Entity/Client.php +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -17,6 +17,7 @@ use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\CategoryInterface; use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\TimestampableInterface; +use App\Shared\Domain\Contract\VisitableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -135,7 +136,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Index(name: 'idx_client_created_by', columns: ['created_by'])] #[ORM\Index(name: 'idx_client_updated_by', columns: ['updated_by'])] #[Auditable] -class Client implements TimestampableInterface, BlamableInterface +class Client implements TimestampableInterface, BlamableInterface, VisitableInterface { use TimestampableBlamableTrait; @@ -321,6 +322,24 @@ class Client implements TimestampableInterface, BlamableInterface return $this; } + /** + * Libelle affichable du Tiers pour le module FieldSales (carte/etapes). + * La raison sociale est NotBlank (RG M1), le fallback chaine vide ne sert + * qu'a honorer le type non-nullable du contrat VisitableInterface. + */ + public function getDisplayName(): string + { + return $this->companyName ?? ''; + } + + /** + * Type stable porte par tour_stop.tier_type pour un Client (cf. M6 § 3.1). + */ + public function getVisitableType(): string + { + return 'client'; + } + public function getDistributor(): ?Client { return $this->distributor; diff --git a/src/Module/Commercial/Domain/Entity/Supplier.php b/src/Module/Commercial/Domain/Entity/Supplier.php index 30709a1..a658fea 100644 --- a/src/Module/Commercial/Domain/Entity/Supplier.php +++ b/src/Module/Commercial/Domain/Entity/Supplier.php @@ -17,6 +17,7 @@ use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\CategoryInterface; use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\TimestampableInterface; +use App\Shared\Domain\Contract\VisitableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -130,7 +131,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Index(name: 'idx_supplier_created_by', columns: ['created_by'])] #[ORM\Index(name: 'idx_supplier_updated_by', columns: ['updated_by'])] #[Auditable] -class Supplier implements TimestampableInterface, BlamableInterface +class Supplier implements TimestampableInterface, BlamableInterface, VisitableInterface { use TimestampableBlamableTrait; @@ -375,6 +376,24 @@ class Supplier implements TimestampableInterface, BlamableInterface return $this; } + /** + * Libelle affichable du Tiers pour le module FieldSales (carte/etapes). + * La raison sociale est NotBlank (RG M2), le fallback chaine vide ne sert + * qu'a honorer le type non-nullable du contrat VisitableInterface. + */ + public function getDisplayName(): string + { + return $this->companyName ?? ''; + } + + /** + * Type stable porte par tour_stop.tier_type pour un Fournisseur (M6 § 3.1). + */ + public function getVisitableType(): string + { + return 'supplier'; + } + /** @return Collection */ public function getCategories(): Collection { diff --git a/src/Module/Core/Application/Rbac/RbacSeeder.php b/src/Module/Core/Application/Rbac/RbacSeeder.php index 20669e9..e3e2d9f 100644 --- a/src/Module/Core/Application/Rbac/RbacSeeder.php +++ b/src/Module/Core/Application/Rbac/RbacSeeder.php @@ -66,6 +66,8 @@ final class RbacSeeder // Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite). 'commercial.suppliers.view', 'commercial.suppliers.manage', + // Tournees (M6 § 8, ERP-123) : Bureau = consultation seule. + 'field_sales.tours.view', // Lecture des referentiels transverses pour les selects client (ERP-102). 'catalog.categories.read_ref', 'sites.read_ref', @@ -96,6 +98,9 @@ final class RbacSeeder // (onglet Comptabilite masque/filtre pour la Commerciale). 'commercial.suppliers.view', 'commercial.suppliers.manage', + // Tournees (M6 § 8, ERP-123) : Commerciale = view + manage. + 'field_sales.tours.view', + 'field_sales.tours.manage', // Lecture des referentiels transverses pour les selects client (ERP-102). 'catalog.categories.read_ref', 'sites.read_ref', diff --git a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php index 7e7545b..9f19605 100644 --- a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php +++ b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php @@ -203,6 +203,10 @@ final class SeedE2ECommand extends Command 'commercial.suppliers.accounting.view', 'commercial.suppliers.accounting.manage', 'commercial.suppliers.archive', + // FieldSales — Tournees (M6, ERP-123). Mappe sur le persona + // "tout". Miroir de frontend/tests/e2e/_fixtures/personas.ts. + 'field_sales.tours.view', + 'field_sales.tours.manage', ], ], [ diff --git a/src/Module/FieldSales/FieldSalesModule.php b/src/Module/FieldSales/FieldSalesModule.php new file mode 100644 index 0000000..eb6b21c --- /dev/null +++ b/src/Module/FieldSales/FieldSalesModule.php @@ -0,0 +1,40 @@ + + */ + public static function permissions(): array + { + return [ + ['code' => 'field_sales.tours.view', 'label' => 'Voir les tournées et l\'onglet Carte'], + ['code' => 'field_sales.tours.manage', 'label' => 'Créer / modifier / optimiser / dupliquer / supprimer une tournée'], + ]; + } +} diff --git a/src/Shared/Domain/Contract/VisitableInterface.php b/src/Shared/Domain/Contract/VisitableInterface.php new file mode 100644 index 0000000..de0faa1 --- /dev/null +++ b/src/Shared/Domain/Contract/VisitableInterface.php @@ -0,0 +1,41 @@ + Commercial (regle ABSOLUE n°1). + * + * Implemente par les Tiers du referentiel : Client (M1) et Supplier (M2), et + * extensible aux futurs types (Prestataire...) sans toucher au module FieldSales. + * + * Resolution polymorphe : une etape de tournee (TourStop, ERP-124) cible un Tiers + * via le couple (`tier_type`, `tier_id`) plutot que via une association Doctrine. + * `getVisitableType()` fournit la valeur stable de `tier_type` ('client', + * 'supplier', ...) qui permet, cote service, de retrouver l'implementation + * concrete a partir de l'id. Cette interface n'est donc PAS une cible + * `resolve_target_entities` (qui ne mappe qu'un contrat -> une seule classe, + * alors qu'ici plusieurs entites l'implementent) : cf. note dans doctrine.yaml. + */ +interface VisitableInterface +{ + /** + * Identifiant du Tiers (null tant que non persiste). + */ + public function getId(): ?int; + + /** + * Libelle affichable du Tiers (ex: raison sociale), pour les pins de la carte + * et la liste d'etapes d'une tournee. Jamais null (chaine vide a defaut). + */ + public function getDisplayName(): string; + + /** + * Type stable du Tiers, valeur portee par `tour_stop.tier_type` + * ('client' | 'supplier' | ... ). Volontairement une string (et non un enum + * ferme) pour rester ouvert aux futurs types Visitable + au point `custom`. + */ + public function getVisitableType(): string; +} diff --git a/tests/Module/FieldSales/FieldSalesModuleTest.php b/tests/Module/FieldSales/FieldSalesModuleTest.php new file mode 100644 index 0000000..71f6f17 --- /dev/null +++ b/tests/Module/FieldSales/FieldSalesModuleTest.php @@ -0,0 +1,48 @@ +." (sinon la commande de sync echoue). + foreach (FieldSalesModule::permissions() as $permission) { + self::assertStringStartsWith(FieldSalesModule::ID.'.', $permission['code']); + } + } +}