feat(field_sales) : fondations du module Tournées + VisitableInterface + RBAC (ERP-123)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m26s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 13s

- 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).
This commit is contained in:
Matthieu
2026-06-11 14:51:52 +02:00
parent de4aaa1d64
commit be9204eca7
12 changed files with 214 additions and 2 deletions
+2
View File
@@ -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,
];
+7
View File
@@ -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
+17
View File
@@ -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).
//
+4
View File
@@ -40,6 +40,10 @@
},
"catalog": {
"categories": "Gestion des catégories"
},
"field_sales": {
"section": "Tournées",
"tours": "Tournées"
}
},
"dashboard": {
+6
View File
@@ -84,6 +84,12 @@ export const personas: Record<PersonaKey, Persona> = {
'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'],
},
+20 -1
View File
@@ -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;
@@ -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<int, CategoryInterface> */
public function getCategories(): Collection
{
@@ -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',
@@ -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',
],
],
[
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Module\FieldSales;
final class FieldSalesModule
{
public const string ID = 'field_sales';
public const string LABEL = 'Tournées';
public const bool REQUIRED = false;
/**
* Liste declarative des permissions RBAC exposees par le module FieldSales.
*
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
* qui upserte ces entrees dans la table `permission`, reactive les codes
* precedemment orphelins et marque comme orphelins ceux disparus du code.
*
* La cle `module` est auto-injectee par le sync command a partir de
* `self::ID`, inutile de la repeter dans chaque entree.
*
* Convention de nommage : `module.resource[.sub].action` en snake_case, le
* prefixe devant correspondre exactement a `self::ID`.
*
* Scope V0.2 (spec M6 § 8) : UNIQUEMENT les tournees (le volet « rapport de
* visite » a ete retire, plus aucune permission `reports.*`). Granularite
* view/manage, alignee sur Core/Commercial. Attribution (matrice § 8) :
* Commerciale + Admin = manage ; Bureau = view ; Compta exclue.
*
* @return array<int, array{code: string, label: string}>
*/
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'],
];
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat partage rendant un Tiers « visitable » par le module FieldSales (M6),
* sans creer de couplage direct FieldSales -> 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;
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\FieldSales;
use App\Module\FieldSales\FieldSalesModule;
use PHPUnit\Framework\TestCase;
/**
* Tests structurels du module FieldSales (M6) : identite du module et contrat
* `permissions()`. Fige le set de permissions du scope V0.2 (tournees seules,
* plus aucune permission `reports.*`).
*
* @internal
*/
final class FieldSalesModuleTest extends TestCase
{
public function testModuleIdentity(): void
{
self::assertSame('field_sales', FieldSalesModule::ID);
self::assertSame('Tournées', FieldSalesModule::LABEL);
self::assertFalse(FieldSalesModule::REQUIRED);
}
public function testPermissionsSetContainsExactlyTwoCodes(): void
{
// Garde-fou : si quelqu'un ajoute/retire une permission sans ajuster les
// tests ou la doc, ce test casse explicitement. Le scope V0.2 est limite
// aux tournees (view + manage) : aucune permission `reports.*`.
$codes = array_column(FieldSalesModule::permissions(), 'code');
sort($codes);
self::assertSame(
['field_sales.tours.manage', 'field_sales.tours.view'],
$codes,
);
}
public function testEveryPermissionCodeIsPrefixedByModuleId(): void
{
// Invariant verifie aussi par app:sync-permissions : chaque code doit
// etre prefixe par "<ID>." (sinon la commande de sync echoue).
foreach (FieldSalesModule::permissions() as $permission) {
self::assertStringStartsWith(FieldSalesModule::ID.'.', $permission['code']);
}
}
}