Compare commits

..

3 Commits

Author SHA1 Message Date
Matthieu 380b0c9ef1 test(commercial) : SupplierExportControllerTest sur base fournisseurs (catégories FOURNISSEUR, dédup F3) (ERP-113)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m29s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m6s
Fait étendre SupplierExportControllerTest à AbstractSupplierApiTestCase au
lieu d'AbstractCommercialApiTestCase. Supprime le seedSupplier() privé (qui
seedait des catégories de type CLIENT via createCategory(), violant RG-2.10)
et le tearDown() redondant, désormais portés par la base sur des catégories
FOURNISSEUR.

Le contact principal utilise le helper addContact() de la base ; le téléphone
secondaire, non porté par ce helper, est posé via le setter sur le contact
retourné. L'assertion de la colonne Catégories dérive le libellé attendu de
supplierCategory('NEGOCIANT') au lieu de hardcoder le préfixe de nom de test.
2026-06-08 09:49:33 +02:00
Matthieu 1343c1b797 test(commercial) : fix CI anti-N+1 (profiling test) + durcissement 422/gating M2 fournisseurs (ERP-92)
- config/packages/test/doctrine.yaml : force dbal profiling en test pour que
  doctrine.debug_data_holder existe sous APP_DEBUG=0 (CI). Le test anti-N+1
  SupplierListTest passait en local (debug=1) mais cassait en CI.
- RBACMatrix/SupplierApi : les 422 RG-2.03 et RG-2.14 assertent desormais le
  propertyPath / message (plus seulement le code) — un 422 orthogonal ne peut
  plus faire passer le test.
- RBACMatrix : gating bureau/commerciale verifie l'ensemble des champs
  comptables (accountNumber/nTva/tvaMode/paymentType), plus seulement siren/ribs.
- violationsByPath() mutualise dans AbstractSupplierApiTestCase (dedup).
2026-06-08 09:49:33 +02:00
Matthieu 2a17e9c45c test(commercial) : tests PHPUnit M2 fournisseurs (matrice RG + contrat sérialisation + DoD JSON réel) (ERP-92)
Suite fonctionnelle M2 assertant sur le CORPS JSON (jamais les annotations),
jumelle de la suite clients M1 :
- contrat de sérialisation : 4 régressions M1 re-testées (RIB gaté absent pour
  Commerciale, booléens triageProvider/isArchived présents, embed
  categories[].code/name, embed sites[].name/postalCode objet) + enveloppe AP4
  (member/totalItems/view, archivés exclus) + suppression du contact inline ;
- matrice RBAC réelle (app:seed-rbac) bureau/compta/commerciale/usine 200/403,
  gating accounting par omission de clé, mode strict PATCH (RG-2.16) ;
- RG-2.03/2.04/2.05/2.06/2.07/2.08/2.09/2.10/2.11/2.12/2.14/2.15/2.17 ;
- sous-ressources contacts/adresses/ribs (CRUD, sécurité, normalisation) ;
- anti N+1 liste (compte de requêtes constant), audit Supplier + RIB iban/bic.

Fix de contrat découvert et corrigé (sinon DoD figée sur un contrat faux) :
les référentiels comptables (TvaMode/PaymentType/PaymentDelay/Bank) ne portaient
que le groupe client:read:accounting (M1) → sur un fournisseur ils sortaient en
IRI nu. Ajout de supplier:read:accounting → objet {id, code, label} embarqué.

makefile : test-db-setup recrée l'index partiel uq_supplier_company_name_active
(droppé par schema:update comme pour le client) — oubli M2.

