Files
Starseed/docs/specs/M6-produit/spec-back.md
T
matthieu 4207a4ae12
Auto Tag Develop / tag (push) Successful in 11s
feat(catalog) : M6 — Catalogue produits (ERP-197 → ERP-203) (#154)
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
2026-06-25 12:50:14 +00:00

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
Catalog
Sites
Core
Shared

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 Category codée (ERP-78).

Dépendances déjà en place sur develop :

  • CatalogCategory (taxonomie codée, soft delete, CategoryInterface) + CategoryType (référentiel statique, types CLIENT / FOURNISSEUR / PRESTATAIRE seedés). Le type PRODUIT n'est PAS encore seedé — le M6 l'ajoute (§ 2.5).
  • Sites → 3 sites Châtellerault (code 86) / Saint-Jean (17) / Pommevic (82) ; Site.code déjà mappé ; SiteInterface.
  • SharedTimestampableBlamableTrait + 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 Product sous src/Module/Catalog/Domain/Entity/Product.php.
  • Référentiel StorageType sous src/Module/Catalog/Domain/Entity/StorageType.php (§ 2.4).
  • Permissions catalog.products.view / catalog.products.manage ajoutées à CatalogModule::permissions() (§ 5.1).
  • Pas de nouveau layer front (le module catalog n'a pas de layer dédié — les écrans admin du Catalog vivent dans le shell frontend/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 jonction product_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é), relation sites ManyToMany → 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 CategoryType PRODUIT (code PRODUIT, label « Produit ») : ajout dans CategoryTypeFixtures::TYPES ET dans une migration de seed (miroir dev/prod, comme CLIENT/FOURNISSEUR/PRESTATAIRE — cf. CategoryTypeFixtures docblock).
  • Le M6 seede aussi quelques Category de type PRODUIT (ex. provisoires : « Céréales », « Oléagineux », « Aliments du bétail », « Engrais ») pour alimenter le select. Codes auto-générés par CategoryCodeGenerator (slug MAJUSCULE stable).
  • Product.category = ManyToOne Category (obligatoire). Le select du formulaire est filtré ?typeCode=PRODUIT (provider Category existant — filtre typeCode déjà supporté). Lecture du référentiel via catalog.categories.read_ref ou .view (déjà en place).

Garde-fou : on ne contraint pas en base que category soit 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] sur Product. 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) : ajouter audit.entity.catalog_product dans frontend/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 dans EntitiesAreTimestampableBlamableTest::EXCLUDED, miroir CategoryType).

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 sur make 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 par addStandardTimestampableBlamableComments.

-- =====================================================================
-- 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 ...
}

Site appartient au module Sites — on consomme son read-group (site:read), pas de logique inter-module (§ 2.1). Category / StorageType sont dans le même module Catalog.

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 normalizationContext de 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 nameproduct:read
Numéro codeproduct:read
Catégorie categoryproduct:read (embed) category:read (affiche category.name)

DÉTAIL — maillons : states, manufactured, containsMolassesproduct: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, appeler GET /api/products (liste) ET GET /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 : category en objet embarqué (pas IRI nu) ; sites / storageTypes en tableaux d'objets (pas tableaux d'IRI) ; states en tableau de chaînes ; manufactured / containsMolasses présents (booléens). skip_null_values actif → ne pas présumer la présence des champs null.

Capture réelle (ERP-203) : produit créé par un POST réel puis relu, via ProductSerializationContractTest (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 + storageTypes embarqués (la propriété product:read est dans le contexte liste ET détail) : pas besoin d'un appel détail pour les obtenir.
  • category embarque sa collection categoryTypes (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/me pour l'utilisateur courant), pas en objet User embarqué.
  • chaque site embarque l'adresse complète (street, postalCode, city, color, fullAddress — groupe site:read).
  • un StorageType n'expose que id / code / label (sa relation sites n'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 StorageTypeGET /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 une read_ref dé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'] ; tri label 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) :
    1. Normalise code (trim + UPPER) et name (trim) — RG-6.07.
    2. Valide l'unicité globale du code parmi les actifs → 409 sur doublon (RG-6.01).
    3. Force manufactured / containsMolasses à false si states ne contient pas SALE (RG-6.03).
    4. Valide que category est de type PRODUIT (RG-6.05) et que storageTypes ⊆ types disponibles sur les sites choisis (RG-6.06) → 422 sinon.
  • Réponse 201 avec 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é (miroir ClientExportController) 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 :

  1. config/sidebar.php (item + permission ci-dessus),
  2. frontend/tests/e2e/_fixtures/personas.ts (le persona Admin gagne catalog.products.view/manage + expectedAdminLinks ; les personas métier ne gagnent rien),
  3. 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/storageTypes embarqués (objets, pas IRI) ; states tableau ; booléens présents.
  • ProductCodeUniquenessTest : 409 sur doublon de code (actifs) ; réutilisation possible d'un code soft-deleted (index partiel).
  • ProductStatesValidationTest : ≥ 1 état (RG-6.02) ; valeurs hors enum rejetées.
  • ProductConditionalFieldsTest : manufactured/containsMolasses forcés false si pas SALE (RG-6.03).
  • ProductCategoryTypeTest : 422 si category n'est pas de type PRODUIT (RG-6.05).
  • ProductStorageTypeBySiteTest : 422 si un storageType n'est pas disponible sur les sites choisis (RG-6.06).
  • RBAC : Admin OK ; Bureau/Compta/Commerciale/Usine → 403 (view + manage).
  • Architecture (déjà en place, ne pas casser) : ColumnsHaveSqlCommentTest, EntitiesAreTimestampableBlamableTest (whitelister StorageType), 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