From 4ae3265925f02e007966de022970d4568cc4ddc0 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 8 Jun 2026 10:26:55 +0200 Subject: [PATCH] docs(commercial) : aligner spec-back M2 sur le code (security PATCH, gating par ajout, anti-N+1, types colonnes) --- docs/specs/M2-suppliers/spec-back.md | 34 ++++++++++++++++++---------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/docs/specs/M2-suppliers/spec-back.md b/docs/specs/M2-suppliers/spec-back.md index 6906ef3..5d3e542 100644 --- a/docs/specs/M2-suppliers/spec-back.md +++ b/docs/specs/M2-suppliers/spec-back.md @@ -126,7 +126,7 @@ Toutes les entités métier nouvelles implémentent `TimestampableInterface` + ` Notes (miroir M1) : - **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un fournisseur existant. Compta ne peut pas **créer** un fournisseur (pas de `manage` global). -- **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back, 2 niveaux : `security` API Platform + `SupplierProvider`). +- **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back). Mécanisme réel (le code fait foi) : le groupe de lecture `supplier:read:accounting` n'est **pas** dans le contexte de sérialisation par défaut ; le `SupplierReadGroupContextBuilder` ne l'**ajoute** dynamiquement que si l'utilisateur porte `commercial.suppliers.accounting.view` (gating **par ajout** de groupe, jamais par retrait). Sans la permission, les champs comptables (et les RIB) ne sont donc jamais sérialisés. La colonne SIREN de l'export XLSX suit la même règle (`accounting.view`). - **Bureau** : `view` + `manage` (tout sauf Comptabilité). - **Usine** : aucune permission → item sidebar invisible, accès direct 403. @@ -159,9 +159,11 @@ final class SupplierFieldNormalizer Le formatage `XX XX XX XX XX` est fait à l'affichage côté front. Le back stocke `0612345678` (chiffres seuls). -### 2.12 Liste : embed catégories + sites + fetch-joins (cohérence M1/ERP-62) +### 2.12 Liste : embed catégories + sites + hydratation anti-N+1 (cohérence M1/ERP-62) -Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis. Conséquence performance : le `DoctrineSupplierRepository` **DOIT** poser des **fetch-joins** (`leftJoin`+`addSelect`) sur `categories` et `addresses.sites` dans la requête de liste pour éviter le N+1. Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas. +Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis. + +Conséquence performance — **implémentation réelle (le code fait foi)** : le `DoctrineSupplierRepository` **ne fetch-joine PAS** les to-many dans la requête de sélection (`createListQueryBuilder` ne fait que filtres + tri). L'anti-N+1 passe par `hydrateListCollections()` (puis `hydrateContacts()`) : une fois le jeu de fournisseurs borné (page ou export), des requêtes **`IN` bornées séparées** remplissent `categories`, puis `addresses.sites`, puis `contacts` sur les **mêmes** instances `Supplier` (identity map). Ce découpage évite le **produit cartésien** qu'un fetch-join combiné `categories × addresses.sites` imposerait aux chemins non paginés (export, `?pagination=false`). Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas. > Dépendance confirmée sur le JSON réel (#82 mergé) : `Category` expose `code`/`name` sous `category:read` ; `Site` expose `name`/`postalCode`/`city`/`color` sous `site:read` (**pas de `code`**). L'embed est pleinement matérialisé. @@ -213,6 +215,8 @@ Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrati > **Rappel règle ABSOLUE n°12** : chaque colonne créée ci-dessous DOIT recevoir son `COMMENT ON COLUMN`. Les 4 colonnes Timestampable/Blamable passent par le helper `addStandardTimestampableBlamableComments($schema, '')`. Le SQL ci-dessous montre la structure ; les `COMMENT ON COLUMN` (un par colonne métier) sont à écrire dans la migration (exemples §3.2.bis). +> **Types réels de la migration (le code fait foi)** : le SQL ci-dessous est *illustratif*. La migration mergée (`Version20260605130000`) utilise le **style aligné M1** : clés primaires en `INT GENERATED BY DEFAULT AS IDENTITY` (et **non** `SERIAL`) et horodatages en `TIMESTAMP(0) WITHOUT TIME ZONE` (et **non** `TIMESTAMPTZ`, car le `TimestampableBlamableTrait` mappe `datetime_immutable`). Garantit que `schema:update` reste un no-op une fois les entités mappées. + ```sql -- ===================================================================== -- Seed taxonomie : nouveau type FOURNISSEUR (référentiels comptables = M1, non recréés) @@ -422,8 +426,10 @@ use Symfony\Component\Validator\Constraints as Assert; // Cohérence M1/ERP-62 : la LISTE embarque catégories + sites (pas de // champ dérivé aplati). Maillon (c) : category:read + site:read dans // le contexte pour exposer Category(code/name) + Site(name/postalCode). - // ⚠ Le SupplierRepository DOIT fetch-join categories + addresses.sites - // pour éviter le N+1 sur la liste (cf. § 2.12). + // ⚠ Anti-N+1 : pas de fetch-join dans la requête de liste — le + // SupplierRepository hydrate categories/sites/contacts via des requêtes + // IN bornées séparées (hydrateListCollections), pour éviter le produit + // cartésien sur les chemins non paginés (export) — cf. § 2.12. normalizationContext: ['groups' => [ 'supplier:read', 'category:read', @@ -442,13 +448,14 @@ use Symfony\Component\Validator\Constraints as Assert; normalizationContext: ['groups' => [ 'supplier:read', 'supplier:item:read', // embed contacts / addresses - 'supplier:read:accounting', // scalaires compta + embed ribs (filtré par le Provider selon accounting.view) + // ⚠ supplier:read:accounting est volontairement ABSENT ici : il est + // AJOUTÉ dynamiquement par le SupplierReadGroupContextBuilder quand + // l'user porte accounting.view (gating par ajout, pas par retrait — + // parade bug #4 M1). Il porte les scalaires compta + l'embed ribs. 'category:read', // embed des Category (id/code/name) — relation imbriquée 'site:read', // embed des Site (id/name/postalCode/city/color, pas de code) — relation imbriquée 'default:read', ]], - // Le Provider RETIRE supplier:read:accounting du contexte si l'user - // n'a pas is_granted('commercial.suppliers.accounting.view'). provider: SupplierProvider::class, ), new Post( @@ -458,10 +465,13 @@ use Symfony\Component\Validator\Constraints as Assert; processor: SupplierProcessor::class, ), new Patch( - security: "is_granted('commercial.suppliers.manage')", - // Le SupplierProcessor inspecte les groupes envoyés pour autoriser - // onglet par onglet (cf. § 2.10 + § 5). Patch des champs comptables - // exige is_granted('commercial.suppliers.accounting.manage') ; + // Security élargie : `manage` OU `accounting.manage` — le rôle Compta + // n'a pas `manage` mais doit pouvoir éditer l'onglet Comptabilité d'un + // fournisseur existant (§ 2.9). Le SupplierProcessor re-gate ensuite + // onglet par onglet (mode strict RG-2.16) : + security: "is_granted('commercial.suppliers.manage') or is_granted('commercial.suppliers.accounting.manage')", + // Patch des champs comptables exige accounting.manage (guardAccounting) ; + // champs main/information exigent manage (guardManage) ; // patch isArchived exige is_granted('commercial.suppliers.archive'). normalizationContext: ['groups' => ['supplier:read', 'default:read']], denormalizationContext: ['groups' => [