Compare commits

..

10 Commits

Author SHA1 Message Date
gitea-actions 229a16a7b3 chore: bump version to v0.1.88
Build & Push Docker Image / build (push) Successful in 19s
2026-06-08 07:34:41 +00:00
matthieu 145d4362db feat(commercial) : sous-ressources M2 fournisseurs (contacts/adresses/ribs) (ERP-88) (#67)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-88 — Sous-ressources M2 (contacts / adresses / ribs)

Étape 4/7 du pipeline M2. Dépend de #86 (entités) et #87 (Provider/Processor). Bloque #92.

### Contenu
Opérations API Platform + Processors d'écriture des sous-collections du fournisseur (POST/PATCH/DELETE + GET unitaire).

**SupplierContactProcessor**
- Rattachement au fournisseur parent (404 si absent).
- Normalisation serveur RG-2.12 (Title Case nom/prénom, téléphones chiffres seuls, email lowercase).
- RG-2.04 : firstName **ou** lastName obligatoire (422 sur `firstName`).
- DELETE libre (RG-2.13 front-driven : collection peut rester vide côté back).

**SupplierAddressProcessor**
- Rattachement au fournisseur parent.
- RG-2.05 (CP `^[0-9]{4,5}$`), RG-2.06 (≥1 site), RG-2.09 (type d'adresse) portées par les contraintes d'entité (ERP-86).
- RG-2.10 (catégorie de type FOURNISSEUR) ajoutée via `Assert\Callback validateCategoryType` (propertyPath=`categories`).

**SupplierRibProcessor**
- Rattachement au fournisseur parent.
- RG-2.08 : refus du DELETE du dernier RIB quand `paymentType.code = LCR` → **409**.

### Security différenciée
| Sous-ressource | Écriture | Lecture |
|---|---|---|
| contacts / adresses | `commercial.suppliers.manage` | `commercial.suppliers.view` |
| ribs | `commercial.suppliers.accounting.manage` | `commercial.suppliers.accounting.view` |

POST en `read:false` (parent rattaché manuellement) — parade NonUniqueResult héritée du M1. Messages FR (ERP-107) + `violations[].propertyPath` aligné (ERP-101).

### Vérifications
- `make php-cs-fixer-allow-risky` : 0 fichier à corriger
- `make test` : 483 tests OK
- `debug:router` : 12 routes générées (4 par sous-ressource)

### Hors périmètre (tickets suivants)
- Déclaration RBAC `commercial.suppliers.*` dans `CommercialModule` (#7) — sans elle, l'accès reste 403.
- Tests fonctionnels de la matrice RG (#8) — dépendent du RBAC + fixtures Supplier.

### Notes de review (non bloquantes, alignées M1)
- `position` des sous-collections non exposé à l'API (décision ERP-86, géré serveur).
- M2M `SupplierAddress.contacts` non vérifié same-supplier — comportement identique au M1 (ClientAddress), à traiter globalement si besoin.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #67
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 07:31:48 +00:00
gitea-actions cd36c45b67 chore: bump version to v0.1.87
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 20s
2026-06-08 07:29:59 +00:00
matthieu e77c6378d3 feat(commercial) : SupplierProvider + SupplierProcessor + gating compta (ERP-87) (#66)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-87 — Provider + Processor du répertoire fournisseurs (M2)

Étape 3/7 du pipeline M2. Dépend de #86, bloque #88/#91/#92. Jumelle du M1 (Client*).

### Livré
- **SupplierProvider** : liste paginée (Paginator ORM), exclusion archivés + soft-deletes par défaut, filtres `includeArchived`/`categoryCode`/`siteId`/`search`, échappatoire `?pagination=false`, item 404 si soft-delete (RG-2.17).
- **SupplierProcessor** : normalisation `companyName`, archivage `isArchived`/`archivedAt` (RG-2.14/2.15), gating fin accounting/manage en **mode strict** (403 sur tout payload hors-permission, RG-2.16), 409 doublon `companyName` + conflit de restauration (RG-2.11).
- **SupplierReadGroupContextBuilder** : ajoute `supplier:read:accounting` au contexte de lecture si `accounting.view` → gating compta + RIB **par omission de clé** (parade bug #4 M1). Un Provider ne pouvant pas influencer les groupes de sérialisation, c'est le point d'extension idiomatique (miroir de `ClientReadGroupContextBuilder`).
- **SupplierFieldNormalizer** : normalisation serveur (RG-2.12).
- **Supplier** : ajout `#[ApiResource]` (GetCollection/Get/Post/Patch) wirant Provider/Processor.

### Décision d'archi
La spec décrit « le Provider retire le groupe accounting » — techniquement impossible (le Provider ne touche pas les groupes de sérialisation). Implémenté via décorateur `SerializerContextBuilder` (mirror M1), résultat fonctionnel identique (clé absente sans permission).

### Hors périmètre (ticket suivant #5)
Validators métier : RG-2.03 (complétude Information Commerciale), RG-2.07 (Virement→banque), RG-2.08 (LCR→RIB), RG-2.10 (catégorie type FOURNISSEUR). Le Processor est structuré pour les accueillir.

### À noter
Les permissions `commercial.suppliers.*` (référencées par les `security`) ne sont pas encore déclarées — ticket RBAC #7. Sans elles, `is_granted` renvoie `false` (pas d'erreur de compilation).

### Vérifs
- `make test` : 483/483 vert
- `make php-cs-fixer-allow-risky` : appliqué

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #66
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 07:29:51 +00:00
gitea-actions 3e138e1c17 chore: bump version to v0.1.86
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 37s
2026-06-08 07:18:39 +00:00
matthieu 6a01067746 feat(commercial) : entités + repositories M2 fournisseurs (ERP-86) (#65)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-86 — Entités + Repositories M2 Fournisseurs (étape 2/7)

PR **empilée sur ERP-85** (#64) : ne contient que le commit ERP-86. À merger après #64 (la base rebascule automatiquement au fil des merges de la chaîne #63#64 → develop).

Dépend de #64 (migration BDD). Bloque #87 (Provider + Processor) et suivants.

### Contenu

4 entités jumelles du M1 `Client*`, mapping ORM aligné **exactement** sur la migration ERP-85 (noms, types, longueurs, FK, M2M, index), **sans contact inline** (ERP-106) :

- **`Supplier`** — `#[Auditable]` + Timestampable/Blamable. Formulaire principal, onglet Information (+ `volumeForecast`, spécifique fournisseur), onglet Comptabilité (FK référentiels M1 partagés), archivage (`isArchived`/`archivedAt`), soft-delete préparé. Catégories M2M via `CategoryInterface` (règle n°1, pas d'import inter-module). Pas de `distributor`/`broker`.
- **`SupplierContact`** — onglet Contacts (RG-2.04 : `firstName` OU `lastName`).
- **`SupplierAddress`** — enum `addressType` (`PROSPECT`/`DEPART`/`RENDU` via `Assert\Choice`), `bennes`, `triageProvider` ; M2M sites/contacts/categories.
- **`SupplierRib`** — RIB, embed gaté comptable.
- **Repositories** : interfaces `Domain/Repository/` + impls `Infrastructure/Doctrine/`.

### Points clés

- **Contrat de sérialisation (RETEX M1, 3 maillons posés sur l'entité)** : read-groups sur les propriétés ; getters `isArchived()` / `isTriageProvider()` avec `#[Groups]` + `#[SerializedName('isX')]` (parade piège booléen n°3) ; embed `contacts`/`addresses` (`supplier:item:read`) et `ribs` (`supplier:read:accounting`). `getSites()` agrège/dédoublonne les `Site` des adresses (`name`/`postalCode`, pas de `code`).
- **Fetch-joins anti-N+1** dans le **repository de liste** : `hydrateListCollections()` en 2 passes (`categories`, puis `addresses.sites`) — évite le produit cartésien (pattern ERP-100). Filtres : recherche `companyName` + contacts liés (D1), `categoryCode`, `siteId`, archivage.
- **Pas d'`#[ApiResource]`** : Provider/Processor (gating accounting, archivage, mode strict) sont au ticket **ERP-87**. L'ajouter ici référencerait des classes inexistantes → boot/tests cassés. Les groupes de lecture/écriture sont déjà en place ; le `normalizationContext` viendra avec #87.
- **Validation FR (ERP-107)** : messages FR sur toutes les contraintes ; `Assert\Length(max)` calé sur les colonnes. Garde-fou `EntityConstraintsHaveFrenchMessageTest` étendu : `Assert\Choice` ajouté au mapping ; `addressType` et `postalCode` whitelistés du miroir Length (déjà bornés par Choice / Regex).
- Clés i18n `audit.entity.commercial_supplier*` ajoutées (garde-fou `AuditableEntitiesHaveI18nLabelTest`).

### Vérifications

- `make test` : **483/483 OK** (1965 assertions).
- `make php-cs-fixer-allow-risky` : 0 correction.
- `doctrine:schema:validate` : mapping correct (bruit d'index FK cosmétique identique au M1 `client`).

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #65
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 07:18:30 +00:00
gitea-actions cd98817b0a chore: bump version to v0.1.85
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 20s
2026-06-08 07:06:09 +00:00
matthieu 1a29bcf76c feat(commercial) : migration BDD M2 fournisseurs (supplier + sous-collections + M2M) (ERP-85) (#64)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-85 — Migration BDD M2 Fournisseurs (étape 1/7)

PR **empilée sur ERP-84** (#63) : ne contient que le commit ERP-85. À merger après #63 (la base rebascule sur develop automatiquement au merge de #63).

### Contenu
Migration `Version20260605130000.php` (namespace racine `DoctrineMigrations`) — schéma M2 sous le module Commercial, jumeau du M1 client.

**8 tables** : `supplier`, `supplier_category` (M2M), `supplier_contact`, `supplier_address`, `supplier_address_site` / `_contact` / `_category` (3 M2M), `supplier_rib`.

**Spécificités M2 (vs M1 client)**
- `supplier` **sans contact inline** (ERP-106) ni auto-référence distributor/broker ; ajout `volume_forecast`.
- `supplier_address` : enum `address_type` `CHECK (PROSPECT|DEPART|RENDU)`, `bennes` + `triage_provider`, **pas** de `billing_email`.
- Index partiel unique `uq_supplier_company_name_active` (nom seul, hors archives/soft-delete).

**Réutilisations (zéro duplication)** : référentiels comptables M1 (`tva_mode`/`payment_delay`/`payment_type`/`bank`) + `CategoryType FOURNISSEUR` (seedé par ERP-84). Pas de re-seed.

**Conventions** : `COMMENT ON COLUMN` sur chaque colonne (règle n°12) + helper Timestampable/Blamable ; namespace racine (FK cross-module, exception règle n°11).

### Vérifications
- `make db-reset`  de bout en bout (aucune erreur FK)
- `make test`  483 tests OK (`ColumnsHaveSqlCommentTest` vert, 0 colonne sans commentaire)
- `make php-cs-fixer-allow-risky`  0 fichier à corriger

Bloque : #86 (entités `Supplier*` + ApiResource).
---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #64
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 07:06:01 +00:00
gitea-actions da343464c6 chore: bump version to v0.1.84
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 2m30s
2026-06-08 06:57:41 +00:00
matthieu 0b33bcb0f2 feat(catalog) : taxonomie FOURNISSEUR (type + filtre ?typeCode= + seed) (ERP-84) (#63)
Auto Tag Develop / tag (push) Successful in 8s
## ERP-84 — Taxonomie FOURNISSEUR (Catalog)

Prérequis du multi-select « Catégorie » de l'écran Ajouter fournisseur (#94) et de #92.
Spec : `docs/specs/M2-suppliers/spec-back.md` § 2.4 + § 4.7.

### Contexte
ERP-78 avait unifié la taxonomie sur un **type unique CLIENT** ; `GET /api/categories?typeCode=FOURNISSEUR` renvoyait alors les catégories CLIENT (filtre **ignoré**, un seul `CategoryType`). Le filtre `?typeCode=` n'existait pas en prod.

### Changements
- **Filtre `?typeCode=` réel** sur `GET /api/categories` : `CategoryProvider` lit le filtre (même pattern que `includeDeleted`) et le passe à `DoctrineCategoryRepository::createListQueryBuilder`, qui joint le `CategoryType` et filtre sur son `code`. N'altère pas l'échappatoire `?pagination=false` ni la pagination Hydra.
- **CategoryType FOURNISSEUR recréé** : migration racine `Version20260605120000` (`INSERT … ON CONFLICT` pour le type + 5 catégories de démo en `NOT EXISTS` : Négociant, Coopérative, Producteur, Grossiste, Importateur). Aucune colonne créée → pas de `COMMENT ON COLUMN`.
- **Fixtures étendues** : `CategoryTypeFixtures` + `CategoryFixtures` seedent FOURNISSEUR de façon idempotente (survit à `make db-reset`).
- **Test** : `CategoryTypeCodeFilterTest` (filtre exclusif, compat pagination Hydra, code inexistant → liste vide).

### Vérifications
- `make php-cs-fixer-allow-risky` : clean.
- `make test` : **483 tests OK** (1844 assertions).
- Après `make db-reset` :
  - `/api/category_types` → `CLIENT` + `FOURNISSEUR`.
  - `?typeCode=FOURNISSEUR` → uniquement les 5 catégories FOURNISSEUR.
  - `?typeCode=CLIENT` → 11 catégories, type unique CLIENT.

### Critères d'acceptation
- [x] `CategoryType` FOURNISSEUR présent après `make db-reset`.
- [x] `?typeCode=FOURNISSEUR` ne renvoie QUE les catégories FOURNISSEUR.
- [x] Catégories fournisseurs seedées sous ce type.
- [x] `make test` vert.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #63
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 06:57:32 +00:00
16 changed files with 27 additions and 760 deletions
+4 -5
View File
@@ -53,11 +53,10 @@ return [
'permission' => 'commercial.clients.view',
],
[
'label' => 'sidebar.commercial.suppliers',
'to' => '/suppliers',
'icon' => 'mdi:account-arrow-left-outline',
'module' => 'commercial',
'permission' => 'commercial.suppliers.view',
'label' => 'sidebar.commercial.suppliers',
'to' => '/suppliers',
'icon' => 'mdi:account-arrow-left-outline',
'module' => 'commercial',
],
],
],
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.83'
app.version: '0.1.88'
-9
View File
@@ -75,15 +75,6 @@ export const personas: Record<PersonaKey, Persona> = {
'commercial.clients.accounting.view',
'commercial.clients.accounting.manage',
'commercial.clients.archive',
// Commercial — Repertoire fournisseurs (M2, ERP-90). Meme logique que
// les clients : mappe sur le persona "tout", pas de nouveau persona
// (regle ABSOLUE n°7). commercial.suppliers.view n'ajoute pas de lien
// dans la section Administration, donc expectedAdminLinks reste inchange.
'commercial.suppliers.view',
'commercial.suppliers.manage',
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
'commercial.suppliers.archive',
],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
},
@@ -1,79 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Domain\Entity\Supplier;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier RG-2.03 (jumeau du ClientInformationCompletenessValidator M1) :
* 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 SupplierProcessor des que l'utilisateur courant porte le role
* Commerciale (detection du role cote back). Pour les autres roles, ces champs
* restent optionnels — le validator n'est pas appele.
*
* NEW vs Client : ajoute le champ `volumeForecast` (volume previsionnel),
* specifique fournisseur.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, chaque
* violation portant son propertyPath (consommable par extractApiViolations,
* ERP-101), par coherence avec les violations Symfony rendues par API Platform.
*/
final class SupplierInformationCompletenessValidator
{
public function validate(Supplier $supplier): void
{
// Map champ -> valeur courante de l'onglet Information.
$fields = [
'description' => $supplier->getDescription(),
'competitors' => $supplier->getCompetitors(),
'foundedAt' => $supplier->getFoundedAt(),
'employeesCount' => $supplier->getEmployeesCount(),
'revenueAmount' => $supplier->getRevenueAmount(),
'directorName' => $supplier->getDirectorName(),
'profitAmount' => $supplier->getProfitAmount(),
'volumeForecast' => $supplier->getVolumeForecast(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
sprintf('Ce champ est obligatoire pour le rôle Commerciale (champ "%s").', $property),
null,
[],
$supplier,
$property,
$value,
));
}
}
if (count($violations) > 0) {
throw new ValidationException($violations);
}
}
/**
* Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
* zeros numeriques (employeesCount = 0, profitAmount = "0.00",
* volumeForecast = 0) sont des valeurs valides : on ne les considere pas
* manquants.
*/
private function isMissing(mixed $value): bool
{
if (null === $value) {
return true;
}
return is_string($value) && '' === trim($value);
}
}
@@ -39,11 +39,6 @@ final class CommercialModule
['code' => 'commercial.clients.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un client'],
['code' => 'commercial.clients.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un client'],
['code' => 'commercial.clients.archive', 'label' => 'Archiver / restaurer un client'],
['code' => 'commercial.suppliers.view', 'label' => 'Voir les fournisseurs'],
['code' => 'commercial.suppliers.manage', 'label' => 'Créer / modifier les fournisseurs (hors onglet Comptabilité)'],
['code' => 'commercial.suppliers.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un fournisseur'],
['code' => 'commercial.suppliers.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un fournisseur'],
['code' => 'commercial.suppliers.archive', 'label' => 'Archiver / restaurer un fournisseur'],
];
}
}
+3 -3
View File
@@ -25,7 +25,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['bank:read']],
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
order: ['position' => 'ASC', 'label' => 'ASC'],
@@ -33,11 +33,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['bank:read']],
),
],
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
security: "is_granted('commercial.clients.view')",
)]
#[ORM\Entity(repositoryClass: DoctrineBankRepository::class)]
#[ORM\Table(name: 'bank')]
@@ -25,7 +25,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['payment_delay:read']],
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
order: ['position' => 'ASC', 'label' => 'ASC'],
@@ -33,11 +33,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['payment_delay:read']],
),
],
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
security: "is_granted('commercial.clients.view')",
)]
#[ORM\Entity(repositoryClass: DoctrinePaymentDelayRepository::class)]
#[ORM\Table(name: 'payment_delay')]
@@ -28,7 +28,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['payment_type:read']],
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
order: ['position' => 'ASC', 'label' => 'ASC'],
@@ -36,11 +36,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['payment_type:read']],
),
],
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
security: "is_granted('commercial.clients.view')",
)]
#[ORM\Entity(repositoryClass: DoctrinePaymentTypeRepository::class)]
#[ORM\Table(name: 'payment_type')]
@@ -25,7 +25,6 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Fournisseur (M2 Commercial) — entite racine du repertoire fournisseurs,
@@ -134,20 +133,6 @@ class Supplier implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
/**
* RG-2.10 : seules les categories de ce type sont autorisees sur le
* fournisseur (entite principale). Miroir de SupplierAddress (ERP-88).
* S'appuie sur CategoryInterface::getCategoryTypeCode() (pas d'import du
* module Catalog — regle ABSOLUE n°1).
*/
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
/** RG-2.07 : code du type de reglement imposant une banque. */
private const string PAYMENT_TYPE_VIREMENT = 'VIREMENT';
/** RG-2.08 : code du type de reglement imposant au moins un RIB. */
private const string PAYMENT_TYPE_LCR = 'LCR';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -295,65 +280,6 @@ class Supplier implements TimestampableInterface, BlamableInterface
$this->ribs = new ArrayCollection();
}
/**
* RG-2.10 : toute categorie posee sur le fournisseur doit etre de type
* FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
* (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
* SupplierAddress::validateCategoryType (ERP-88). S'appuie sur
* CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog —
* regle ABSOLUE n°1). Joue avant la base via la validation API Platform, sur
* POST (categories ∈ supplier:write:main) comme sur PATCH.
*/
#[Assert\Callback]
public function validateCategoryType(ExecutionContextInterface $context): void
{
foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface
&& self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) {
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
->atPath('categories')
->addViolation()
;
return;
}
}
}
/**
* RG-2.07 / RG-2.08 : coherence du type de reglement comptable. Decision
* figee ERP-89 : ces RG inter-champs passent par une contrainte d'entite
* (Assert\Callback + ->atPath()) et NON par le SupplierProcessor, afin que
* chaque 422 porte un propertyPath exploitable par extractApiViolations
* (mapping inline sous le champ, pas un toast — convention ERP-101).
* - RG-2.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
* - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur `ribs`
* (le 409 sur DELETE du dernier RIB en LCR est porte par ERP-88).
*
* Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui
* n'expose que supplier:write:main), la contrainte ne mord en pratique que
* sur le PATCH de l'onglet Comptabilite.
*/
#[Assert\Callback]
public function validatePaymentTypeConsistency(ExecutionContextInterface $context): void
{
$paymentCode = $this->paymentType?->getCode();
if (self::PAYMENT_TYPE_VIREMENT === $paymentCode && null === $this->bank) {
$context->buildViolation('La banque est obligatoire pour le type de règlement Virement.')
->atPath('bank')
->addViolation()
;
}
if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
$context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
->atPath('ribs')
->addViolation()
;
}
}
public function getId(): ?int
{
return $this->id;
@@ -17,8 +17,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
* re-seede en dev/test par CommercialReferentialFixtures.
*
* Lecture seule au M1 (HP-M2-2) : seules GetCollection et Get sont exposees
* (ERP-56), sous la permission commercial.clients.view (elargie aux roles
* fournisseurs au M2 via commercial.suppliers.view, ERP-90) ; aucune ecriture
* (ERP-56), sous la permission commercial.clients.view ; aucune ecriture
* declaree -> POST/PATCH/DELETE renvoient 405.
*
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
@@ -29,7 +28,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['tva_mode:read']],
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC
// (ordre des selecteurs comptables) — provider Doctrine par defaut.
@@ -40,11 +39,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['tva_mode:read']],
),
],
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
security: "is_granted('commercial.clients.view')",
)]
#[ORM\Entity(repositoryClass: DoctrineTvaModeRepository::class)]
#[ORM\Table(name: 'tva_mode')]
@@ -7,10 +7,7 @@ namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
@@ -43,19 +40,14 @@ use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
* collisions d'unicite en 409 (RG-2.11 doublon de nom ; RG-2.15 conflit de
* restauration).
*
* Validators metier (ERP-89). Decision figee : ce processor ne porte QUE
* RG-2.03 (completude Information exigee pour le role Commerciale — detection du
* role cote back, non exprimable en contrainte d'entite). Les RG inter-champs
* RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB) et RG-2.10 (categorie
* de type FOURNISSEUR) sont portees par des Assert\Callback + ->atPath() sur
* l'entite Supplier (jouees par API Platform AVANT ce processor), pour que
* chaque 422 porte un propertyPath consommable par extractApiViolations
* (mapping inline, pas un toast — convention ERP-101).
* Hors perimetre ERP-87 (ticket #5 « Validators ») : RG-2.03 (completude
* Information pour la Commerciale), RG-2.07 (Virement -> banque), RG-2.08 (LCR ->
* RIB), RG-2.10 (categorie de type FOURNISSEUR). Ces regles metier seront
* branchees ici via des validators dedies au ticket suivant.
*
* Note : la validation Symfony (Assert\NotBlank, Assert\Count sur categories,
* les Callback RG-2.07/2.08/2.10...) est jouee par API Platform AVANT ce
* processor ; on n'y traite donc que les regles non exprimables en simples
* contraintes d'entite (RG-2.03, qui depend du role de l'utilisateur courant).
* Note : la validation Symfony (Assert\NotBlank, Assert\Count sur categories...)
* est jouee par API Platform AVANT ce processor ; on n'y traite donc que les
* regles non exprimables en simples contraintes d'attribut.
*
* @implements ProcessorInterface<Supplier, Supplier>
*/
@@ -102,7 +94,6 @@ final class SupplierProcessor implements ProcessorInterface
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly SupplierFieldNormalizer $normalizer,
private readonly SupplierInformationCompletenessValidator $informationValidator,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
@@ -126,8 +117,6 @@ final class SupplierProcessor implements ProcessorInterface
// normalisees des deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data);
$this->validateInformationCompleteness($data);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
@@ -255,38 +244,6 @@ final class SupplierProcessor implements ProcessorInterface
}
}
/**
* RG-2.03 : 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. Garantit qu'un
* fournisseur cree/edite par une Commerciale ne reste jamais avec un onglet
* Information incomplet. Pour les autres roles, ces champs restent optionnels.
*
* Consequence (cf. spec § 7, miroir RG-1.04) : le POST n'exposant que
* supplier:write:main, une Commerciale obtient 422 sur tout POST tant que
* l'Information n'est pas complete -> la completude se fait via les PATCH
* supplier:write:information.
*/
private function validateInformationCompleteness(Supplier $data): void
{
if ($this->currentUserIsCommerciale()) {
$this->informationValidator->validate($data);
}
}
/**
* Detection du role metier Commerciale cote back (jamais front), via le
* contrat BusinessRoleAwareInterface (pas d'import de User — regle ABSOLUE
* n°1). Identique au ClientProcessor (M1).
*/
private function currentUserIsCommerciale(): bool
{
$user = $this->security->getUser();
return $user instanceof BusinessRoleAwareInterface
&& $user->hasBusinessRole(BusinessRoles::COMMERCIALE);
}
/**
* Champs « metier » (onglets principal + Information, hors comptabilite et
* archivage) dont la valeur courante differe de l'etat persiste. Memes
@@ -51,9 +51,8 @@ final class RbacSeeder
* 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` et
* `commercial.suppliers.archive` ne sont attaches a aucun role metier —
* admin seul).
* 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>}>
*/
@@ -63,9 +62,6 @@ final class RbacSeeder
'permissions' => [
'commercial.clients.view',
'commercial.clients.manage',
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
'commercial.suppliers.view',
'commercial.suppliers.manage',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -77,11 +73,6 @@ final class RbacSeeder
'commercial.clients.view',
'commercial.clients.accounting.view',
'commercial.clients.accounting.manage',
// Fournisseurs (M2 § 2.9, ERP-90) : view + onglet Comptabilite uniquement
// (pas de manage global -> ne peut pas creer un fournisseur).
'commercial.suppliers.view',
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -92,10 +83,6 @@ final class RbacSeeder
'permissions' => [
'commercial.clients.view',
'commercial.clients.manage',
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage, sans accounting
// (onglet Comptabilite masque/filtre pour la Commerciale).
'commercial.suppliers.view',
'commercial.suppliers.manage',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -195,14 +195,6 @@ final class SeedE2ECommand extends Command
'commercial.clients.accounting.view',
'commercial.clients.accounting.manage',
'commercial.clients.archive',
// Commercial — Repertoire fournisseurs (M2, ERP-90). Meme
// logique que les clients : mappe sur le persona "tout".
// Miroir de frontend/tests/e2e/_fixtures/personas.ts.
'commercial.suppliers.view',
'commercial.suppliers.manage',
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
'commercial.suppliers.archive',
],
],
[
@@ -1,172 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Domain\Entity;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Domain\Entity\SupplierRib;
use App\Shared\Domain\Contract\CategoryInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Tests des contraintes inter-champs de l'entite Supplier portees par
* Assert\Callback (decision figee ERP-89) : RG-2.10 (categorie de type
* FOURNISSEUR), RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB).
*
* On valide l'entite avec le validator Symfony (mapping par attributs) et on
* assert le propertyPath exact de chaque violation (contrat ERP-101 :
* exploitable par extractApiViolations). Pas de base : les Callback ne touchent
* que des champs en memoire (categories via un double CategoryInterface).
*
* @internal
*/
final class SupplierValidationTest extends TestCase
{
private ValidatorInterface $validator;
protected function setUp(): void
{
$this->validator = Validation::createValidatorBuilder()
->enableAttributeMapping()
->getValidator()
;
}
// === RG-2.10 : categories de type FOURNISSEUR ===
public function testFournisseurCategoryIsAccepted(): void
{
$supplier = $this->validSupplier();
self::assertSame([], $this->violationPaths($supplier));
}
public function testNonFournisseurCategoryIsRejectedOnCategoriesPath(): void
{
$supplier = new Supplier();
$supplier->setCompanyName('Recycla SAS');
$supplier->addCategory($this->category('CLIENT'));
self::assertContains('categories', $this->violationPaths($supplier));
}
// === RG-2.07 : Virement impose une banque ===
public function testVirementWithoutBankIsRejectedOnBankPath(): void
{
$supplier = $this->validSupplier();
$supplier->setPaymentType($this->paymentType('VIREMENT'));
self::assertContains('bank', $this->violationPaths($supplier));
}
public function testVirementWithBankPasses(): void
{
$supplier = $this->validSupplier();
$supplier->setPaymentType($this->paymentType('VIREMENT'));
$supplier->setBank(new Bank());
self::assertNotContains('bank', $this->violationPaths($supplier));
}
// === RG-2.08 : LCR impose au moins un RIB ===
public function testLcrWithoutRibIsRejectedOnRibsPath(): void
{
$supplier = $this->validSupplier();
$supplier->setPaymentType($this->paymentType('LCR'));
self::assertContains('ribs', $this->violationPaths($supplier));
}
public function testLcrWithRibPasses(): void
{
$supplier = $this->validSupplier();
$supplier->setPaymentType($this->paymentType('LCR'));
$supplier->addRib(new SupplierRib());
self::assertNotContains('ribs', $this->violationPaths($supplier));
}
public function testNeutralPaymentTypeRequiresNeitherBankNorRib(): void
{
// Un type de reglement neutre (ni VIREMENT ni LCR) n'exige ni banque ni RIB.
$supplier = $this->validSupplier();
$supplier->setPaymentType($this->paymentType('CHEQUE'));
$paths = $this->violationPaths($supplier);
self::assertNotContains('bank', $paths);
self::assertNotContains('ribs', $paths);
}
/**
* Fournisseur valide (nom + 1 categorie FOURNISSEUR), sans onglet
* Comptabilite renseigne : sert de base aux tests RG-2.07/2.08.
*/
private function validSupplier(): Supplier
{
$supplier = new Supplier();
$supplier->setCompanyName('Recycla SAS');
$supplier->addCategory($this->category('FOURNISSEUR'));
return $supplier;
}
/**
* @return list<string> propertyPaths des violations levees par le validator
*/
private function violationPaths(Supplier $supplier): array
{
$paths = [];
foreach ($this->validator->validate($supplier) as $violation) {
$paths[] = $violation->getPropertyPath();
}
return $paths;
}
/**
* Double minimal de CategoryInterface (pas d'acces base) renvoyant le code de
* type de categorie voulu — seul element regarde par validateCategoryType.
*/
private function category(string $typeCode): CategoryInterface
{
return new class($typeCode) implements CategoryInterface {
public function __construct(private readonly string $typeCode) {}
public function getId(): ?int
{
return 1;
}
public function getName(): ?string
{
return 'Categorie test';
}
public function getCode(): ?string
{
return 'TEST';
}
public function getCategoryTypeCode(): ?string
{
return $this->typeCode;
}
};
}
private function paymentType(string $code): PaymentType
{
$type = new PaymentType();
$type->setCode($code);
$type->setLabel($code);
return $type;
}
}
@@ -1,129 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Unit;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Supplier;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires du SupplierInformationCompletenessValidator (RG-2.03) : pour le
* role Commerciale, TOUS les champs de l'onglet Information sont obligatoires.
* Chaque champ manquant produit une violation portant son propertyPath (ERP-101).
*
* @internal
*/
final class SupplierInformationCompletenessValidatorTest extends TestCase
{
public function testCompleteInformationPasses(): void
{
$supplier = $this->completeSupplier();
$this->validator()->validate($supplier);
// Aucune exception levee : la completude est satisfaite.
$this->addToAssertionCount(1);
}
public function testEmptyInformationListsEveryMissingField(): void
{
$supplier = new Supplier();
$supplier->setCompanyName('Recycla SAS'); // onglet principal, hors Information
try {
$this->validator()->validate($supplier);
self::fail('Une ValidationException etait attendue (onglet Information vide).');
} catch (ValidationException $e) {
$paths = [];
foreach ($e->getConstraintViolationList() as $violation) {
$paths[] = $violation->getPropertyPath();
}
// Les 8 champs Information (dont volumeForecast, NEW vs Client) sont
// tous signales d'un coup, chacun sous son propre propertyPath.
sort($paths);
self::assertSame([
'competitors',
'description',
'directorName',
'employeesCount',
'foundedAt',
'profitAmount',
'revenueAmount',
'volumeForecast',
], $paths);
}
}
public function testPartialInformationReportsOnlyMissingFields(): void
{
$supplier = $this->completeSupplier();
$supplier->setDirectorName(null);
$supplier->setVolumeForecast(null);
try {
$this->validator()->validate($supplier);
self::fail('Une ValidationException etait attendue (2 champs manquants).');
} catch (ValidationException $e) {
$paths = [];
foreach ($e->getConstraintViolationList() as $violation) {
$paths[] = $violation->getPropertyPath();
}
sort($paths);
self::assertSame(['directorName', 'volumeForecast'], $paths);
}
}
public function testZeroNumericValuesAreNotMissing(): void
{
// employeesCount = 0, profitAmount = "0.00", volumeForecast = 0 sont des
// valeurs valides (un zero n'est pas une absence) -> pas de violation.
$supplier = $this->completeSupplier();
$supplier->setEmployeesCount(0);
$supplier->setProfitAmount('0.00');
$supplier->setVolumeForecast(0);
$this->validator()->validate($supplier);
$this->addToAssertionCount(1);
}
public function testBlankStringIsMissing(): void
{
// Une chaine vide apres trim compte comme manquante.
$supplier = $this->completeSupplier();
$supplier->setDescription(' ');
$this->expectException(ValidationException::class);
$this->validator()->validate($supplier);
}
/**
* Fournisseur dont l'onglet Information est entierement renseigne.
*/
private function completeSupplier(): Supplier
{
$supplier = new Supplier();
$supplier->setCompanyName('Recycla SAS');
$supplier->setDescription('Specialiste du recyclage');
$supplier->setCompetitors('Concurrent A, Concurrent B');
$supplier->setFoundedAt(new DateTimeImmutable('2010-01-01'));
$supplier->setEmployeesCount(42);
$supplier->setRevenueAmount('1000000.00');
$supplier->setDirectorName('Marie Durand');
$supplier->setProfitAmount('150000.00');
$supplier->setVolumeForecast(5000);
return $supplier;
}
private function validator(): SupplierInformationCompletenessValidator
{
return new SupplierInformationCompletenessValidator();
}
}
@@ -1,199 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Unit;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\SupplierProcessor;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\UnitOfWork;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Tests unitaires du SupplierProcessor — perimetre ERP-89 : detection du role
* Commerciale cote back (RG-2.03). Les autres responsabilites du processor
* (gating accounting / archive / mode strict) sont heritees d'ERP-87 et testees
* a leur niveau ; les RG inter-champs (RG-2.07/2.08/2.10) sont des contraintes
* d'entite (cf. SupplierValidationTest), non portees ici.
*
* @internal
*/
final class SupplierProcessorTest extends TestCase
{
public function testCommercialeIncompleteInformationIsUnprocessable(): void
{
// RG-2.03 : role Commerciale + onglet Information incomplet -> 422, meme
// sur un POST (les champs Information n'y sont pas renseignables).
$supplier = $this->minimalSupplier();
$supplier->setDescription('Une description'); // les autres champs Information restent null
$processor = $this->makeProcessor(
payload: ['description' => 'Une description'],
user: $this->commercialeUser(),
);
$this->expectException(ValidationException::class);
$processor->process($supplier, $this->operation());
}
public function testCommercialeIncompleteInformationOnMainOnlyPatchIsUnprocessable(): void
{
// RG-2.03 : pour une Commerciale, la completude Information est exigee
// meme quand le payload ne touche PAS l'onglet Information (ici
// companyName seul) -> 422.
$supplier = $this->minimalSupplier();
$supplier->setCompanyName('Renamed Co');
$processor = $this->makeProcessor(
granted: ['commercial.suppliers.manage'],
payload: ['companyName' => 'Renamed Co'],
user: $this->commercialeUser(),
managed: true,
originalData: [
'companyName' => 'TEST CO',
'isArchived' => false,
],
);
$this->expectException(ValidationException::class);
$processor->process($supplier, $this->operation());
}
public function testCommercialeCompleteInformationPasses(): void
{
// RG-2.03 satisfaite : tous les champs Information renseignes -> 200.
$supplier = $this->completeInformationSupplier();
$processor = $this->makeProcessor(
granted: ['commercial.suppliers.manage'],
payload: ['description' => 'desc'],
user: $this->commercialeUser(),
);
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
}
public function testNonCommercialeSkipsInformationCompleteness(): void
{
// Meme onglet Information incomplet, mais user non-Commerciale -> aucun
// blocage (la completude est specifique a la Commerciale).
$supplier = $this->minimalSupplier();
$supplier->setDescription('Une description');
$processor = $this->makeProcessor(
payload: ['description' => 'Une description'],
user: null,
);
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
}
/**
* @param list<string> $granted Permissions accordees a l'utilisateur courant
* @param array<string, mixed> $payload Corps JSON simule de la requete
* @param bool $managed true = entite geree par l'ORM (PATCH), false = creation (POST)
* @param array<string, mixed> $originalData Etat persiste simule (getOriginalEntityData)
*/
private function makeProcessor(
array $granted = [],
array $payload = [],
?UserInterface $user = null,
bool $managed = false,
array $originalData = [],
): SupplierProcessor {
$persist = new class implements ProcessorInterface {
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
return $data;
}
};
$security = $this->createStub(Security::class);
$security->method('isGranted')->willReturnCallback(
static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true),
);
$security->method('getUser')->willReturn($user);
$requestStack = new RequestStack();
$requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR)));
$uow = $this->createMock(UnitOfWork::class);
$uow->method('getOriginalEntityData')->willReturn($originalData);
$em = $this->createMock(EntityManagerInterface::class);
$em->method('contains')->willReturn($managed);
$em->method('getUnitOfWork')->willReturn($uow);
return new SupplierProcessor(
$persist,
new SupplierFieldNormalizer(),
new SupplierInformationCompletenessValidator(),
$security,
$requestStack,
$em,
);
}
private function minimalSupplier(): Supplier
{
$supplier = new Supplier();
$supplier->setCompanyName('Test Co');
return $supplier;
}
private function completeInformationSupplier(): Supplier
{
$supplier = $this->minimalSupplier();
$supplier->setDescription('desc');
$supplier->setCompetitors('concurrents');
$supplier->setFoundedAt(new DateTimeImmutable('2010-01-01'));
$supplier->setEmployeesCount(10);
$supplier->setRevenueAmount('1000.00');
$supplier->setDirectorName('Marie Durand');
$supplier->setProfitAmount('100.00');
$supplier->setVolumeForecast(500);
return $supplier;
}
private function operation(): Operation
{
return $this->createStub(Operation::class);
}
private function commercialeUser(): UserInterface
{
return new class implements UserInterface, BusinessRoleAwareInterface {
public function hasBusinessRole(string $roleCode): bool
{
return BusinessRoles::COMMERCIALE === $roleCode;
}
public function getRoles(): array
{
return ['ROLE_USER'];
}
public function eraseCredentials(): void {}
public function getUserIdentifier(): string
{
return 'commerciale-test';
}
};
}
}