DoD § 4.0.bis : réponses JSON RÉELLES (liste + détail admin/commerciale) collées,
capturées via SupplierSerializationContractTest.
2026-06-08 09:49:33 +02:00
45 changed files with 300 additions and 1128 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_active` car Doctrine ORM ne sait
# partiel `uq_category_name_type_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_active ON category (LOWER(name)) WHERE deleted_at IS NULL"
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"
- 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.96'
app.version: '0.1.89'
+12 -22
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). 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`).
- **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back, 2 niveaux : `security` API Platform + `SupplierProvider`).
- **Bureau** : `view` + `manage` (tout sauf Comptabilité).
- **Usine** : aucune permission → item sidebar invisible, accès direct 403.
@@ -159,11 +159,9 @@ 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 + hydratation anti-N+1 (cohérence M1/ERP-62)
### 2.12 Liste : embed catégories + sites + fetch-joins (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 — **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é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é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é.
@@ -215,8 +213,6 @@ 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)
@@ -426,10 +422,8 @@ 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).
// ⚠ 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.
// ⚠ Le SupplierRepository DOIT fetch-join categories + addresses.sites
// pour éviter le N+1 sur la liste (cf. § 2.12).
normalizationContext: ['groups' => [
'supplier:read',
'category:read',
@@ -448,14 +442,13 @@ use Symfony\Component\Validator\Constraints as Assert;
normalizationContext: ['groups' => [
'supplier:read',
'supplier:item:read', // embed contacts / addresses
// ⚠ 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.
'supplier:read:accounting', // scalaires compta + embed ribs (filtré par le Provider selon accounting.view)
'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(
@@ -465,13 +458,10 @@ use Symfony\Component\Validator\Constraints as Assert;
processor: SupplierProcessor::class,
),
new Patch(
// 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) ;
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') ;
// patch isArchived exige is_granted('commercial.suppliers.archive').
normalizationContext: ['groups' => ['supplier:read', 'default:read']],
denormalizationContext: ['groups' => [
+5 -12
View File
@@ -420,24 +420,17 @@
"noCategories": "Aucune catégorie pour l'instant.",
"table": {
"name": "Nom",
"types": "Types"
},
"filters": {
"title": "Filtres",
"search": "Recherche",
"types": "Types de catégorie",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
"type": "Type"
},
"form": {
"name": "Nom",
"types": "Types de catégorie",
"typesPlaceholder": "Sélectionner un ou plusieurs types"
"type": "Type de catégorie",
"typePlaceholder": "Sélectionner un type"
},
"validation": {
"nameRequired": "Le nom est obligatoire.",
"nameLength": "Le nom doit faire entre 2 et 120 caractères.",
"typesRequired": "Sélectionnez au moins un type de catégorie."
"typeRequired": "Le type de catégorie est obligatoire."
},
"delete": {
"title": "Supprimer la catégorie",
@@ -447,7 +440,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à.",
"duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.",
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
}
}
@@ -24,18 +24,16 @@
required
/>
<!-- 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"
<!-- 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"
:options="typeOptions"
:label="t('admin.categories.form.types')"
:empty-option-label="t('admin.categories.form.typesPlaceholder')"
:error="form.errors.categoryTypes"
:display-tag="true"
:label="t('admin.categories.form.type')"
:empty-option-label="t('admin.categories.form.typePlaceholder')"
:error="form.errors.categoryType"
: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',
categoryTypes: [TYPE_VENTE],
categoryType: 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 (multi-types)', () => {
it('pre-remplit le formulaire depuis une categorie existante', () => {
const form = useCategoryForm()
form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
form.loadFrom(CAT)
expect(form.name.value).toBe('Vis')
expect(form.categoryTypeIds.value).toEqual([1, 2])
expect(form.categoryTypeId.value).toBe(1)
expect(form.errors).toEqual({})
})
it('vide le formulaire en mode creation (null)', () => {
const form = useCategoryForm()
form.name.value = 'old'
form.categoryTypeIds.value = [99]
form.categoryTypeId.value = 99
form.loadFrom(null)
expect(form.name.value).toBe('')
expect(form.categoryTypeIds.value).toEqual([])
expect(form.categoryTypeId.value).toBeNull()
})
it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
@@ -98,32 +98,13 @@ 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.categoryTypeIds.value = [1]
form.categoryTypeId.value = 1
const ok = form.validate()
@@ -134,7 +115,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name est whitespace-only (trim → vide)', () => {
const form = useCategoryForm()
form.name.value = ' '
form.categoryTypeIds.value = [1]
form.categoryTypeId.value = 1
const ok = form.validate()
@@ -145,7 +126,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
const form = useCategoryForm()
form.name.value = 'A'
form.categoryTypeIds.value = [1]
form.categoryTypeId.value = 1
const ok = form.validate()
@@ -156,7 +137,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.categoryTypeIds.value = [1]
form.categoryTypeId.value = 1
const ok = form.validate()
@@ -164,21 +145,21 @@ describe('useCategoryForm', () => {
expect(form.errors.name).toBe('admin.categories.validation.nameLength')
})
it('signale erreur si aucun type selectionne (RG-1.05)', () => {
it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeIds.value = []
form.categoryTypeId.value = null
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.categoryTypes).toBe('admin.categories.validation.typesRequired')
expect(form.errors.categoryType).toBe('admin.categories.validation.typeRequired')
})
it('passe quand name et au moins un type sont valides', () => {
it('passe quand name et categoryType sont valides', () => {
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeIds.value = [1, 2]
form.categoryTypeId.value = 1
const ok = form.validate()
@@ -190,7 +171,7 @@ describe('useCategoryForm', () => {
const form = useCategoryForm()
// Erreur prealable : une validation en echec peuple errors.name.
form.name.value = ''
form.categoryTypeIds.value = [1]
form.categoryTypeId.value = 1
form.validate()
expect(form.errors.name).toBeTruthy()
@@ -203,17 +184,17 @@ describe('useCategoryForm', () => {
})
describe('submitCreate', () => {
it('appelle POST /categories avec body { name trimme, categoryTypes en IRI[] }', async () => {
it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => {
mockPost.mockResolvedValueOnce(CAT)
const form = useCategoryForm()
form.name.value = ' Vis '
form.categoryTypeIds.value = [1, 2]
form.categoryTypeId.value = 1
const result = await form.submitCreate()
expect(mockPost).toHaveBeenCalledWith(
'/categories',
{ name: 'Vis', categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
{ name: 'Vis', categoryType: '/api/category_types/1' },
{ toast: false },
)
expect(result).toEqual(CAT)
@@ -222,7 +203,7 @@ describe('useCategoryForm', () => {
it('ne declenche aucun appel API si la validation client echoue', async () => {
const form = useCategoryForm()
form.name.value = ''
form.categoryTypeIds.value = [1]
form.categoryTypeId.value = 1
const result = await form.submitCreate()
@@ -234,7 +215,7 @@ describe('useCategoryForm', () => {
mockPost.mockResolvedValueOnce(CAT)
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeIds.value = [1]
form.categoryTypeId.value = 1
await form.submitCreate()
@@ -250,7 +231,7 @@ describe('useCategoryForm', () => {
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeIds.value = [1]
form.categoryTypeId.value = 1
const result = await form.submitCreate()
@@ -277,7 +258,7 @@ describe('useCategoryForm', () => {
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeIds.value = [1]
form.categoryTypeId.value = 1
const result = await form.submitCreate()
@@ -288,24 +269,24 @@ describe('useCategoryForm', () => {
expect(mockToastError).not.toHaveBeenCalled()
})
it('mappe une violation sur categoryTypes (hydra:violations alternative)', async () => {
it('mappe aussi hydra:violations (negociation de format alternative)', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: {
'hydra:violations': [
{ propertyPath: 'categoryTypes', message: 'Sélectionnez au moins un type de catégorie.' },
{ propertyPath: 'categoryType', message: 'Type invalide.' },
],
},
},
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeIds.value = [1]
form.categoryTypeId.value = 1
await form.submitCreate()
expect(form.errors.categoryTypes).toBe('Sélectionnez au moins un type de catégorie.')
expect(form.errors.categoryType).toBe('Type invalide.')
})
it('fallback en toast generique si le status n est ni 409 ni 422', async () => {
@@ -314,7 +295,7 @@ describe('useCategoryForm', () => {
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeIds.value = [1]
form.categoryTypeId.value = 1
await form.submitCreate()
@@ -333,7 +314,7 @@ describe('useCategoryForm', () => {
)
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeIds.value = [1]
form.categoryTypeId.value = 1
const pending = form.submitCreate()
expect(form.submitting.value).toBe(true)
@@ -350,28 +331,28 @@ describe('useCategoryForm', () => {
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
const form = useCategoryForm()
form.loadFrom(CAT)
form.name.value = 'Vis V2' // types inchanges
form.name.value = 'Vis V2' // categoryTypeId inchange
await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ name: 'Vis V2' }, // pas de categoryTypes car non modifies
{ name: 'Vis V2' }, // pas de categoryType car non modifie
{ toast: false },
)
})
it('envoie categoryTypes en IRI[] quand on ajoute un type', async () => {
mockPatch.mockResolvedValueOnce({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
it('envoie categoryType en IRI quand seul le type a change', async () => {
mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT })
const form = useCategoryForm()
form.loadFrom(CAT)
form.categoryTypeIds.value = [1, 2]
form.categoryTypeId.value = 2
await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
{ categoryType: '/api/category_types/2' },
{ toast: false },
)
})
@@ -457,7 +438,7 @@ describe('useCategoryForm', () => {
form.reset()
expect(form.name.value).toBe('')
expect(form.categoryTypeIds.value).toEqual([])
expect(form.categoryTypeId.value).toBeNull()
expect(form.errors).toEqual({})
expect(form.submitting.value).toBe(false)
})
@@ -13,10 +13,9 @@
* revalide toujours (defense en profondeur).
*
* Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les
* violations 422 sont mappees par `propertyPath` (`name`, `categoryTypes`) ;
* violations 422 sont mappees par `propertyPath` (`name`, `categoryType`) ;
* l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon
* de nom GLOBAL, RG-1.07) reste un cas metier specifique : erreur inline sur
* `name` + toast.
* 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'
@@ -43,29 +42,20 @@ 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 categoryTypeIds = ref<number[]>([])
const categoryTypeId = ref<number | null>(null)
// 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 initialCategoryTypeIds = ref<number[]>([])
const initialCategoryTypeId = ref<number | null>(null)
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
|| !sameIds(categoryTypeIds.value, initialCategoryTypeIds.value),
|| categoryTypeId.value !== initialCategoryTypeId.value,
)
/**
@@ -76,16 +66,15 @@ 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
categoryTypeIds.value = [...ids]
categoryTypeId.value = category.categoryType.id
initialName.value = category.name
initialCategoryTypeIds.value = [...ids]
initialCategoryTypeId.value = category.categoryType.id
} else {
name.value = ''
categoryTypeIds.value = []
categoryTypeId.value = null
initialName.value = ''
initialCategoryTypeIds.value = []
initialCategoryTypeId.value = null
}
}
@@ -106,23 +95,23 @@ export function useCategoryForm() {
formErrors.setError('name', t('admin.categories.validation.nameLength'))
}
// RG-1.05 — au moins un type obligatoire.
if (categoryTypeIds.value.length === 0) {
formErrors.setError('categoryTypes', t('admin.categories.validation.typesRequired'))
// RG-1.05 — categoryType obligatoire.
if (categoryTypeId.value === null) {
formErrors.setError('categoryType', t('admin.categories.validation.typeRequired'))
}
return !formErrors.errors.name && !formErrors.errors.categoryTypes
return !formErrors.errors.name && !formErrors.errors.categoryType
}
/**
* 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.
* 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.
*/
function buildCreatePayload(): Record<string, unknown> {
return {
name: name.value.trim(),
categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`),
categoryType: `/api/category_types/${categoryTypeId.value}`,
}
}
@@ -185,8 +174,8 @@ export function useCategoryForm() {
if (name.value !== initialName.value) {
payload.name = name.value.trim()
}
if (!sameIds(categoryTypeIds.value, initialCategoryTypeIds.value)) {
payload.categoryTypes = categoryTypeIds.value.map(id => `/api/category_types/${id}`)
if (categoryTypeId.value !== initialCategoryTypeId.value) {
payload.categoryType = `/api/category_types/${categoryTypeId.value}`
}
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
@@ -244,9 +233,9 @@ export function useCategoryForm() {
*/
function reset(): void {
name.value = ''
categoryTypeIds.value = []
categoryTypeId.value = null
initialName.value = ''
initialCategoryTypeIds.value = []
initialCategoryTypeId.value = null
formErrors.clearErrors()
submitting.value = false
}
@@ -254,7 +243,7 @@ export function useCategoryForm() {
return {
// State
name,
categoryTypeIds,
categoryTypeId,
errors: formErrors.errors,
submitting,
isDirty,
@@ -3,28 +3,13 @@
<PageHeader>
{{ t('admin.categories.title') }}
<template #actions>
<!-- 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>
<MalioButton
v-if="canManage"
:label="t('admin.categories.newCategory')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
</template>
</PageHeader>
@@ -62,60 +47,6 @@
: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>
@@ -124,7 +55,7 @@ import type { Category } from '~/modules/catalog/types/category'
const { t } = useI18n()
const { can } = usePermissions()
const { types, fetchTypes } = useCategoriesAdmin()
const { fetchTypes } = useCategoriesAdmin()
const { submitDelete } = useCategoryForm()
useHead({ title: t('admin.categories.title') })
@@ -143,7 +74,6 @@ const {
fetch: fetchCategories,
goToPage,
setItemsPerPage,
setFilters,
} = usePaginatedList<Category>({ url: '/categories' })
const drawerOpen = ref(false)
@@ -152,96 +82,21 @@ const deleteModalOpen = ref(false)
const categoryToDelete = ref<Category | null>(null)
const deleting = ref(false)
// Colonnes du datatable. Les types sont embarques cote API (ManyToMany) — on
// aplatit en libelles joints par une virgule pour l'affichage.
// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) —
// on aplatit en label lisible pour l'affichage.
const columns = [
{ key: 'name', label: t('admin.categories.table.name') },
{ key: 'typesLabel', label: t('admin.categories.table.types') },
{ key: 'typeLabel', label: t('admin.categories.table.type') },
]
const categoryItems = computed(() =>
categories.value.map(cat => ({
id: cat.id,
name: cat.name,
typesLabel: (cat.categoryTypes ?? []).map(ct => ct.label).join(', '),
typeLabel: cat.categoryType?.label ?? '',
})),
)
// ── 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)
}
+10 -11
View File
@@ -4,15 +4,15 @@
* Contrats API consommes :
* - GET /api/categories → HydraCollection<Category>
* - GET /api/categories/{id} → Category
* - POST /api/categories → body { name, categoryTypes: IRI[] }
* - PATCH /api/categories/{id} → body partiel { name?, categoryTypes?: IRI[] }
* - POST /api/categories → body { name, categoryType: IRI }
* - PATCH /api/categories/{id} → body partiel { name?, categoryType?: 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"]).
* - `categoryTypes` est embarque (groupe Serializer `category:read` sur les
* proprietes de CategoryType) : tableau d'objets type en lecture.
* - 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).
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
*/
@@ -43,8 +43,7 @@ export interface CategoryType {
export interface Category {
id: number
name: string
/** Types de la categorie (>= 1, ManyToMany embarque en lecture). */
categoryTypes: CategoryType[]
categoryType: CategoryType
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
deletedAt: string | null
createdAt: string
@@ -54,12 +53,12 @@ export interface Category {
}
/**
* Payload accepte en POST /api/categories. `categoryTypes` est un tableau
* d'IRI Hydra (ex. `['/api/category_types/3', '/api/category_types/5']`).
* Payload accepte en POST /api/categories. `categoryType` est envoye en
* IRI Hydra (ex. `/api/category_types/3`).
*/
export interface CategoryCreateInput {
name: string
categoryTypes: string[]
categoryType: string
}
/**
@@ -68,5 +67,5 @@ export interface CategoryCreateInput {
*/
export interface CategoryUpdateInput {
name?: string
categoryTypes?: string[]
categoryType?: string
}
+2 -3
View File
@@ -207,8 +207,7 @@ 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_active` (M0 Catalog) : unicite GLOBALE du nom parmi
# les actifs (M:N categorie<->type), tests RG-1.07.
# - `uq_category_name_type_active` (M0 Catalog) : 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
@@ -227,7 +226,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_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_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_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 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');
$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');
}
// =================================================================
-149
View File
@@ -1,149 +0,0 @@
<?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,
));
}
}
+20 -54
View File
@@ -19,18 +19,14 @@ 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 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).
* fournisseurs, prestataires). Porte un `name` libre et un `categoryType`
* (FK vers le referentiel statique CategoryType).
*
* - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par
* defaut les categories supprimees (cf. CategoryProvider, ticket 0.3).
@@ -85,11 +81,12 @@ 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_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.
// 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.
#[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]
@@ -129,21 +126,11 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
#[Groups(['category:read'])]
private ?string $code = null;
/**
* 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.')]
#[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.')]
#[Groups(['category:read', 'category:write'])]
private Collection $categoryTypes;
private ?CategoryType $categoryType = null;
/**
* Soft delete : null = active, valeur = supprimee logiquement le {date}.
@@ -154,11 +141,6 @@ 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;
@@ -191,42 +173,26 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
return $this;
}
/**
* @return Collection<int, CategoryType>
*/
public function getCategoryTypes(): Collection
public function getCategoryType(): ?CategoryType
{
return $this->categoryTypes;
return $this->categoryType;
}
public function addCategoryType(CategoryType $categoryType): static
public function setCategoryType(?CategoryType $categoryType): static
{
if (!$this->categoryTypes->contains($categoryType)) {
$this->categoryTypes->add($categoryType);
}
return $this;
}
public function removeCategoryType(CategoryType $categoryType): static
{
$this->categoryTypes->removeElement($categoryType);
$this->categoryType = $categoryType;
return $this;
}
/**
* 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>
* Implemente CategoryInterface : code du type rattache (ou null). Permet
* aux modules tiers de filtrer/valider par type metier sans dependre de
* Catalog.
*/
public function getCategoryTypeCodes(): array
public function getCategoryTypeCode(): ?string
{
return array_values(array_filter(
$this->categoryTypes->map(static fn (CategoryType $t): ?string => $t->getCode())->toArray(),
));
return $this->categoryType?->getCode();
}
public function getDeletedAt(): ?DateTimeImmutable
@@ -23,26 +23,10 @@ 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 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).
* - $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).
* - 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,
?string $nameSearch = null,
array $typeIds = [],
): QueryBuilder;
public function createListQueryBuilder(bool $includeDeleted = false, ?string $typeCode = null): 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_active — unicite GLOBALE du nom parmi les
* actifs) est traduite en HTTP 409 avec le message attendu par la spec (RG-1.07).
* l'index partiel uq_category_name_type_active) 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,12 +78,10 @@ final class CategoryProcessor implements ProcessorInterface
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// 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.
// RG-1.07 : doublon (LOWER(name), category_type_id) parmi les non-soft-deleted.
throw new HttpException(
409,
sprintf('Une catégorie nommée "%s" existe déjà.', $data->getName() ?? ''),
sprintf('Une catégorie nommée "%s" existe déjà pour ce type.', $data->getName() ?? ''),
$e,
);
}
@@ -40,12 +40,7 @@ final class CategoryProvider implements ProviderInterface
$includeDeleted = $this->readIncludeDeleted($context);
if ($operation instanceof CollectionOperationInterface) {
$qb = $this->repository->createListQueryBuilder(
$includeDeleted,
$this->readTypeCode($context),
$this->readNameSearch($context),
$this->readTypeIds($context),
);
$qb = $this->repository->createListQueryBuilder($includeDeleted, $this->readTypeCode($context));
// Echappatoire ?pagination=false : retourne la collection complete sans Paginator.
// Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un <select>.
@@ -120,48 +115,4 @@ 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->addCategoryType($type);
$category->setCategoryType($type);
$manager->persist($category);
}
}
@@ -48,19 +48,9 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
return [] !== $qb->getQuery()->getResult();
}
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.
public function createListQueryBuilder(bool $includeDeleted = false, ?string $typeCode = null): QueryBuilder
{
$qb = $this->createQueryBuilder('c')
->leftJoin('c.categoryTypes', 'cte')
->addSelect('cte')
->orderBy('c.name', 'ASC')
;
@@ -68,45 +58,16 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
$qb->andWhere('c.deletedAt IS NULL');
}
// 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.
// 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.
if (null !== $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()))
$qb->join('c.categoryType', 'ct')
->andWhere('ct.code = :typeCode')
->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,10 +43,7 @@ final class ClientInformationCompletenessValidator
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
// 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.',
sprintf('Ce champ est obligatoire pour le role Commerciale (champ "%s").', $property),
null,
[],
$client,
@@ -47,10 +47,7 @@ final class SupplierInformationCompletenessValidator
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
// 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.',
sprintf('Ce champ est obligatoire pour le rôle Commerciale (champ "%s").', $property),
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: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'Ce champ 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 PORTANT ce type sont autorisees sur le
* RG-2.10 : seules les categories de ce type sont autorisees sur le
* fournisseur (entite principale). Miroir de SupplierAddress (ERP-88).
* S'appuie sur CategoryInterface::getCategoryTypeCodes() (pas d'import du
* S'appuie sur CategoryInterface::getCategoryTypeCode() (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: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['supplier:read', 'supplier:write:information'])]
private ?string $competitors = null;
@@ -300,17 +300,16 @@ 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::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.
* CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog
* regle ABSOLUE n°1). Joue avant la base via la validation API Platform, sur
* POST (categories supplier:write:main) comme sur PATCH.
*/
#[Assert\Callback]
public function validateCategoryType(ExecutionContextInterface $context): void
{
foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
&& self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) {
$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 PORTANT ce type sont autorisees sur une
* adresse fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes()
* (pas d'import du module Catalog regle ABSOLUE n°1).
* 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).
*/
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
@@ -219,16 +219,15 @@ 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::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.
* CategoryInterface::getCategoryTypeCode() (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
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
&& self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) {
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
->atPath('categories')
->addViolation()
@@ -121,12 +121,6 @@ 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,12 +114,6 @@ 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,11 +57,6 @@ 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;
@@ -82,12 +82,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
*/
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 = [];
@@ -421,28 +415,19 @@ class SupplierFixtures extends Fixture implements DependentFixtureInterface
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([
$category = $manager->getRepository(CategoryInterface::class)->findOneBy([
'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;
}
if (!$category instanceof CategoryInterface) {
throw new RuntimeException(sprintf(
'Categorie "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.',
$name,
));
}
throw new RuntimeException(sprintf(
'Categorie FOURNISSEUR "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.',
$name,
));
return $this->categoryCache[$name] = $category;
}
/**
@@ -35,14 +35,10 @@ interface CategoryInterface
public function getCode(): ?string;
/**
* 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>
* 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.
*/
public function getCategoryTypeCodes(): array;
public function getCategoryTypeCode(): ?string;
}
@@ -50,11 +50,12 @@ final class ColumnCommentsCatalog
],
'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).',
'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.',
'_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.',
] + self::timestampableBlamableComments(),
'category_type' => [
@@ -64,12 +65,6 @@ 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,17 +70,11 @@ 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();
@@ -92,10 +86,7 @@ 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->addCategoryType($type);
foreach ($additionalTypes as $additionalType) {
$category->addCategoryType($additionalType);
}
$category->setCategoryType($type);
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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'categoryType' => '/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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'categoryType' => '/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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'categoryType' => '/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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'categoryType' => '/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_active)
// Deux noms differents (donc autorises par uq_category_name_type_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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'categoryType' => '/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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'categoryType' => '/api/category_types/'.$type->getId(),
],
])->toArray();
@@ -1,137 +0,0 @@
<?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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'categoryType' => '/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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'categoryType' => '/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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'categoryType' => '/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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'categoryType' => '/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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'categoryType' => '/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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'categoryType' => '/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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertSame(201, $response->getStatusCode());
@@ -47,10 +47,9 @@ final class CategoryTypeCodeFilterTest extends AbstractCatalogApiTestCase
'Le filtre ?typeCode= doit ne renvoyer QUE les categories du type demande.',
);
// Chaque categorie remontee doit PORTER le type filtre (multi-types :
// la collection categoryTypes embarquee contient le code demande).
// Tous les types embarques doivent etre le type filtre.
foreach ($members as $member) {
self::assertContains('TEST_FOURNISSEUR', array_column($member['categoryTypes'], 'code'));
self::assertSame('TEST_FOURNISSEUR', $member['categoryType']['code']);
}
}
@@ -69,7 +68,7 @@ final class CategoryTypeCodeFilterTest extends AbstractCatalogApiTestCase
self::assertArrayHasKey('member', $data);
foreach ($data['member'] as $member) {
self::assertContains('TEST_FOURNISSEUR', array_column($member['categoryTypes'], 'code'));
self::assertSame('TEST_FOURNISSEUR', $member['categoryType']['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 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.
* 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.
*
* Cas couverts :
* - 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).
* - 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).
*
* @internal
*/
final class CategoryUniqueTest extends AbstractCatalogApiTestCase
{
public function testDuplicateNameReturns409(): void
public function testDuplicateNameSameTypeReturns409(): 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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'name' => self::TEST_CATEGORY_PREFIX.'unique',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertResponseStatusCodeSame(201);
// 2eme POST : meme name → doublon (unicite globale).
// 2eme POST : meme name + meme type → doublon strict.
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'unique',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'name' => self::TEST_CATEGORY_PREFIX.'unique',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertSame(409, $response->getStatusCode());
// Message attendu par la spec RG-1.07 (reformulee, sans "pour ce type").
// Message attendu par la spec RG-1.07.
$payload = $response->toArray(false);
$description = $payload['description'] ?? $payload['detail'] ?? $payload['hydra:description'] ?? '';
self::assertStringContainsString(
'existe déjà',
'existe déjà pour ce type',
$description,
'Le message d\'erreur 409 doit citer la spec ("existe deja").',
'Le message d\'erreur 409 doit citer la spec ("existe deja pour ce type").',
);
}
@@ -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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'name' => self::TEST_CATEGORY_PREFIX.'Vis',
'categoryType' => '/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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'name' => self::TEST_CATEGORY_PREFIX.'VIS',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertSame(409, $response->getStatusCode());
}
public function testSameNameDifferentTypeReturns409(): void
public function testSameNameDifferentTypeAllowed(): void
{
// RG-1.07 (reformulee) : l'unicite du nom est desormais GLOBALE — le
// meme nom sur deux types differents est un doublon.
// RG-1.07 : la contrainte est SUR (name, type), pas sur name seul.
// Le meme nom doit etre acceptable sur deux types differents.
$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',
'categoryTypes' => ['/api/category_types/'.$type1->getId()],
'name' => self::TEST_CATEGORY_PREFIX.'shared',
'categoryType' => '/api/category_types/'.$type1->getId(),
],
]);
self::assertResponseStatusCodeSame(201);
$response = $client->request('POST', '/api/categories', [
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'shared',
'categoryTypes' => ['/api/category_types/'.$type2->getId()],
'name' => self::TEST_CATEGORY_PREFIX.'shared',
'categoryType' => '/api/category_types/'.$type2->getId(),
],
]);
self::assertSame(409, $response->getStatusCode());
self::assertResponseStatusCodeSame(201);
}
public function testRecreateAfterSoftDeleteAllowed(): void
{
// RG-1.07 : l'index Postgres est partiel (WHERE deleted_at IS NULL).
// Apres un soft delete, le nom est libere et un nouveau POST identique
// doit reussir.
// Apres un soft delete, le couple (name, type) 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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
'categoryType' => '/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 → autorise (nom libere par l'archivage).
// 3) recreation : meme name + meme type → autorise (couple libere).
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
'categoryType' => '/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 : `categoryTypes` au moins un type (Count min 1) ;
* - RG-1.06 : chaque IRI de `categoryTypes` doit pointer un type existant.
* - RG-1.05 : `categoryType` obligatoire ;
* - RG-1.06 : `categoryType` 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' => [
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'categoryType' => '/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' => '',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'name' => '',
'categoryType' => '/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' => ' ',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'name' => ' ',
'categoryType' => '/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,
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'name' => $payloadName,
'categoryType' => '/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',
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'name' => 'A',
'categoryType' => '/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),
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'name' => str_repeat('a', 121),
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
@@ -140,74 +140,71 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => $name,
'categoryTypes' => ['/api/category_types/'.$type->getId()],
'name' => $name,
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertResponseStatusCodeSame(201);
}
// ============ RG-1.05 — au moins un type (Count min 1) ============
// ============ RG-1.05 — categoryType obligatoire ============
public function testCategoryTypesRequiredReturns422(): void
public function testCategoryTypeRequiredReturns422(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'no_type',
// categoryTypes absent -> collection vide -> Count(min:1) viole.
// categoryType absent
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testCategoryTypesEmptyReturns422(): void
public function testCategoryTypeNullIsRejected(): void
{
// Tableau vide explicite : Assert\Count(min: 1) doit declencher 422 avec
// une violation sur le propertyPath `categoryTypes` (consommable inline).
// `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".
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'empty_types',
'categoryTypes' => [],
'name' => self::TEST_CATEGORY_PREFIX.'null_type',
'categoryType' => null,
],
]);
self::assertSame(422, $response->getStatusCode());
$payload = $response->toArray(false);
$violations = $payload['violations'] ?? $payload['hydra:violations'] ?? [];
$paths = array_column($violations, 'propertyPath');
self::assertContains(
'categoryTypes',
$paths,
'La violation Count doit porter le propertyPath `categoryTypes`.',
$response->getStatusCode(),
[400, 422],
'categoryType=null doit etre rejete (400 deserialization ou 422 validation).',
);
}
// ============ RG-1.06 — chaque type doit exister ============
// ============ RG-1.06 — categoryType 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 declenchee). La spec § 4.3
// accepte les deux : on assert le contrat "ne passe pas".
// (resolution IRI echouee) ou 422 (validation NotNull 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',
'categoryTypes' => ['/api/category_types/9999999'],
'name' => self::TEST_CATEGORY_PREFIX.'ghost_type',
'categoryType' => '/api/category_types/9999999',
],
]);
self::assertContains(
$response->getStatusCode(),
[400, 404, 422],
'IRI categoryTypes inexistante doit etre rejetee (400/404/422 selon API Platform).',
'IRI categoryType 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->addCategoryType($this->clientCategoryType());
$category->setCategoryType($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->addCategoryType($this->supplierCategoryType());
$category->setCategoryType($this->supplierCategoryType());
$em->persist($category);
$em->flush();
@@ -15,9 +15,8 @@ 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 (admin ET user minimal a permission
* explicite), dedup F3 (fournisseur multi-categories rendu sur une seule ligne),
* 403 sans commercial.suppliers.view, 401 anonyme.
* commercial.suppliers.accounting.view, 403 sans commercial.suppliers.view,
* 401 anonyme.
*
* @internal
*/
@@ -179,60 +178,6 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
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');
@@ -126,10 +126,6 @@ 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,17 +55,6 @@ 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
@@ -142,17 +131,13 @@ final class SupplierValidationTest extends TestCase
}
/**
* 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
* Double minimal de CategoryInterface (pas d'acces base) renvoyant le code de
* type de categorie voulu seul element regarde par validateCategoryType.
*/
private function category(string ...$typeCodes): CategoryInterface
private function category(string $typeCode): CategoryInterface
{
return new class(array_values($typeCodes)) implements CategoryInterface {
/** @param list<string> $typeCodes */
public function __construct(private readonly array $typeCodes) {}
return new class($typeCode) implements CategoryInterface {
public function __construct(private readonly string $typeCode) {}
public function getId(): ?int
{
@@ -169,10 +154,9 @@ final class SupplierValidationTest extends TestCase
return 'TEST';
}
/** @return list<string> */
public function getCategoryTypeCodes(): array
public function getCategoryTypeCode(): ?string
{
return $this->typeCodes;
return $this->typeCode;
}
};
}
@@ -101,24 +101,6 @@ 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
@@ -193,33 +175,6 @@ 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 {
+12 -36
View File
@@ -90,26 +90,6 @@ 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();
@@ -117,6 +97,17 @@ 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';
@@ -125,22 +116,7 @@ abstract class AbstractApiTestCase extends ApiTestCase
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$role = new Role('test_'.$suffix, 'Test Role '.$suffix, false);
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);
}
$role->addPermission($permission);
$em->persist($role);
$user = new User();