Compare commits

...

7 Commits

Author SHA1 Message Date
gitea-actions 843e4b0a0c chore: bump version to v0.1.96
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 49s
2026-06-08 09:47:21 +00:00
matthieu a9c14704b7 feat(catalog) : categories multi-types (M:N) + bouton Filtres liste (#75)
Auto Tag Develop / tag (push) Successful in 7s
## Contexte

Une `Category` ne pouvait appartenir qu'à **un seul** `CategoryType` (ManyToOne). Le besoin métier : plusieurs types par catégorie. Cette MR fait passer la relation en **ManyToMany** et ajoute le bouton **« Filtres »** à droite de la liste des catégories (modèle Répertoire Clients).

Slice vertical complet (le passage M:N casse mécaniquement le contrat inter-module `CategoryInterface`, consommé par la RG-2.10 fournisseurs).

## Volet A — Relation M:N
- `Category.categoryType` (ManyToOne) → `categoryTypes` (ManyToMany, jonction `category_category_type`). Au moins un type obligatoire (`Assert\\Count(min:1)`).
- **Unicité du nom GLOBALE** parmi les actifs (`uq_category_name_active`, remplace `uq_category_name_type_active`). Message 409 reformulé.
- Migration : table de jonction + backfill + drop colonne `category_type_id` + nouvel index. Validée **rejouable sur base fraîche**.
- Contrat Shared : `getCategoryTypeCode()` → `getCategoryTypeCodes(): array`. `Supplier`/`SupplierAddress`/`SupplierFixtures` revalident « contient FOURNISSEUR » (RG-2.10).
- Provider/Repository : filtre type via sous-requête `EXISTS` (ne tronque pas la collection embarquée), eager-load anti-N+1.

## Volet B — Bouton « Filtres »
- Drawer recherche par nom + types multi (OR). Compteur de filtres actifs. État local, jamais persisté en URL.
- Back : filtres `?name=` et `?typeId[]=` sur la collection.

## Front
- Multi-select `MalioSelectCheckbox`, `useCategoryForm` en `categoryTypeIds[]`, colonne « Types », clés i18n.

## Tests / vérifs
- `make test` : **582 tests, 2474 assertions, 0 échec** 
- `make nuxt-test` : **236 tests** 
- `make php-cs-fixer-allow-risky` 
- Migration rejouée sur base fraîche (`make db-reset`) 
- Nouveau `CategoryFilterTest` (name partiel + typeId[] OR + multi-type non dupliqué)

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #75
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 09:47:15 +00:00
gitea-actions 43b2251ef1 chore: bump version to v0.1.95
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 21s
2026-06-08 08:50:41 +00:00
matthieu 9cda225bdf Correctifs post-review M2 fournisseurs (P1 + P2/P3 + alignement M1) (#74)
Auto Tag Develop / tag (push) Successful in 8s
Correctifs issus de la review lead du stack M2 fournisseurs (ERP-84→113), répartis en priorités. Base : `develop`. Suite verte : `make test` 577 tests / 2475 assertions, `php-cs-fixer` 0 correction.

## P1 — défauts bloquants
- **ERP-89** — Le message de complétude Information ne fuit plus le nom de champ technique (`(champ "%s")` retiré). Correction miroir appliquée aux deux validators (Supplier + Client), accent uniformisé. Le `propertyPath` est conservé pour le mapping inline front.
- **ERP-112** — La fixture fournisseurs résout désormais la catégorie en filtrant sur le type `FOURNISSEUR` (via `CategoryInterface::getCategoryTypeCode()`), évitant de rattacher une catégorie homonyme d'un autre type (RG-2.10).
- **ERP-113** — Tests d'export complétés : dédup F3 (fournisseur multi-catégories rendu sur une seule ligne) ; gating SIREN prouvé via un utilisateur minimal non-admin portant `suppliers.view` + `suppliers.accounting.view` (nouveau helper `createUserWithPermissions`).

## P2 / P3
- **ERP-86** — `maxMessage` explicite sur `competitors` (Supplier).
- **ERP-92** — Garde `skipIfSitesModuleDisabled()` sur le test POST adresse sans site (évite un faux positif si le module Sites est désactivé).
- **ERP-89 bis** — Nouveau test : Admin authentifié non-Commerciale + Information incomplète → 200 (distinct du cas `user=null`).
- **ERP-85** — `down()` de la migration fournisseurs en `DROP TABLE IF EXISTS`.
- **ERP-87** — Reset de la mémoïsation payload en début de `process()` du SupplierProcessor + documentation du filtre `?archivedOnly` de l'export (parité avec le provider liste).
- **spec-back.md (M2)** — Alignée sur le code (le code fait foi) : security PATCH `manage or accounting.manage`, gating accounting par ajout de groupe (`SupplierReadGroupContextBuilder`), anti-N+1 via `hydrateListCollections` (pas de fetch-join), types de colonnes réels (`IDENTITY` / `TIMESTAMP(0)`).

## Alignement M1 ↔ M2
- **ERP-86/87 (Client)** — Mêmes corrections appliquées aux jumeaux M1 : message `competitors` explicite + reset mémoïsation `ClientProcessor`.

## Décision actée
- **RG-2.10 (catégorie)** : court-circuit conservé (une seule violation sur `categories`). Les violations partageant path + message sont fusionnées côté front ; ERP-101 (toutes les erreurs en un aller-retour) est déjà respecté car le Callback n'interrompt pas la validation des autres champs.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #74
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 08:47:43 +00:00
gitea-actions f031c70393 chore: bump version to v0.1.94
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 19s
2026-06-08 08:04:20 +00:00
matthieu e050a7b910 test(commercial) : SupplierExportControllerTest sur base fournisseurs (catégories FOURNISSEUR, dédup F3) (ERP-113) (#73)
Auto Tag Develop / tag (push) Successful in 7s
Suivi du finding F3 de la review ERP-92. **Test uniquement** — aucune modif de code applicatif (le controller d'export ERP-91 est correct).

### Problème (F3)
`SupplierExportControllerTest` étendait `AbstractCommercialApiTestCase` et redéfinissait un `seedSupplier()` privé appelant `createCategory()` du parent → catégorie de **type CLIENT**, ce qui viole RG-2.10 dans les données de test (latent : l'export ne filtre pas par type de catégorie, mais le contrat de test était faux).

### Changements
- Bascule de base : `extends AbstractSupplierApiTestCase` (helpers `seedSupplier`/`addContact`/`supplierCategory` sur type **FOURNISSEUR**).
- Suppression du `seedSupplier()` privé (type CLIENT) et du `tearDown()` redondant — dédup F3.
- `testExportUsesPrincipalContactColumns` : utilise `addContact()` de la base ; le téléphone secondaire (non porté par ce helper) est posé via le setter sur le contact retourné.
- `testExportPopulatesCategoryAndSiteColumns` : l'assertion de la colonne « Catégories » dérive le libellé de `supplierCategory('NEGOCIANT')->getName()` au lieu de hardcoder le préfixe de nom de test (la base nomme `test_cli_cat_fr_negociant`).
- Imports `Supplier` / `SupplierContact` / `DateTimeImmutable` retirés (inutilisés).

### Vérifications
- `SupplierExportControllerTest` : 9 tests, 48 assertions — vert sous APP_DEBUG=0.
- Suite complète `make test` : 574 tests, 2448 assertions — OK sous APP_DEBUG=0.
- `make php-cs-fixer-allow-risky` : 0 correction.

> MR stackée sur `feature/ERP-112-fixtures-fournisseurs`.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #73
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 07:49:48 +00:00
matthieu b35deed8fe feat(commercial) : fixtures Doctrine fournisseurs (≈13 suppliers complets + sous-collections) (ERP-112) (#72)
Auto Tag Develop / tag (push) Successful in 6s
## ERP-112 — Fixtures Doctrine fournisseurs (M2)

`SupplierFixtures` (calquée sur `ClientFixtures` / ERP-68) : ~13 fournisseurs de démonstration couvrant les cas pivots du répertoire fournisseurs (M2), chargés par `make db-reset`.

### Contenu
- **13 fournisseurs** (dont **2 archivés** — RG-2.17), `companyName` variés (UPPERCASE serveur), mono et multi-catégories de type FOURNISSEUR (RG-2.10).
- **19 contacts** (1 à 3 par fournisseur, dont un avec téléphone secondaire et un nommé par le seul nom — RG-2.04).
- **15 adresses** multi-types PROSPECT / DEPART / RENDU (RG-2.09) et multi-sites 86/17/82 (RG-2.06), avec `bennes` et `triageProvider`.
- **3 RIB**, compta complète sur une partie (siren, tvaMode, paymentDelay, paymentType).

### Cas pivots
- VIREMENT → banque renseignée (RG-2.07) ; LCR → 1 puis 2 RIB (RG-2.08) ; CHEQUE et NON_SOUMISE sans RIB.
- Onglet Information complet (dont `volumeForecast`, spécifique fournisseur).
- Cohérence gating comptable (un rôle sans `accounting.view` ne voit pas la compta) — support des tests ERP-92 et du golden path front.

### Notes
- **Idempotent** (lookup par companyName normalisé, aligné sur `uq_supplier_company_name_active`) ; rejouable sans doublon même purger désactivé.
- Référentiels comptables **réutilisés de M1** (tva_modes / payment_delays / payment_types / banks) — aucune nouvelle table.
- Données de démonstration **dev uniquement** : early return en env `test` (les tests seedent leurs propres données).

### Vérifications
- `make db-reset` : 13 fournisseurs (2 archivés), 19 contacts, 15 adresses, 3 RIB chargés sans erreur.
- Idempotence `--append` : compteurs inchangés.
- `make php-cs-fixer-allow-risky` : 0 fichier à corriger.
- `make test` : 574 tests OK.

Base : `feature/ERP-92-tests-phpunit-m2` (sommet de la pile M2).
---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #72
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 07:49:28 +00:00
45 changed files with 1643 additions and 359 deletions
+2 -2
View File
@@ -75,7 +75,7 @@ jobs:
- name: Bootstrap test database
# Aligne sur la cible `test-db-setup` du makefile : apres
# `schema:update --force`, on RECREE manuellement l'index unique
# partiel `uq_category_name_type_active` car Doctrine ORM ne sait
# partiel `uq_category_name_active` car Doctrine ORM ne sait
# pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE
# deleted_at IS NULL) et `schema:update` les considere comme
# orphelins et les DROP — collisions non detectees, tests d'unicite
@@ -89,7 +89,7 @@ jobs:
php bin/console app:apply-column-comments --env=test --no-interaction
php bin/console doctrine:fixtures:load --env=test --no-interaction
php bin/console app:sync-permissions --env=test --no-interaction
php bin/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"
php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_active ON category (LOWER(name)) WHERE deleted_at IS NULL"
- name: Run PHPUnit
run: php -d memory_limit=512M vendor/bin/phpunit
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.89'
app.version: '0.1.96'
+22 -12
View File
@@ -126,7 +126,7 @@ Toutes les entités métier nouvelles implémentent `TimestampableInterface` + `
Notes (miroir M1) :
- **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un fournisseur existant. Compta ne peut pas **créer** un fournisseur (pas de `manage` global).
- **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back, 2 niveaux : `security` API Platform + `SupplierProvider`).
- **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back). Mécanisme réel (le code fait foi) : le groupe de lecture `supplier:read:accounting` n'est **pas** dans le contexte de sérialisation par défaut ; le `SupplierReadGroupContextBuilder` ne l'**ajoute** dynamiquement que si l'utilisateur porte `commercial.suppliers.accounting.view` (gating **par ajout** de groupe, jamais par retrait). Sans la permission, les champs comptables (et les RIB) ne sont donc jamais sérialisés. La colonne SIREN de l'export XLSX suit la même règle (`accounting.view`).
- **Bureau** : `view` + `manage` (tout sauf Comptabilité).
- **Usine** : aucune permission → item sidebar invisible, accès direct 403.
@@ -159,9 +159,11 @@ final class SupplierFieldNormalizer
Le formatage `XX XX XX XX XX` est fait à l'affichage côté front. Le back stocke `0612345678` (chiffres seuls).
### 2.12 Liste : embed catégories + sites + fetch-joins (cohérence M1/ERP-62)
### 2.12 Liste : embed catégories + sites + hydratation anti-N+1 (cohérence M1/ERP-62)
Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis. Conséquence performance : le `DoctrineSupplierRepository` **DOIT** poser des **fetch-joins** (`leftJoin`+`addSelect`) sur `categories` et `addresses.sites` dans la requête de liste pour éviter le N+1. Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas.
Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis.
Conséquence performance — **implémentation réelle (le code fait foi)** : le `DoctrineSupplierRepository` **ne fetch-joine PAS** les to-many dans la requête de sélection (`createListQueryBuilder` ne fait que filtres + tri). L'anti-N+1 passe par `hydrateListCollections()` (puis `hydrateContacts()`) : une fois le jeu de fournisseurs borné (page ou export), des requêtes **`IN` bornées séparées** remplissent `categories`, puis `addresses.sites`, puis `contacts` sur les **mêmes** instances `Supplier` (identity map). Ce découpage évite le **produit cartésien** qu'un fetch-join combiné `categories × addresses.sites` imposerait aux chemins non paginés (export, `?pagination=false`). Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas.
> Dépendance confirmée sur le JSON réel (#82 mergé) : `Category` expose `code`/`name` sous `category:read` ; `Site` expose `name`/`postalCode`/`city`/`color` sous `site:read` (**pas de `code`**). L'embed est pleinement matérialisé.
@@ -213,6 +215,8 @@ Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrati
> **Rappel règle ABSOLUE n°12** : chaque colonne créée ci-dessous DOIT recevoir son `COMMENT ON COLUMN`. Les 4 colonnes Timestampable/Blamable passent par le helper `addStandardTimestampableBlamableComments($schema, '<table>')`. Le SQL ci-dessous montre la structure ; les `COMMENT ON COLUMN` (un par colonne métier) sont à écrire dans la migration (exemples §3.2.bis).
> **Types réels de la migration (le code fait foi)** : le SQL ci-dessous est *illustratif*. La migration mergée (`Version20260605130000`) utilise le **style aligné M1** : clés primaires en `INT GENERATED BY DEFAULT AS IDENTITY` (et **non** `SERIAL`) et horodatages en `TIMESTAMP(0) WITHOUT TIME ZONE` (et **non** `TIMESTAMPTZ`, car le `TimestampableBlamableTrait` mappe `datetime_immutable`). Garantit que `schema:update` reste un no-op une fois les entités mappées.
```sql
-- =====================================================================
-- Seed taxonomie : nouveau type FOURNISSEUR (référentiels comptables = M1, non recréés)
@@ -422,8 +426,10 @@ use Symfony\Component\Validator\Constraints as Assert;
// Cohérence M1/ERP-62 : la LISTE embarque catégories + sites (pas de
// champ dérivé aplati). Maillon (c) : category:read + site:read dans
// le contexte pour exposer Category(code/name) + Site(name/postalCode).
// ⚠ Le SupplierRepository DOIT fetch-join categories + addresses.sites
// pour éviter le N+1 sur la liste (cf. § 2.12).
// ⚠ Anti-N+1 : pas de fetch-join dans la requête de liste — le
// SupplierRepository hydrate categories/sites/contacts via des requêtes
// IN bornées séparées (hydrateListCollections), pour éviter le produit
// cartésien sur les chemins non paginés (export) — cf. § 2.12.
normalizationContext: ['groups' => [
'supplier:read',
'category:read',
@@ -442,13 +448,14 @@ use Symfony\Component\Validator\Constraints as Assert;
normalizationContext: ['groups' => [
'supplier:read',
'supplier:item:read', // embed contacts / addresses
'supplier:read:accounting', // scalaires compta + embed ribs (filtré par le Provider selon accounting.view)
// ⚠ supplier:read:accounting est volontairement ABSENT ici : il est
// AJOUTÉ dynamiquement par le SupplierReadGroupContextBuilder quand
// l'user porte accounting.view (gating par ajout, pas par retrait —
// parade bug #4 M1). Il porte les scalaires compta + l'embed ribs.
'category:read', // embed des Category (id/code/name) — relation imbriquée
'site:read', // embed des Site (id/name/postalCode/city/color, pas de code) — relation imbriquée
'default:read',
]],
// Le Provider RETIRE supplier:read:accounting du contexte si l'user
// n'a pas is_granted('commercial.suppliers.accounting.view').
provider: SupplierProvider::class,
),
new Post(
@@ -458,10 +465,13 @@ use Symfony\Component\Validator\Constraints as Assert;
processor: SupplierProcessor::class,
),
new Patch(
security: "is_granted('commercial.suppliers.manage')",
// Le SupplierProcessor inspecte les groupes envoyés pour autoriser
// onglet par onglet (cf. § 2.10 + § 5). Patch des champs comptables
// exige is_granted('commercial.suppliers.accounting.manage') ;
// Security élargie : `manage` OU `accounting.manage` — le rôle Compta
// n'a pas `manage` mais doit pouvoir éditer l'onglet Comptabilité d'un
// fournisseur existant (§ 2.9). Le SupplierProcessor re-gate ensuite
// onglet par onglet (mode strict RG-2.16) :
security: "is_granted('commercial.suppliers.manage') or is_granted('commercial.suppliers.accounting.manage')",
// Patch des champs comptables exige accounting.manage (guardAccounting) ;
// champs main/information exigent manage (guardManage) ;
// patch isArchived exige is_granted('commercial.suppliers.archive').
normalizationContext: ['groups' => ['supplier:read', 'default:read']],
denormalizationContext: ['groups' => [
+12 -5
View File
@@ -420,17 +420,24 @@
"noCategories": "Aucune catégorie pour l'instant.",
"table": {
"name": "Nom",
"type": "Type"
"types": "Types"
},
"filters": {
"title": "Filtres",
"search": "Recherche",
"types": "Types de catégorie",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
},
"form": {
"name": "Nom",
"type": "Type de catégorie",
"typePlaceholder": "Sélectionner un type"
"types": "Types de catégorie",
"typesPlaceholder": "Sélectionner un ou plusieurs types"
},
"validation": {
"nameRequired": "Le nom est obligatoire.",
"nameLength": "Le nom doit faire entre 2 et 120 caractères.",
"typeRequired": "Le type de catégorie est obligatoire."
"typesRequired": "Sélectionnez au moins un type de catégorie."
},
"delete": {
"title": "Supprimer la catégorie",
@@ -440,7 +447,7 @@
"created": "Catégorie créée avec succès",
"updated": "Catégorie mise à jour avec succès",
"deleted": "Catégorie supprimée avec succès",
"duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.",
"duplicate": "Une catégorie nommée « {name} » existe déjà.",
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
}
}
@@ -24,16 +24,18 @@
required
/>
<!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
number (categoryType id) ; conversion en IRI au moment du save
par le composable useCategoryForm. -->
<MalioSelect
v-model="form.categoryTypeId.value"
<!-- Types (RG-1.05 : au moins un obligatoire). MalioSelectCheckbox
porte un tableau d'ids (categoryType id) ; conversion en tableau
d'IRI au moment du save par le composable useCategoryForm. -->
<MalioSelectCheckbox
v-model="form.categoryTypeIds.value"
:options="typeOptions"
:label="t('admin.categories.form.type')"
:empty-option-label="t('admin.categories.form.typePlaceholder')"
:error="form.errors.categoryType"
:label="t('admin.categories.form.types')"
:empty-option-label="t('admin.categories.form.typesPlaceholder')"
:error="form.errors.categoryTypes"
:display-tag="true"
:disabled="loadingTypes"
required
/>
</form>
@@ -39,7 +39,7 @@ const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
const CAT: Category = {
id: 42,
name: 'Vis',
categoryType: TYPE_VENTE,
categoryTypes: [TYPE_VENTE],
deletedAt: null,
createdAt: '2026-01-01T10:00:00+00:00',
updatedAt: '2026-01-01T10:00:00+00:00',
@@ -58,25 +58,25 @@ describe('useCategoryForm', () => {
})
describe('loadFrom', () => {
it('pre-remplit le formulaire depuis une categorie existante', () => {
it('pre-remplit le formulaire depuis une categorie existante (multi-types)', () => {
const form = useCategoryForm()
form.loadFrom(CAT)
form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
expect(form.name.value).toBe('Vis')
expect(form.categoryTypeId.value).toBe(1)
expect(form.categoryTypeIds.value).toEqual([1, 2])
expect(form.errors).toEqual({})
})
it('vide le formulaire en mode creation (null)', () => {
const form = useCategoryForm()
form.name.value = 'old'
form.categoryTypeId.value = 99
form.categoryTypeIds.value = [99]
form.loadFrom(null)
expect(form.name.value).toBe('')
expect(form.categoryTypeId.value).toBeNull()
expect(form.categoryTypeIds.value).toEqual([])
})
it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
@@ -98,13 +98,32 @@ describe('useCategoryForm', () => {
expect(form.isDirty.value).toBe(true)
})
it('passe a true quand on ajoute un type (selection multi)', () => {
const form = useCategoryForm()
form.loadFrom(CAT)
expect(form.isDirty.value).toBe(false)
form.categoryTypeIds.value = [1, 2]
expect(form.isDirty.value).toBe(true)
})
it('reste false si la selection est identique dans un autre ordre', () => {
const form = useCategoryForm()
form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
form.categoryTypeIds.value = [2, 1]
expect(form.isDirty.value).toBe(false)
})
})
describe('validate', () => {
it('signale une erreur si name est vide (RG-1.02)', () => {
const form = useCategoryForm()
form.name.value = ''
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
const ok = form.validate()
@@ -115,7 +134,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name est whitespace-only (trim → vide)', () => {
const form = useCategoryForm()
form.name.value = ' '
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
const ok = form.validate()
@@ -126,7 +145,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
const form = useCategoryForm()
form.name.value = 'A'
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
const ok = form.validate()
@@ -137,7 +156,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
const form = useCategoryForm()
form.name.value = 'A'.repeat(121)
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
const ok = form.validate()
@@ -145,21 +164,21 @@ describe('useCategoryForm', () => {
expect(form.errors.name).toBe('admin.categories.validation.nameLength')
})
it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
it('signale erreur si aucun type selectionne (RG-1.05)', () => {
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = null
form.categoryTypeIds.value = []
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.categoryType).toBe('admin.categories.validation.typeRequired')
expect(form.errors.categoryTypes).toBe('admin.categories.validation.typesRequired')
})
it('passe quand name et categoryType sont valides', () => {
it('passe quand name et au moins un type sont valides', () => {
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1, 2]
const ok = form.validate()
@@ -171,7 +190,7 @@ describe('useCategoryForm', () => {
const form = useCategoryForm()
// Erreur prealable : une validation en echec peuple errors.name.
form.name.value = ''
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
form.validate()
expect(form.errors.name).toBeTruthy()
@@ -184,17 +203,17 @@ describe('useCategoryForm', () => {
})
describe('submitCreate', () => {
it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => {
it('appelle POST /categories avec body { name trimme, categoryTypes en IRI[] }', async () => {
mockPost.mockResolvedValueOnce(CAT)
const form = useCategoryForm()
form.name.value = ' Vis '
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1, 2]
const result = await form.submitCreate()
expect(mockPost).toHaveBeenCalledWith(
'/categories',
{ name: 'Vis', categoryType: '/api/category_types/1' },
{ name: 'Vis', categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
{ toast: false },
)
expect(result).toEqual(CAT)
@@ -203,7 +222,7 @@ describe('useCategoryForm', () => {
it('ne declenche aucun appel API si la validation client echoue', async () => {
const form = useCategoryForm()
form.name.value = ''
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
const result = await form.submitCreate()
@@ -215,7 +234,7 @@ describe('useCategoryForm', () => {
mockPost.mockResolvedValueOnce(CAT)
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
await form.submitCreate()
@@ -231,7 +250,7 @@ describe('useCategoryForm', () => {
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
const result = await form.submitCreate()
@@ -258,7 +277,7 @@ describe('useCategoryForm', () => {
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
const result = await form.submitCreate()
@@ -269,24 +288,24 @@ describe('useCategoryForm', () => {
expect(mockToastError).not.toHaveBeenCalled()
})
it('mappe aussi hydra:violations (negociation de format alternative)', async () => {
it('mappe une violation sur categoryTypes (hydra:violations alternative)', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: {
'hydra:violations': [
{ propertyPath: 'categoryType', message: 'Type invalide.' },
{ propertyPath: 'categoryTypes', message: 'Sélectionnez au moins un type de catégorie.' },
],
},
},
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
await form.submitCreate()
expect(form.errors.categoryType).toBe('Type invalide.')
expect(form.errors.categoryTypes).toBe('Sélectionnez au moins un type de catégorie.')
})
it('fallback en toast generique si le status n est ni 409 ni 422', async () => {
@@ -295,7 +314,7 @@ describe('useCategoryForm', () => {
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
await form.submitCreate()
@@ -314,7 +333,7 @@ describe('useCategoryForm', () => {
)
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
const pending = form.submitCreate()
expect(form.submitting.value).toBe(true)
@@ -331,28 +350,28 @@ describe('useCategoryForm', () => {
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
const form = useCategoryForm()
form.loadFrom(CAT)
form.name.value = 'Vis V2' // categoryTypeId inchange
form.name.value = 'Vis V2' // types inchanges
await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ name: 'Vis V2' }, // pas de categoryType car non modifie
{ name: 'Vis V2' }, // pas de categoryTypes car non modifies
{ toast: false },
)
})
it('envoie categoryType en IRI quand seul le type a change', async () => {
mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT })
it('envoie categoryTypes en IRI[] quand on ajoute un type', async () => {
mockPatch.mockResolvedValueOnce({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
const form = useCategoryForm()
form.loadFrom(CAT)
form.categoryTypeId.value = 2
form.categoryTypeIds.value = [1, 2]
await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ categoryType: '/api/category_types/2' },
{ categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
{ toast: false },
)
})
@@ -438,7 +457,7 @@ describe('useCategoryForm', () => {
form.reset()
expect(form.name.value).toBe('')
expect(form.categoryTypeId.value).toBeNull()
expect(form.categoryTypeIds.value).toEqual([])
expect(form.errors).toEqual({})
expect(form.submitting.value).toBe(false)
})
@@ -13,9 +13,10 @@
* revalide toujours (defense en profondeur).
*
* Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les
* violations 422 sont mappees par `propertyPath` (`name`, `categoryType`) ;
* violations 422 sont mappees par `propertyPath` (`name`, `categoryTypes`) ;
* l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon
* RG-1.07) reste un cas metier specifique : erreur inline sur `name` + toast.
* de nom GLOBAL, RG-1.07) reste un cas metier specifique : erreur inline sur
* `name` + toast.
*/
import { computed, ref } from 'vue'
import type { Category } from '~/modules/catalog/types/category'
@@ -42,20 +43,29 @@ export function useCategoryForm() {
// State local du formulaire — pas singleton, chaque appel a useCategoryForm
// cree son propre state (cohérent avec le pattern « un drawer = un form »).
const name = ref('')
const categoryTypeId = ref<number | null>(null)
const categoryTypeIds = ref<number[]>([])
// Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
// pattern view → edit du drawer (le bouton Enregistrer reste masque tant
// que rien n'a change en mode consultation).
const initialName = ref('')
const initialCategoryTypeId = ref<number | null>(null)
const initialCategoryTypeIds = ref<number[]>([])
const submitting = ref(false)
// Compare deux listes d'ids sans tenir compte de l'ordre (la selection
// multi-types n'est pas ordonnee).
function sameIds(a: number[], b: number[]): boolean {
if (a.length !== b.length) return false
const sortedA = [...a].sort((x, y) => x - y)
const sortedB = [...b].sort((x, y) => x - y)
return sortedA.every((v, i) => v === sortedB[i])
}
const isDirty = computed(
() =>
name.value !== initialName.value
|| categoryTypeId.value !== initialCategoryTypeId.value,
|| !sameIds(categoryTypeIds.value, initialCategoryTypeIds.value),
)
/**
@@ -66,15 +76,16 @@ export function useCategoryForm() {
function loadFrom(category: Category | null): void {
formErrors.clearErrors()
if (category) {
const ids = category.categoryTypes.map(t => t.id)
name.value = category.name
categoryTypeId.value = category.categoryType.id
categoryTypeIds.value = [...ids]
initialName.value = category.name
initialCategoryTypeId.value = category.categoryType.id
initialCategoryTypeIds.value = [...ids]
} else {
name.value = ''
categoryTypeId.value = null
categoryTypeIds.value = []
initialName.value = ''
initialCategoryTypeId.value = null
initialCategoryTypeIds.value = []
}
}
@@ -95,23 +106,23 @@ export function useCategoryForm() {
formErrors.setError('name', t('admin.categories.validation.nameLength'))
}
// RG-1.05 — categoryType obligatoire.
if (categoryTypeId.value === null) {
formErrors.setError('categoryType', t('admin.categories.validation.typeRequired'))
// RG-1.05 — au moins un type obligatoire.
if (categoryTypeIds.value.length === 0) {
formErrors.setError('categoryTypes', t('admin.categories.validation.typesRequired'))
}
return !formErrors.errors.name && !formErrors.errors.categoryType
return !formErrors.errors.name && !formErrors.errors.categoryTypes
}
/**
* Construit le payload POST a partir du state. Le `categoryType` est
* envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
* Platform pour referencer une ressource liee.
* Construit le payload POST a partir du state. Les `categoryTypes` sont
* envoyes en tableau d'IRI Hydra (`/api/category_types/{id}`) — convention
* API Platform pour referencer une collection de ressources liees.
*/
function buildCreatePayload(): Record<string, unknown> {
return {
name: name.value.trim(),
categoryType: `/api/category_types/${categoryTypeId.value}`,
categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`),
}
}
@@ -174,8 +185,8 @@ export function useCategoryForm() {
if (name.value !== initialName.value) {
payload.name = name.value.trim()
}
if (categoryTypeId.value !== initialCategoryTypeId.value) {
payload.categoryType = `/api/category_types/${categoryTypeId.value}`
if (!sameIds(categoryTypeIds.value, initialCategoryTypeIds.value)) {
payload.categoryTypes = categoryTypeIds.value.map(id => `/api/category_types/${id}`)
}
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
@@ -233,9 +244,9 @@ export function useCategoryForm() {
*/
function reset(): void {
name.value = ''
categoryTypeId.value = null
categoryTypeIds.value = []
initialName.value = ''
initialCategoryTypeId.value = null
initialCategoryTypeIds.value = []
formErrors.clearErrors()
submitting.value = false
}
@@ -243,7 +254,7 @@ export function useCategoryForm() {
return {
// State
name,
categoryTypeId,
categoryTypeIds,
errors: formErrors.errors,
submitting,
isDirty,
@@ -3,13 +3,28 @@
<PageHeader>
{{ t('admin.categories.title') }}
<template #actions>
<MalioButton
v-if="canManage"
:label="t('admin.categories.newCategory')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
<!-- gap-12 = 48px d'espacement entre Ajouter et Filtres (meme
design que le Repertoire Clients). -->
<div class="flex items-center gap-12">
<MalioButton
v-if="canManage"
:label="t('admin.categories.newCategory')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
<!-- Bouton Filtres a DROITE d'Ajouter. Le compteur reflete
les filtres actifs. -->
<MalioButton
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
button-class="w-[184px] justify-start gap-4 text-black"
@click="openFilters"
/>
</div>
</template>
</PageHeader>
@@ -47,6 +62,60 @@
:loading="deleting"
@confirm="handleDelete"
/>
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
« Appliquer ». Meme pattern que le Repertoire Clients. Etat 100 %
local, jamais dans l'URL (regle ABSOLUE n°6). -->
<MalioDrawer
v-model="filterDrawerOpen"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between border-t border-black p-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('admin.categories.filters.title') }}</h2>
</template>
<MalioAccordion>
<!-- Recherche par nom (param `name`, partiel insensible a la casse). -->
<MalioAccordionItem :title="t('admin.categories.filters.search')" value="search">
<MalioInputText
v-model="draftSearch"
icon-name="mdi:magnify"
/>
</MalioAccordionItem>
<!-- Type(s) : cases a cocher (multi). Une categorie remonte si
elle porte AU MOINS UN des types coches (OR cote back). -->
<MalioAccordionItem :title="t('admin.categories.filters.types')" value="types">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in typeFilterOptions"
:id="`filter-type-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftTypeIds.includes(opt.value)"
@update:model-value="(val: boolean) => toggleType(opt.value, val)"
/>
</div>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
variant="tertiary"
:label="t('admin.categories.filters.reset')"
button-class="w-m-btn-action"
@click="resetFilters"
/>
<MalioButton
variant="primary"
:label="t('admin.categories.filters.apply')"
button-class="w-[170px]"
@click="applyFilters"
/>
</template>
</MalioDrawer>
</div>
</template>
@@ -55,7 +124,7 @@ import type { Category } from '~/modules/catalog/types/category'
const { t } = useI18n()
const { can } = usePermissions()
const { fetchTypes } = useCategoriesAdmin()
const { types, fetchTypes } = useCategoriesAdmin()
const { submitDelete } = useCategoryForm()
useHead({ title: t('admin.categories.title') })
@@ -74,6 +143,7 @@ const {
fetch: fetchCategories,
goToPage,
setItemsPerPage,
setFilters,
} = usePaginatedList<Category>({ url: '/categories' })
const drawerOpen = ref(false)
@@ -82,21 +152,96 @@ const deleteModalOpen = ref(false)
const categoryToDelete = ref<Category | null>(null)
const deleting = ref(false)
// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) —
// on aplatit en label lisible pour l'affichage.
// Colonnes du datatable. Les types sont embarques cote API (ManyToMany) — on
// aplatit en libelles joints par une virgule pour l'affichage.
const columns = [
{ key: 'name', label: t('admin.categories.table.name') },
{ key: 'typeLabel', label: t('admin.categories.table.type') },
{ key: 'typesLabel', label: t('admin.categories.table.types') },
]
const categoryItems = computed(() =>
categories.value.map(cat => ({
id: cat.id,
name: cat.name,
typeLabel: cat.categoryType?.label ?? '',
typesLabel: (cat.categoryTypes ?? []).map(ct => ct.label).join(', '),
})),
)
// ── Filtres (drawer) ────────────────────────────────────────────────────────
// Deux niveaux d'etat (pattern Repertoire Clients) :
// - APPLIED : pilote la liste + le compteur du bouton. Modifie uniquement au
// clic « Appliquer » / « Réinitialiser ».
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftTypeIds = ref<number[]>([])
const appliedSearch = ref('')
const appliedTypeIds = ref<number[]>([])
// Options du filtre Type(s), derivees du referentiel deja charge (fetchTypes).
const typeFilterOptions = computed(() =>
types.value.map(ct => ({ value: ct.id, label: ct.label })),
)
const activeFilterCount = computed(() => {
let count = 0
if (appliedSearch.value.trim() !== '') count++
if (appliedTypeIds.value.length > 0) count++
return count
})
const filterButtonLabel = computed(() => {
const base = t('admin.categories.filters.title')
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
})
// Recopie l'etat applique vers le brouillon puis ouvre le drawer.
function openFilters(): void {
draftSearch.value = appliedSearch.value
draftTypeIds.value = [...appliedTypeIds.value]
filterDrawerOpen.value = true
}
function toggleType(id: number, selected: boolean): void {
draftTypeIds.value = selected
? [...draftTypeIds.value, id]
: draftTypeIds.value.filter(t => t !== id)
}
/**
* Construit le payload de filtres serveur a partir de l'etat applique. Cle
* `typeId[]` pour que PHP la parse en tableau (OR cote back). Filtres vides omis.
*/
function buildFilterPayload(): Record<string, string | string[]> {
const payload: Record<string, string | string[]> = {}
if (appliedSearch.value.trim() !== '') payload.name = appliedSearch.value.trim()
if (appliedTypeIds.value.length > 0) payload['typeId[]'] = appliedTypeIds.value.map(String)
return payload
}
// « Appliquer » : recopie brouillon → applied, pousse les filtres (retombe en
// page 1 via usePaginatedList) et ferme le drawer.
function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedTypeIds.value = [...draftTypeIds.value]
setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false
}
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
// Le drawer reste ouvert pour montrer le formulaire vide.
function resetFilters(): void {
draftSearch.value = ''
draftTypeIds.value = []
appliedSearch.value = ''
appliedTypeIds.value = []
setFilters({}, { replace: true })
}
function getCategoryById(id: number): Category | undefined {
return categories.value.find(c => c.id === id)
}
+11 -10
View File
@@ -4,15 +4,15 @@
* Contrats API consommes :
* - GET /api/categories → HydraCollection<Category>
* - GET /api/categories/{id} → Category
* - POST /api/categories → body { name, categoryType: IRI }
* - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI }
* - POST /api/categories → body { name, categoryTypes: IRI[] }
* - PATCH /api/categories/{id} → body partiel { name?, categoryTypes?: IRI[] }
* - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor)
* - GET /api/category_types → HydraCollection<CategoryType>
*
* Notes :
* - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3").
* - `categoryType` est embarque (groupe Serializer `category:read` sur les
* proprietes de CategoryType, cf. spec-back § 3.4).
* - Les IRI sont envoyes en POST/PATCH (ex. ["/api/category_types/3"]).
* - `categoryTypes` est embarque (groupe Serializer `category:read` sur les
* proprietes de CategoryType) : tableau d'objets type en lecture.
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
*/
@@ -43,7 +43,8 @@ export interface CategoryType {
export interface Category {
id: number
name: string
categoryType: CategoryType
/** Types de la categorie (>= 1, ManyToMany embarque en lecture). */
categoryTypes: CategoryType[]
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
deletedAt: string | null
createdAt: string
@@ -53,12 +54,12 @@ export interface Category {
}
/**
* Payload accepte en POST /api/categories. `categoryType` est envoye en
* IRI Hydra (ex. `/api/category_types/3`).
* Payload accepte en POST /api/categories. `categoryTypes` est un tableau
* d'IRI Hydra (ex. `['/api/category_types/3', '/api/category_types/5']`).
*/
export interface CategoryCreateInput {
name: string
categoryType: string
categoryTypes: string[]
}
/**
@@ -67,5 +68,5 @@ export interface CategoryCreateInput {
*/
export interface CategoryUpdateInput {
name?: string
categoryType?: string
categoryTypes?: string[]
}
+3 -2
View File
@@ -207,7 +207,8 @@ migration-migrate:
# orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas
# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc
# ils disparaissent apres schema:update. On les recree par dbal:run-sql :
# - `uq_category_name_type_active` (M0 Catalog) : tests RG-1.07.
# - `uq_category_name_active` (M0 Catalog) : unicite GLOBALE du nom parmi
# les actifs (M:N categorie<->type), tests RG-1.07.
# - `uq_category_code` (Catalog ERP-78) : unicite du code categorie parmi
# les actifs (slug du nom), pilote RG-1.03/1.29.
# - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe
@@ -226,7 +227,7 @@ test-db-setup:
$(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_category_name_active ON category (LOWER(name)) WHERE deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) 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"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
+8 -8
View File
@@ -82,14 +82,14 @@ final class Version20260605130000 extends AbstractMigration
// Ordre inverse des dependances FK : jointures et sous-collections
// d'abord, puis supplier. Les referentiels comptables et le
// CategoryType FOURNISSEUR ne sont pas touches (crees ailleurs).
$this->addSql('DROP TABLE supplier_address_category');
$this->addSql('DROP TABLE supplier_address_contact');
$this->addSql('DROP TABLE supplier_address_site');
$this->addSql('DROP TABLE supplier_rib');
$this->addSql('DROP TABLE supplier_address');
$this->addSql('DROP TABLE supplier_contact');
$this->addSql('DROP TABLE supplier_category');
$this->addSql('DROP TABLE supplier');
$this->addSql('DROP TABLE IF EXISTS supplier_address_category');
$this->addSql('DROP TABLE IF EXISTS supplier_address_contact');
$this->addSql('DROP TABLE IF EXISTS supplier_address_site');
$this->addSql('DROP TABLE IF EXISTS supplier_rib');
$this->addSql('DROP TABLE IF EXISTS supplier_address');
$this->addSql('DROP TABLE IF EXISTS supplier_contact');
$this->addSql('DROP TABLE IF EXISTS supplier_category');
$this->addSql('DROP TABLE IF EXISTS supplier');
}
// =================================================================
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Catalog — Category multi-types : passage de la relation Category -> CategoryType
* de ManyToOne a ManyToMany.
*
* Ordre critique :
* 1. Creation de la table de jonction `category_category_type` (FK category ON
* DELETE CASCADE, FK category_type ON DELETE RESTRICT — conserve le garde-fou
* « on ne supprime pas un type encore reference »).
* 2. Backfill : chaque categorie existante recoit une ligne de jonction vers son
* ancien `category_type_id` (avant de dropper la colonne).
* 3. Drop de l'index unique (LOWER(name), category_type_id), de l'index FK et de
* la colonne `category.category_type_id` (Postgres drope la FK dependante).
* 4. Nouvel index unique GLOBAL sur le nom : LOWER(name) WHERE deleted_at IS NULL
* (l'unicite n'est plus liee au type — RG-1.07 reformulee).
*
* Sur base fraiche, les categories seedees CLIENT (Distributeur/Courtier/Secteur/
* Autre) et FOURNISSEUR (Negociant/Cooperative/...) n'ont aucun nom en collision
* -> l'index unique global passe sans conflit.
*
* Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) :
* Doctrine Migrations 3.x trie par FQCN puis version ; le namespace racine garantit
* l'ordre par timestamp apres les migrations d'init des tables.
*/
final class Version20260608120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Catalog : Category <-> CategoryType en ManyToMany (jonction category_category_type), unicite du nom globalisee.';
}
public function up(Schema $schema): void
{
// 1. Table de jonction.
$this->addSql(<<<'SQL'
CREATE TABLE category_category_type (
category_id INT NOT NULL,
category_type_id INT NOT NULL,
PRIMARY KEY (category_id, category_type_id),
CONSTRAINT fk_category_category_type_category
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE CASCADE,
CONSTRAINT fk_category_category_type_type
FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT
)
SQL);
$this->addSql('CREATE INDEX idx_cat_cat_type_type ON category_category_type (category_type_id)');
$this->comment('category_category_type', '_table', 'Jointure M2M category <-> category_type (Catalog) — types portes par la categorie, au moins un obligatoire (RG-1.05).');
$this->comment('category_category_type', 'category_id', 'FK -> category.id, ON DELETE CASCADE — categorie portant le type.');
$this->comment('category_category_type', 'category_type_id', 'FK -> category_type.id, ON DELETE RESTRICT — type rattache (un type ne peut etre supprime tant qu il reste reference).');
// 2. Backfill depuis l'ancienne colonne ManyToOne (chaque categorie -> 1 type).
$this->addSql(<<<'SQL'
INSERT INTO category_category_type (category_id, category_type_id)
SELECT id, category_type_id FROM category
SQL);
// 3. Suppression de l'ancien modele : index unique par type, index FK, colonne.
$this->addSql('DROP INDEX uq_category_name_type_active');
$this->addSql('DROP INDEX idx_category_type_id');
// DROP COLUMN drope automatiquement la FK fk_category_type qui en depend.
$this->addSql('ALTER TABLE category DROP COLUMN category_type_id');
// 4. Unicite du nom desormais GLOBALE parmi les actifs (RG-1.07 reformulee).
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX uq_category_name_active
ON category (LOWER(name))
WHERE deleted_at IS NULL
SQL);
// Realignement de la doc SQL de `category` (le type n'est plus une colonne).
$this->comment('category', '_table', 'Categories — referentiel multi-types via la jonction category_category_type, soft-delete via deleted_at, unicite LOWER(name) GLOBALE parmi les actifs (uq_category_name_active).');
$this->comment('category', 'name', 'Libelle de la categorie (≤ 120 caracteres) — unique GLOBALEMENT parmi les actifs (RG-1.07, uq_category_name_active).');
}
public function down(Schema $schema): void
{
// Restauration best-effort de l'ancien modele ManyToOne (1 type par categorie).
$this->addSql('DROP INDEX IF EXISTS uq_category_name_active');
$this->addSql('ALTER TABLE category ADD COLUMN category_type_id INT DEFAULT NULL');
// Reprend le premier type de chaque categorie (l'ordre des types perdus
// au-dela du premier est best-effort : le modele cible n'en gardait qu'un).
$this->addSql(<<<'SQL'
UPDATE category c
SET category_type_id = (
SELECT cct.category_type_id
FROM category_category_type cct
WHERE cct.category_id = c.id
ORDER BY cct.category_type_id ASC
LIMIT 1
)
SQL);
// Categories sans aucun type (theorique) : on les rattache a defaut au
// premier type existant pour pouvoir reposer le NOT NULL.
$this->addSql(<<<'SQL'
UPDATE category
SET category_type_id = (SELECT id FROM category_type ORDER BY id ASC LIMIT 1)
WHERE category_type_id IS NULL
SQL);
$this->addSql('ALTER TABLE category ALTER COLUMN category_type_id SET NOT NULL');
$this->addSql(<<<'SQL'
ALTER TABLE category
ADD CONSTRAINT fk_category_type
FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT
SQL);
$this->addSql('CREATE INDEX idx_category_type_id ON category (category_type_id)');
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX uq_category_name_type_active
ON category (LOWER(name), category_type_id)
WHERE deleted_at IS NULL
SQL);
$this->addSql('DROP TABLE category_category_type');
}
/**
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN`
* en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
+54 -20
View File
@@ -19,14 +19,18 @@ use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Categorie : referentiel metier classifiant les futurs tiers (clients,
* fournisseurs, prestataires). Porte un `name` libre et un `categoryType`
* (FK vers le referentiel statique CategoryType).
* fournisseurs, prestataires). Porte un `name` libre et un ou plusieurs
* `categoryTypes` (ManyToMany vers le referentiel statique CategoryType,
* table de jonction `category_category_type`). Une categorie peut appartenir
* a plusieurs types simultanement (>= 1 obligatoire, RG-1.05).
*
* - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par
* defaut les categories supprimees (cf. CategoryProvider, ticket 0.3).
@@ -81,12 +85,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
#[ORM\Table(name: 'category')]
// Index nommes pour matcher la migration (cf. Role/Permission/Site). Les index
// uniques partiels `uq_category_name_type_active` (LOWER(name), category_type_id
// WHERE deleted_at IS NULL) et `uq_category_code` (code WHERE deleted_at IS NULL)
// restent possedes par la seule migration : Doctrine ORM ne sait pas exprimer un
// index partiel via attribut.
// uniques partiels `uq_category_name_active` (LOWER(name) WHERE deleted_at IS
// NULL — unicite GLOBALE du nom parmi les actifs) et `uq_category_code` (code
// WHERE deleted_at IS NULL) restent possedes par la seule migration : Doctrine
// ORM ne sait pas exprimer un index partiel via attribut.
#[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_category_type_id', columns: ['category_type_id'])]
#[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
#[Auditable]
@@ -126,11 +129,21 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
#[Groups(['category:read'])]
private ?string $code = null;
#[ORM\ManyToOne(targetEntity: CategoryType::class)]
#[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
/**
* Types de la categorie (>= 1 obligatoire, RG-1.05). ManyToMany vers le
* referentiel statique CategoryType via la jonction `category_category_type`.
* Cote inverse (category_type) en ON DELETE RESTRICT : un type ne peut etre
* supprime tant qu'il reste reference par une categorie.
*
* @var Collection<int, CategoryType>
*/
#[ORM\ManyToMany(targetEntity: CategoryType::class)]
#[ORM\JoinTable(name: 'category_category_type')]
#[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'category_type_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de catégorie.')]
#[Groups(['category:read', 'category:write'])]
private ?CategoryType $categoryType = null;
private Collection $categoryTypes;
/**
* Soft delete : null = active, valeur = supprimee logiquement le {date}.
@@ -141,6 +154,11 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
#[Groups(['category:read'])]
private ?DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->categoryTypes = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
@@ -173,26 +191,42 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
return $this;
}
public function getCategoryType(): ?CategoryType
/**
* @return Collection<int, CategoryType>
*/
public function getCategoryTypes(): Collection
{
return $this->categoryType;
return $this->categoryTypes;
}
public function setCategoryType(?CategoryType $categoryType): static
public function addCategoryType(CategoryType $categoryType): static
{
$this->categoryType = $categoryType;
if (!$this->categoryTypes->contains($categoryType)) {
$this->categoryTypes->add($categoryType);
}
return $this;
}
public function removeCategoryType(CategoryType $categoryType): static
{
$this->categoryTypes->removeElement($categoryType);
return $this;
}
/**
* Implemente CategoryInterface : code du type rattache (ou null). Permet
* aux modules tiers de filtrer/valider par type metier sans dependre de
* Catalog.
* Implemente CategoryInterface : liste des codes de types rattaches a la
* categorie. Permet aux modules tiers de filtrer/valider par type metier
* (ex: RG-2.10 « contient FOURNISSEUR ») sans dependre de Catalog.
*
* @return list<string>
*/
public function getCategoryTypeCode(): ?string
public function getCategoryTypeCodes(): array
{
return $this->categoryType?->getCode();
return array_values(array_filter(
$this->categoryTypes->map(static fn (CategoryType $t): ?string => $t->getCode())->toArray(),
));
}
public function getDeletedAt(): ?DateTimeImmutable
@@ -23,10 +23,26 @@ interface CategoryRepositoryInterface
/**
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
* - $typeCode non null : ne garde que les categories dont le CategoryType
* porte ce code (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au
* multi-select Categorie du fournisseur (M2, RG-2.10).
* - $typeCode non null : ne garde que les categories PORTANT ce code de type
* (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au multi-select
* Categorie du fournisseur (M2, RG-2.10).
* - $nameSearch non null : recherche partielle case-insensitive sur le nom
* (filtre `?name=` de la liste admin).
* - $typeIds non vide : ne garde que les categories portant AU MOINS UN des
* types (OR, filtre `?typeId[]=` de la liste admin).
* - Tri : name ASC (RG-1.10).
*
* Les categories etant en ManyToMany avec leurs types, la collection
* `categoryTypes` est eager-loadee (addSelect) pour eviter un N+1 a la
* serialisation, et `distinct` est applique des qu'un filtre type joint la
* table de jonction (evite les lignes dupliquees).
*
* @param list<int> $typeIds
*/
public function createListQueryBuilder(bool $includeDeleted = false, ?string $typeCode = null): QueryBuilder;
public function createListQueryBuilder(
bool $includeDeleted = false,
?string $typeCode = null,
?string $nameSearch = null,
array $typeIds = [],
): QueryBuilder;
}
@@ -22,8 +22,8 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
* via CategoryCodeGenerator ; puis delegation au persist_processor Doctrine
* ORM. Le code est FIGE a la creation (jamais recalcule sur PATCH). Toute
* UniqueConstraintViolationException remontee par Postgres (collision sur
* l'index partiel uq_category_name_type_active) est traduite en HTTP 409 avec
* le message attendu par la spec (RG-1.07).
* l'index partiel uq_category_name_active — unicite GLOBALE du nom parmi les
* actifs) est traduite en HTTP 409 avec le message attendu par la spec (RG-1.07).
* - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ;
* on pose deletedAt = now() puis on delegue au persist_processor pour que
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
@@ -78,10 +78,12 @@ final class CategoryProcessor implements ProcessorInterface
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// RG-1.07 : doublon (LOWER(name), category_type_id) parmi les non-soft-deleted.
// RG-1.07 : doublon de nom GLOBAL (LOWER(name)) parmi les non-soft-deleted
// (uq_category_name_active). L'unicite n'est plus liee au type depuis le
// passage en ManyToMany.
throw new HttpException(
409,
sprintf('Une catégorie nommée "%s" existe déjà pour ce type.', $data->getName() ?? ''),
sprintf('Une catégorie nommée "%s" existe déjà.', $data->getName() ?? ''),
$e,
);
}
@@ -40,7 +40,12 @@ final class CategoryProvider implements ProviderInterface
$includeDeleted = $this->readIncludeDeleted($context);
if ($operation instanceof CollectionOperationInterface) {
$qb = $this->repository->createListQueryBuilder($includeDeleted, $this->readTypeCode($context));
$qb = $this->repository->createListQueryBuilder(
$includeDeleted,
$this->readTypeCode($context),
$this->readNameSearch($context),
$this->readTypeIds($context),
);
// Echappatoire ?pagination=false : retourne la collection complete sans Paginator.
// Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un <select>.
@@ -115,4 +120,48 @@ final class CategoryProvider implements ProviderInterface
return '' === $raw ? null : $raw;
}
/**
* Lit le filtre `?name=` (recherche partielle sur le nom, liste admin).
* Renvoie la valeur trimmee ou null si absente / vide.
*/
private function readNameSearch(array $context): ?string
{
$raw = $context['filters']['name'] ?? null;
if (!is_string($raw)) {
return null;
}
$raw = trim($raw);
return '' === $raw ? null : $raw;
}
/**
* Lit le filtre `?typeId[]=` (liste admin) : ids des types coches (OR).
* Tolere une valeur scalaire unique (`?typeId=3`) ou un tableau. Ignore
* les entrees non numeriques.
*
* @return list<int>
*/
private function readTypeIds(array $context): array
{
$raw = $context['filters']['typeId'] ?? null;
if (null === $raw) {
return [];
}
$values = is_array($raw) ? $raw : [$raw];
$ids = [];
foreach ($values as $value) {
if (is_int($value) || (is_string($value) && ctype_digit($value))) {
$ids[] = (int) $value;
}
}
return array_values(array_unique($ids));
}
}
@@ -138,7 +138,7 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
$category = new Category();
$category->setName($name);
$category->setCode($code);
$category->setCategoryType($type);
$category->addCategoryType($type);
$manager->persist($category);
}
}
@@ -48,9 +48,19 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
return [] !== $qb->getQuery()->getResult();
}
public function createListQueryBuilder(bool $includeDeleted = false, ?string $typeCode = null): QueryBuilder
{
public function createListQueryBuilder(
bool $includeDeleted = false,
?string $typeCode = null,
?string $nameSearch = null,
array $typeIds = [],
): QueryBuilder {
// Eager-load de la collection categoryTypes (ManyToMany) : embarquee a la
// serialisation -> on la fetch-joine pour eviter un N+1 par categorie. Le
// provider enveloppe la requete dans un Paginator(fetchJoinCollection: true),
// compatible avec ce fetch-join to-many.
$qb = $this->createQueryBuilder('c')
->leftJoin('c.categoryTypes', 'cte')
->addSelect('cte')
->orderBy('c.name', 'ASC')
;
@@ -58,16 +68,45 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
$qb->andWhere('c.deletedAt IS NULL');
}
// Filtre `?typeCode=` : jointure sur le CategoryType pour ne garder que
// les categories du type demande (ex. FOURNISSEUR). La jointure reste
// compatible avec le Paginator ORM (fetchJoinCollection) du provider.
// Filtre `?typeCode=` : la categorie doit PORTER ce code de type (RG-2.10,
// multi-select fournisseur). Sous-requete EXISTS correlee pour ne PAS
// restreindre la collection eager-loadee `cte` (sinon les autres types de
// la categorie disparaitraient du JSON) et eviter les lignes dupliquees.
if (null !== $typeCode) {
$qb->join('c.categoryType', 'ct')
->andWhere('ct.code = :typeCode')
$sub = $this->getEntityManager()->createQueryBuilder()
->select('1')
->from(Category::class, 'c_tc')
->join('c_tc.categoryTypes', 'ct_tc')
->where('c_tc = c')
->andWhere('ct_tc.code = :typeCode')
;
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
->setParameter('typeCode', $typeCode)
;
}
// Filtre `?typeId[]=` (liste admin) : la categorie porte AU MOINS UN des
// types coches (OR). Meme strategie EXISTS correlee que `typeCode`.
if ([] !== $typeIds) {
$sub = $this->getEntityManager()->createQueryBuilder()
->select('1')
->from(Category::class, 'c_ti')
->join('c_ti.categoryTypes', 'ct_ti')
->where('c_ti = c')
->andWhere('ct_ti.id IN (:typeIds)')
;
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
->setParameter('typeIds', $typeIds)
;
}
// Filtre `?name=` (liste admin) : recherche partielle case-insensitive.
if (null !== $nameSearch && '' !== $nameSearch) {
$qb->andWhere('LOWER(c.name) LIKE :nameSearch')
->setParameter('nameSearch', '%'.mb_strtolower($nameSearch).'%')
;
}
return $qb;
}
}
@@ -43,7 +43,10 @@ final class ClientInformationCompletenessValidator
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
sprintf('Ce champ est obligatoire pour le role Commerciale (champ "%s").', $property),
// Pas de nom de champ technique dans le message : la violation est
// deja rattachee au bon champ via son propertyPath (mappe inline
// cote front par useFormErrors).
'Ce champ est obligatoire pour le rôle Commerciale.',
null,
[],
$client,
@@ -47,7 +47,10 @@ final class SupplierInformationCompletenessValidator
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),
// Pas de nom de champ technique dans le message : la violation est
// deja rattachee au bon champ via son propertyPath (mappe inline
// cote front par useFormErrors).
'Ce champ est obligatoire pour le rôle Commerciale.',
null,
[],
$supplier,
@@ -188,7 +188,7 @@ class Client implements TimestampableInterface, BlamableInterface
private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client:read', 'client:write:information'])]
private ?string $competitors = null;
@@ -135,9 +135,9 @@ class Supplier implements TimestampableInterface, BlamableInterface
use TimestampableBlamableTrait;
/**
* RG-2.10 : seules les categories de ce type sont autorisees sur le
* RG-2.10 : seules les categories PORTANT ce type sont autorisees sur le
* fournisseur (entite principale). Miroir de SupplierAddress (ERP-88).
* S'appuie sur CategoryInterface::getCategoryTypeCode() (pas d'import du
* S'appuie sur CategoryInterface::getCategoryTypeCodes() (pas d'import du
* module Catalog — regle ABSOLUE n°1).
*/
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
@@ -181,7 +181,7 @@ class Supplier implements TimestampableInterface, BlamableInterface
private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['supplier:read', 'supplier:write:information'])]
private ?string $competitors = null;
@@ -300,16 +300,17 @@ class Supplier implements TimestampableInterface, BlamableInterface
* 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.
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
* acceptee des qu'elle PORTE le type FOURNISSEUR ; 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()) {
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
->atPath('categories')
->addViolation()
@@ -108,9 +108,9 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU'];
/**
* RG-2.10 : seules les categories de ce type sont autorisees sur une adresse
* fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCode() (pas
* d'import du module Catalog — regle ABSOLUE n°1).
* RG-2.10 : seules les categories PORTANT ce type sont autorisees sur une
* adresse fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes()
* (pas d'import du module Catalog — regle ABSOLUE n°1).
*/
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
@@ -219,15 +219,16 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
* RG-2.10 : toute categorie posee sur une adresse fournisseur doit etre de
* type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
* CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog —
* regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
* acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
*/
#[Assert\Callback]
public function validateCategoryType(ExecutionContextInterface $context): void
{
foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface
&& self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) {
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
->atPath('categories')
->addViolation()
@@ -121,6 +121,12 @@ final class ClientProcessor implements ProcessorInterface
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// Reinitialisation de la memoisation du payload en debut de traitement :
// le service est partage (stateful), on repart du corps de LA requete
// courante et on n'herite jamais des cles decodees d'une requete passee.
$this->decodedContent = null;
$this->decodedPayloadKeys = [];
$writableKeys = $this->writablePayloadKeys();
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
@@ -114,6 +114,12 @@ final class SupplierProcessor implements ProcessorInterface
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// Reinitialisation de la memoisation du payload en debut de traitement :
// le service est partage (stateful), on repart du corps de LA requete
// courante et on n'herite jamais des cles decodees d'une requete passee.
$this->decodedContent = null;
$this->decodedPayloadKeys = [];
$writableKeys = $this->writablePayloadKeys();
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
@@ -57,6 +57,11 @@ final class SupplierExportController
#[IsGranted('commercial.suppliers.view')]
public function __invoke(Request $request): Response
{
// Memes filtres d'archivage que la vue liste (SupplierProvider) pour que
// l'export reflete exactement ce que l'utilisateur voit a l'ecran :
// - includeArchived : inclut les archives en plus des actifs ;
// - archivedOnly : restreint aux seules archives (prioritaire, cf.
// createListQueryBuilder).
$includeArchived = $this->readBool($request->query->get('includeArchived'));
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
$search = $request->query->getString('search') ?: null;
@@ -0,0 +1,525 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\DataFixtures;
use App\Module\Catalog\Infrastructure\DataFixtures\CategoryFixtures;
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Domain\Entity\SupplierAddress;
use App\Module\Commercial\Domain\Entity\SupplierContact;
use App\Module\Commercial\Domain\Entity\SupplierRib;
use App\Module\Commercial\Domain\Entity\TvaMode;
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 : ~13 fournisseurs de demonstration
* couvrant l'ensemble des cas metier RG-2.xx du repertoire fournisseurs (M2),
* jumelles des fixtures Client (ERP-68). Theme metier : negoce / recyclage de
* metaux (d'ou les champs `bennes` et `triageProvider` sur les adresses).
*
* Cas pivots couverts (criteres d'acceptation ERP-112) :
* - reglement VIREMENT avec banque renseignee (RG-2.07) ;
* - reglement LCR avec 1 puis 2 RIB (RG-2.08) ; CHEQUE et NON_SOUMISE sans RIB ;
* - adresses multi-types PROSPECT / DEPART / RENDU (RG-2.09) et multi-sites
* (86 / 17 / 82, RG-2.06) ; bennes + prestataire de triage ;
* - 1 a 3 contacts dont un avec telephone secondaire et un nomme par le seul
* nom (RG-2.04) ;
* - 2 fournisseurs archives (isArchived + archivedAt) pour l'exclusion de la
* liste (RG-2.17) ;
* - mono et multi-categories de type FOURNISSEUR (RG-2.10) ;
* - onglet Information complet (dont volumeForecast, specifique fournisseur).
*
* 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 SupplierFieldNormalizer avant persist, exactement
* comme le ferait le SupplierProcessor via l'API (companyName UPPERCASE,
* first/last Capitalize, telephones chiffres seuls, emails lowercase).
*
* Coherence gating comptable (RG-2.16) : les scalaires comptables (siren,
* tvaMode, paymentType, bank...) et les RIB ne sont visibles qu'avec
* accounting.view. Les donnees sont posees pour que les roles SANS cette
* permission (ex. Commerciale) ne voient pas de compta — support des tests
* ERP-92 et du golden path front.
*
* Idempotence : lookup par companyName normalise (coherent avec l'index unique
* partiel uq_supplier_company_name_active). Un fournisseur deja present n'est pas
* reconstruit (ses sous-collections ne sont pas redupliquees). Rejouable sans
* doublon meme si le purger Doctrine est desactive.
*
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
* restent null (« Systeme » cote front), c'est attendu. Les donnees respectent
* les CHECK BDD (chk_supplier_contact_name : firstName OU lastName ;
* chk_supplier_address_type : PROSPECT | DEPART | RENDU) ET la coherence des
* validators d'entite (RG-2.07/2.08 : VIREMENT => banque, LCR => >= 1 RIB).
*
* Depend de CategoryFixtures (categories FOURNISSEUR), SitesFixtures (sites) et
* CommercialReferentialFixtures (referentiels comptables — REUTILISES de M1,
* aucune nouvelle table).
*
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`,
* la fixture ne charge rien : les tests seedent et nettoient leurs propres
* fournisseurs et comptent sur une table `supplier` vierge — y injecter 13
* fournisseurs de demo casserait les comptages de liste et les cleanups. Meme
* garde-fou que ClientFixtures / CategoryFixtures.
*/
class SupplierFixtures extends Fixture implements DependentFixtureInterface
{
/**
* Type de categorie exige pour un fournisseur et ses adresses (RG-2.10).
* Miroir de Supplier::REQUIRED_CATEGORY_TYPE_CODE (non importable — regle n°1).
*/
private const string SUPPLIER_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
/** Cache des categories resolues par nom (evite des requetes repetees). */
private array $categoryCache = [];
/** Cache des sites resolus par nom. */
private array $siteCache = [];
/** ObjectManager courant, capture en debut de load (resolution categories). */
private ObjectManager $manager;
public function __construct(
private readonly SupplierFieldNormalizer $normalizer,
private readonly SiteProviderInterface $siteProvider,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
) {}
/**
* @return array<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;
// === Fournisseur basique — VIREMENT + banque (RG-2.07), compta complete ===
[$negoce, $isNew] = $this->ensureSupplier($manager, 'Négoce Métaux Atlantique', ['Négociant']);
if ($isNew) {
$negoce->setSiren('841611054');
$negoce->setAccountNumber('F0001');
$negoce->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$negoce->setNTva('FR12841611054');
$negoce->setPaymentDelay($this->paymentDelay($manager, 'J30'));
$negoce->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$negoce->setBank($this->bank($manager, 'SG'));
$this->addContact($negoce, 'Jean', 'Dubois', 'Responsable achats', '05 49 00 00 01', null, 'jean.dubois@negoce-metaux.fr');
$this->addAddress($negoce, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '12 rue de la Ferraille', bennes: 4, triageProvider: true, categoryNames: ['Négociant']);
}
// === LCR avec 1 RIB (RG-2.08) + 2 contacts ===
[$coop, $isNew] = $this->ensureSupplier($manager, 'Coopérative Agricole du Sud-Ouest', ['Coopérative']);
if ($isNew) {
$coop->setSiren('775680459');
$coop->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$coop->setPaymentDelay($this->paymentDelay($manager, 'J15'));
$coop->setPaymentType($this->paymentType($manager, 'LCR'));
$this->addContact($coop, 'Sophie', 'Marchand', 'Directrice', '05 56 10 20 30', '06 11 22 33 44', 'sophie.marchand@coop-so.fr', 0);
$this->addContact($coop, 'Marc', 'Girard', 'Acheteur', '05 56 10 20 31', null, 'marc.girard@coop-so.fr', 1);
$this->addAddress($coop, 'RENDU', ['Pommevic'], '82400', 'Pommevic', '8 route des Cooperateurs', bennes: 2);
$this->addRib($coop, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
}
// === Prospect seul (adresse PROSPECT), compta minimale ===
[$producteur, $isNew] = $this->ensureSupplier($manager, 'Producteur Bio Charente', ['Producteur']);
if ($isNew) {
$this->addContact($producteur, 'Claire', 'Moreau', 'Gérante', '05 49 21 22 23', null, 'claire.moreau@bio-charente.fr');
$this->addAddress($producteur, 'PROSPECT', ['Saint-Jean'], '17400', 'Fontenet', '1 chemin des Producteurs');
}
// === Multi-categories M2M + LCR avec 2 RIB + 3 contacts ===
[$grossiste, $isNew] = $this->ensureSupplier($manager, 'Grossiste Multi-Métaux', ['Grossiste', 'Négociant']);
if ($isNew) {
$grossiste->setSiren('552081317');
$grossiste->setAccountNumber('F0004');
$grossiste->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$grossiste->setNTva('FR45552081317');
$grossiste->setPaymentDelay($this->paymentDelay($manager, 'J30'));
$grossiste->setPaymentType($this->paymentType($manager, 'LCR'));
$this->addContact($grossiste, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@grossiste-mm.fr', 0);
$this->addContact($grossiste, 'Julie', 'Roux', 'Assistante commerciale', '05 56 31 32 34', null, 'julie.roux@grossiste-mm.fr', 1);
$this->addContact($grossiste, 'Hélène', 'Faure', 'Logistique', '05 56 31 32 35', null, 'helene.faure@grossiste-mm.fr', 2);
$this->addAddress($grossiste, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '20 zone des Activités', streetComplement: 'Bâtiment C', bennes: 6, triageProvider: true, categoryNames: ['Grossiste', 'Négociant']);
$this->addRib($grossiste, 'Compte principal', 'BNPAFRPPXXX', 'FR7630006000011234567890189', 0);
$this->addRib($grossiste, 'Compte secondaire', 'SOGEFRPPXXX', 'FR7630001007941234567890185', 1);
}
// === VIREMENT + banque, TVA intracom (importateur), multi-sites sur l'adresse ===
[$import, $isNew] = $this->ensureSupplier($manager, 'Import Recyclage International', ['Importateur']);
if ($isNew) {
$import->setSiren('409512012');
$import->setTvaMode($this->tvaMode($manager, 'INTRACOM_VENTES'));
$import->setNTva('FR90409512012');
$import->setPaymentDelay($this->paymentDelay($manager, 'J30'));
$import->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$import->setBank($this->bank($manager, 'CIC'));
$this->addContact($import, 'Paul', 'Garnier', 'Import manager', '05 56 44 55 66', null, 'paul.garnier@import-recyclage.fr', 0);
$this->addContact($import, null, 'Bernard', 'Douanes', '05 56 44 55 67', null, 'douanes@import-recyclage.fr', 1);
$this->addAddress($import, 'RENDU', ['Pommevic', 'Saint-Jean'], '82400', 'Pommevic', '3 quai des Importateurs', bennes: 8);
}
// === Multi-adresses PROSPECT / DEPART / RENDU (RG-2.09) + VIREMENT/banque ===
[$ferrailleur, $isNew] = $this->ensureSupplier($manager, 'Ferrailleur Grand Ouest', ['Négociant']);
if ($isNew) {
$ferrailleur->setSiren('732829320');
$ferrailleur->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$ferrailleur->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
$ferrailleur->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$ferrailleur->setBank($this->bank($manager, 'CA'));
$this->addContact($ferrailleur, 'Olivier', 'Renard', 'Responsable site', '05 49 61 62 63', null, 'olivier.renard@ferrailleur-go.fr', 0);
$this->addContact($ferrailleur, 'Nadia', 'Benali', 'Pesée', '05 49 61 62 64', null, 'nadia.benali@ferrailleur-go.fr', 1);
// Prospect (site en cours de demarchage).
$this->addAddress($ferrailleur, 'PROSPECT', ['Chatellerault'], '86100', 'Châtellerault', '5 avenue de la Prospection', position: 0);
// Depart (collecte) multi-sites avec bennes + triage.
$this->addAddress($ferrailleur, 'DEPART', ['Saint-Jean', 'Pommevic'], '17400', 'Fontenet', '4 rue de la Collecte', bennes: 5, triageProvider: true, categoryNames: ['Négociant'], position: 1);
// Rendu (livraison).
$this->addAddress($ferrailleur, 'RENDU', ['Pommevic'], '82400', 'Pommevic', '7 boulevard du Rendu', bennes: 3, position: 2);
}
// === Onglet Information complet (dont volumeForecast) + VIREMENT/banque ===
[$holding, $isNew] = $this->ensureSupplier($manager, 'Holding Recyclage Premium', ['Importateur']);
if ($isNew) {
$holding->setDescription('Holding de recyclage diversifiée, présente sur le Grand Sud-Ouest.');
$holding->setCompetitors('Groupe Atlantique Recyclage, Sud Métaux');
$holding->setFoundedAt(new DateTimeImmutable('2008-09-01'));
$holding->setEmployeesCount(180);
$holding->setRevenueAmount('24500000.00');
$holding->setDirectorName('Antoine Lefèvre');
$holding->setProfitAmount('1850000.00');
$holding->setVolumeForecast(120000);
$holding->setSiren('318471925');
$holding->setAccountNumber('F0007');
$holding->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$holding->setNTva('FR33318471925');
$holding->setPaymentDelay($this->paymentDelay($manager, 'J30'));
$holding->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$holding->setBank($this->bank($manager, 'SG'));
$this->addContact($holding, 'Antoine', 'Lefèvre', 'PDG', '05 56 51 52 53', null, 'antoine.lefevre@holding-recyclage.fr');
$this->addAddress($holding, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '1 allée des Investisseurs', bennes: 5, triageProvider: true, categoryNames: ['Importateur']);
}
// === Coop minimale — contact par le seul nom (RG-2.04), sans compta ===
[$coopMin, $isNew] = $this->ensureSupplier($manager, 'Coop Métaux Réunis', ['Coopérative']);
if ($isNew) {
$this->addContact($coopMin, null, 'Caron', 'Président', '05 49 81 82 83', null, 'president@coop-metaux-reunis.fr');
$this->addAddress($coopMin, 'DEPART', ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village');
}
// === Reglement CHEQUE (sans banque ni RIB requis) ===
[$petit, $isNew] = $this->ensureSupplier($manager, 'Petit Négoce Local', ['Négociant']);
if ($isNew) {
$petit->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$petit->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
$petit->setPaymentType($this->paymentType($manager, 'CHEQUE'));
$this->addContact($petit, 'Luc', 'Martin', 'Gérant', '05 56 71 72 73', null, 'luc.martin@petit-negoce.fr');
$this->addAddress($petit, 'RENDU', ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Commerce');
}
// === Reglement NON_SOUMISE + adresse multi-sites avec triage ===
[$recup, $isNew] = $this->ensureSupplier($manager, 'Récupération Métaux Express', ['Grossiste']);
if ($isNew) {
$recup->setSiren('490212019');
$recup->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$recup->setPaymentDelay($this->paymentDelay($manager, 'J15'));
$recup->setPaymentType($this->paymentType($manager, 'NON_SOUMISE'));
$this->addContact($recup, 'Marie', 'Lemoine', 'Responsable', '05 49 77 88 99', null, 'marie.lemoine@recup-express.fr', 0);
$this->addContact($recup, 'Pierre', 'Durand', 'Chauffeur', '05 49 77 88 98', null, 'pierre.durand@recup-express.fr', 1);
$this->addAddress($recup, 'DEPART', ['Saint-Jean', 'Chatellerault'], '17400', 'Fontenet', '10 zone industrielle', bennes: 7, triageProvider: true, categoryNames: ['Grossiste']);
}
// === Centre de tri — focus bennes/triage + multi-categories ===
[$centre, $isNew] = $this->ensureSupplier($manager, 'Centre de Tri Sud', ['Producteur', 'Coopérative']);
if ($isNew) {
$centre->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
$this->addContact($centre, 'Camille', 'Faure', 'Chef de centre', '05 56 91 92 93', null, 'camille.faure@centre-tri-sud.fr');
$this->addAddress($centre, 'DEPART', ['Pommevic'], '82400', 'Pommevic', '2 route du Tri', bennes: 12, triageProvider: true, categoryNames: ['Producteur']);
}
// === Fournisseur archive #1 (RG-2.17) ===
[$ancien, $isNew] = $this->ensureSupplier($manager, 'Ancien Fournisseur Fermé', ['Producteur'], isArchived: true);
if ($isNew) {
$this->addContact($ancien, null, 'Lambert', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancien-fournisseur.fr');
$this->addAddress($ancien, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée');
}
// === Fournisseur archive #2 (RG-2.17) ===
[$disparu, $isNew] = $this->ensureSupplier($manager, 'Négoce Disparu', ['Grossiste'], isArchived: true);
if ($isNew) {
$this->addContact($disparu, 'Gérard', 'Blanc', 'Ex-gérant', '05 56 00 00 00', null, 'gerard.blanc@negoce-disparu.fr');
$this->addAddress($disparu, 'RENDU', ['Saint-Jean'], '17400', 'Fontenet', '0 impasse Oubliée');
}
$manager->flush();
}
/**
* Cree un fournisseur (base normalisee + categories de type FOURNISSEUR)
* s'il n'existe pas encore, sinon retourne l'existant. Retourne
* [Supplier, isNew] : isNew=false bloque la reconstruction des
* sous-collections (idempotence sans doublon).
*
* @param list<string> $categoryNames categories de type FOURNISSEUR (RG-2.10)
*
* @return array{0: Supplier, 1: bool}
*/
private function ensureSupplier(
ObjectManager $manager,
string $companyName,
array $categoryNames,
bool $isArchived = false,
): array {
$normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName);
$existing = $manager->getRepository(Supplier::class)->findOneBy(['companyName' => $normalizedName]);
if ($existing instanceof Supplier) {
return [$existing, false];
}
$supplier = new Supplier();
$supplier->setCompanyName($normalizedName);
foreach ($categoryNames as $categoryName) {
$supplier->addCategory($this->category($manager, $categoryName));
}
if ($isArchived) {
$supplier->setIsArchived(true);
$supplier->setArchivedAt(new DateTimeImmutable());
}
$manager->persist($supplier);
return [$supplier, true];
}
/**
* Ajoute un contact normalise au fournisseur (cascade persist via
* Supplier.contacts). Au moins firstName OU lastName est toujours fourni
* (RG-2.04, chk_supplier_contact_name).
*/
private function addContact(
Supplier $supplier,
?string $firstName,
?string $lastName,
?string $jobTitle,
?string $phonePrimary,
?string $phoneSecondary,
?string $email,
int $position = 0,
): void {
$contact = new SupplierContact();
$contact->setFirstName($this->normalizer->normalizePersonName($firstName));
$contact->setLastName($this->normalizer->normalizePersonName($lastName));
$contact->setJobTitle($jobTitle);
$contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
$contact->setEmail($this->normalizer->normalizeEmail($email));
$contact->setPosition($position);
$supplier->addContact($contact);
}
/**
* Ajoute une adresse au fournisseur (cascade persist via Supplier.addresses).
* Le type d'adresse est exclusif (PROSPECT | DEPART | RENDU — RG-2.09,
* chk_supplier_address_type) ; au moins un site est rattache (RG-2.06) ; les
* categories d'adresse sont de type FOURNISSEUR (RG-2.10).
*
* @param list<string> $siteNames au moins un site (RG-2.06)
* @param list<string> $categoryNames categories de type FOURNISSEUR (RG-2.10)
*/
private function addAddress(
Supplier $supplier,
string $addressType,
array $siteNames,
string $postalCode,
string $city,
string $street,
?string $streetComplement = null,
?int $bennes = null,
bool $triageProvider = false,
array $categoryNames = [],
int $position = 0,
): void {
$address = new SupplierAddress();
$address->setAddressType($addressType);
$address->setPostalCode($postalCode);
$address->setCity($city);
$address->setStreet($street);
$address->setStreetComplement($streetComplement);
$address->setBennes($bennes);
$address->setTriageProvider($triageProvider);
$address->setPosition($position);
foreach ($siteNames as $siteName) {
$address->addSite($this->site($siteName));
}
foreach ($categoryNames as $categoryName) {
$address->addCategory($this->category($this->manager, $categoryName));
}
$supplier->addAddress($address);
}
/**
* Ajoute un RIB au fournisseur (cascade persist via Supplier.ribs). IBAN/BIC
* valides (Assert\Iban/Bic non rejouee sur persist direct mais donnees
* coherentes pour le golden path / les tests).
*/
private function addRib(Supplier $supplier, string $label, string $bic, string $iban, int $position = 0): void
{
$rib = new SupplierRib();
$rib->setLabel($label);
$rib->setBic($bic);
$rib->setIban($iban);
$rib->setPosition($position);
$supplier->addRib($rib);
}
/**
* Resout une categorie par son nom via le contrat Shared CategoryInterface
* (resolve_target_entities -> Category), sans importer le module Catalog
* (regle n°1). Mise en cache par nom.
*/
private function category(ObjectManager $manager, string $name): CategoryInterface
{
if (isset($this->categoryCache[$name])) {
return $this->categoryCache[$name];
}
// RG-2.10 : on garde la categorie des qu'elle PORTE le type FOURNISSEUR
// (multi-type depuis le passage en ManyToMany). Le nom etant desormais
// unique GLOBALEMENT parmi les actifs, le lookup par `name` renvoie au
// plus une categorie, mais on conserve la verification du type pour
// ecarter un homonyme qui ne porterait pas FOURNISSEUR. Le filtre type
// est porte cote PHP (findBy ne sait pas filtrer la collection categoryTypes).
$candidates = $manager->getRepository(CategoryInterface::class)->findBy([
'name' => $name,
'deletedAt' => null,
]);
foreach ($candidates as $candidate) {
if ($candidate instanceof CategoryInterface
&& in_array(self::SUPPLIER_CATEGORY_TYPE_CODE, $candidate->getCategoryTypeCodes(), true)) {
return $this->categoryCache[$name] = $candidate;
}
}
throw new RuntimeException(sprintf(
'Categorie FOURNISSEUR "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.',
$name,
));
}
/**
* Resout un site par son nom via le contrat Shared SiteProviderInterface,
* sans importer le module Sites (regle n°1). Mise en cache par nom.
*/
private function site(string $name): SiteInterface
{
if (isset($this->siteCache[$name])) {
return $this->siteCache[$name];
}
$site = $this->siteProvider->findByName($name);
if (!$site instanceof SiteInterface) {
throw new RuntimeException(sprintf(
'Site "%s" introuvable : SitesFixtures doit tourner avant SupplierFixtures.',
$name,
));
}
return $this->siteCache[$name] = $site;
}
private function tvaMode(ObjectManager $manager, string $code): TvaMode
{
$mode = $manager->getRepository(TvaMode::class)->findOneBy(['code' => $code]);
if (!$mode instanceof TvaMode) {
throw new RuntimeException(sprintf(
'TvaMode "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
$code,
));
}
return $mode;
}
private function paymentDelay(ObjectManager $manager, string $code): PaymentDelay
{
$delay = $manager->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]);
if (!$delay instanceof PaymentDelay) {
throw new RuntimeException(sprintf(
'PaymentDelay "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
$code,
));
}
return $delay;
}
private function paymentType(ObjectManager $manager, string $code): PaymentType
{
$type = $manager->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
if (!$type instanceof PaymentType) {
throw new RuntimeException(sprintf(
'PaymentType "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
$code,
));
}
return $type;
}
private function bank(ObjectManager $manager, string $code): Bank
{
$bank = $manager->getRepository(Bank::class)->findOneBy(['code' => $code]);
if (!$bank instanceof Bank) {
throw new RuntimeException(sprintf(
'Bank "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
$code,
));
}
return $bank;
}
}
@@ -35,10 +35,14 @@ interface CategoryInterface
public function getCode(): ?string;
/**
* Code du type de categorie rattache (CategoryType::code), ou null si la
* categorie n'a pas de type. Depuis ERP-78, le modele n'a plus qu'un seul
* type (CLIENT) : le filtrage metier passe desormais par getCode() ci-dessus.
* Conserve pour l'affichage / la retrocompatibilite.
* Codes des types de categorie rattaches (CategoryType::code), tableau vide
* si aucun. Depuis le passage en ManyToMany, une categorie peut porter
* plusieurs types : un module tiers teste l'appartenance via
* `in_array($code, $category->getCategoryTypeCodes(), true)`. Pilote, cote
* M2 Commercial, la RG-2.10 (une categorie de fournisseur doit etre de type
* FOURNISSEUR).
*
* @return list<string>
*/
public function getCategoryTypeCode(): ?string;
public function getCategoryTypeCodes(): array;
}
@@ -50,12 +50,11 @@ final class ColumnCommentsCatalog
],
'category' => [
'_table' => 'Categories M0 — referentiel type par category_type, soft-delete via deleted_at, unicite (LOWER(name), category_type_id) parmi les actifs.',
'id' => 'Identifiant interne auto-incremente.',
'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique par type parmi les actifs (RG-1.06).',
'code' => 'Code technique stable (slug MAJUSCULE du nom, ≤ 50) — unique parmi les actifs (uq_category_code). Fige a la creation. DISTRIBUTEUR/COURTIER pilotent RG-1.03/1.29.',
'category_type_id' => 'Reference au type de la categorie — FK -> category_type.id, ON DELETE RESTRICT (un type ne peut etre supprime tant qu il a des categories).',
'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.',
'_table' => 'Categories — referentiel multi-types via la jonction category_category_type, soft-delete via deleted_at, unicite LOWER(name) GLOBALE parmi les actifs (uq_category_name_active).',
'id' => 'Identifiant interne auto-incremente.',
'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique GLOBALEMENT parmi les actifs (RG-1.07, uq_category_name_active).',
'code' => 'Code technique stable (slug MAJUSCULE du nom, ≤ 50) — unique parmi les actifs (uq_category_code). Fige a la creation. DISTRIBUTEUR/COURTIER pilotent RG-1.03/1.29.',
'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.',
] + self::timestampableBlamableComments(),
'category_type' => [
@@ -65,6 +64,12 @@ final class ColumnCommentsCatalog
'label' => 'Libelle affichable du type (FR, ≤ 120 caracteres).',
],
'category_category_type' => [
'_table' => 'Jointure M2M category <-> category_type (Catalog) — types portes par la categorie, au moins un obligatoire (RG-1.05).',
'category_id' => 'FK -> category.id, ON DELETE CASCADE — categorie portant le type.',
'category_type_id' => 'FK -> category_type.id, ON DELETE RESTRICT — type rattache (un type ne peut etre supprime tant qu il reste reference).',
],
'permission' => [
'_table' => 'Referentiel des permissions RBAC — codes au format module.resource[.subresource].action, synchronise par app:sync-permissions.',
'id' => 'Identifiant interne auto-incremente.',
@@ -70,11 +70,17 @@ abstract class AbstractCatalogApiTestCase extends AbstractApiTestCase
* cleanup. Si aucun type n'est fourni, un nouveau CategoryType est cree.
* Le flag $deletedAt permet de seeder directement une categorie
* soft-deleted (pour les tests RG-1.08 / RG-1.11).
*
* Multi-types (ManyToMany) : `$type` est le type principal (cree si null) ;
* `$additionalTypes` permet d'attacher d'autres types pour les cas multi.
*
* @param list<CategoryType> $additionalTypes
*/
protected function createCategory(
?string $name = null,
?CategoryType $type = null,
?DateTimeImmutable $deletedAt = null,
array $additionalTypes = [],
): Category {
$em = $this->getEm();
@@ -86,7 +92,10 @@ abstract class AbstractCatalogApiTestCase extends AbstractApiTestCase
// ERP-78 : code NOT NULL + unique parmi les actifs (uq_category_code).
// Nonce aleatoire -> unicite garantie entre seeds successifs du test.
$category->setCode('TEST_'.strtoupper($suffix));
$category->setCategoryType($type);
$category->addCategoryType($type);
foreach ($additionalTypes as $additionalType) {
$category->addCategoryType($additionalType);
}
if (null !== $deletedAt) {
$category->setDeletedAt($deletedAt);
}
@@ -57,7 +57,7 @@ final class CategoryAuditTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'audit_create',
'categoryType' => '/api/category_types/'.$type->getId(),
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertSame(201, $response->getStatusCode());
@@ -139,7 +139,7 @@ final class CategoryAuditTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'audit_manager',
'categoryType' => '/api/category_types/'.$type->getId(),
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertSame(201, $response->getStatusCode());
@@ -26,7 +26,7 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'Agro-alimentaire',
'categoryType' => '/api/category_types/'.$type->getId(),
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
@@ -48,7 +48,7 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'readonly',
'categoryType' => '/api/category_types/'.$type->getId(),
'categoryTypes' => ['/api/category_types/'.$type->getId()],
// Le client tente d'imposer un code : doit etre ignore.
'code' => 'CLIENT_FORGED',
],
@@ -65,13 +65,13 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
$type = $this->createCategoryType();
$client = $this->createAdminClient();
// Deux noms differents (donc autorises par uq_category_name_type_active)
// Deux noms differents (donc autorises par uq_category_name_active)
// mais qui produisent le meme slug -> codes distincts (suffixe `_2`).
$first = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'Agro Plus',
'categoryType' => '/api/category_types/'.$type->getId(),
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
])->toArray();
@@ -79,7 +79,7 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'Agro-Plus',
'categoryType' => '/api/category_types/'.$type->getId(),
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
])->toArray();
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* Tests des filtres de la liste admin sur GET /api/categories :
* - `?name=` : recherche partielle case-insensitive sur le nom ;
* - `?typeId[]=` : categories portant AU MOINS UN des types coches (OR), sans
* doublon meme pour une categorie multi-types ;
* - combinaison `?name=` + `?typeId[]=` (ET entre filtres).
*
* @internal
*/
final class CategoryFilterTest extends AbstractCatalogApiTestCase
{
/**
* @param array<int, array<string, mixed>> $members
*
* @return list<string>
*/
private function testNames(array $members): array
{
$names = array_map(static fn (array $m): string => $m['name'], $members);
$names = array_values(array_filter(
$names,
fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
));
sort($names);
return $names;
}
public function testNameFilterIsPartialAndCaseInsensitive(): void
{
$type = $this->createCategoryType();
$this->createCategory(self::TEST_CATEGORY_PREFIX.'Acier inox', $type);
$this->createCategory(self::TEST_CATEGORY_PREFIX.'Aluminium', $type);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?name=ACIER&pagination=false');
self::assertSame(200, $response->getStatusCode());
self::assertSame(
[self::TEST_CATEGORY_PREFIX.'Acier inox'],
$this->testNames($response->toArray()['member']),
'Le filtre ?name= doit etre partiel et insensible a la casse.',
);
}
public function testTypeIdFilterReturnsCategoriesWithAtLeastOneType(): void
{
$typeA = $this->createCategoryType();
$typeB = $this->createCategoryType();
$typeC = $this->createCategoryType();
$this->createCategory(self::TEST_CATEGORY_PREFIX.'only_a', $typeA);
$this->createCategory(self::TEST_CATEGORY_PREFIX.'only_b', $typeB);
$this->createCategory(self::TEST_CATEGORY_PREFIX.'only_c', $typeC);
$client = $this->createAdminClient();
$response = $client->request(
'GET',
sprintf('/api/categories?typeId[]=%d&typeId[]=%d&pagination=false', $typeA->getId(), $typeB->getId()),
);
self::assertSame(200, $response->getStatusCode());
self::assertSame(
[
self::TEST_CATEGORY_PREFIX.'only_a',
self::TEST_CATEGORY_PREFIX.'only_b',
],
$this->testNames($response->toArray()['member']),
'Le filtre ?typeId[]= doit remonter les categories portant AU MOINS UN des types (OR).',
);
}
public function testMultiTypeCategoryAppearsOnceWhenFilteredByOneType(): void
{
// Une categorie portant deux types ne doit pas etre dupliquee quand on
// filtre sur l'un de ses types (la sous-requete EXISTS evite les doublons).
$typeA = $this->createCategoryType();
$typeB = $this->createCategoryType();
$this->createCategory(
self::TEST_CATEGORY_PREFIX.'multi',
$typeA,
null,
[$typeB],
);
$client = $this->createAdminClient();
$response = $client->request(
'GET',
sprintf('/api/categories?typeId[]=%d&pagination=false', $typeA->getId()),
);
self::assertSame(200, $response->getStatusCode());
$members = $response->toArray()['member'];
self::assertSame(
[self::TEST_CATEGORY_PREFIX.'multi'],
$this->testNames($members),
'La categorie multi-types ne doit apparaitre qu une seule fois.',
);
// Les deux types restent embarques (la collection n'est pas tronquee).
$multi = array_values(array_filter(
$members,
fn (array $m): bool => $m['name'] === self::TEST_CATEGORY_PREFIX.'multi',
))[0];
self::assertCount(2, $multi['categoryTypes'], 'Les 2 types doivent rester embarques malgre le filtre.');
}
public function testNameAndTypeIdFiltersCombine(): void
{
$typeA = $this->createCategoryType();
$typeB = $this->createCategoryType();
$this->createCategory(self::TEST_CATEGORY_PREFIX.'steel_a', $typeA);
$this->createCategory(self::TEST_CATEGORY_PREFIX.'steel_b', $typeB);
$this->createCategory(self::TEST_CATEGORY_PREFIX.'wood_a', $typeA);
$client = $this->createAdminClient();
$response = $client->request(
'GET',
sprintf('/api/categories?name=steel&typeId[]=%d&pagination=false', $typeA->getId()),
);
self::assertSame(200, $response->getStatusCode());
self::assertSame(
[self::TEST_CATEGORY_PREFIX.'steel_a'],
$this->testNames($response->toArray()['member']),
'Les filtres ?name= et ?typeId[]= doivent se combiner (ET).',
);
}
}
@@ -66,7 +66,7 @@ final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'forbidden',
'categoryType' => '/api/category_types/'.$type->getId(),
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -81,7 +81,7 @@ final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'anon',
'categoryType' => '/api/category_types/'.$type->getId(),
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -96,7 +96,7 @@ final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'admin_create',
'categoryType' => '/api/category_types/'.$type->getId(),
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -112,7 +112,7 @@ final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'view_only',
'categoryType' => '/api/category_types/'.$type->getId(),
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -69,7 +69,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'tsb_admin',
'categoryType' => '/api/category_types/'.$type->getId(),
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertSame(201, $response->getStatusCode());
@@ -140,7 +140,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'tsb_patch',
'categoryType' => '/api/category_types/'.$type->getId(),
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertSame(201, $response->getStatusCode());
@@ -220,7 +220,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'tsb_delete',
'categoryType' => '/api/category_types/'.$type->getId(),
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertSame(201, $response->getStatusCode());
@@ -47,9 +47,10 @@ final class CategoryTypeCodeFilterTest extends AbstractCatalogApiTestCase
'Le filtre ?typeCode= doit ne renvoyer QUE les categories du type demande.',
);
// Tous les types embarques doivent etre le type filtre.
// Chaque categorie remontee doit PORTER le type filtre (multi-types :
// la collection categoryTypes embarquee contient le code demande).
foreach ($members as $member) {
self::assertSame('TEST_FOURNISSEUR', $member['categoryType']['code']);
self::assertContains('TEST_FOURNISSEUR', array_column($member['categoryTypes'], 'code'));
}
}
@@ -68,7 +69,7 @@ final class CategoryTypeCodeFilterTest extends AbstractCatalogApiTestCase
self::assertArrayHasKey('member', $data);
foreach ($data['member'] as $member) {
self::assertSame('TEST_FOURNISSEUR', $member['categoryType']['code']);
self::assertContains('TEST_FOURNISSEUR', array_column($member['categoryTypes'], 'code'));
}
}
+37 -37
View File
@@ -5,22 +5,22 @@ declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* Tests RG-1.07 : unicite case-insensitive de (LOWER(name), category_type_id)
* parmi les categories non soft-deleted. L'index Postgres partiel
* `uq_category_name_type_active` est traduit en 409 Conflict par le
* CategoryProcessor.
* Tests RG-1.07 : unicite case-insensitive du nom GLOBALEMENT (LOWER(name))
* parmi les categories non soft-deleted. Depuis le passage en ManyToMany,
* l'unicite n'est plus liee au type. L'index Postgres partiel
* `uq_category_name_active` est traduit en 409 Conflict par le CategoryProcessor.
*
* Cas couverts :
* - doublon strict (meme name + meme type) → 409 ;
* - doublon case-insensitive (Vis / vis sur meme type) → 409 ;
* - meme name sur 2 types differents → les deux passent (pas de doublon) ;
* - recreation apres soft delete → 201 (l'index partiel libere le couple).
* - doublon strict (meme name) → 409 ;
* - doublon case-insensitive (Vis / VIS) → 409 ;
* - meme name avec des types differents → 409 (unicite GLOBALE) ;
* - recreation apres soft delete → 201 (l'index partiel libere le nom).
*
* @internal
*/
final class CategoryUniqueTest extends AbstractCatalogApiTestCase
{
public function testDuplicateNameSameTypeReturns409(): void
public function testDuplicateNameReturns409(): void
{
$type = $this->createCategoryType();
$client = $this->createAdminClient();
@@ -29,29 +29,29 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'unique',
'categoryType' => '/api/category_types/'.$type->getId(),
'name' => self::TEST_CATEGORY_PREFIX.'unique',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
// 2eme POST : meme name + meme type → doublon strict.
// 2eme POST : meme name → doublon (unicite globale).
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'unique',
'categoryType' => '/api/category_types/'.$type->getId(),
'name' => self::TEST_CATEGORY_PREFIX.'unique',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertSame(409, $response->getStatusCode());
// Message attendu par la spec RG-1.07.
// Message attendu par la spec RG-1.07 (reformulee, sans "pour ce type").
$payload = $response->toArray(false);
$description = $payload['description'] ?? $payload['detail'] ?? $payload['hydra:description'] ?? '';
self::assertStringContainsString(
'existe déjà pour ce type',
'existe déjà',
$description,
'Le message d\'erreur 409 doit citer la spec ("existe deja pour ce type").',
'Le message d\'erreur 409 doit citer la spec ("existe deja").',
);
}
@@ -64,8 +64,8 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'Vis',
'categoryType' => '/api/category_types/'.$type->getId(),
'name' => self::TEST_CATEGORY_PREFIX.'Vis',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
@@ -74,17 +74,17 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
// Meme prefix mais variation de casse → meme LOWER → collision.
'name' => self::TEST_CATEGORY_PREFIX.'VIS',
'categoryType' => '/api/category_types/'.$type->getId(),
'name' => self::TEST_CATEGORY_PREFIX.'VIS',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertSame(409, $response->getStatusCode());
}
public function testSameNameDifferentTypeAllowed(): void
public function testSameNameDifferentTypeReturns409(): void
{
// RG-1.07 : la contrainte est SUR (name, type), pas sur name seul.
// Le meme nom doit etre acceptable sur deux types differents.
// RG-1.07 (reformulee) : l'unicite du nom est desormais GLOBALE — le
// meme nom sur deux types differents est un doublon.
$type1 = $this->createCategoryType();
$type2 = $this->createCategoryType();
$client = $this->createAdminClient();
@@ -92,27 +92,27 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'shared',
'categoryType' => '/api/category_types/'.$type1->getId(),
'name' => self::TEST_CATEGORY_PREFIX.'shared',
'categoryTypes' => ['/api/category_types/'.$type1->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
$client->request('POST', '/api/categories', [
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'shared',
'categoryType' => '/api/category_types/'.$type2->getId(),
'name' => self::TEST_CATEGORY_PREFIX.'shared',
'categoryTypes' => ['/api/category_types/'.$type2->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
self::assertSame(409, $response->getStatusCode());
}
public function testRecreateAfterSoftDeleteAllowed(): void
{
// RG-1.07 : l'index Postgres est partiel (WHERE deleted_at IS NULL).
// Apres un soft delete, le couple (name, type) est libere et un
// nouveau POST identique doit reussir.
// Apres un soft delete, le nom est libere et un nouveau POST identique
// doit reussir.
$type = $this->createCategoryType();
$client = $this->createAdminClient();
@@ -120,8 +120,8 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
'categoryType' => '/api/category_types/'.$type->getId(),
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertSame(201, $response->getStatusCode());
@@ -131,12 +131,12 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
$client->request('DELETE', '/api/categories/'.$created['id']);
self::assertResponseStatusCodeSame(204);
// 3) recreation : meme name + meme type → autorise (couple libere).
// 3) recreation : meme name → autorise (nom libere par l'archivage).
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
'categoryType' => '/api/category_types/'.$type->getId(),
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
@@ -11,8 +11,8 @@ use App\Module\Catalog\Domain\Entity\Category;
* - RG-1.02 : `name` obligatoire (NotBlank) ;
* - RG-1.03 : `name` trim cote serveur via CategoryProcessor ;
* - RG-1.04 : `name` longueur 2..120 (Length) ;
* - RG-1.05 : `categoryType` obligatoire ;
* - RG-1.06 : `categoryType` doit pointer un type existant.
* - RG-1.05 : `categoryTypes` — au moins un type (Count min 1) ;
* - RG-1.06 : chaque IRI de `categoryTypes` doit pointer un type existant.
*
* @internal
*/
@@ -27,7 +27,7 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'categoryType' => '/api/category_types/'.$type->getId(),
'categoryTypes' => ['/api/category_types/'.$type->getId()],
// name absent
],
]);
@@ -42,8 +42,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => '',
'categoryType' => '/api/category_types/'.$type->getId(),
'name' => '',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -59,8 +59,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => ' ',
'categoryType' => '/api/category_types/'.$type->getId(),
'name' => ' ',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -79,8 +79,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => $payloadName,
'categoryType' => '/api/category_types/'.$type->getId(),
'name' => $payloadName,
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -103,8 +103,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'A',
'categoryType' => '/api/category_types/'.$type->getId(),
'name' => 'A',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -118,8 +118,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => str_repeat('a', 121),
'categoryType' => '/api/category_types/'.$type->getId(),
'name' => str_repeat('a', 121),
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
@@ -140,71 +140,74 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => $name,
'categoryType' => '/api/category_types/'.$type->getId(),
'name' => $name,
'categoryTypes' => ['/api/category_types/'.$type->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
}
// ============ RG-1.05 — categoryType obligatoire ============
// ============ RG-1.05 — au moins un type (Count min 1) ============
public function testCategoryTypeRequiredReturns422(): void
public function testCategoryTypesRequiredReturns422(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'no_type',
// categoryType absent
// categoryTypes absent -> collection vide -> Count(min:1) viole.
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testCategoryTypeNullIsRejected(): void
public function testCategoryTypesEmptyReturns422(): void
{
// `categoryType: null` echoue a la deserialization IRI (API Platform
// renvoie 400) bien avant la validation Assert\NotNull. La spec § 4.3
// accepte les deux : on assert le contrat fort "ne passe pas en BDD".
// Tableau vide explicite : Assert\Count(min: 1) doit declencher 422 avec
// une violation sur le propertyPath `categoryTypes` (consommable inline).
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'null_type',
'categoryType' => null,
'name' => self::TEST_CATEGORY_PREFIX.'empty_types',
'categoryTypes' => [],
],
]);
self::assertSame(422, $response->getStatusCode());
$payload = $response->toArray(false);
$violations = $payload['violations'] ?? $payload['hydra:violations'] ?? [];
$paths = array_column($violations, 'propertyPath');
self::assertContains(
$response->getStatusCode(),
[400, 422],
'categoryType=null doit etre rejete (400 deserialization ou 422 validation).',
'categoryTypes',
$paths,
'La violation Count doit porter le propertyPath `categoryTypes`.',
);
}
// ============ RG-1.06 — categoryType doit exister ============
// ============ RG-1.06 — chaque type doit exister ============
public function testCategoryTypeMustExistReturns4xx(): void
{
// IRI vers un id qui n'existe pas. API Platform peut renvoyer 400
// (resolution IRI echouee) ou 422 (validation NotNull declenchee).
// La spec § 4.3 accepte les deux : on assert le contrat "ne passe pas".
// (resolution IRI echouee) ou 422 (validation declenchee). La spec § 4.3
// accepte les deux : on assert le contrat "ne passe pas".
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'ghost_type',
'categoryType' => '/api/category_types/9999999',
'name' => self::TEST_CATEGORY_PREFIX.'ghost_type',
'categoryTypes' => ['/api/category_types/9999999'],
],
]);
self::assertContains(
$response->getStatusCode(),
[400, 404, 422],
'IRI categoryType inexistante doit etre rejetee (400/404/422 selon API Platform).',
'IRI categoryTypes inexistante doit etre rejetee (400/404/422 selon API Platform).',
);
}
}
@@ -107,7 +107,7 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
$category = new Category();
$category->setName($name);
$category->setCode($effectiveCode);
$category->setCategoryType($this->clientCategoryType());
$category->addCategoryType($this->clientCategoryType());
$em->persist($category);
$em->flush();
@@ -77,7 +77,7 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
$category = new Category();
$category->setName(self::TEST_CATEGORY_PREFIX.'fr_'.strtolower($code));
$category->setCode($code);
$category->setCategoryType($this->supplierCategoryType());
$category->addCategoryType($this->supplierCategoryType());
$em->persist($category);
$em->flush();
@@ -4,11 +4,8 @@ declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Domain\Entity\SupplierAddress;
use App\Module\Commercial\Domain\Entity\SupplierContact;
use App\Module\Sites\Domain\Entity\Site;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
@@ -18,29 +15,17 @@ use PhpOffice\PhpSpreadsheet\IOFactory;
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
* archives par defaut, respect du filtre ?search, peuplement des colonnes
* contact principal / categories / sites, gating de la colonne SIREN selon
* commercial.suppliers.accounting.view, 403 sans commercial.suppliers.view,
* 401 anonyme.
* commercial.suppliers.accounting.view (admin ET user minimal a permission
* explicite), dedup F3 (fournisseur multi-categories rendu sur une seule ligne),
* 403 sans commercial.suppliers.view, 401 anonyme.
*
* @internal
*/
final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/suppliers/export.xlsx';
/**
* Les fournisseurs doivent etre purges AVANT les categories de test (le parent
* supprime les categories `test_cli_cat_*`) : la jointure supplier_category est
* en ON DELETE CASCADE cote supplier mais RESTRICT cote category. Le DELETE DQL
* sur Supplier declenche le cascade BDD sur supplier_category / _contact /
* _address (et leurs sous-jointures), liberant les categories pour le parent.
*/
protected function tearDown(): void
{
$this->getEm()->createQuery('DELETE FROM '.Supplier::class)->execute();
parent::tearDown();
}
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
{
$client = $this->createAdminClient();
@@ -110,9 +95,13 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
$supplier = $this->seedSupplier('Contact Co');
// position 1 (secondaire) insere en premier...
$this->addContact($supplier, 'Secondaire', 'Bob', 1, '0600000001', '0600000002', 'bob@contact.co');
$this->addContact($supplier, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1);
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
$this->addContact($supplier, 'Principal', 'Alice', 0, '0612345678', '0698765432', 'alice@contact.co');
$principal = $this->addContact($supplier, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0);
// Le telephone secondaire n'est pas porte par le helper de base : on le pose
// directement sur le contact principal pour alimenter la colonne dediee.
$principal->setPhoneSecondary('0698765432');
$this->getEm()->flush();
$row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
@@ -149,8 +138,10 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
// Colonne « Catégories » : libelle de la categorie du fournisseur (getName()).
self::assertStringContainsString('test_cli_cat_negociant', $flat);
// Colonne « Catégories » : libelle de la categorie FOURNISSEUR du fournisseur
// (getName()). On le derive du helper de base (idempotent) plutot que de
// hardcoder le prefixe de nom de test.
self::assertStringContainsString((string) $this->supplierCategory('NEGOCIANT')->getName(), $flat);
// Colonne « Sites » : site agrege depuis l'adresse (RG-2.06).
self::assertStringContainsString((string) $site->getName(), $flat);
}
@@ -188,6 +179,60 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
self::assertStringNotContainsString('987654321', $this->flatten($grid));
}
/**
* Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) :
* un user minimal portant uniquement commercial.suppliers.view +
* commercial.suppliers.accounting.view voit bien la colonne SIREN et sa
* valeur. Complement de testSirenColumnPresentWithAccountingView (admin), qui
* ne prouve pas que accounting.view SEULE suffit (l'admin bypasse le RBAC).
* Le pendant negatif (sans accounting.view -> colonne absente) est couvert par
* testSirenColumnAbsentWithoutAccountingView.
*/
public function testSirenColumnPresentForMinimalUserWithAccountingView(): void
{
// Seed via admin, puis relecture par un user non-admin a 2 permissions.
$this->createAdminClient();
$supplier = $this->seedSupplier('Gated Siren Co');
$em = $this->getEm();
$supplier->setSiren('456789123');
$em->flush();
$creds = $this->createUserWithPermissions([
'commercial.suppliers.view',
'commercial.suppliers.accounting.view',
]);
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('SIREN', $grid[0]);
self::assertStringContainsString('456789123', $this->flatten($grid));
}
/**
* Dedup F3 : un fournisseur portant >= 2 categories FOURNISSEUR est multiplie
* par la jointure (selection/hydratation des collections) ; l'export doit le
* rendre sur UNE SEULE ligne. On seede un fournisseur a 2 categories et on
* assert qu'il n'apparait qu'une fois dans la colonne « Nom fournisseur ».
*/
public function testExportDeduplicatesSupplierWithMultipleCategories(): void
{
$client = $this->createAdminClient();
$supplier = $this->seedSupplier('Multi Cat Co', false, 'NEGOCIANT');
// 2e categorie FOURNISSEUR sur le meme fournisseur (RG-2.10).
$supplier->addCategory($this->supplierCategory('GROSSISTE'));
$this->getEm()->flush();
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
$occurrences = count(array_filter($names, static fn (string $name): bool => 'MULTI CAT CO' === $name));
self::assertSame(
1,
$occurrences,
'Un fournisseur multi-categories doit apparaitre sur une seule ligne (dedup F3).',
);
}
public function testForbiddenWithoutSuppliersViewPermission(): void
{
$creds = $this->createUserWithPermission('core.users.view');
@@ -206,50 +251,6 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(401);
}
/**
* Seede directement un Supplier en base (sans passer par l'API), pour les
* tests de liste / archivage. Stocke le nom en MAJUSCULES pour refleter l'etat
* normalise (RG-2.12) qu'aurait produit le SupplierProcessor via l'API.
*/
private function seedSupplier(string $companyName, bool $isArchived = false, string $categoryCode = 'SECTEUR'): Supplier
{
$em = $this->getEm();
$supplier = new Supplier();
$supplier->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
$supplier->addCategory($this->createCategory($categoryCode));
$supplier->setIsArchived($isArchived);
if ($isArchived) {
$supplier->setArchivedAt(new DateTimeImmutable());
}
$em->persist($supplier);
$em->flush();
return $supplier;
}
private function addContact(
Supplier $supplier,
string $lastName,
string $firstName,
int $position,
?string $phonePrimary = null,
?string $phoneSecondary = null,
?string $email = null,
): void {
$contact = new SupplierContact();
$contact->setSupplier($supplier);
$contact->setLastName($lastName);
$contact->setFirstName($firstName);
$contact->setPosition($position);
$contact->setPhonePrimary($phonePrimary);
$contact->setPhoneSecondary($phoneSecondary);
$contact->setEmail($email);
$supplier->addContact($contact);
$this->getEm()->persist($contact);
$this->getEm()->flush();
}
/**
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
*
@@ -284,7 +285,7 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
/**
* Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
*
* @return array<int, mixed>|null
* @return null|array<int, mixed>
*/
private function rowFor(string $binary, string $companyName): ?array
{
@@ -126,6 +126,10 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
public function testPostAddressWithoutSiteReturns422(): void
{
// Sans cette garde, un module Sites desactive renverrait 404 (route
// /addresses indisponible) et le test passerait pour la MAUVAISE raison
// au lieu de prouver RG-2.06 (Assert\Count min 1 sur sites).
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address No Site');
@@ -55,6 +55,17 @@ final class SupplierValidationTest extends TestCase
self::assertContains('categories', $this->violationPaths($supplier));
}
public function testMultiTypeCategoryContainingFournisseurIsAccepted(): void
{
// RG-2.10 sous ManyToMany : une categorie qui PORTE FOURNISSEUR (parmi
// d'autres types) reste autorisee sur un fournisseur.
$supplier = new Supplier();
$supplier->setCompanyName('Recycla SAS');
$supplier->addCategory($this->category('CLIENT', 'FOURNISSEUR'));
self::assertNotContains('categories', $this->violationPaths($supplier));
}
// === RG-2.07 : Virement impose une banque ===
public function testVirementWithoutBankIsRejectedOnBankPath(): void
@@ -131,13 +142,17 @@ final class SupplierValidationTest extends TestCase
}
/**
* Double minimal de CategoryInterface (pas d'acces base) renvoyant le code de
* type de categorie voulu — seul element regarde par validateCategoryType.
* Double minimal de CategoryInterface (pas d'acces base) PORTANT les codes de
* type voulus — seul element regarde par validateCategoryType. Variadic pour
* couvrir le cas multi-types (ManyToMany).
*
* @return list<string> n'est pas le type de retour : helper renvoyant un double
*/
private function category(string $typeCode): CategoryInterface
private function category(string ...$typeCodes): CategoryInterface
{
return new class($typeCode) implements CategoryInterface {
public function __construct(private readonly string $typeCode) {}
return new class(array_values($typeCodes)) implements CategoryInterface {
/** @param list<string> $typeCodes */
public function __construct(private readonly array $typeCodes) {}
public function getId(): ?int
{
@@ -154,9 +169,10 @@ final class SupplierValidationTest extends TestCase
return 'TEST';
}
public function getCategoryTypeCode(): ?string
/** @return list<string> */
public function getCategoryTypeCodes(): array
{
return $this->typeCode;
return $this->typeCodes;
}
};
}
@@ -101,6 +101,24 @@ final class SupplierProcessorTest extends TestCase
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
}
public function testAdminIncompleteInformationPasses(): void
{
// Distinct du cas user=null : un utilisateur AUTHENTIFIE mais non-Commerciale
// (ici un admin, BusinessRoleAwareInterface renvoyant false pour tout role
// metier) n'est pas soumis a la completude Information -> 200 malgre un
// onglet Information incomplet. Prouve que le gate porte bien sur le ROLE
// metier Commerciale, et pas sur « il y a un utilisateur connecte ».
$supplier = $this->minimalSupplier();
$supplier->setDescription('Une description');
$processor = $this->makeProcessor(
payload: ['description' => 'Une description'],
user: $this->adminUser(),
);
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
@@ -175,6 +193,33 @@ final class SupplierProcessorTest extends TestCase
return $this->createStub(Operation::class);
}
/**
* Utilisateur authentifie non-Commerciale (profil admin) : porte
* BusinessRoleAwareInterface mais ne reconnait aucun role metier. Sert a
* distinguer « pas de role Commerciale » de « pas d'utilisateur » (null).
*/
private function adminUser(): UserInterface
{
return new class implements UserInterface, BusinessRoleAwareInterface {
public function hasBusinessRole(string $roleCode): bool
{
return false;
}
public function getRoles(): array
{
return ['ROLE_ADMIN'];
}
public function eraseCredentials(): void {}
public function getUserIdentifier(): string
{
return 'admin-test';
}
};
}
private function commercialeUser(): UserInterface
{
return new class implements UserInterface, BusinessRoleAwareInterface {
+36 -12
View File
@@ -90,6 +90,26 @@ abstract class AbstractApiTestCase extends ApiTestCase
* @return array{username: string, password: string} Les identifiants pour authenticatedClient()
*/
protected function createUserWithPermission(string $permissionCode): array
{
return $this->createUserWithPermissions([$permissionCode]);
}
/**
* Variante multi-permissions de {@see createUserWithPermission()} : cree un
* utilisateur non-admin portant PLUSIEURS permissions via un unique role
* jetable. Utile pour prouver qu'une combinaison precise de permissions
* (sans le bypass admin) suffit a debloquer un comportement — ex. la colonne
* SIREN de l'export, gatee par accounting.view EN PLUS de suppliers.view.
*
* Memes garanties que le singulier : suffixe aleatoire, password "testpass",
* rattachement a tous les sites, echec explicite si une permission est
* introuvable en base.
*
* @param list<string> $permissionCodes codes des permissions a accorder
*
* @return array{username: string, password: string} identifiants pour authenticatedClient()
*/
protected function createUserWithPermissions(array $permissionCodes): array
{
if (!self::$kernel) {
self::bootKernel();
@@ -97,17 +117,6 @@ abstract class AbstractApiTestCase extends ApiTestCase
$em = $this->getEm();
/** @var null|Permission $permission */
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => $permissionCode]);
self::assertNotNull(
$permission,
sprintf(
'Permission "%s" introuvable en base. Assurez-vous que `app:sync-permissions` a ete execute.',
$permissionCode,
),
);
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$username = 'testuser_'.$suffix;
$password = 'testpass';
@@ -116,7 +125,22 @@ abstract class AbstractApiTestCase extends ApiTestCase
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$role = new Role('test_'.$suffix, 'Test Role '.$suffix, false);
$role->addPermission($permission);
foreach ($permissionCodes as $permissionCode) {
/** @var null|Permission $permission */
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => $permissionCode]);
self::assertNotNull(
$permission,
sprintf(
'Permission "%s" introuvable en base. Assurez-vous que `app:sync-permissions` a ete execute.',
$permissionCode,
),
);
$role->addPermission($permission);
}
$em->persist($role);
$user = new User();