Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a668a8eb28 | |||
| a52e3bec34 | |||
| 865180e648 | |||
| 0e3299300f |
@@ -3,7 +3,7 @@
|
||||
## Contexte
|
||||
CRM/ERP en architecture **modular monolith DDD**. Le backend est la source de verite unique (modules actifs, sidebar). Le frontend scanne `frontend/modules/*/` comme layers Nuxt et consomme l'API pour la navigation. Multi-tenant : chaque module est activable/desactivable.
|
||||
|
||||
Doc humaine : @README.md — Spec audit : @doc/audit-log.md
|
||||
Doc humaine : `README.md` — Spec audit : `doc/audit-log.md` (à lire à la demande, non chargés en permanence).
|
||||
|
||||
## Stack
|
||||
- Backend : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16 (port 5437)
|
||||
@@ -37,7 +37,7 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md
|
||||
@.claude/rules/git.md
|
||||
@.claude/rules/workflow.md
|
||||
|
||||
## Commandes (liste complete dans @README.md)
|
||||
## Commandes (liste complete dans `README.md`)
|
||||
|
||||
- Demarrer : `make start`
|
||||
- Dev front (hot reload) : `make dev-nuxt` (port 3004)
|
||||
@@ -70,3 +70,5 @@ Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique
|
||||
## Credentials (dev)
|
||||
|
||||
`admin` / `admin` (ROLE_ADMIN) ; `alice` / `alice`, `bob` / `bob` (ROLE_USER).
|
||||
|
||||
Comptes demo des roles metier (seedes par `RbacDemoFixtures` / `app:seed-rbac --with-demo-users`, mot de passe `demo`) : `bureau` / `demo`, `compta` / `demo`, `commerciale` / `demo`, `usine` / `demo`. Matrice RBAC § 2.7 (M1 Clients) attachee aux roles correspondants.
|
||||
|
||||
@@ -169,13 +169,41 @@ Secrets requis dans Gitea :
|
||||
- `RELEASE_TOKEN` — PAT avec droits `write:repository`
|
||||
- `REGISTRY_TOKEN` — token pour le registry Docker
|
||||
|
||||
## Déploiement — seed RBAC (recette / prod)
|
||||
|
||||
Le RBAC métier (rôles `bureau` / `compta` / `commerciale` / `usine` + matrice § 2.7)
|
||||
est seedé par une **commande applicative idempotente** (présente dans le build prod,
|
||||
contrairement aux fixtures Doctrine en `require-dev`). À jouer dans l'étape de release,
|
||||
**après** les migrations et la synchronisation des permissions :
|
||||
|
||||
```bash
|
||||
php bin/console doctrine:migrations:migrate --no-interaction
|
||||
php bin/console app:sync-permissions # pose les permissions commercial.clients.*
|
||||
php bin/console app:seed-rbac # PROD : rôles + matrice § 2.7 (sans comptes démo)
|
||||
```
|
||||
|
||||
En **recette / staging**, ajouter le flag pour disposer de logins de test (mot de passe
|
||||
fourni explicitement, jamais en dur) :
|
||||
|
||||
```bash
|
||||
php bin/console app:seed-rbac --with-demo-users --password='<mot-de-passe>'
|
||||
# ou via la variable d'env RBAC_DEMO_PASSWORD
|
||||
```
|
||||
|
||||
La commande est rejouable sans effet de bord (aucun doublon de rôle, de lien ou de compte).
|
||||
En dev, `make db-reset` produit le même résultat (rôles + matrice + comptes démo).
|
||||
|
||||
## Credentials (dev)
|
||||
|
||||
| Username | Password | Role |
|
||||
|----------|----------|------|
|
||||
| admin | admin | ROLE_ADMIN |
|
||||
| alice | alice | ROLE_USER |
|
||||
| bob | bob | ROLE_USER |
|
||||
| Username | Password | Role | RBAC métier |
|
||||
|----------|----------|------|-------------|
|
||||
| admin | admin | ROLE_ADMIN | bypass (is_admin) |
|
||||
| alice | alice | ROLE_USER | — |
|
||||
| bob | bob | ROLE_USER | — |
|
||||
| bureau | demo | ROLE_USER | clients : view + manage |
|
||||
| compta | demo | ROLE_USER | clients : view + accounting.view/manage |
|
||||
| commerciale | demo | ROLE_USER | clients : view + manage (Information obligatoire — RG-1.04) |
|
||||
| usine | demo | ROLE_USER | aucun accès clients |
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.60'
|
||||
app.version: '0.1.62'
|
||||
|
||||
@@ -23,10 +23,10 @@ merge de la stack.
|
||||
| RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 |
|
||||
| RG-1.04 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** |
|
||||
| RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 |
|
||||
| RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation (CHECK) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | **ERP-60** |
|
||||
| RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation → 422 (Assert\Callback + CHECK filet) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | ERP-60 / **ERP-76** |
|
||||
| RG-1.09 | Code postal `^[0-9]{4,5}$` → 422 | `ClientSubResourceApiTest::testPostAddressWithInvalidPostalCodeReturns422` | ERP-57 |
|
||||
| RG-1.10 | ≥ 1 site sur adresse → 422 | `ClientSubResourceApiTest::testPostAddressWithoutSiteReturns422` | ERP-57 |
|
||||
| RG-1.11 | billingEmail obligatoire ssi isBilling (CHECK) | `ClientAddressTest::testBillingAddressRequiresBillingEmail` ; `::testNonBillingAddressRejectsBillingEmail` | **ERP-60** |
|
||||
| RG-1.11 | billingEmail obligatoire ssi isBilling → 422 (Assert\Callback + CHECK filet) | `ClientAddressTest::testBillingAddressRequiresBillingEmail` ; `::testNonBillingAddressRejectsBillingEmail` | ERP-60 / **ERP-76** |
|
||||
| RG-1.12 | Virement → banque obligatoire → 422 | `ClientProcessorTest::testVirementWithoutBankIsUnprocessable` ; `::testVirementWithBankPasses` (unit) | ERP-55 |
|
||||
| RG-1.13 | LCR → ≥ 1 RIB ; DELETE dernier RIB en LCR → 409 | `ClientProcessorTest::testLcrWithoutRibIsUnprocessable` / `::testLcrWithRibPasses` (unit) ; `ClientSubResourceApiTest::testDeleteLastRibUnderLcrReturns409` / `::testDeleteRibNonLcrReturns204` | ERP-55 / ERP-57 |
|
||||
| RG-1.14 | ≥ 1 bloc Contact pour finaliser l'onglet | **Front-driven (pas de state machine back).** Back voisin : `ClientSubResourceApiTest::testDeleteLastContactReturns409` | ERP-57 |
|
||||
@@ -44,7 +44,7 @@ merge de la stack.
|
||||
| RG-1.26 | Tri par défaut companyName ASC | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 |
|
||||
| RG-1.27 | Timestampable/Blamable : created* figés, updated* mis à jour | `ClientAuditTest::testCreatedFrozenAndUpdatedByReflectsModifier` | **ERP-60** |
|
||||
| RG-1.28 | PATCH multi-groupes sans permission → 403 strict (tout le payload) | `ClientProcessorTest::testStrictMixWithAccountingFieldIsForbidden` / `::testAccountingFieldWithoutPermissionIsForbidden` (unit) ; **`ClientPatchStrictTest::testMixedGroupsPatchWithoutAccountingPermissionIsForbidden`** (fonctionnel) | ERP-55 / **ERP-60** |
|
||||
| RG-1.29 | Catégorie d'adresse limitée aux types SECTEUR/AUTRE | **Filtrage LECTURE = front-driven** (SearchFilter `GET /api/categories?categoryType.code[]=…`). **Validation ÉCRITURE (POST/PATPH catégorie DISTRIBUTEUR/COURTIER → 422) NON IMPLÉMENTÉE côté back au M1** (absente du `ClientAddressProcessor` et de la liste § 8.1). → voir « Gaps & suivi » | — (gap) |
|
||||
| RG-1.29 | Catégorie d'adresse limitée aux types SECTEUR/AUTRE | **Filtrage LECTURE = front-driven** (SearchFilter `GET /api/categories?categoryType.code[]=…`). **Validation ÉCRITURE** : `ClientAddress::validateCategoryTypes` (Assert\Callback) rejette une catégorie DISTRIBUTEUR/COURTIER en 422 (violation `categories`). Tests : `ClientAddressTest::testAddressRejectsDistributorCategory` / `::testAddressRejectsBrokerCategory` / `::testAddressAcceptsSectorCategory` / `::testAddressAcceptsOtherCategory` | **ERP-76** |
|
||||
|
||||
## Couvertures transverses
|
||||
|
||||
@@ -66,14 +66,15 @@ merge de la stack.
|
||||
|
||||
## Gaps & suivi
|
||||
|
||||
- **RG-1.29 (validation écriture)** : refuser une catégorie de type
|
||||
`DISTRIBUTEUR`/`COURTIER` sur une `ClientAddress` (→ 422, violation
|
||||
`categories`) n'est pas implémenté au M1. La spec § 8.1 ne le liste pas comme
|
||||
cas de test back ; le filtrage de lecture est front-driven. **Suggestion** :
|
||||
ouvrir un follow-up (durcissement `ClientAddressProcessor`) ou l'intégrer à
|
||||
ERP-74. Aucune invention de feature dans ERP-60 (ticket test-only).
|
||||
- **Violations CHECK → statut HTTP** : les CHECK d'adresse (RG-1.06/07/08/11)
|
||||
sont aujourd'hui rejetées par la base (statut ≥ 400) mais sans mapping fin
|
||||
vers 422 (pas d'`exception_to_status` ni de listener DBAL→HTTP). Les tests
|
||||
ERP-60 assertent donc le **rejet** (≥ 400). Un mapping explicite vers 422
|
||||
serait une amélioration UX d'API (follow-up possible).
|
||||
- ~~**RG-1.29 (validation écriture)**~~ — **résolu en ERP-76**. La validation
|
||||
d'écriture refuse désormais une catégorie de type `DISTRIBUTEUR`/`COURTIER` sur
|
||||
une `ClientAddress` (→ 422, violation `categories`) via l'Assert\Callback
|
||||
`ClientAddress::validateCategoryTypes`. Le filtrage de lecture reste
|
||||
front-driven (SearchFilter). Couvert par `ClientAddressTest`.
|
||||
- ~~**Violations CHECK → statut HTTP**~~ — **résolu en ERP-76**. Les règles
|
||||
d'adresse RG-1.06/07/08/11 sont désormais rejetées en **422** par des
|
||||
Assert\Callback applicatifs (`validateProspectExclusivity` /
|
||||
`validateBillingEmailPresence`) qui s'exécutent AVANT la base. Les CHECK
|
||||
Postgres (`chk_client_address_prospect_exclusive` /
|
||||
`chk_client_address_billing_email`) restent en filet de sécurité. Les tests
|
||||
`ClientAddressTest` assertent maintenant le 422 explicite (et non plus ≥ 400).
|
||||
|
||||
@@ -885,7 +885,8 @@ Cf. § 2.6. Pattern Shared standard.
|
||||
|
||||
### Onglet Information
|
||||
|
||||
- **RG-1.04** : Pour un utilisateur portant le rôle métier **Commerciale**, **tous** les champs de l'onglet Information (`description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount`) deviennent obligatoires lors d'un PATCH sur le groupe `client:write:information`. Pour les autres rôles, ces champs restent optionnels. Implémenté via un validator custom `ClientInformationCompletenessValidator` invoqué par le `ClientProcessor` quand le user porte le rôle Commerciale.
|
||||
- **RG-1.04** _(durcie — ERP-74)_ : Pour un utilisateur portant le rôle métier **Commerciale**, **tous** les champs de l'onglet Information (`description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount`) sont obligatoires sur **POST et sur tout PATCH**, **indépendamment des champs réellement envoyés** (la condition d'intersection avec `client:write:information` a été retirée). Pour les autres rôles, ces champs restent optionnels. Implémenté via un validator custom `ClientInformationCompletenessValidator` invoqué systématiquement par le `ClientProcessor` quand le user porte le rôle Commerciale.
|
||||
- **Conséquence** : le POST n'exposant que le groupe `client:write:main`, l'onglet Information n'y est pas renseignable → une Commerciale obtient **422** sur tout POST (cf. § 8.1). La complétude se fait donc via les PATCH `client:write:information` ultérieurs. Un Admin (non gaté) crée normalement (201).
|
||||
|
||||
### Onglet Contact
|
||||
|
||||
|
||||
@@ -198,8 +198,11 @@ migration-migrate:
|
||||
# doctrine:fixtures:load essaie de DELETE toutes les tables connues
|
||||
# via les mappings — si fake_site_aware_entity est mappe mais absent
|
||||
# en DB, le purger crash.
|
||||
# 3. fixtures -> sync-permissions : fixtures:load purge la table permission,
|
||||
# donc sync doit passer apres.
|
||||
# 3. fixtures -> sync-permissions -> seed-rbac : fixtures:load purge la table
|
||||
# permission, donc sync doit passer apres. seed-rbac (matrice RBAC § 2.7)
|
||||
# passe ensuite, car attachMatrix() exige les permissions en base. Les
|
||||
# comptes demo sont crees par RbacDemoFixtures au load (sans la matrice,
|
||||
# attachee ici). Cf. ERP-74.
|
||||
# 4. recreation des index partiels uniques : schema:update drop les index
|
||||
# orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas
|
||||
# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc
|
||||
@@ -220,6 +223,7 @@ test-db-setup:
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:apply-column-comments
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:seed-rbac
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
|
||||
@@ -231,6 +235,15 @@ fixtures:
|
||||
sync-permissions:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:sync-permissions
|
||||
|
||||
# Seed RBAC metier : roles (bureau/compta/commerciale/usine) + matrice § 2.7
|
||||
# (+ comptes demo en dev). Idempotent et NON destructif. A lancer APRES
|
||||
# sync-permissions (attachMatrix exige les permissions en base). Les comptes
|
||||
# demo dev sont deja crees par RbacDemoFixtures (make fixtures) ; ici on attache
|
||||
# la matrice (les permissions etaient purgees au moment du load fixtures).
|
||||
# En recette/prod, c'est cette commande (avec/sans --with-demo-users) qui seede.
|
||||
seed-rbac:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:seed-rbac
|
||||
|
||||
# Attention, supprime votre bdd local
|
||||
db-reset:
|
||||
$(DOCKER_COMPOSE) down -v
|
||||
@@ -240,6 +253,7 @@ db-reset:
|
||||
$(MAKE) migration-migrate
|
||||
$(MAKE) fixtures
|
||||
$(MAKE) sync-permissions
|
||||
$(MAKE) seed-rbac
|
||||
$(MAKE) test-db-setup
|
||||
|
||||
# Restart la bdd
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Catalog\Domain\Repository\CategoryTypeRepositoryInterface;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Fixtures dev/test du module Catalog : ~12 categories de demonstration reparties
|
||||
* sur les 4 types metier (DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE). Alimente le
|
||||
* repertoire clients (ClientFixtures, module Commercial) avec des donnees
|
||||
* realistes couvrant les categorisations RG-1.03 (DISTRIBUTEUR/COURTIER) et
|
||||
* RG-1.29 (SECTEUR/AUTRE sur adresse).
|
||||
*
|
||||
* Depend de CategoryTypeFixtures : les 4 CategoryType doivent etre seedes avant
|
||||
* de pouvoir y rattacher des Category.
|
||||
*
|
||||
* Idempotence : lookup par (name, categoryType) parmi les categories non
|
||||
* supprimees (deletedAt null), coherent avec l'index unique partiel
|
||||
* uq_category_name_type_active (LOWER(name), category_type_id WHERE deleted_at
|
||||
* IS NULL). 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.
|
||||
*
|
||||
* 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<string, list<string>>
|
||||
*/
|
||||
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<int, class-string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
+8
-10
@@ -10,17 +10,15 @@ use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Validator metier RG-1.04 : pour un utilisateur portant le role metier
|
||||
* Commerciale, TOUS les champs de l'onglet Information deviennent obligatoires
|
||||
* lors d'un PATCH touchant le groupe `client:write:information`.
|
||||
* Validator metier RG-1.04 (durcie ERP-74) : pour un utilisateur portant le
|
||||
* role metier Commerciale, TOUS les champs de l'onglet Information sont
|
||||
* obligatoires sur POST comme sur tout PATCH, independamment des champs
|
||||
* reellement envoyes.
|
||||
*
|
||||
* Invoque par le ClientProcessor UNIQUEMENT quand les deux conditions sont
|
||||
* reunies (role Commerciale + payload touchant l'onglet Information). Pour les
|
||||
* autres roles, ces champs restent optionnels — le validator n'est pas appele.
|
||||
*
|
||||
* Tant qu'aucun user ne porte le role `commerciale` (seede par ERP-74,
|
||||
* cf. App\Shared\Domain\Security\BusinessRoles::COMMERCIALE), cette regle reste
|
||||
* DORMANTE : aucun appelant ne la declenche.
|
||||
* Invoque par le ClientProcessor des que l'utilisateur courant porte le role
|
||||
* Commerciale (plus de condition d'intersection avec l'onglet Information).
|
||||
* Pour les autres roles, ces champs restent optionnels — le validator n'est
|
||||
* pas appele.
|
||||
*
|
||||
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
|
||||
* coherence avec les violations Symfony rendues par API Platform.
|
||||
|
||||
@@ -83,11 +83,19 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
processor: ClientProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('commercial.clients.manage')",
|
||||
// Security elargie (ERP-74) : `manage` OU `accounting.manage`. Le
|
||||
// role Compta n'a pas `manage` mais doit pouvoir editer l'onglet
|
||||
// Comptabilite d'un client existant (§ 2.7). Le ClientProcessor
|
||||
// re-gate ensuite onglet par onglet :
|
||||
// - champs accounting -> accounting.manage (guardAccounting, RG-1.28) ;
|
||||
// - champs main/information -> manage (guardManage : empeche Compta
|
||||
// d'editer les autres onglets) ;
|
||||
// - isArchived -> archive (guardArchive, RG-1.22).
|
||||
security: "is_granted('commercial.clients.manage') or is_granted('commercial.clients.accounting.manage')",
|
||||
// Le ClientProcessor inspecte les champs reellement envoyes pour
|
||||
// autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les
|
||||
// champs accounting exigent accounting.manage, isArchived exige
|
||||
// archive.
|
||||
// archive, le reste (main/information) exige manage.
|
||||
normalizationContext: ['groups' => ['client:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => [
|
||||
'client:write:main',
|
||||
|
||||
@@ -23,19 +23,23 @@ use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* Adresse d'un client (1:n) — onglet Adresse. Une adresse de prospection
|
||||
* (isProspect) est exclusive d'une adresse de livraison/facturation
|
||||
* (RG-1.06/07/08, CHECK BDD). Un email de facturation est obligatoire ssi
|
||||
* isBilling (RG-1.11, CHECK BDD). Au moins un site doit etre rattache
|
||||
* (RG-1.10, Assert\Count).
|
||||
* (RG-1.06/07/08). Un email de facturation est obligatoire ssi isBilling
|
||||
* (RG-1.11). Au moins un site doit etre rattache (RG-1.10, Assert\Count). Ces
|
||||
* regles sont portees par des Assert\Callback (cf. validateProspectExclusivity /
|
||||
* validateBillingEmailPresence, ERP-76) qui remontent une 422 avant la base ;
|
||||
* les CHECK Postgres (chk_client_address_prospect_exclusive /
|
||||
* chk_client_address_billing_email) restent en filet de securite.
|
||||
*
|
||||
* Relations M2M :
|
||||
* - sites : SiteInterface (module Sites) via resolve_target_entities
|
||||
* - contacts : ClientContact (meme module)
|
||||
* - categories : CategoryInterface (module Catalog) via resolve_target_entities
|
||||
* — limitees aux types SECTEUR/AUTRE cote validation (RG-1.29, hors ERP-57)
|
||||
* — limitees aux types SECTEUR/AUTRE (RG-1.29, validateCategoryTypes, ERP-76)
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable/Blamable.
|
||||
*
|
||||
@@ -83,6 +87,9 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/** RG-1.29 : seuls ces types de categorie qualifient une adresse physique. */
|
||||
private const array ALLOWED_CATEGORY_TYPES = ['SECTEUR', 'AUTRE'];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
@@ -130,7 +137,7 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
// RG-1.11 : obligatoire ssi isBilling (CHECK BDD + futur Processor).
|
||||
// RG-1.11 : obligatoire ssi isBilling (validateBillingEmailPresence + CHECK BDD).
|
||||
#[ORM\Column(length: 180, nullable: true)]
|
||||
#[Assert\Email]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
@@ -158,7 +165,7 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private Collection $contacts;
|
||||
|
||||
// RG-1.29 : categories de type SECTEUR/AUTRE uniquement (filtre au Processor).
|
||||
// RG-1.29 : categories de type SECTEUR/AUTRE uniquement (validateCategoryTypes).
|
||||
/** @var Collection<int, CategoryInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||
#[ORM\JoinTable(name: 'client_address_category')]
|
||||
@@ -174,6 +181,79 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
$this->categories = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.06 / RG-1.07 / RG-1.08 : une adresse de prospection est exclusive
|
||||
* d'une adresse de livraison ou de facturation. Mirror applicatif (422) du
|
||||
* CHECK chk_client_address_prospect_exclusive, joue avant la base afin de
|
||||
* remonter une violation Hydra plutot qu'une 500 DBAL.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateProspectExclusivity(ExecutionContextInterface $context): void
|
||||
{
|
||||
if ($this->isProspect && ($this->isDelivery || $this->isBilling)) {
|
||||
$context->buildViolation('Une adresse de prospection ne peut pas être une adresse de livraison ni de facturation.')
|
||||
->atPath('isProspect')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.11 : l'email de facturation est obligatoire si l'adresse est de
|
||||
* facturation, et interdit sinon. Mirror applicatif (422) du CHECK
|
||||
* chk_client_address_billing_email.
|
||||
*
|
||||
* On raisonne sur la PRESENCE effective de l'email : null ET chaine vide
|
||||
* sont traites comme « absent », car le ClientAddressProcessor normalise une
|
||||
* chaine vide en null APRES la validation (RG-1.21). Sans ce traitement,
|
||||
* billingEmail="" passerait les callbacks (null === "" est faux) puis serait
|
||||
* persiste en null avec is_billing=true -> violation du CHECK -> 500 au lieu
|
||||
* du 422 attendu (et symetriquement, "" sur une adresse non facturable
|
||||
* serait rejete a tort).
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateBillingEmailPresence(ExecutionContextInterface $context): void
|
||||
{
|
||||
$hasBillingEmail = null !== $this->billingEmail && '' !== trim($this->billingEmail);
|
||||
|
||||
if ($this->isBilling && !$hasBillingEmail) {
|
||||
$context->buildViolation('L\'email de facturation est obligatoire pour une adresse de facturation.')
|
||||
->atPath('billingEmail')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
if (!$this->isBilling && $hasBillingEmail) {
|
||||
$context->buildViolation('L\'email de facturation n\'est autorisé que sur une adresse de facturation.')
|
||||
->atPath('billingEmail')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.29 : seules les categories de type SECTEUR / AUTRE qualifient une
|
||||
* adresse physique. Les types DISTRIBUTEUR / COURTIER decrivent une relation
|
||||
* entre clients (RG-1.03) et n'ont pas de sens sur une adresse -> 422 avec
|
||||
* violation sur le champ `categories`. S'appuie sur
|
||||
* CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog).
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCategoryTypes(ExecutionContextInterface $context): void
|
||||
{
|
||||
foreach ($this->categories as $category) {
|
||||
if ($category instanceof CategoryInterface
|
||||
&& !in_array($category->getCategoryTypeCode(), self::ALLOWED_CATEGORY_TYPES, true)) {
|
||||
$context->buildViolation('Type de catégorie non autorisé sur une adresse.')
|
||||
->atPath('categories')
|
||||
->addViolation()
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
|
||||
+196
-15
@@ -16,6 +16,7 @@ use App\Shared\Domain\Security\BusinessRoles;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\PersistentCollection;
|
||||
use JsonException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
@@ -31,16 +32,19 @@ use Symfony\Component\Validator\ConstraintViolationList;
|
||||
* § 2.9 / § 4.3 / § 4.4 + RG-1.01 a RG-1.28.
|
||||
*
|
||||
* Sequence (POST / PATCH) :
|
||||
* 1. Autorisation additionnelle par groupe d'onglet (le `security` de
|
||||
* l'operation a deja exige commercial.clients.manage) :
|
||||
* - champ comptable dans le payload -> exige accounting.manage (RG-1.28, 403) ;
|
||||
* 1. Autorisation additionnelle par groupe d'onglet. La security d'operation
|
||||
* du PATCH a ete elargie (ERP-74) a `manage` OU `accounting.manage` pour
|
||||
* laisser entrer le role Compta ; ce processor re-gate alors finement :
|
||||
* - champ comptable modifie dans le payload -> exige accounting.manage (RG-1.28, 403) ;
|
||||
* - champ main/information modifie -> exige manage (guardManage, 403) : empeche
|
||||
* Compta d'editer un autre onglet que la Comptabilite (§ 2.7) ;
|
||||
* - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et
|
||||
* interdit toute autre modification dans la meme requete (RG-1.22, 422).
|
||||
* 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer.
|
||||
* 3. Regles metier : RG-1.01 (prenom/nom), RG-1.03 (distributor/broker
|
||||
* exclusifs + type de categorie), RG-1.12 (Virement -> banque),
|
||||
* RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information pour le role
|
||||
* Commerciale).
|
||||
* RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST
|
||||
* et tout PATCH pour le role Commerciale).
|
||||
* 4. Pose / retrait de archivedAt (RG-1.22 true=now, RG-1.23 false=null).
|
||||
* 5. Persistance via le persist_processor Doctrine, avec traduction des
|
||||
* collisions d'unicite en 409 (RG-1.16 doublon de nom ; RG-1.23 conflit de
|
||||
@@ -75,9 +79,23 @@ final class ClientProcessor implements ProcessorInterface
|
||||
/** Champ d'archivage (groupe client:write:archive). */
|
||||
private const string ARCHIVE_FIELD = 'isArchived';
|
||||
|
||||
private const string PERM_MANAGE = 'commercial.clients.manage';
|
||||
private const string PERM_ACCOUNTING_MANAGE = 'commercial.clients.accounting.manage';
|
||||
private const string PERM_ARCHIVE = 'commercial.clients.archive';
|
||||
|
||||
/**
|
||||
* Memoisation du dernier corps de requete decode, clos par le contenu brut.
|
||||
* payloadKeys() est appele plusieurs fois par requete (writablePayloadKeys,
|
||||
* categoriesChanged...) : on evite de rejouer json_decode a chaque appel. La
|
||||
* cle etant le contenu lui-meme et le calcul une fonction pure de ce contenu,
|
||||
* aucune fuite n'est possible entre requetes sur ce service partage (un meme
|
||||
* corps redonne les memes cles).
|
||||
*/
|
||||
private ?string $decodedContent = null;
|
||||
|
||||
/** @var list<string> Cles de premier niveau correspondant au corps memoise. */
|
||||
private array $decodedPayloadKeys = [];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
@@ -101,10 +119,15 @@ final class ClientProcessor implements ProcessorInterface
|
||||
|
||||
$this->normalize($data);
|
||||
|
||||
// guardManage apres normalize : la comparaison « change vs etat
|
||||
// persiste » des champs texte (companyName, email...) se fait sur des
|
||||
// valeurs normalisees des deux cotes (l'etat persiste l'a deja ete).
|
||||
$this->guardManage($data);
|
||||
|
||||
$this->validateMainContact($data);
|
||||
$this->validateDistributorBroker($data);
|
||||
$this->validateAccountingConsistency($data);
|
||||
$this->validateInformationCompleteness($data, $writableKeys);
|
||||
$this->validateInformationCompleteness($data);
|
||||
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
@@ -199,6 +222,145 @@ final class ClientProcessor implements ProcessorInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* § 2.7 / RG-1.28 (ERP-74) : la modification effective d'un champ « metier »
|
||||
* (onglets principal ou Information) exige `commercial.clients.manage`. Sans
|
||||
* cette permission -> 403 sur l'ensemble du payload (mode strict, miroir de
|
||||
* guardAccounting). C'est ce qui empeche le role Compta — qui entre dans le
|
||||
* PATCH via `accounting.manage` (security d'operation elargie) — d'editer
|
||||
* autre chose que l'onglet Comptabilite.
|
||||
*
|
||||
* Ne s'applique qu'aux mises a jour (entite geree) : la creation (POST) est
|
||||
* deja gardee par la security d'operation `manage`, donc inutile de la
|
||||
* re-gater ici (et un POST par un porteur de `manage` passerait de toute
|
||||
* facon).
|
||||
*/
|
||||
private function guardManage(Client $data): void
|
||||
{
|
||||
if (!$this->em->contains($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$changed = $this->changedBusinessFields($data);
|
||||
|
||||
if ([] === $changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted(self::PERM_MANAGE)) {
|
||||
throw new AccessDeniedHttpException(sprintf(
|
||||
'Le champ "%s" requiert la permission "%s".',
|
||||
$changed[0],
|
||||
self::PERM_MANAGE,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs « metier » (onglets principal + Information, hors comptabilite et
|
||||
* archivage) dont la valeur courante differe de l'etat persiste. Memes
|
||||
* regles de comparaison que changedAccountingFields (scalaires par valeur,
|
||||
* relations ManyToOne distributor/broker par identite via l'identity map).
|
||||
*
|
||||
* Cas particulier `categories` (M2M) : non trace par getOriginalEntityData,
|
||||
* compare par valeur via le snapshot de la PersistentCollection (cf.
|
||||
* categoriesChanged) — la simple presence dans le payload ne suffit pas, sous
|
||||
* peine de 403 parasite sur un PATCH representation complete reincluant des
|
||||
* categories inchangees.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function changedBusinessFields(Client $data): array
|
||||
{
|
||||
$newValues = [
|
||||
'companyName' => $data->getCompanyName(),
|
||||
'firstName' => $data->getFirstName(),
|
||||
'lastName' => $data->getLastName(),
|
||||
'phonePrimary' => $data->getPhonePrimary(),
|
||||
'phoneSecondary' => $data->getPhoneSecondary(),
|
||||
'email' => $data->getEmail(),
|
||||
'distributor' => $data->getDistributor(),
|
||||
'broker' => $data->getBroker(),
|
||||
'triageService' => $data->isTriageService(),
|
||||
'description' => $data->getDescription(),
|
||||
'competitors' => $data->getCompetitors(),
|
||||
'foundedAt' => $data->getFoundedAt(),
|
||||
'employeesCount' => $data->getEmployeesCount(),
|
||||
'revenueAmount' => $data->getRevenueAmount(),
|
||||
'directorName' => $data->getDirectorName(),
|
||||
'profitAmount' => $data->getProfitAmount(),
|
||||
];
|
||||
|
||||
$changed = [];
|
||||
foreach ($newValues as $field => $newValue) {
|
||||
if ($this->fieldChanged($data, $field, $newValue)) {
|
||||
$changed[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->categoriesChanged($data)) {
|
||||
$changed[] = 'categories';
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si l'ensemble des categories (M2M) differe reellement de l'etat
|
||||
* persiste. La collection n'etant pas tracee par getOriginalEntityData, on
|
||||
* compare par identifiants (independamment de l'ordre) le snapshot de la
|
||||
* PersistentCollection (etat charge depuis la base) a l'etat courant (apres
|
||||
* application du payload). Symetrique de changedAccountingFields : seul un
|
||||
* changement effectif compte, pas la simple presence dans le payload.
|
||||
*
|
||||
* - POST / entite non geree : fournir des categories est un acte metier
|
||||
* (comportement historique conserve) — branche defensive, guardManage ne
|
||||
* s'execute de toute facon que sur entite geree.
|
||||
* - categories absent du payload (PATCH partiel) : aucun changement.
|
||||
*/
|
||||
private function categoriesChanged(Client $data): bool
|
||||
{
|
||||
if (!$this->em->contains($data)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!in_array('categories', $this->payloadKeys(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$collection = $data->getCategories();
|
||||
|
||||
// Hors PersistentCollection (cas limite hors flux PATCH reel) : faute
|
||||
// d'etat persiste comparable, on se rabat sur la presence payload.
|
||||
if (!$collection instanceof PersistentCollection) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->categoryIdSet($collection->toArray())
|
||||
!== $this->categoryIdSet($collection->getSnapshot());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensemble trie des identifiants d'une liste de categories — pour une
|
||||
* comparaison par valeur independante de l'ordre.
|
||||
*
|
||||
* @param array<int, object> $categories
|
||||
*
|
||||
* @return list<mixed>
|
||||
*/
|
||||
private function categoryIdSet(array $categories): array
|
||||
{
|
||||
$ids = array_map(
|
||||
static fn (object $category): mixed => method_exists($category, 'getId')
|
||||
? $category->getId()
|
||||
: spl_object_id($category),
|
||||
array_values($categories),
|
||||
);
|
||||
sort($ids);
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs comptables dont la valeur courante differe de l'etat persiste. Les
|
||||
* relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
|
||||
@@ -353,17 +515,16 @@ final class ClientProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.04 : si l'utilisateur porte le role metier Commerciale ET que le
|
||||
* payload touche l'onglet Information, tous les champs Information sont
|
||||
* obligatoires. Dormant tant qu'aucun user ne porte le role `commerciale`.
|
||||
*
|
||||
* @param list<string> $payloadKeys
|
||||
* RG-1.04 (durcie ERP-74) : si l'utilisateur porte le role metier
|
||||
* Commerciale, TOUS les champs de l'onglet Information sont obligatoires sur
|
||||
* POST comme sur TOUT PATCH — independamment des champs reellement envoyes
|
||||
* (plus de condition d'intersection avec INFORMATION_FIELDS). Garantit qu'un
|
||||
* client cree/edite par une Commerciale ne reste jamais avec un onglet
|
||||
* Information incomplet.
|
||||
*/
|
||||
private function validateInformationCompleteness(Client $data, array $payloadKeys): void
|
||||
private function validateInformationCompleteness(Client $data): void
|
||||
{
|
||||
$touchesInformation = [] !== array_intersect($payloadKeys, self::INFORMATION_FIELDS);
|
||||
|
||||
if ($touchesInformation && $this->currentUserIsCommerciale()) {
|
||||
if ($this->currentUserIsCommerciale()) {
|
||||
$this->informationValidator->validate($data);
|
||||
}
|
||||
}
|
||||
@@ -428,6 +589,26 @@ final class ClientProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
$content = $request->getContent();
|
||||
|
||||
// Cache hit : meme corps brut que le dernier decodage -> memes cles.
|
||||
if ($content === $this->decodedContent) {
|
||||
return $this->decodedPayloadKeys;
|
||||
}
|
||||
|
||||
$this->decodedContent = $content;
|
||||
$this->decodedPayloadKeys = $this->extractPayloadKeys($content);
|
||||
|
||||
return $this->decodedPayloadKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode le corps brut et en extrait les cles de premier niveau (chaines).
|
||||
* Corps vide ou JSON invalide -> aucune cle.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractPayloadKeys(string $content): array
|
||||
{
|
||||
if ('' === $content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Catalog\Infrastructure\DataFixtures\CategoryFixtures;
|
||||
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
use App\Module\Commercial\Domain\Entity\ClientContact;
|
||||
use App\Module\Commercial\Domain\Entity\ClientRib;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SiteProviderInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Fixtures dev/test du module Commercial : ~14 clients de demonstration couvrant
|
||||
* l'ensemble des cas metier RG-1.xx du repertoire clients (M1) :
|
||||
* - client basique ; dependant distributeur / courtier (RG-1.03) ;
|
||||
* - reglement LCR avec 2 RIB (RG-1.13) ; reglement Cheque sans RIB ;
|
||||
* - multi-adresses Prospect / Livraison / Facturation (RG-1.06/07/08/11) ;
|
||||
* - prospect seul ; 3 contacts dont un avec telephone secondaire (RG-1.05/1.02) ;
|
||||
* - client archive (RG-1.22) ; onglet Information complet ; multi-categories M2M.
|
||||
*
|
||||
* Resolution inter-modules conforme a la regle n°1 (pas d'import direct) :
|
||||
* - categories resolues via le contrat Shared CategoryInterface
|
||||
* (resolve_target_entities -> 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<int, class-string>
|
||||
*/
|
||||
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<string> $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<string> $siteNames au moins un site (RG-1.10)
|
||||
* @param list<string> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Application\Rbac;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\RbacSeedException;
|
||||
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
|
||||
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
|
||||
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteProviderInterface;
|
||||
use App\Shared\Domain\Security\BusinessRoles;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Source UNIQUE (anti-drift) du RBAC metier MALIO : les 4 roles
|
||||
* (bureau / compta / commerciale / usine), la matrice § 2.7 (role -> permissions)
|
||||
* et les comptes demo par role. Aucun de ces litteraux ne doit etre duplique
|
||||
* ailleurs (ni SQL en dur, ni autre fixture).
|
||||
*
|
||||
* Consomme par :
|
||||
* - la commande applicative `app:seed-rbac` (presente dans le build prod, donc
|
||||
* rejouable en recette/prod, contrairement aux fixtures `require-dev`) ;
|
||||
* - la fixture Core dev/test (DRY : meme seeder).
|
||||
*
|
||||
* Toutes les operations sont idempotentes et non destructives :
|
||||
* - ensureRoles() : cree un role par lookup de code (skip si present) ;
|
||||
* - attachMatrix() : attache les permissions § 2.7 via la M2M role_permission,
|
||||
* sans re-attacher un lien existant ; STOP explicite si un code manque ;
|
||||
* - ensureDemoUsers() : cree un user par role (lookup par username, skip si
|
||||
* present), rattache au role + a >= 1 site.
|
||||
*/
|
||||
final class RbacSeeder
|
||||
{
|
||||
/**
|
||||
* Codes des roles metier (snake_case, regex Role respectee). `commerciale`
|
||||
* reference la constante Shared deja consommee par le ClientProcessor
|
||||
* (RG-1.04) pour eviter tout drift : un seul litteral pour ce code.
|
||||
*/
|
||||
public const string ROLE_BUREAU = 'bureau';
|
||||
public const string ROLE_COMPTA = 'compta';
|
||||
public const string ROLE_COMMERCIALE = BusinessRoles::COMMERCIALE;
|
||||
public const string ROLE_USINE = 'usine';
|
||||
|
||||
/** Site de rattachement par defaut des comptes demo (cf. SitesFixtures). */
|
||||
private const string DEFAULT_SITE_NAME = 'Chatellerault';
|
||||
|
||||
/**
|
||||
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
|
||||
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
|
||||
* attacher (vide pour usine : aucun acces ; admin n'apparait pas car il
|
||||
* bypass tout via isAdmin ; `commercial.clients.archive` n'est attache a
|
||||
* aucun role metier — admin seul).
|
||||
*
|
||||
* @var array<string, array{label: string, permissions: list<string>}>
|
||||
*/
|
||||
private const array MATRIX = [
|
||||
self::ROLE_BUREAU => [
|
||||
'label' => 'Bureau',
|
||||
'permissions' => [
|
||||
'commercial.clients.view',
|
||||
'commercial.clients.manage',
|
||||
],
|
||||
],
|
||||
self::ROLE_COMPTA => [
|
||||
'label' => 'Comptabilité',
|
||||
'permissions' => [
|
||||
'commercial.clients.view',
|
||||
'commercial.clients.accounting.view',
|
||||
'commercial.clients.accounting.manage',
|
||||
],
|
||||
],
|
||||
self::ROLE_COMMERCIALE => [
|
||||
'label' => 'Commerciale',
|
||||
'permissions' => [
|
||||
'commercial.clients.view',
|
||||
'commercial.clients.manage',
|
||||
],
|
||||
],
|
||||
self::ROLE_USINE => [
|
||||
'label' => 'Usine',
|
||||
'permissions' => [],
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly RoleRepositoryInterface $roleRepository,
|
||||
private readonly PermissionRepositoryInterface $permissionRepository,
|
||||
private readonly UserRepositoryInterface $userRepository,
|
||||
private readonly SiteProviderInterface $siteProvider,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Cree chaque role metier absent (lookup par code). Idempotent.
|
||||
*
|
||||
* @return list<string> codes des roles effectivement crees (vide au rejeu)
|
||||
*/
|
||||
public function ensureRoles(): array
|
||||
{
|
||||
$created = [];
|
||||
|
||||
foreach (self::MATRIX as $code => $definition) {
|
||||
if (null !== $this->roleRepository->findByCode($code)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// isSystem=false : ce sont des roles metier, supprimables par un
|
||||
// admin (contrairement aux roles systeme admin/user).
|
||||
$this->roleRepository->save(new Role($code, $definition['label'], isSystem: false));
|
||||
$created[] = $code;
|
||||
}
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attache la matrice § 2.7 a chaque role via la M2M role_permission. Lookup
|
||||
* de la permission par code ; un code absent leve une RbacSeedException
|
||||
* (garde-fou : `app:sync-permissions` doit avoir tourne). Idempotent : un
|
||||
* lien deja present n'est pas recree.
|
||||
*
|
||||
* @return int nombre de liens role->permission effectivement ajoutes (0 au rejeu)
|
||||
*
|
||||
* @throws RbacSeedException si un role ou une permission de la matrice manque
|
||||
*/
|
||||
public function attachMatrix(): int
|
||||
{
|
||||
$added = 0;
|
||||
|
||||
foreach (self::MATRIX as $code => $definition) {
|
||||
$role = $this->roleRepository->findByCode($code);
|
||||
if (null === $role) {
|
||||
throw RbacSeedException::missingRole($code);
|
||||
}
|
||||
|
||||
$touched = false;
|
||||
foreach ($definition['permissions'] as $permissionCode) {
|
||||
$permission = $this->permissionRepository->findByCode($permissionCode);
|
||||
if (null === $permission) {
|
||||
throw RbacSeedException::missingPermission($permissionCode);
|
||||
}
|
||||
|
||||
if (!$role->getPermissions()->contains($permission)) {
|
||||
$role->addPermission($permission);
|
||||
$touched = true;
|
||||
++$added;
|
||||
}
|
||||
}
|
||||
|
||||
// Un seul flush par role, et seulement si un lien a change.
|
||||
if ($touched) {
|
||||
$this->roleRepository->save($role);
|
||||
}
|
||||
}
|
||||
|
||||
return $added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un compte demo par role metier (username = code du role), non-admin,
|
||||
* mot de passe hashe, rattache a son role et a >= 1 site. Lookup par
|
||||
* username : idempotent (un compte existant est laisse intact, mot de passe
|
||||
* inchange).
|
||||
*
|
||||
* @return list<string> usernames effectivement crees (vide au rejeu)
|
||||
*
|
||||
* @throws RbacSeedException si un role metier attendu est absent (ensureRoles non joue)
|
||||
*/
|
||||
public function ensureDemoUsers(string $password): array
|
||||
{
|
||||
// Rattachement a un site par defaut s'il existe (les flux login / me en
|
||||
// ont besoin ; le repertoire clients n'est pas site-scope mais on reste
|
||||
// coherent avec les fixtures admin/alice/bob).
|
||||
$defaultSite = $this->siteProvider->findByName(self::DEFAULT_SITE_NAME);
|
||||
$created = [];
|
||||
|
||||
foreach (array_keys(self::MATRIX) as $code) {
|
||||
$username = $code;
|
||||
if (null !== $this->userRepository->findByUsername($username)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$role = $this->roleRepository->findByCode($code);
|
||||
if (null === $role) {
|
||||
throw RbacSeedException::missingRole($code);
|
||||
}
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
$user->setIsAdmin(false);
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, $password));
|
||||
$user->addRbacRole($role);
|
||||
|
||||
if (null !== $defaultSite) {
|
||||
$user->addSite($defaultSite);
|
||||
$user->setCurrentSite($defaultSite);
|
||||
}
|
||||
|
||||
$this->userRepository->save($user);
|
||||
$created[] = $username;
|
||||
}
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des codes des roles metier definis (pour reporting / tests).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function roleCodes(): array
|
||||
{
|
||||
return array_keys(self::MATRIX);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Erreur de seed RBAC (service RbacSeeder / commande app:seed-rbac).
|
||||
*
|
||||
* Deux causes possibles, toutes deux fatales et explicites :
|
||||
* - role metier attendu introuvable (ensureRoles() n'a pas tourne avant
|
||||
* attachMatrix() ou ensureDemoUsers()) ;
|
||||
* - code de permission de la matrice § 2.7 absent du catalogue : signe que
|
||||
* `app:sync-permissions` n'a pas ete joue. Le message embarque alors
|
||||
* l'invite a lancer la synchronisation, exploitee telle quelle par la
|
||||
* commande.
|
||||
*/
|
||||
final class RbacSeedException extends RuntimeException
|
||||
{
|
||||
public static function missingRole(string $roleCode): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Role metier "%s" introuvable. Appelle RbacSeeder::ensureRoles() avant attachMatrix()/ensureDemoUsers().',
|
||||
$roleCode,
|
||||
));
|
||||
}
|
||||
|
||||
public static function missingPermission(string $permissionCode): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Permission "%s" (matrice § 2.7) absente du catalogue. '
|
||||
.'Lance d\'abord `bin/console app:sync-permissions` pour la poser en base, puis relance le seed RBAC.',
|
||||
$permissionCode,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Console;
|
||||
|
||||
use App\Module\Core\Application\Rbac\RbacSeeder;
|
||||
use App\Module\Core\Domain\Exception\RbacSeedException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Seed RBAC metier idempotent et NON destructif (cf. ERP-74 / spec-back M1
|
||||
* § 2.7). Contrairement aux fixtures Doctrine (`require-dev`, absentes du build
|
||||
* prod `--no-dev`), cette commande applicative est presente dans l'image prod :
|
||||
* elle est donc rejouable en recette/staging/prod.
|
||||
*
|
||||
* Etape de release : a lancer APRES `doctrine:migrations:migrate` et
|
||||
* `app:sync-permissions`.
|
||||
* - En prod : `app:seed-rbac` (roles + matrice § 2.7, sans comptes demo).
|
||||
* - En recette : `app:seed-rbac --with-demo-users --password=<...>` pour
|
||||
* disposer de logins de test.
|
||||
*
|
||||
* Toute la logique (litteraux des roles, matrice, comptes demo) vit dans
|
||||
* RbacSeeder — cette commande n'en est que l'enveloppe CLI.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:seed-rbac',
|
||||
description: 'Seede les roles metier RBAC + la matrice § 2.7 (idempotent, non destructif).',
|
||||
)]
|
||||
final class SeedRbacCommand extends Command
|
||||
{
|
||||
/** Variable d'environnement de repli pour le mot de passe des comptes demo. */
|
||||
private const string PASSWORD_ENV = 'RBAC_DEMO_PASSWORD';
|
||||
|
||||
public function __construct(private readonly RbacSeeder $seeder)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption(
|
||||
'with-demo-users',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Cree aussi un compte demo par role metier (recette/dev — JAMAIS en prod).',
|
||||
)
|
||||
->addOption(
|
||||
'password',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Mot de passe des comptes demo (defaut : variable d\'env '.self::PASSWORD_ENV.'). Requis avec --with-demo-users.',
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
// 1. Roles metier + matrice § 2.7. attachMatrix() exige que les
|
||||
// permissions soient en base : sinon RbacSeedException porteuse de
|
||||
// l'invite a lancer `app:sync-permissions`.
|
||||
try {
|
||||
$createdRoles = $this->seeder->ensureRoles();
|
||||
$addedLinks = $this->seeder->attachMatrix();
|
||||
} catch (RbacSeedException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->text(sprintf(
|
||||
'Roles metier : %d cree(s), matrice § 2.7 : %d lien(s) ajoute(s).',
|
||||
count($createdRoles),
|
||||
$addedLinks,
|
||||
));
|
||||
|
||||
// 2. Comptes demo (optionnel, jamais en prod).
|
||||
if ((bool) $input->getOption('with-demo-users')) {
|
||||
$password = $this->resolveDemoPassword($input);
|
||||
if (null === $password) {
|
||||
$io->error(sprintf(
|
||||
'--with-demo-users exige un mot de passe : passe --password=<...> ou definis la variable d\'env %s. '
|
||||
.'(Aucun mot de passe en dur cote serveur.)',
|
||||
self::PASSWORD_ENV,
|
||||
));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$createdUsers = $this->seeder->ensureDemoUsers($password);
|
||||
} catch (RbacSeedException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->text(sprintf(
|
||||
'Comptes demo : %d cree(s)%s.',
|
||||
count($createdUsers),
|
||||
[] === $createdUsers ? '' : ' ['.implode(', ', $createdUsers).']',
|
||||
));
|
||||
}
|
||||
|
||||
$io->success('Seed RBAC metier termine (idempotent).');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resout le mot de passe demo : option `--password` prioritaire, sinon
|
||||
* variable d'environnement. Renvoie null si aucun n'est fourni (la commande
|
||||
* refuse alors --with-demo-users plutot que d'inventer un mot de passe).
|
||||
*/
|
||||
private function resolveDemoPassword(InputInterface $input): ?string
|
||||
{
|
||||
/** @var null|string $option */
|
||||
$option = $input->getOption('password');
|
||||
if (null !== $option && '' !== $option) {
|
||||
return $option;
|
||||
}
|
||||
|
||||
$env = $_SERVER[self::PASSWORD_ENV] ?? $_ENV[self::PASSWORD_ENV] ?? getenv(self::PASSWORD_ENV);
|
||||
if (is_string($env) && '' !== $env) {
|
||||
return $env;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,11 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
* systeme de maniere idempotente avant de rattacher les utilisateurs, afin
|
||||
* que le workflow "make db-reset && make fixtures" reste one-shot.
|
||||
*
|
||||
* Idempotence complete (y compris `doctrine:fixtures:load --append`, sans
|
||||
* purge) : roles via ensureSystemRole, utilisateurs via ensureUser (lookup par
|
||||
* username). Rejouer la fixture ne cree donc aucun doublon ni violation
|
||||
* d'unicite de username.
|
||||
*
|
||||
* Dependance explicite a SitesFixtures (ticket 2) : les 3 sites Chatellerault,
|
||||
* Saint-Jean et Pommevic doivent etre presents en base avant d'etre rattaches
|
||||
* aux users. L'inversion volontaire de l'ordre (AppFixtures ← SitesFixtures)
|
||||
@@ -75,8 +80,7 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
|
||||
$saintJean = $this->requireSite('Saint-Jean');
|
||||
$pommevic = $this->requireSite('Pommevic');
|
||||
|
||||
$admin = new User();
|
||||
$admin->setUsername('admin');
|
||||
$admin = $this->ensureUser($manager, 'admin');
|
||||
$admin->setIsAdmin(true);
|
||||
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
|
||||
$admin->addRbacRole($adminRole);
|
||||
@@ -87,8 +91,7 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
|
||||
$admin->setCurrentSite($chatellerault);
|
||||
$manager->persist($admin);
|
||||
|
||||
$alice = new User();
|
||||
$alice->setUsername('alice');
|
||||
$alice = $this->ensureUser($manager, 'alice');
|
||||
$alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice'));
|
||||
$alice->addRbacRole($userRole);
|
||||
// Alice : un seul site, site courant = ce site.
|
||||
@@ -96,8 +99,7 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
|
||||
$alice->setCurrentSite($chatellerault);
|
||||
$manager->persist($alice);
|
||||
|
||||
$bob = new User();
|
||||
$bob->setUsername('bob');
|
||||
$bob = $this->ensureUser($manager, 'bob');
|
||||
$bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob'));
|
||||
$bob->addRbacRole($userRole);
|
||||
// Bob : site different de Alice, pour prouver le filtrage par site
|
||||
@@ -135,6 +137,27 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
|
||||
return $role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'utilisateur correspondant au username, en le creant s'il
|
||||
* n'existe pas encore. Rend la fixture idempotente y compris en
|
||||
* `doctrine:fixtures:load --append` (sans purge) : sans ce lookup, recreer
|
||||
* « admin » / « alice » / « bob » violerait l'unicite de username. Meme
|
||||
* esprit que ensureSystemRole ci-dessus et RbacDemoFixtures::ensureDemoUsers.
|
||||
*/
|
||||
private function ensureUser(ObjectManager $manager, string $username): User
|
||||
{
|
||||
$user = $manager->getRepository(User::class)->findOneBy(['username' => $username]);
|
||||
|
||||
if (null !== $user) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function requireSite(string $name): SiteInterface
|
||||
{
|
||||
$site = $this->siteProvider->findByName($name);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Core\Application\Rbac\RbacSeeder;
|
||||
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
/**
|
||||
* Fixture dev/test des roles metier MALIO (bureau / compta / commerciale /
|
||||
* usine) + comptes demo associes. DRY : delegue au MEME service RbacSeeder que
|
||||
* la commande `app:seed-rbac`, de sorte que `make db-reset` reproduise l'etat
|
||||
* de recette.
|
||||
*
|
||||
* Depend de SitesFixtures : les comptes demo sont rattaches au site par defaut
|
||||
* (cf. RbacSeeder::DEFAULT_SITE_NAME).
|
||||
*
|
||||
* ⚠ N'attache PAS la matrice § 2.7 ici : `doctrine:fixtures:load` PURGE la table
|
||||
* `permission` avant de charger, donc les codes `commercial.clients.*` ne sont
|
||||
* pas encore en base au moment du load (cf. ordre du makefile : fixtures PUIS
|
||||
* `app:sync-permissions`). La matrice est attachee juste apres, par l'etape
|
||||
* `app:seed-rbac` du makefile (db-reset / test-db-setup), via le meme seeder.
|
||||
* Resultat final identique a la recette : roles + matrice + comptes demo.
|
||||
*/
|
||||
final class RbacDemoFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
/**
|
||||
* Mot de passe DEV/TEST connu des comptes demo (bureau / compta /
|
||||
* commerciale / usine). Reference par les tests fonctionnels de matrice
|
||||
* RBAC. Sans rapport avec la prod : en recette/prod le mot de passe est
|
||||
* fourni explicitement a `app:seed-rbac --with-demo-users --password=...`.
|
||||
*/
|
||||
public const string DEMO_PASSWORD = 'demo';
|
||||
|
||||
public function __construct(private readonly RbacSeeder $seeder) {}
|
||||
|
||||
/**
|
||||
* @return array<int, class-string>
|
||||
*/
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return [SitesFixtures::class];
|
||||
}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Idempotent : ensureRoles puis ensureDemoUsers (lookup par code /
|
||||
// username). La matrice est volontairement deferree (cf. docblock).
|
||||
$this->seeder->ensureRoles();
|
||||
$this->seeder->ensureDemoUsers(self::DEMO_PASSWORD);
|
||||
}
|
||||
}
|
||||
@@ -7,27 +7,22 @@ namespace App\Tests\Module\Commercial\Api;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'onglet Adresse — combler les trous (ERP-60).
|
||||
* Tests fonctionnels de l'onglet Adresse.
|
||||
*
|
||||
* RG-1.09 (code postal) et RG-1.10 (>= 1 site) sont DEJA couverts par
|
||||
* ClientSubResourceApiTest (ERP-57) et ne sont pas reduplique ici. Ce fichier
|
||||
* cible les contraintes CHECK BDD non encore testees :
|
||||
* - RG-1.06 / RG-1.07 / RG-1.08 : `chk_client_address_prospect_exclusive`
|
||||
* (is_prospect exclusif de is_delivery / is_billing) ;
|
||||
* - RG-1.11 : `chk_client_address_billing_email` (billing_email obligatoire
|
||||
* ssi is_billing).
|
||||
* cible :
|
||||
* - RG-1.06 / RG-1.07 / RG-1.08 : exclusivite is_prospect vs
|
||||
* is_delivery / is_billing ;
|
||||
* - RG-1.11 : billing_email obligatoire ssi is_billing ;
|
||||
* - RG-1.29 : seules les categories de type SECTEUR / AUTRE sont autorisees sur
|
||||
* une adresse (DISTRIBUTEUR / COURTIER -> 422).
|
||||
*
|
||||
* Note : ces regles sont portees par des CHECK Postgres (pas d'Assert ni de
|
||||
* regle Processor au M1). On verifie donc que la combinaison invalide est
|
||||
* REJETEE par le serveur (statut >= 400), sans coupler le test au code exact :
|
||||
* une violation CHECK non mappee remonte aujourd'hui en erreur serveur ; un
|
||||
* mapping fin vers 422 serait une amelioration ulterieure (hors perimetre
|
||||
* ERP-60, test-only).
|
||||
*
|
||||
* RG-1.29 (filtrage du type de categorie SECTEUR/AUTRE sur une adresse) n'est
|
||||
* PAS testee : la validation d'ecriture correspondante n'est pas implementee
|
||||
* cote back au M1 (et ne figure pas dans la liste § 8.1). Documentee comme gap
|
||||
* dans le cahier de test #478.
|
||||
* Depuis ERP-76, ces regles sont portees par des Assert\Callback sur l'entite
|
||||
* ClientAddress (mirror applicatif des CHECK Postgres) : la combinaison invalide
|
||||
* est donc rejetee en 422 AVANT la base, et non plus par une violation CHECK
|
||||
* remontant en 500. Les CHECK BDD restent en filet de securite (non testes ici,
|
||||
* inatteignables tant que les validators applicatifs passent en premier).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
@@ -37,7 +32,8 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
|
||||
/**
|
||||
* RG-1.06 / RG-1.07 : une adresse de prospection ne peut pas etre une
|
||||
* adresse de livraison (CHECK chk_client_address_prospect_exclusive).
|
||||
* adresse de livraison -> 422 (Assert\Callback, mirror du CHECK
|
||||
* chk_client_address_prospect_exclusive).
|
||||
*/
|
||||
public function testProspectAddressCannotBeDelivery(): void
|
||||
{
|
||||
@@ -45,7 +41,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Prospect Delivery');
|
||||
|
||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isProspect' => true,
|
||||
@@ -57,13 +53,13 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.06 / RG-1.08 : une adresse de prospection ne peut pas etre une
|
||||
* adresse de facturation (meme CHECK). On fournit billingEmail pour que la
|
||||
* seule violation possible soit l'exclusivite prospect/billing.
|
||||
* adresse de facturation -> 422. On fournit billingEmail pour que la seule
|
||||
* violation possible soit l'exclusivite prospect/billing.
|
||||
*/
|
||||
public function testProspectAddressCannotBeBilling(): void
|
||||
{
|
||||
@@ -71,7 +67,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Prospect Billing');
|
||||
|
||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isProspect' => true,
|
||||
@@ -84,12 +80,11 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.11 : une adresse de facturation exige un billingEmail
|
||||
* (CHECK chk_client_address_billing_email).
|
||||
* RG-1.11 : une adresse de facturation exige un billingEmail -> 422.
|
||||
*/
|
||||
public function testBillingAddressRequiresBillingEmail(): void
|
||||
{
|
||||
@@ -97,7 +92,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Billing No Email');
|
||||
|
||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isBilling' => true,
|
||||
@@ -108,12 +103,39 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.11 (cas chaine vide) : une adresse de facturation avec un billingEmail
|
||||
* vide ("") doit etre rejetee en 422, et NON passer la validation pour finir
|
||||
* en 500 sur le CHECK BDD. Le ClientAddressProcessor normalise "" -> null
|
||||
* APRES la validation : le callback doit donc traiter "" comme « absent ».
|
||||
*/
|
||||
public function testBillingAddressRejectsEmptyBillingEmail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Billing Empty Email');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isBilling' => true,
|
||||
'billingEmail' => '',
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.11 (sens inverse) : une adresse NON facturable ne peut pas porter un
|
||||
* billingEmail (meme CHECK).
|
||||
* billingEmail -> 422.
|
||||
*/
|
||||
public function testNonBillingAddressRejectsBillingEmail(): void
|
||||
{
|
||||
@@ -121,7 +143,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Non Billing With Email');
|
||||
|
||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isBilling' => false,
|
||||
@@ -133,7 +155,135 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.11 (sens inverse, cas chaine vide) : une adresse NON facturable avec
|
||||
* un billingEmail vide ("") est ACCEPTEE (201). Le "" equivaut a « pas
|
||||
* d'email » : il ne doit pas declencher la violation « email interdit hors
|
||||
* facturation » (sinon un champ simplement vide serait refuse a tort).
|
||||
*/
|
||||
public function testNonBillingAddressAcceptsEmptyBillingEmail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Non Billing Empty Email');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isBilling' => false,
|
||||
'billingEmail' => '',
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.29 : poster une categorie de type DISTRIBUTEUR sur une adresse -> 422
|
||||
* avec violation sur le champ `categories`.
|
||||
*/
|
||||
public function testAddressRejectsDistributorCategory(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Distributor Cat');
|
||||
$category = $this->createCategory('DISTRIBUTEUR');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertStringContainsString(
|
||||
'Type de catégorie non autorisé sur une adresse.',
|
||||
(string) $client->getResponse()->getContent(false),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.29 : poster une categorie de type COURTIER sur une adresse -> 422.
|
||||
*/
|
||||
public function testAddressRejectsBrokerCategory(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Broker Cat');
|
||||
$category = $this->createCategory('COURTIER');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.29 : une categorie de type SECTEUR est autorisee sur une adresse.
|
||||
*/
|
||||
public function testAddressAcceptsSectorCategory(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Sector Cat');
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.29 : une categorie de type AUTRE est autorisee sur une adresse.
|
||||
*/
|
||||
public function testAddressAcceptsOtherCategory(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Other Cat');
|
||||
$category = $this->createCategory('AUTRE');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Matrice RBAC complete du repertoire clients par role metier (spec-back M1
|
||||
* § 2.7 + cahier ERP-74). Valide 200/403 par verbe et par onglet pour
|
||||
* bureau / compta / commerciale / usine, plus le durcissement RG-1.04
|
||||
* (Commerciale) au POST.
|
||||
*
|
||||
* Les comptes demo et la matrice sont seedes via la commande reelle
|
||||
* `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente.
|
||||
* Pre-requis du run : `app:sync-permissions` a tourne (cf. make test-db-setup).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
private const string MERGE = 'application/merge-patch+json';
|
||||
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Seed idempotent via la commande applicative (roles + matrice § 2.7 +
|
||||
// comptes demo). Exerce aussi le chemin de code prod.
|
||||
self::bootKernel();
|
||||
$application = new Application(self::$kernel);
|
||||
$application->setAutoExit(false);
|
||||
$exit = $application->run(
|
||||
new ArrayInput([
|
||||
'command' => 'app:seed-rbac',
|
||||
'--with-demo-users' => true,
|
||||
'--password' => self::PWD,
|
||||
]),
|
||||
new NullOutput(),
|
||||
);
|
||||
self::assertSame(
|
||||
0,
|
||||
$exit,
|
||||
'app:seed-rbac a echoue : les permissions commercial.clients.* sont-elles synchronisees (app:sync-permissions) ?',
|
||||
);
|
||||
|
||||
// Liberer le kernel pour que authenticatedClient()/createClient() reparte propre.
|
||||
self::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
public function testUsineIsForbiddenEverywhere(): void
|
||||
{
|
||||
$seed = $this->seedClient('Usine Target');
|
||||
$client = $this->authAs('usine');
|
||||
|
||||
// Aucune permission : 403 sur tous les verbes.
|
||||
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('GET', '/api/clients/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Usine Post'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Renamed By Usine'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testBureauHasViewAndManageButNoAccountingNoArchive(): void
|
||||
{
|
||||
$seed = $this->seedClient('Bureau Target');
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
$client = $this->authAs('bureau');
|
||||
|
||||
// view
|
||||
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// manage : creation OK
|
||||
$client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Bureau Created', $cat->getId()),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// manage : edition onglet principal OK
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Bureau Renamed'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS accounting : edition onglet Comptabilite refusee
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['siren' => '123456789'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS archive : archivage refuse
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testComptaCanEditAccountingOnly(): void
|
||||
{
|
||||
$seed = $this->seedClient('Compta Target');
|
||||
$client = $this->authAs('compta');
|
||||
|
||||
// view
|
||||
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS manage : creation refusee
|
||||
$client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Compta Post'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// accounting.manage : edition onglet Comptabilite OK
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['siren' => '123456789'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS manage : edition onglet principal refusee (guardManage)
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Compta Renamed'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS manage : edition onglet Information refusee (guardManage)
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['description' => 'Une description'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS archive : archivage refuse
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testCommercialeHasViewAndManageButNoAccountingNoArchive(): void
|
||||
{
|
||||
$seed = $this->seedClient('Commerciale Target');
|
||||
$client = $this->authAs('commerciale');
|
||||
|
||||
// view
|
||||
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// manage : la creation passe la security d'operation (pas un 403 comme
|
||||
// Compta) mais bute sur RG-1.04 (onglet Information incomplet) -> 422.
|
||||
// C'est la preuve que Commerciale porte `manage` (sinon 403).
|
||||
$client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Commerciale Post'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
|
||||
// PAS accounting : edition onglet Comptabilite refusee
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['siren' => '123456789'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS archive : archivage refuse
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testRG104CommercialePostIncompleteIs422AdminIs201(): void
|
||||
{
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
|
||||
// RG-1.04 durcie : Commerciale POST sans onglet Information complet -> 422.
|
||||
$commerciale = $this->authAs('commerciale');
|
||||
$commerciale->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('RG104 Commerciale', $cat->getId()),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
|
||||
// Meme payload par un Admin (non gate par RG-1.04) -> 201.
|
||||
$admin = $this->createAdminClient();
|
||||
$admin->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('RG104 Admin', $cat->getId()),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testComptaFullRepresentationPatchWithUnchangedCategoriesIsNotForbidden(): void
|
||||
{
|
||||
// FIX review MR #40 : un Compta (accounting.manage, PAS manage) faisant un
|
||||
// PATCH representation complete de l'onglet Comptabilite et reincluant ses
|
||||
// categories INCHANGEES ne doit PAS prendre de 403. guardManage compare
|
||||
// desormais les categories par valeur (et non par simple presence) : seul
|
||||
// l'onglet Comptabilite change ici -> 200.
|
||||
$seed = $this->seedClient('Compta Cat Unchanged');
|
||||
$category = $seed->getCategories()->first();
|
||||
self::assertNotFalse($category);
|
||||
$catId = $category->getId();
|
||||
$client = $this->authAs('compta');
|
||||
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => [
|
||||
'siren' => '123456789',
|
||||
'categories' => ['/api/categories/'.$catId],
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
public function testComptaChangingCategoriesIsForbidden(): void
|
||||
{
|
||||
// Non-regression : si le Compta change REELLEMENT l'ensemble des
|
||||
// categories (sans manage) -> 403 via guardManage. La comparaison par
|
||||
// valeur detecte bien le changement.
|
||||
$seed = $this->seedClient('Compta Cat Change');
|
||||
$newCat = $this->createCategory('SECTEUR');
|
||||
$client = $this->authAs('compta');
|
||||
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['categories' => ['/api/categories/'.$newCat->getId()]],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testBureauChangingCategoriesIsAllowed(): void
|
||||
{
|
||||
// Non-regression : un role porteur de `manage` (Bureau) peut changer les
|
||||
// categories -> 200.
|
||||
$seed = $this->seedClient('Bureau Cat Change');
|
||||
$newCat = $this->createCategory('SECTEUR');
|
||||
$client = $this->authAs('bureau');
|
||||
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['categories' => ['/api/categories/'.$newCat->getId()]],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
private function authAs(string $role): Client
|
||||
{
|
||||
return $this->authenticatedClient($role, self::PWD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload minimal valide de l'onglet principal (RG-1.01 : un nom de contact ;
|
||||
* une categorie SECTEUR). Si $categoryId est null, une categorie est creee a
|
||||
* la volee.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validMainPayload(string $companyName, ?int $categoryId = null): array
|
||||
{
|
||||
$categoryId ??= $this->createCategory('SECTEUR')->getId();
|
||||
|
||||
return [
|
||||
'companyName' => $companyName,
|
||||
'firstName' => 'Jean',
|
||||
'phonePrimary' => '0612345678',
|
||||
'email' => strtolower(str_replace(' ', '', $companyName)).'@matrix.test',
|
||||
'categories' => ['/api/categories/'.$categoryId],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,17 @@ final class ClientProcessorTest extends TestCase
|
||||
'isArchived' => false,
|
||||
],
|
||||
managed: true,
|
||||
originalData: ['isArchived' => false],
|
||||
// Etat persiste complet (valeurs normalisees) : sans les champs
|
||||
// metier, guardManage (ERP-74) les croirait modifies (companyName,
|
||||
// lastName... compares a null) et leverait un 403 parasite.
|
||||
originalData: [
|
||||
'companyName' => 'TEST CO',
|
||||
'lastName' => 'Dupont',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 't@test.fr',
|
||||
'triageService' => false,
|
||||
'isArchived' => false,
|
||||
],
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
|
||||
@@ -153,8 +163,69 @@ final class ClientProcessorTest extends TestCase
|
||||
payload: ['companyName' => 'Test Co', 'siren' => '123456789'],
|
||||
managed: true,
|
||||
// getOriginalEntityData renvoie tous les champs mappes d'une entite
|
||||
// geree : isArchived (non-null) y figure toujours.
|
||||
originalData: ['siren' => '123456789', 'isArchived' => false],
|
||||
// geree : isArchived (non-null) y figure toujours, ainsi que les
|
||||
// champs metier (sinon guardManage les croirait modifies).
|
||||
originalData: [
|
||||
'siren' => '123456789',
|
||||
'companyName' => 'TEST CO',
|
||||
'lastName' => 'Dupont',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 't@test.fr',
|
||||
'triageService' => false,
|
||||
'isArchived' => false,
|
||||
],
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
|
||||
}
|
||||
|
||||
public function testBusinessFieldWithoutManagePermissionIsForbidden(): void
|
||||
{
|
||||
// ERP-74 (guardManage) : modifier un champ metier (companyName) sur un
|
||||
// client existant sans `manage` -> 403, meme avec accounting.manage
|
||||
// (cas Compta qui sort de son onglet).
|
||||
$client = $this->minimalClient();
|
||||
$client->setCompanyName('Renamed Co');
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.clients.accounting.manage'],
|
||||
payload: ['companyName' => 'Renamed Co'],
|
||||
managed: true,
|
||||
originalData: [
|
||||
'companyName' => 'TEST CO',
|
||||
'lastName' => 'Dupont',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 't@test.fr',
|
||||
'triageService' => false,
|
||||
'isArchived' => false,
|
||||
],
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testAccountingOnlyPatchWithAccountingManageOnlyPasses(): void
|
||||
{
|
||||
// ERP-74 : Compta (accounting.manage, PAS manage) qui ne touche QUE
|
||||
// l'onglet Comptabilite d'un client existant -> 200. guardManage ne
|
||||
// declenche pas (aucun champ metier modifie), guardAccounting passe.
|
||||
$client = $this->minimalClient();
|
||||
$client->setSiren('999999999');
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.clients.accounting.manage'],
|
||||
payload: ['siren' => '999999999'],
|
||||
managed: true,
|
||||
originalData: [
|
||||
'siren' => '111111111',
|
||||
'companyName' => 'TEST CO',
|
||||
'lastName' => 'Dupont',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 't@test.fr',
|
||||
'triageService' => false,
|
||||
'isArchived' => false,
|
||||
],
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
|
||||
@@ -237,6 +308,34 @@ final class ClientProcessorTest extends TestCase
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testCommercialeIncompleteInformationOnNonInformationPatchIsUnprocessable(): void
|
||||
{
|
||||
// RG-1.04 durcie (ERP-74) : pour une Commerciale, la completude de
|
||||
// l'onglet Information est exigee meme quand le payload ne touche PAS
|
||||
// l'onglet Information (ici seulement companyName). L'ancienne condition
|
||||
// d'intersection avec INFORMATION_FIELDS a ete retiree.
|
||||
$client = $this->minimalClient();
|
||||
$client->setCompanyName('Renamed Co'); // onglet principal uniquement, Information vide
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.clients.manage'],
|
||||
payload: ['companyName' => 'Renamed Co'],
|
||||
user: $this->commercialeUser(),
|
||||
managed: true,
|
||||
originalData: [
|
||||
'companyName' => 'TEST CO',
|
||||
'lastName' => 'Dupont',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 't@test.fr',
|
||||
'triageService' => false,
|
||||
'isArchived' => false,
|
||||
],
|
||||
);
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testNonCommercialeSkipsInformationCompleteness(): void
|
||||
{
|
||||
// Meme payload incomplet, mais user non-Commerciale -> aucun blocage.
|
||||
|
||||
Reference in New Issue
Block a user