Module **M6 — Catalogue produits** (ERP-197 → ERP-203), pile consolidée en une seule MR vers `develop` pour un CI unique. Contenu (commits) : - ERP-197 — permissions `catalog.products.*` + sidebar + 3 miroirs RBAC - ERP-198 — migration schéma M6 (storage_type, product, jonctions, type PRODUIT) - ERP-199 — entités Product + StorageType + repositories + contrat de sérialisation - ERP-200 — ProductProvider + ProductProcessor (unicité code, RG-6.03/05/06, normalisation) - ERP-201 — référentiel StorageType exposé (filtre site) + seed Figma + catégories PRODUIT - ERP-202 — export XLSX du catalogue produits (filtres liste) - ERP-203 — tests PHPUnit RG-6.01→6.10 + capture du contrat JSON produit - fix review M6 — default jsonb mort (states) + constante préfixe storage-type de test Remplace et clôt les MR #148, #149, #150, #151, #152, #153 (commits intégralement inclus ici). --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #154
44 KiB
module, nom, ecran, owner_spec, backup_spec, version, date_redaction, spec_front, maquette_figma, trace_fonctionnelle, lesstime_project_id, lesstime_taskgroup_id, statut_global, depend_de
| module | nom | ecran | owner_spec | backup_spec | version | date_redaction | spec_front | maquette_figma | trace_fonctionnelle | lesstime_project_id | lesstime_taskgroup_id | statut_global | depend_de | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| M6 | Catalogue produit | produits | Matthieu | Tristan | V0.1 | 2026-06-24 | ./spec-front.md | https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1503-34285&p=f&m=dev | uploads/M6-produit-V0.pdf (V0, 15/06/2026, validation client en attente) | 6 | 36 | pret_a_dev |
|
Spec back — Module 6 : Catalogue produit
1. Contexte
Cette spec complète et précise la spec front V0.1 (docx M6-produit-V0, V0 du 15/06/2026, validation client en attente) avec tout ce qui touche au back : décisions d'archi, modèle de données, migration, API REST, RBAC, règles de gestion (RG-6.01 → RG-6.10), tests, hors-périmètre.
Module cible : module EXISTANT Catalog (src/Module/Catalog/) — DÉCISION Matthieu (24/06). Le docx parle d'un « Module 7 — Catalogue produit » rattaché à « l'Administration », mais le projet possède déjà un module Catalog (ID = 'catalog', REQUIRED = true) qui porte Category / CategoryType. « Catalogue produit » y a sa place naturelle : on n'ajoute pas de module, on ajoute l'entité Product (+ le référentiel StorageType) au module Catalog. L'item de menu vit dans la section Administration de la sidebar, sous « Répertoire transporteurs » (cf. § 5.3).
RETEX obligatoire (M1→M5) : ~80 % des frictions venaient du contrat de sérialisation (groupes / sous-ressources / embed), pas du métier. La section § 4.0 applique ce RETEX au M6. On réutilise le pattern Provider/Processor + normalisation serveur + Timestampable/Blamable + audit i18n + soft delete posé aux modules précédents, et la taxonomie
Categorycodée (ERP-78).
Dépendances déjà en place sur develop :
Catalog→Category(taxonomie codée, soft delete,CategoryInterface) +CategoryType(référentiel statique, types CLIENT / FOURNISSEUR / PRESTATAIRE seedés). Le typePRODUITn'est PAS encore seedé — le M6 l'ajoute (§ 2.5).Sites→ 3 sites Châtellerault (code86) / Saint-Jean (17) / Pommevic (82) ;Site.codedéjà mappé ;SiteInterface.Shared→TimestampableBlamableTrait+Subscriber(ERP-52).Core→ User, Role, Permission, Audit, JWT.
1.bis Remise en question du docx (incohérences relevées + résolutions)
Le docx V0 est volontairement léger. Voici les points ambigus ou contradictoires relevés à la relecture, et la décision retenue (validée Matthieu 24/06). Toute la spec qui suit applique ces décisions.
| # | Point du docx | Problème | Décision retenue |
|---|---|---|---|
| C1 | « Module 7 — Catalogue produit » / « Module Administration » | Le projet n'a pas de « Module 7 » ni de module « Administration » ; un module Catalog existe déjà. |
Produit logé dans Catalog ; item sidebar dans la section Administration, sous « Répertoire transporteurs » (§ 2.1 / § 5.3). |
| C2 | Colonnes liste = Nom, Numéro, Catégorie ; formulaire = Nom, Code produit, Catégorie |
« Numéro » (liste) vs « Code produit » (formulaire) : 2 noms pour quoi ? | Même champ : code (= « Numéro » = « Code produit »), saisi, unique global, 409 sur doublon (RG-6.01). La colonne liste « Numéro » affiche code. |
| C3 | « État du produit » : Multi-select obligatoire, valeurs Achat / Vendu / Autre | Les onglets parlent de Achat / Vendu / Aucun → « Autre » ≠ « Aucun ». Et « obligatoire » + « Aucun » se contredisent. | Enum PURCHASE / SALE / OTHER, multi-select, ≥ 1 obligatoire (RG-6.02). « Aucun » des onglets = « ni Achat ni Vendu » (donc OTHER seul). |
| C4 | « Type de stockage » : « liste fournie par Aurore en fonction des sites » | Aucun référentiel de stockage n'existe en base ; la vraie liste est en attente. | Référentiel minimal StorageType créé maintenant (provisoire), seedé avec la liste Figma (node 1503-34285) ; options filtrées par les sites sélectionnés (RG-6.06, § 2.4). À re-seeder quand Aurore livre la liste/le mapping site définitifs (HP-M6-02). |
| C5 | « Catégorie produit » : « Liste des catégories produit » | Le type PRODUIT n'est pas seedé ; aucune catégorie produit n'existe. |
Le M6 seede le CategoryType PRODUIT + quelques Category produit, et le select est filtré ?typeCode=PRODUIT (RG-6.05, § 2.5). |
| C6 | « Fabriqué » / « Contient de la mélasse » : « apparaît si État = Vendu » | Comportement front only ? Que stocke-t-on si l'état n'est plus Vendu ? | Booléens conditionnés à SALE : saisis seulement si l'état contient SALE, sinon forcés false serveur (RG-6.03). |
| C7 | RBAC : Admin = Tout, tous les autres rôles = Non | Très restrictif (admin-only). Confirmé ? | Confirmé : catalog.products.view / .manage attribués au seul rôle Admin (§ 5.2). |
| C8 | Onglets « Fournisseurs » / « Clients » (contrats, prestation de triage, contrats TAF) | Référencent une notion de Contrat (client/fournisseur) inexistante dans le code. | Hors périmètre V0 : onglets rendus en placeholder « en cours de développement » (comme les autres onglets non encore dev). Tracé HP-M6-01 (§ 9). |
2. Décisions d'archi
2.1 Entité Product dans le module Catalog
Ajout au module Catalog (pas de nouveau module — C1) :
- Entité racine
Productsoussrc/Module/Catalog/Domain/Entity/Product.php. - Référentiel
StorageTypesoussrc/Module/Catalog/Domain/Entity/StorageType.php(§ 2.4). - Permissions
catalog.products.view/catalog.products.manageajoutées àCatalogModule::permissions()(§ 5.1). - Pas de nouveau layer front (le module
catalogn'a pas de layer dédié — les écrans admin du Catalog vivent dans le shellfrontend/app//frontend/shared/, comme/admin/categories). Route Nuxt :/admin/products(cf. spec-front).
Référentiels cross-module consommés en relation ORM partagée (PAS d'import de logique) — comme M2→M5 : Product référence Site (Sites) via une relation ORM (ManyToMany). Donnée de référence partagée, aucun service/repository d'un autre module appelé. Category et StorageType appartiennent au même module Catalog → relations internes classiques.
2.2 IDs — convention INT (alignée Catalog / Core)
Product et StorageType s'alignent sur la convention du module Catalog : INT GENERATED BY DEFAULT AS IDENTITY. Horodatages TIMESTAMP(0) WITHOUT TIME ZONE (le TimestampableBlamableTrait mappe datetime_immutable).
2.3 État du produit — multi-valeur states (C3 / RG-6.02)
états est un multi-select : un produit peut être à la fois PURCHASE et SALE. Modélisation : colonne states JSONB NOT NULL DEFAULT '[]' (tableau de chaînes), valeurs autorisées PURCHASE / SALE / OTHER, ≥ 1 (Callback + CHECK de non-vacuité).
Alternative écartée : 3 colonnes booléennes (
is_purchase/is_sale/is_other). Plus simple à requêter mais s'éloigne de la sémantique « multi-select » et multiplie les colonnes. Le JSONB est retenu pour la fidélité au champ unique du docx ; si un besoin de filtrage SQL fin apparaît (HP), on bascule sur une table de jonctionproduct_state.
Pilotage des champs conditionnels (RG-6.03) : manufactured et containsMolasses ne sont saisissables que si states contient SALE ; sinon forcés false côté serveur (Processor) — pas de state machine.
2.4 Référentiel StorageType (C4 / RG-6.06) — PROVISOIRE
Décision Matthieu (24/06) : créer un référentiel minimal en attendant la liste/mapping définitifs d'Aurore. Seed = liste Figma (node 1503-34285).
- Entité
StorageType(Catalog) :id,code(slug MAJUSCULE stable, unique),label(FR affiché), relationsitesManyToMany → Site (sur quels sites ce type de stockage est disponible). - Seed initial (10 valeurs, Figma) : Boisseau, Boisseau dosage, Case, Cellule, Container, Cuve mélasse, Stockage big bag, Stockage palette, Tas, Zone. Provisoirement rattachés aux 3 sites (86/17/82) tant qu'Aurore n'a pas précisé le mapping réel par site.
- Le champ produit « Type de stockage » est un multi-select filtré par les sites sélectionnés dans le formulaire :
GET /api/storage_types?siteId[]=…ne renvoie que les types disponibles sur ces sites (RG-6.06). - Provisoire : codes, libellés et mapping site sont à revalider/re-seeder à réception de la liste Aurore (HP-M6-02). Référentiel en lecture seule au M6 (pas de CRUD admin du StorageType — HP-M6-03).
2.5 Catégorie produit — type PRODUIT (C5 / RG-6.05)
- Le M6 seede le
CategoryTypePRODUIT(codePRODUIT, label « Produit ») : ajout dansCategoryTypeFixtures::TYPESET dans une migration de seed (miroir dev/prod, comme CLIENT/FOURNISSEUR/PRESTATAIRE — cf.CategoryTypeFixturesdocblock). - Le M6 seede aussi quelques
Categoryde type PRODUIT (ex. provisoires : « Céréales », « Oléagineux », « Aliments du bétail », « Engrais ») pour alimenter le select. Codes auto-générés parCategoryCodeGenerator(slug MAJUSCULE stable). Product.category= ManyToOneCategory(obligatoire). Le select du formulaire est filtré?typeCode=PRODUIT(provider Category existant — filtretypeCodedéjà supporté). Lecture du référentiel viacatalog.categories.read_refou.view(déjà en place).
Garde-fou : on ne contraint pas en base que
categorysoit de type PRODUIT (le filtrage est applicatif via le select + une validation#[Assert\Callback]côté Processor qui rejette une catégorie non-PRODUIT en 422). Justification : éviter un couplage SQL fragile au référentiel type.
2.6 Audit & traces temporelles
Pattern Starseed standard (miroir M1→M5) :
#[Auditable]surProduct. Pas de champ sensible (password/token) → pas d'#[AuditIgnore].- Audit des relations (
category,sites,storageTypes) tracé automatiquement (ManyToMany inclus). Product implements TimestampableInterface, BlamableInterface+use TimestampableBlamableTrait(4 colonnes standard).- Libellé i18n (règle ABSOLUE backend —
AuditableEntitiesHaveI18nLabelTest) : ajouteraudit.entity.catalog_productdansfrontend/i18n/locales/fr.json(clé =strtolower(module)+_+strtolower(Entity)=catalog_product). StorageType= référentiel statique en lecture seule → pas de Timestampable/Blamable, pas#[Auditable](whitelister dansEntitiesAreTimestampableBlamableTest::EXCLUDED, miroirCategoryType).
2.7 Soft delete préparé ; pas de Delete exposé au M6
Le docx M6 ne prévoit ni archivage ni suppression (actions = + Ajouter / Exporter / Filtrer). On n'expose pas de Delete. On prépare néanmoins une colonne deleted_at (soft delete technique) non exposée (cohérent avec Category et le pattern M5). Le provider exclut par défaut les produits soft-deleted.
3. Modèle de données
3.1 Diagramme
+------------------+ +------------------------+
| site (Sites) | | category_type (Catalog)| + seed type PRODUIT (§ 2.5)
+------------------+ +------------------------+
^ ^ ^
| | | (ManyToMany existant)
product_ | | storage_type_ |
site | | site +------------------+
| | | category | (type PRODUIT)
+------------------+ +------------------+ +------------------+
| product | | storage_type | ^
| id (PK) | | id (PK) | | category_id (FK, NOT NULL)
| code (UNIQUE) | | code (UNIQUE) |----------+
| name | | label | (product.category ManyToOne)
| states (JSONB) | +------------------+
| manufactured | ^
| contains_molasses| | product_storage_type (ManyToMany)
| category_id (FK) |--------+
| deleted_at |
| created_at/by … |
+------------------+
^ ^
| | product_site (ManyToMany) / product_storage_type (ManyToMany)
+---+
Tables de jonction : product_site (product_id, site_id), product_storage_type (product_id, storage_type_id), storage_type_site (storage_type_id, site_id).
3.2 Migration Doctrine — SQL Postgres (illustratif)
Namespace : DoctrineMigrations (racine migrations/) — fichier migrations/VersionYYYYMMDDHHMMSS.php (postérieur aux migrations existantes).
Même justification qu'aux M1→M5 : FK cross-module (
user,site,category) → le namespace modulaire casserait l'ordre surmake db-reset(exception racine de la règle ABSOLUE n°11).Rappel règle ABSOLUE n°12 : chaque colonne créée DOIT recevoir son
COMMENT ON COLUMN(FR, ≤ 200 car., sémantique + contrainte/RG). Les 4 colonnes Timestampable/Blamable passent paraddStandardTimestampableBlamableComments.
-- =====================================================================
-- Référentiel des types de stockage (provisoire — § 2.4 / RG-6.06)
-- =====================================================================
CREATE TABLE storage_type (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
code VARCHAR(40) NOT NULL,
label VARCHAR(120) NOT NULL
);
CREATE UNIQUE INDEX uq_storage_type_code ON storage_type (code);
CREATE TABLE storage_type_site (
storage_type_id INT NOT NULL REFERENCES storage_type(id) ON DELETE CASCADE,
site_id INT NOT NULL REFERENCES site(id) ON DELETE CASCADE,
PRIMARY KEY (storage_type_id, site_id)
);
-- =====================================================================
-- Table principale `product`
-- =====================================================================
CREATE TABLE product (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
code VARCHAR(50) NOT NULL, -- = « Numéro » liste, unique global (RG-6.01)
name VARCHAR(255) NOT NULL,
states JSONB NOT NULL DEFAULT '[]'::jsonb, -- PURCHASE|SALE|OTHER, >=1 (RG-6.02)
manufactured BOOLEAN NOT NULL DEFAULT FALSE, -- saisi si SALE, sinon false (RG-6.03)
contains_molasses BOOLEAN NOT NULL DEFAULT FALSE, -- saisi si SALE, sinon false (RG-6.03)
category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT, -- type PRODUIT (RG-6.05)
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE, -- soft delete, non exposé (§ 2.7)
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL,
CONSTRAINT chk_product_states_not_empty CHECK (jsonb_array_length(states) >= 1)
);
-- Unicité GLOBALE du code parmi les actifs (soft delete toléré) — index partiel.
CREATE UNIQUE INDEX uq_product_code_active ON product (code) WHERE deleted_at IS NULL;
CREATE INDEX idx_product_category ON product (category_id);
CREATE INDEX idx_product_deleted_at ON product (deleted_at);
CREATE INDEX idx_product_created_by ON product (created_by);
CREATE INDEX idx_product_updated_by ON product (updated_by);
-- =====================================================================
-- Jonctions produit ↔ sites / types de stockage
-- =====================================================================
CREATE TABLE product_site (
product_id INT NOT NULL REFERENCES product(id) ON DELETE CASCADE,
site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
PRIMARY KEY (product_id, site_id)
);
CREATE TABLE product_storage_type (
product_id INT NOT NULL REFERENCES product(id) ON DELETE CASCADE,
storage_type_id INT NOT NULL REFERENCES storage_type(id) ON DELETE RESTRICT,
PRIMARY KEY (product_id, storage_type_id)
);
-- =====================================================================
-- Seed du type de catégorie PRODUIT (§ 2.5) — miroir CategoryTypeFixtures
-- =====================================================================
INSERT INTO category_type (code, label) VALUES ('PRODUIT', 'Produit')
ON CONFLICT (code) DO NOTHING;
3.2.bis Commentaires SQL obligatoires (échantillon)
$this->addSql("COMMENT ON TABLE product IS 'Produits du catalogue (M6 Catalog) — état Achat/Vendu/Autre, sites de disponibilité, catégorie produit, types de stockage.'");
$this->addSql("COMMENT ON COLUMN product.code IS 'Code produit (= « Numéro » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active.'");
$this->addSql("COMMENT ON COLUMN product.states IS 'États du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02). Pilote les champs conditionnels.'");
$this->addSql("COMMENT ON COLUMN product.manufactured IS '« Fabriqué » : saisi uniquement si states contient SALE, sinon forcé false serveur (RG-6.03).'");
$this->addSql("COMMENT ON COLUMN product.contains_molasses IS '« Contient de la mélasse » : saisi uniquement si states contient SALE, sinon forcé false serveur (RG-6.03).'");
$this->addSql("COMMENT ON COLUMN product.category_id IS 'Catégorie produit (FK category, type PRODUIT) — obligatoire, validée applicativement (RG-6.05).'");
$this->addSql("COMMENT ON COLUMN product.deleted_at IS 'Horodatage de suppression logique (soft delete) — non exposé au M6 ; la liste exclut les produits supprimés (§ 2.7).'");
$this->addSql("COMMENT ON TABLE storage_type IS 'Référentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Silo, Tas… (RG-6.06).'");
$this->addSql("COMMENT ON COLUMN storage_type.code IS 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).'");
$this->addSql("COMMENT ON COLUMN storage_type.label IS 'Libellé FR affiché du type de stockage (ex. « Cuve mélasse »).'");
// + COMMENT ON COLUMN sur les tables de jonction (product_site, product_storage_type, storage_type_site)
$this->addStandardTimestampableBlamableComments($schema, 'product');
3.3 Entité Product — squelette (extrait)
Pattern jumeau de Category (#[Auditable], TimestampableBlamableTrait, soft delete). Chaque propriété affichée porte un read-group (RETEX M1).
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\ProductProcessor;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\ProductProvider;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository;
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagée (§ 2.1)
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
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;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.products.view')",
normalizationContext: ['groups' => ['product:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
provider: ProductProvider::class,
),
new Get(
security: "is_granted('catalog.products.view')",
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
provider: ProductProvider::class,
),
new Post(
security: "is_granted('catalog.products.manage')",
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
denormalizationContext: ['groups' => ['product:write']],
processor: ProductProcessor::class,
),
new Patch(
security: "is_granted('catalog.products.manage')",
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
denormalizationContext: ['groups' => ['product:write']],
provider: ProductProvider::class,
processor: ProductProcessor::class,
),
// Pas de Delete au M6 (docx) ; soft delete préparé non exposé (§ 2.7).
],
)]
#[ORM\Entity(repositoryClass: DoctrineProductRepository::class)]
#[ORM\Table(name: 'product')]
#[Auditable]
class Product implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
#[Groups(['product:read'])]
private ?int $id = null;
/** Code produit (= « Numéro » liste), unique global, saisi (RG-6.01). */
#[ORM\Column(length: 50)]
#[Assert\NotBlank(message: 'Le code produit est obligatoire.')]
#[Assert\Length(max: 50, maxMessage: 'Le code produit ne peut pas dépasser {{ limit }} caractères.')]
#[Groups(['product:read', 'product:write'])]
private ?string $code = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'Le nom du produit est obligatoire.')]
#[Assert\Length(max: 255, maxMessage: 'Le nom du produit ne peut pas dépasser {{ limit }} caractères.')]
#[Groups(['product:read', 'product:write'])]
private ?string $name = null;
/** États (multi-select) ⊆ {PURCHASE, SALE, OTHER}, ≥ 1 (RG-6.02). */
#[ORM\Column(type: 'json')]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')]
#[Assert\All([new Assert\Choice(choices: ['PURCHASE', 'SALE', 'OTHER'], message: 'État de produit invalide.')])]
#[Groups(['product:read', 'product:write'])]
private array $states = [];
#[ORM\Column(options: ['default' => false])]
#[Groups(['product:read', 'product:write'])]
private bool $manufactured = false; // saisi si SALE, sinon false (RG-6.03)
#[ORM\Column(name: 'contains_molasses', options: ['default' => false])]
#[Groups(['product:read', 'product:write'])]
private bool $containsMolasses = false; // saisi si SALE, sinon false (RG-6.03)
#[ORM\ManyToOne(targetEntity: Category::class)]
#[ORM\JoinColumn(name: 'category_id', nullable: false, onDelete: 'RESTRICT')]
#[Assert\NotNull(message: 'La catégorie produit est obligatoire.')]
#[Groups(['product:read', 'product:write'])]
private ?Category $category = null; // type PRODUIT, validé Callback (RG-6.05)
/** @var Collection<int, Site> Sites de disponibilité (≥ 1, RG-6.04). */
#[ORM\ManyToMany(targetEntity: Site::class)]
#[ORM\JoinTable(name: 'product_site')]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un site.')]
#[Groups(['product:read', 'product:write'])]
private Collection $sites;
/** @var Collection<int, StorageType> Types de stockage (≥ 1, filtrés par sites — RG-6.06). */
#[ORM\ManyToMany(targetEntity: StorageType::class)]
#[ORM\JoinTable(name: 'product_storage_type')]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de stockage.')]
#[Groups(['product:read', 'product:write'])]
private Collection $storageTypes;
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $deletedAt = null; // soft delete, non exposé (§ 2.7)
public function __construct()
{
$this->sites = new ArrayCollection();
$this->storageTypes = new ArrayCollection();
}
// RG-6.03 (champs conditionnels SALE) + RG-6.05 (catégorie de type PRODUIT)
// + RG-6.06 (types de stockage ⊆ sites) : cohérence via #[Assert\Callback] (§ 7).
// ... getters/setters ...
}
⚠
Siteappartient au module Sites — on consomme son read-group (site:read), pas de logique inter-module (§ 2.1).Category/StorageTypesont dans le même moduleCatalog.
4. API REST (API Platform)
4.0 Contrat de sérialisation (RETEX M1 — section critique)
Leçon M1→M5 : pour chaque champ affiché (liste OU détail), les 3 maillons : (a) groupe sur la propriété, (b) groupe dans le
normalizationContextde l'opération, (c) read-group de l'entité imbriquée présent dans le contexte parent.
Contexte par opération :
| Opération | normalizationContext (groupes) |
|---|---|
GetCollection (liste) |
product:read + category:read + site:read + storage_type:read + default:read |
Get / Post / Patch (détail) |
+ product:item:read |
LISTE — colonne datatable → maillons (docx p.3 : Nom, Numéro, Catégorie) :
| Colonne affichée | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) |
|---|---|---|---|
| Nom | name ∈ product:read |
✅ | — |
| Numéro | code ∈ product:read |
✅ | — |
| Catégorie | category ∈ product:read (embed) |
✅ | category:read ✅ (affiche category.name) |
DÉTAIL — maillons : states, manufactured, containsMolasses ∈ product:read ; sites (embed site:read) + storageTypes (embed storage_type:read) ∈ product:read (ensembles bornés → embed autorisé, ne viole pas la règle n°13). Rien de spécifique en product:item:read au-delà des relations (tout le produit tient en liste) — product:item:read réservé si on ajoute des champs détail-only ultérieurement.
4.0.bis Réponse JSON de référence (DoD — CAPTURÉ sur l'API réelle, ERP-203)
Definition of Done (miroir M2→M5) : créer un produit via
POST /api/products, appelerGET /api/products(liste) ETGET /api/products/{id}(détail), coller la réponse JSON réelle ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON. Pièges re-testés :categoryen objet embarqué (pas IRI nu) ;sites/storageTypesen tableaux d'objets (pas tableaux d'IRI) ;statesen tableau de chaînes ;manufactured/containsMolassesprésents (booléens).skip_null_valuesactif → ne pas présumer la présence des champs null.Capture réelle (ERP-203) : produit créé par un
POSTréel puis relu, viaProductSerializationContractTest(régénérable :PRODUCT_DOD_DUMP=1→/tmp/product-dod-{list,detail}.json). Valeurs ci-dessous reformatées avec des libellés lisibles ; les clés sont celles de la réponse réelle. Écarts notables vs l'esquisse initiale, à connaître côté front :
- La LISTE porte déjà
sites+storageTypesembarqués (la propriétéproduct:readest dans le contexte liste ET détail) : pas besoin d'un appel détail pour les obtenir.categoryembarque sa collectioncategoryTypes(utile pour vérifier le type PRODUIT côté front, RG-6.05) plus ses métadonnées d'audit (createdAt/updatedAt/createdBy/updatedBy).createdBy/updatedBy(produit et catégorie) sortent en IRI (/api/mepour l'utilisateur courant), pas en objet User embarqué.- chaque
siteembarque l'adresse complète (street,postalCode,city,color,fullAddress— groupesite:read).- un
StorageTypen'expose queid/code/label(sa relationsitesn'est pas sérialisée — § 2.4).
GET /api/products (LISTE) — enveloppe Hydra AP4 (member/totalItems/view) :
{
"@context": "/api/contexts/Product",
"@id": "/api/products",
"@type": "Collection",
"totalItems": 1,
"member": [
{
"@id": "/api/products/34",
"@type": "Product",
"id": 34,
"code": "BLE-TENDRE-01",
"name": "Blé tendre",
"states": ["PURCHASE", "SALE"],
"manufactured": true,
"containsMolasses": true,
"category": {
"@id": "/api/categories/12",
"@type": "Category",
"id": 12,
"name": "Céréales",
"code": "CEREALES",
"categoryTypes": [
{ "@id": "/api/category_types/5", "@type": "CategoryType", "id": 5, "code": "PRODUIT", "label": "Produit" }
],
"createdAt": "2026-06-25T12:09:27+02:00",
"updatedAt": "2026-06-25T12:09:27+02:00",
"createdBy": "/api/me",
"updatedBy": "/api/me"
},
"sites": [
{
"@id": "/api/sites/1",
"@type": "Site",
"id": 1,
"name": "Chatellerault",
"code": "86",
"street": "14 All. d'Argenson",
"postalCode": "86100",
"city": "Châtellerault",
"color": "#056CF2",
"createdAt": "2026-06-25T11:32:33+02:00",
"updatedAt": "2026-06-25T11:32:33+02:00",
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
}
],
"storageTypes": [
{ "@id": "/api/storage_types/9", "@type": "StorageType", "id": 9, "code": "TAS", "label": "Tas" }
],
"createdAt": "2026-06-25T12:09:28+02:00",
"updatedAt": "2026-06-25T12:09:28+02:00",
"createdBy": "/api/me",
"updatedBy": "/api/me"
}
],
"view": { "@id": "/api/products?search=BLE-TENDRE-01", "@type": "PartialCollectionView" }
}
GET /api/products/34 (DÉTAIL) — même structure que la ligne de liste (les sites / storageTypes sont déjà embarqués en liste ; product:item:read est réservé à d'éventuels champs détail-only ultérieurs) :
{
"@context": "/api/contexts/Product",
"@id": "/api/products/34",
"@type": "Product",
"id": 34,
"code": "BLE-TENDRE-01",
"name": "Blé tendre",
"states": ["PURCHASE", "SALE"],
"manufactured": true,
"containsMolasses": true,
"category": {
"@id": "/api/categories/12",
"@type": "Category",
"id": 12,
"name": "Céréales",
"code": "CEREALES",
"categoryTypes": [
{ "@id": "/api/category_types/5", "@type": "CategoryType", "id": 5, "code": "PRODUIT", "label": "Produit" }
],
"createdAt": "2026-06-25T12:09:27+02:00",
"updatedAt": "2026-06-25T12:09:27+02:00",
"createdBy": "/api/me",
"updatedBy": "/api/me"
},
"sites": [
{
"@id": "/api/sites/1",
"@type": "Site",
"id": 1,
"name": "Chatellerault",
"code": "86",
"street": "14 All. d'Argenson",
"postalCode": "86100",
"city": "Châtellerault",
"color": "#056CF2",
"createdAt": "2026-06-25T11:32:33+02:00",
"updatedAt": "2026-06-25T11:32:33+02:00",
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
}
],
"storageTypes": [
{ "@id": "/api/storage_types/9", "@type": "StorageType", "id": 9, "code": "TAS", "label": "Tas" }
],
"createdAt": "2026-06-25T12:09:28+02:00",
"updatedAt": "2026-06-25T12:09:28+02:00",
"createdBy": "/api/me",
"updatedBy": "/api/me"
}
4.1 Query params (LISTE)
| Param | Effet |
|---|---|
?page / ?itemsPerPage |
pagination standard (10 / 25 / 50, défaut 10) |
?search= |
recherche sur code et name |
?categoryId= ou ?categoryCode= |
filtre par catégorie (drawer « Filtrer », docx p.3) |
?state= |
filtre par état (PURCHASE / SALE / OTHER) — drawer « Filtrer » |
?siteId[]= |
filtre par site de disponibilité |
?order[name]=asc |
tri (défaut : name ASC) |
Pagination obligatoire (règle ABSOLUE n°13) — provider ORM via ApiPlatform\Doctrine\Orm\Paginator, jamais d'array brut.
4.2 Référentiel StorageType — GET /api/storage_types
- Opérations :
GetCollection+Get(lecture seule au M6 — § 2.4). - Sécurité :
is_granted('catalog.products.view')(référentiel servant le formulaire produit). (Si un autre rôle doit lire ce référentiel sans accès produit, ajouter uneread_refdédiée — non requis au M6 vu le RBAC admin-only.) ?siteId[]=…: filtre les types disponibles sur les sites passés (alimente le multi-select « Type de stockage » filtré par les sites cochés — RG-6.06).?pagination=false: échappatoire select (référentiel borné ≤ quelques dizaines) — règle frontend.normalizationContext: ['storage_type:read']; trilabel ASC.
4.3 POST /api/products (création)
- Le client envoie :
code,name,states[],manufactured,containsMolasses,category(IRI),sites[](IRI),storageTypes[](IRI). - Le Processor (
ProductProcessor) :- Normalise
code(trim + UPPER) etname(trim) — RG-6.07. - Valide l'unicité globale du
codeparmi les actifs → 409 sur doublon (RG-6.01). - Force
manufactured/containsMolassesàfalsesistatesne contient pasSALE(RG-6.03). - Valide que
categoryest de type PRODUIT (RG-6.05) et questorageTypes ⊆types disponibles sur lessiteschoisis (RG-6.06) → 422 sinon.
- Normalise
- Réponse
201avec le produit complet.
4.4 PATCH /api/products/{id} (modification)
- Mise à jour partielle, mêmes règles. Le mode strict PATCH s'applique (RETEX M1) : un champ hors-permission dans le payload = 403 global (ici un seul niveau
manage, donc surface réduite). - Re-validation unicité
code(en excluant le produit courant). Re-force des conditionnels (RG-6.03).
4.5 Export — GET /api/products/export.xlsx
- Exporte toute la liste des produits (docx : bouton « Exporter » → « Exporte toute la liste des produits »), filtres actifs appliqués.
- Colonnes : Numéro (
code), Nom, États (Achat/Vendu/Autre joints), Catégorie, Sites, Types de stockage, Fabriqué, Contient mélasse. - Génération via le helper XLSX standard projet (skill
xlsx) — controller dédié (miroirClientExportController) OU provider binaire ; whitelisté pagination (EXCLUDED) car export complet.
5. RBAC, module & sidebar
5.1 CatalogModule::permissions() — ajout
// Ajouts M6 (à insérer dans CatalogModule::permissions()) :
['code' => 'catalog.products.view', 'label' => 'Voir les produits'],
['code' => 'catalog.products.manage', 'label' => 'Gérer les produits (créer, éditer)'],
Synchronisation : app:sync-permissions.
5.2 Matrice rôle → permissions (docx p.3 — admin-only, C7)
| Rôle | …products.view |
…products.manage |
|---|---|---|
| Admin | ✅ | ✅ |
| Bureau | ❌ | ❌ |
| Compta | ❌ | ❌ |
| Commerciale | ❌ | ❌ |
| Usine | ❌ | ❌ |
Très restrictif : le Catalogue produit est admin-only (docx). Item sidebar masqué pour tous les autres rôles. (Si plus tard Bureau doit consulter, ajouter
catalog.products.viewà son rôle dans les 3 miroirs.)
5.3 Sidebar (config/sidebar.php)
Nouvel item dans la section « Administration » existante, placé juste sous « Répertoire transporteurs » (/carriers) — DÉCISION Matthieu (24/06) :
[
'label' => 'sidebar.catalog.products',
'to' => '/admin/products',
'icon' => 'mdi:package-variant-closed',
'module' => 'catalog',
'permission' => 'catalog.products.view',
],
5.4 Règle ABSOLUE n°8 — 3 miroirs RBAC
Toute permission catalog.products.* doit être posée simultanément dans :
config/sidebar.php(item + permission ci-dessus),frontend/tests/e2e/_fixtures/personas.ts(le persona Admin gagnecatalog.products.view/manage+expectedAdminLinks; les personas métier ne gagnent rien),src/Module/Core/Infrastructure/Console/SeedE2ECommand.php(miroir back du même persona Admin).
6. Normalisation serveur (RG-6.07)
ProductFieldNormalizer (miroir CategoryProcessor / CarrierFieldNormalizer), appelé par le Processor avant validation :
code→ trim + UPPER (cohérent avec la stratégie de codes stables du Catalog).name→ trim (rejet 422 si vide après trim — RG-6.01/6.02 sur le name de Category, même garde-fou).
7. Règles de gestion (RG)
| RG | Source | Énoncé |
|---|---|---|
| RG-6.01 | docx+back | code produit (= « Numéro » liste) obligatoire, unique global parmi les actifs, normalisé (trim/UPPER), 409 sur doublon. |
| RG-6.02 | docx+back | states = multi-select ⊆ {PURCHASE,SALE,OTHER}, ≥ 1 obligatoire (CHECK non-vide + Assert\Count(min:1)). |
| RG-6.03 | docx+back | « Fabriqué » et « Contient de la mélasse » saisissables uniquement si states contient SALE ; sinon forcés false serveur. |
| RG-6.04 | docx | sites (multi-select) obligatoire, ≥ 1 site. |
| RG-6.05 | docx+back | category obligatoire, limitée aux catégories de type PRODUIT (select filtré ?typeCode=PRODUIT + validation Callback 422). |
| RG-6.06 | docx+back | storageTypes (multi-select) obligatoire, ≥ 1, options filtrées par les sites sélectionnés ; référentiel StorageType provisoire (en attente Aurore). |
| RG-6.07 | back | Normalisation serveur : code trim+UPPER, name trim (§ 6). |
| RG-6.08 | back | « Modification » = même formulaire/mêmes règles que « Ajouter » ; bouton « Valider » → « Enregistrer » (docx p.7). Code & contraintes inchangés. |
| RG-6.09 | back | Liste : exclut par défaut les produits soft-deleted ; pas de Delete exposé (§ 2.7). |
| RG-6.10 | back | Onglets « Fournisseurs » / « Clients » = hors périmètre V0 (placeholder), dépendent du module Contrat inexistant (HP-M6-01). |
Cohérence inter-champs (RG-6.03 / 6.05 / 6.06) implémentée via #[Assert\Callback] portant des messages FR + CHECK Postgres (non-vacuité states).
8. Tests (PHPUnit) — make test
ProductSerializationContractTest: capture JSON liste + détail (DoD § 4.0.bis) ;category/sites/storageTypesembarqués (objets, pas IRI) ;statestableau ; booléens présents.ProductCodeUniquenessTest: 409 sur doublon decode(actifs) ; réutilisation possible d'un code soft-deleted (index partiel).ProductStatesValidationTest: ≥ 1 état (RG-6.02) ; valeurs hors enum rejetées.ProductConditionalFieldsTest:manufactured/containsMolassesforcésfalsesi pasSALE(RG-6.03).ProductCategoryTypeTest: 422 sicategoryn'est pas de type PRODUIT (RG-6.05).ProductStorageTypeBySiteTest: 422 si unstorageTypen'est pas disponible sur lessiteschoisis (RG-6.06).- RBAC : Admin OK ; Bureau/Compta/Commerciale/Usine → 403 (view + manage).
- Architecture (déjà en place, ne pas casser) :
ColumnsHaveSqlCommentTest,EntitiesAreTimestampableBlamableTest(whitelisterStorageType),AuditableEntitiesHaveI18nLabelTest(catalog_product),CollectionsArePaginatedTest,EntityConstraintsHaveFrenchMessageTest.
9. Hors périmètre (HP)
| Réf | Sujet |
|---|---|
| HP-M6-01 | Onglets « Fournisseurs » et « Clients » du produit (liaison à des contrats client/fournisseur, « clients en prestation de triage », « contrats TAF »). Dépend d'un module Contrat inexistant. Rendus en placeholder « en cours de développement » au M6 (§ 1.bis C8, RG-6.10). À spécifier quand le module Contrat existera. |
| HP-M6-02 | Liste/mapping définitifs des types de stockage par site (fournis par Aurore). Re-seed du référentiel StorageType + révision du filtrage par site (§ 2.4). |
| HP-M6-03 | CRUD admin du référentiel StorageType (création/édition par un admin). Au M6 : lecture seule + seed. |
| HP-M6-04 | Archivage / suppression d'un produit (non prévu au docx — soft delete préparé mais non exposé, § 2.7). |
| HP-M6-05 | Contrainte SQL « category de type PRODUIT » au niveau base (au M6 : validation applicative seulement, § 2.5). |
10. Tickets Lesstime (à découper — back en tête)
| Ordre | Sujet | Tag |
|---|---|---|
| 0 | Permissions catalog.products.view/manage + sidebar (item sous Transporteurs) + 3 miroirs RBAC |
Backend |
| 1 | Migration : storage_type (+ jonction site) + product (+ jonctions) + seed type PRODUIT + COMMENT |
Backend |
| 2 | Entités Product + StorageType + Repositories + contrat sérialisation |
Backend |
| 3 | ProductProvider + ProductProcessor (unicité code, RG-6.03/6.05/6.06, normalisation) |
Backend |
| 4 | Référentiel StorageType exposé (GetCollection + filtre ?siteId[]) + seed Figma + catégories PRODUIT |
Backend |
| 5 | Export XLSX | Backend |
| 6 | Tests PHPUnit RG-6.01→6.10 + capture contrat JSON | Backend |
| 7 | Page liste /admin/products (usePaginatedList) + drawer filtre + export |
Frontend |
| 8 | Écran Ajouter (champs conditionnels SALE, selects filtrés catégorie/stockage) | Frontend |
| 9 | Écran Modification (« Enregistrer ») + onglets placeholder « en cours de dev » (Fournisseurs / Clients) | Frontend |
| 10 | i18n + libellé audit (catalog_product) |
Frontend |
📦 Tickets Lesstime générés
TaskGroup Lesstime : #36 — M6 — Catalogue produit (projet ERP / Starseed, projectId=6) — créé le 24/06/2026, 11 tickets au statut « Prêt à dev ». Back = Matthieu, Front = Tristan. Chaque ticket porte son prompt d'implémentation .md en pièce jointe (dossier prompts/).
| # | ERP | Ticket | Effort | Tag | Assigné |
|---|---|---|---|---|---|
| 1.1 | ERP-197 | Permissions catalog.products.* + sidebar + 3 miroirs RBAC | S | Backend | Matthieu |
| 1.2 | ERP-198 | Migrer le schéma M6 (storage_type, product, jonctions, type PRODUIT) | M | Backend | Matthieu |
| 1.3 | ERP-199 | Entités Product + StorageType + repositories + contrat sérialisation | M | Backend | Matthieu |
| 1.4 | ERP-200 | ProductProvider + ProductProcessor (unicité code, RG-6.03/05/06) | L | Backend | Matthieu |
| 1.5 | ERP-201 | Exposer le référentiel StorageType + seed Figma + catégories PRODUIT | M | Backend | Matthieu |
| 1.6 | ERP-202 | Export XLSX des produits | S | Backend | Matthieu |
| 1.7 | ERP-203 | Tests PHPUnit RG-6.01→6.10 + capture du contrat JSON | M | Backend | Matthieu |
| 1.8 | ERP-204 | Page liste /admin/products (datatable, filtre, export) | M | Frontend | Tristan |
| 1.9 | ERP-205 | Écran Ajouter un produit (champs conditionnels, selects filtrés) | L | Frontend | Tristan |
| 1.10 | ERP-206 | Écran Modification + onglets placeholder (Fournisseurs/Clients) | M | Frontend | Tristan |
| 1.11 | ERP-207 | i18n + libellé audit catalog_product | S | Frontend | Tristan |