Compare commits

..

6 Commits

Author SHA1 Message Date
gitea-actions 04008f97a9 chore: bump version to v0.1.154
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 40s
2026-06-25 13:02:43 +00:00
tristan 086be7b4f0 fix(logistique) : bon de pesée — cartouche tiers + filtrage des listes contrepartie par site (ERP-208) (#155)
Auto Tag Develop / tag (push) Successful in 14s
## ERP-208 — Fix ticket de pesée

### Bon de pesée (PDF)
Ajout d'un **cartouche bordé en haut à droite** du bon de pesée, contenant le **type de contrepartie** (Client / Fournisseur / Autre, en gras au-dessus) et le **nom du tiers**.
- `WeighingTicket::getCounterpartyName()` + `getCounterpartyTypeLabel()` (testés).
- En-tête du template passé en table 2 colonnes (contrainte Dompdf CSS 2.1).

### Écran de saisie (Ajouter / Modifier)
Les listes **Client / Fournisseur** sont **filtrées sur le site courant** (un tiers est rattaché à un site via les sites de ses adresses) et **rechargées au changement de site**.
- Réutilise le filtre back existant `?siteId[]=` de /clients et /suppliers (aucun changement back sur le filtre).
- Au switch de site : le tiers sélectionné est réinitialisé **uniquement** s'il sort du périmètre du nouveau site.
- Portée limitée au ticket de pesée : les répertoires M1/M2 ne changent pas.

### Tests
- Back : test unitaire `WeighingTicketCounterpartyNameTest` (nom + libellé) ; test PDF existant inchangé.
- Front : specs référentiels + écrans Ajouter/Modifier (673/673).
- Pas de migration, pas de RBAC, pas d'E2E.

### À vérifier en recette
En **modification**, si le tiers d'un ticket n'a pas d'adresse sur le site courant, le select peut s'afficher vide (valeur conservée mais option filtrée).

Reviewed-on: #155
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-25 13:02:31 +00:00
gitea-actions f6c556ca1b chore: bump version to v0.1.153
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 2m48s
2026-06-25 12:50:27 +00:00
matthieu 4207a4ae12 feat(catalog) : M6 — Catalogue produits (ERP-197 → ERP-203) (#154)
Auto Tag Develop / tag (push) Successful in 11s
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
gitea-actions fdd4394e99 chore: bump version to v0.1.152
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 23s
2026-06-25 09:05:46 +00:00
tristan 8085f30077 fix(infra) : copie du dossier templates dans l'image prod (impression bon de pesee)
Auto Tag Develop / tag (push) Successful in 12s
2026-06-25 11:04:34 +02:00
33 changed files with 3054 additions and 36 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.151' app.version: '0.1.154'
+692
View File
@@ -0,0 +1,692 @@
---
# === IDENTITÉ ===
module: M6
nom: "Catalogue produit"
ecran: produits
owner_spec: Matthieu
backup_spec: Tristan
version: V0.1
date_redaction: 2026-06-24
# Historique :
# V0.1 (2026-06-24) — Spec back initiale. Restitution + précisions back du docx fonctionnel
# « M6-produit-V0 » (V0, 15/06/2026, validation client en attente).
# Décisions Matthieu (24/06) :
# (1) Produit logé dans le module EXISTANT `Catalog` (pas de nouveau module) ;
# item sidebar dans la section « Administration », sous « Répertoire transporteurs ».
# (2) « Type de stockage » : référentiel minimal `StorageType` créé maintenant (provisoire,
# en attendant la liste définitive d'Aurore), seedé avec la liste Figma (node 1503-34285).
# (3) « Code produit » = « Numéro » de la liste : MÊME champ, saisi, UNIQUE global (409 doublon).
# (4) « État du produit » : Achat / Vendu / Autre, multi-select, AU MOINS 1 requis
# (corrige l'incohérence « Autre » vs « Aucun » du docx).
# (5) PÉRIMÈTRE V0 = CRUD produit classique uniquement. Les onglets « Fournisseurs » et
# « Clients » sont des PLACEHOLDERS « en cours de développement » (dépendent d'un module
# Contrat inexistant) — hors périmètre, tracés HP-M6-01.
# === LIENS ===
spec_front: ./spec-front.md
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1503-34285&p=f&m=dev"
trace_fonctionnelle: "uploads/M6-produit-V0.pdf (V0, 15/06/2026, validation client en attente)"
# === LIEN LESSTIME ===
lesstime_project_id: 6
lesstime_taskgroup_id: 36 # M6 — Catalogue produit (ERP-197 → ERP-207)
statut_global: pret_a_dev
# === DÉPENDANCES AMONT ===
depend_de:
- Catalog # Module hôte (REQUIRED). Category + CategoryType (ajout du type PRODUIT) + nouveau StorageType + Product
- Sites # Site (relation ManyToMany product↔site) + filtrage des types de stockage par site
- Core # User, Role, Permission, Audit, JWT
- Shared # TimestampableBlamableTrait + Subscriber (ERP-52) + CategoryInterface
---
# Spec back — Module 6 : Catalogue produit
## 1. Contexte
Cette spec **complète et précise** la [spec front V0.1](./spec-front.md) (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`** :
- `Catalog``Category` (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`.
- `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 **`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`.
```sql
-- =====================================================================
-- 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)
```php
$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
<?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 | `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`, 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`) :
```jsonc
{
"@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) :
```jsonc
{
"@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 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
```php
// 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) :
```php
[
'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 |
@@ -0,0 +1,353 @@
# ERP-208 — Fix ticket de pesée — Plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development ou superpowers:executing-plans. Étapes en cases à cocher (`- [ ]`).
**Goal:** Ajouter le nom du tiers dans un cartouche bordé en haut à droite du bon de pesée PDF, et filtrer les listes Client/Fournisseur du formulaire de ticket sur le site courant (avec recharge au changement de site).
**Architecture:** Le filtre back `?siteId[]=` existe déjà sur `/clients` et `/suppliers` (joint adresses→sites) → point 2 = front uniquement. Point 1 = une méthode entité `getCounterpartyName()` + refonte du header du template Twig en table 2 colonnes (Dompdf = CSS 2.1).
**Tech Stack:** PHP 8.4 / Symfony / API Platform / Doctrine / Twig + Dompdf ; Nuxt 4 / Vue 3 / Vitest.
## Global Constraints
- `declare(strict_types=1);` en tête de tout fichier PHP.
- Commentaires en **français**, code (noms) en anglais.
- Front : `useApi()` uniquement, composants `Malio*`, 4 espaces, TS strict.
- Dompdf : **CSS 2.1 uniquement** (pas de flex/grid) → mise en page par tableaux.
- **Aucun commit sans demande explicite de Tristan** (les étapes « commit » sont différées en fin de chantier, sur demande).
- Vérif finale : `make test` + `make nuxt-test` + `make php-cs-fixer-allow-risky`. Pas d'E2E.
---
### Task 1 : `WeighingTicket::getCounterpartyName()` (back)
**Files:**
- Modify: `src/Module/Logistique/Domain/Entity/WeighingTicket.php` (ajout méthode près de `getOtherLabel`, ~ligne 449)
- Test: `tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php` (create)
**Interfaces:**
- Produces: `WeighingTicket::getCounterpartyName(): ?string` — companyName du client/fournisseur ou otherLabel selon `counterpartyType`, null sinon. Consommé par le template Twig (Task 2).
- [ ] **Step 1 : test qui échoue**
```php
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Domain;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use PHPUnit\Framework\TestCase;
final class WeighingTicketCounterpartyNameTest extends TestCase
{
public function testReturnsClientCompanyNameForClientCounterparty(): void
{
$client = (new Client())->setCompanyName('Ferme du Pré');
$ticket = (new WeighingTicket())->setCounterpartyType('CLIENT')->setClient($client);
self::assertSame('Ferme du Pré', $ticket->getCounterpartyName());
}
public function testReturnsSupplierCompanyNameForSupplierCounterparty(): void
{
$supplier = (new Supplier())->setCompanyName('Coop Sud');
$ticket = (new WeighingTicket())->setCounterpartyType('FOURNISSEUR')->setSupplier($supplier);
self::assertSame('Coop Sud', $ticket->getCounterpartyName());
}
public function testReturnsOtherLabelForOtherCounterparty(): void
{
$ticket = (new WeighingTicket())->setCounterpartyType('AUTRE')->setOtherLabel('Particulier');
self::assertSame('Particulier', $ticket->getCounterpartyName());
}
public function testReturnsNullWhenNoCounterparty(): void
{
self::assertNull((new WeighingTicket())->getCounterpartyName());
}
}
```
- [ ] **Step 2 : lancer le test → échec**
`make test` filtré : `docker exec php-starseed-fpm php bin/phpunit tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php`
Attendu : FAIL (`getCounterpartyName` n'existe pas). Vérifier au passage que `Client`/`Supplier` ont bien un constructeur sans argument et `setCompanyName` (sinon adapter l'instanciation du test au pattern existant des entités).
- [ ] **Step 3 : implémentation minimale**
Dans `WeighingTicket.php`, après `getOtherLabel()`/`setOtherLabel()` :
```php
/**
* Nom du tiers à afficher (bon de pesée PDF, ERP-208) : raison sociale du
* client/fournisseur ou libellé libre selon le type de contrepartie (RG-5.03).
* Null si aucune contrepartie cohérente (brouillon).
*/
public function getCounterpartyName(): ?string
{
return match ($this->counterpartyType) {
'CLIENT' => $this->client?->getCompanyName(),
'FOURNISSEUR' => $this->supplier?->getCompanyName(),
'AUTRE' => $this->otherLabel,
default => null,
};
}
```
- [ ] **Step 4 : lancer le test → succès**
`docker exec php-starseed-fpm php bin/phpunit tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php` → PASS.
---
### Task 2 : Cartouche tiers dans le template PDF
**Files:**
- Modify: `templates/logistique/weighing_ticket_print.html.twig`
**Interfaces:**
- Consumes: `ticket.counterpartyName` (Task 1).
- [ ] **Step 1 : ajouter le style du cartouche + header 2 colonnes**
Dans le `<style>`, ajouter :
```css
.header { width: 100%; border-collapse: collapse; }
.header td { vertical-align: top; }
.header .h-right { text-align: right; }
.party-box { display: inline-block; border: 1px solid #000; padding: 8px 12px; min-width: 160px; text-align: center; font-weight: bold; font-size: 12px; }
```
- [ ] **Step 2 : remplacer le bloc logo + identité par une table 2 colonnes**
Remplacer (logo + 3 lignes company) par :
```twig
<table class="header">
<tr>
<td>
{% if logoSrc %}
<div class="logo"><img src="{{ logoSrc }}" alt="LPC LIOT"></div>
{% endif %}
<div class="company-name">SA LIOT Châtellerault</div>
<div class="company-line">Email : lpc.contacts@lpc-liot.fr</div>
<div class="company-line">RCS Châtellerault B 339 505 612</div>
</td>
<td class="h-right">
{% if ticket.counterpartyName %}
<div class="party-box">{{ ticket.counterpartyName }}</div>
{% endif %}
</td>
</tr>
</table>
```
(Le `.title` « Ticket de pesée » et la suite restent inchangés, sous la table.)
- [ ] **Step 3 : vérifier le rendu PDF**
Le test existant `WeighingTicketPrintApiTest` doit rester vert :
`docker exec php-starseed-fpm php bin/phpunit tests/Module/Logistique/Api/WeighingTicketPrintApiTest.php` → PASS (`%PDF`, content-type, disposition inchangés).
---
### Task 3 : `useWeighingTicketReferentials.load(siteId?)` (front)
**Files:**
- Modify: `frontend/modules/logistique/composables/useWeighingTicketReferentials.ts`
- Test: `frontend/modules/logistique/composables/__tests__/useWeighingTicketReferentials.spec.ts` (create)
**Interfaces:**
- Produces: `load(siteId?: number | null): Promise<void>` — passe `siteId[]=<siteId>` aux fetch `/clients` et `/suppliers` quand `siteId` est fourni ; sinon comportement actuel (liste complète).
- [ ] **Step 1 : test qui échoue**
```ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
const getMock = vi.fn()
vi.stubGlobal('useApi', () => ({ get: getMock }))
import { useWeighingTicketReferentials } from '~/modules/logistique/composables/useWeighingTicketReferentials'
describe('useWeighingTicketReferentials', () => {
beforeEach(() => {
getMock.mockReset()
getMock.mockResolvedValue({ member: [] })
})
it('passe siteId[] aux deux endpoints quand un site est fourni', async () => {
const { load } = useWeighingTicketReferentials()
await load(7)
const clientsCall = getMock.mock.calls.find(c => c[0] === '/clients')
const suppliersCall = getMock.mock.calls.find(c => c[0] === '/suppliers')
expect(clientsCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
expect(suppliersCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
})
it('ne passe pas siteId[] quand aucun site (liste complète)', async () => {
const { load } = useWeighingTicketReferentials()
await load(null)
const clientsCall = getMock.mock.calls.find(c => c[0] === '/clients')
expect(clientsCall?.[1]).not.toHaveProperty('siteId[]')
})
})
```
- [ ] **Step 2 : lancer → échec**
`make nuxt-test` (ou ciblé) → FAIL (`load` n'accepte pas d'argument / `siteId[]` absent).
- [ ] **Step 3 : implémentation**
Modifier `fetchAll` et `load` :
```ts
/** Récupère une collection complète (pagination désactivée) en Hydra, filtrée site si fourni. */
async function fetchAll(url: string, siteId?: number | null): Promise<PartyMember[]> {
const query: Record<string, unknown> = { pagination: 'false' }
// Filtre par site courant (ERP-208) : un tiers est rattaché à un site via
// les sites de ses adresses. Param `siteId[]` déjà géré par les providers M1/M2.
if (siteId !== null && siteId !== undefined) {
query['siteId[]'] = [siteId]
}
const res = await api.get<{ member?: PartyMember[] }>(
url,
query,
{ headers: LD_JSON_HEADERS, toast: false },
)
return res.member ?? []
}
async function load(siteId?: number | null): Promise<void> {
await Promise.allSettled([
fetchAll('/clients', siteId).then((list) => {
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
}),
fetchAll('/suppliers', siteId).then((list) => {
suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName }))
}),
])
}
```
- [ ] **Step 4 : lancer → succès**
`make nuxt-test` ciblé sur le spec → PASS.
---
### Task 4 : Brancher site courant + recharge dans new.vue et edit.vue (front)
**Files:**
- Modify: `frontend/modules/logistique/pages/weighing-tickets/new.vue`
- Modify: `frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue`
- Test: `frontend/modules/logistique/pages/__tests__/weighingTicketNew.spec.ts` (étendre)
**Interfaces:**
- Consumes: `useCurrentSite().currentSite` (ref `Site | null`), `useWeighingTicketReferentials().load(siteId?)`, `form.clientIri` / `form.supplierIri` / `referentials.clients` / `referentials.suppliers`.
- [ ] **Step 1 : helper de reset partagé**
Logique commune aux deux pages : après recharge, vider le tiers sélectionné s'il n'est plus dans les options. Implémenté inline dans chaque page (2 lignes) — pas de nouveau composable pour si peu.
- [ ] **Step 2 : new.vue — brancher currentSite + watch**
Remplacer le bloc `onMounted` final :
```ts
const { currentSite } = useCurrentSite()
/** Recharge les référentiels pour le site donné puis purge le tiers devenu hors-site (ERP-208). */
async function reloadReferentials(siteId: number | null): Promise<void> {
await referentials.load(siteId)
if (form.clientIri.value && !referentials.clients.value.some(o => o.value === form.clientIri.value)) {
form.clientIri.value = null
}
if (form.supplierIri.value && !referentials.suppliers.value.some(o => o.value === form.supplierIri.value)) {
form.supplierIri.value = null
}
}
onMounted(() => {
reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
})
// Changement de site pendant la saisie → recharge les listes du nouveau site (ERP-208).
watch(() => currentSite.value?.id, (siteId) => {
reloadReferentials(siteId ?? null).catch(() => {})
})
```
Ajouter `watch` à l'import `vue` et `useCurrentSite` (auto-importé Nuxt — sinon import explicite `~/modules/sites/composables/useCurrentSite`).
- [ ] **Step 3 : edit.vue — même branchement**
Adapter le `onMounted` async existant (qui fait aussi `fetchTicket`/`hydrate`) :
```ts
const { currentSite } = useCurrentSite()
async function reloadReferentials(siteId: number | null): Promise<void> {
await referentials.load(siteId)
if (form.clientIri.value && !referentials.clients.value.some(o => o.value === form.clientIri.value)) {
form.clientIri.value = null
}
if (form.supplierIri.value && !referentials.suppliers.value.some(o => o.value === form.supplierIri.value)) {
form.supplierIri.value = null
}
}
onMounted(async () => {
reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
try {
const detail = await fetchTicket(ticketId)
ticketNumber.value = detail.number ?? ''
form.hydrate(detail)
}
catch {
error.value = true
}
finally {
loading.value = false
}
})
watch(() => currentSite.value?.id, (siteId) => {
reloadReferentials(siteId ?? null).catch(() => {})
})
```
- [ ] **Step 4 : étendre le spec front**
Dans `weighingTicketNew.spec.ts`, ajouter un cas vérifiant que `load` est appelé avec l'id du site courant au montage (mock `useCurrentSite` retournant un `currentSite` avec `id`). Adapter au style de mock déjà en place dans le fichier.
- [ ] **Step 5 : lancer les tests front**
`make nuxt-test` → PASS (specs new/edit + referentials).
---
## Vérification finale
- [ ] `make test` (back) — vert.
- [ ] `make nuxt-test` (front) — vert.
- [ ] `make php-cs-fixer-allow-risky` — pas de diff non voulu.
- [ ] **STOP** : remettre la main à Tristan pour les tests manuels (impression PDF + switch de site). Commits différés jusqu'à sa demande.
## Self-review (couverture spec)
- Point 1 (cartouche PDF nom seul) → Task 1 + Task 2. ✓
- Point 2 (filtre site + recharge au switch + reset-si-absent) → Task 3 + Task 4. ✓
- Définition « lié au site » via adresses → param `siteId[]` (back déjà OK). ✓
- Portée ticket-seulement (pas de modif répertoires) → on n'édite que le composable du ticket + ses pages. ✓
- Pas de migration / RBAC / E2E. ✓
@@ -0,0 +1,124 @@
# ERP-208 — Fix ticket de pesée
> Module : **Logistique (M5)** — écrans « Ajouter / Modifier un ticket de pesée » + bon de pesée imprimé (PDF).
> Branche : `fix/erp-208-ticket-pesee`.
> Date : 2026-06-25.
## 1. Contexte
Le ticket de pesée (M5) est implémenté (ERP-181 → ERP-193). Deux retours client sont
regroupés dans ce fix :
1. **Bon de pesée PDF** : il manque un **cartouche bordé en haut à droite** de la
page contenant le **nom du tiers** (client / fournisseur / champ « autre »). Le
PDF actuel n'affiche que l'identité société (SA LIOT) en haut à gauche.
2. **Écran de saisie** : quand l'utilisateur a **plusieurs sites autorisés**, les
listes déroulantes Client / Fournisseur doivent être **filtrées sur le site
courant** (le tiers est rattaché à un site via les sites de ses adresses), et
**rechargées si l'utilisateur change de site** en restant sur la page.
## 2. État du code existant (constats de cadrage)
- **Le tiers n'a pas de site en propre.** Client (M1) et Supplier (M2) sont
rattachés à un site **via les sites de leurs adresses** (`getSites()` agrège ;
RG-2.06). « Lié au site » = a au moins une adresse rattachée à ce site.
- **Le filtre back existe déjà.** `ClientProvider` / `SupplierProvider` lisent un
filtre répétable `?siteId[]=<id>` (drawers des répertoires M1/M2) et le délèguent
à `createListQueryBuilder(..., array $siteIds, ...)``applySiteIds()` qui joint
`addresses → sites` (`site3.id IN (:siteIds)` / `site4.id IN (:siteIds)`).
**Aucun travail back n'est nécessaire pour le filtre.**
- **La donnée du PDF est déjà chargée.** `DoctrineWeighingTicketRepository::findById()`
fetch-joine `client` et `supplier` ; le `WeighingTicketPrintProvider` charge le
ticket par cette méthode. Le template a donc accès au nom du tiers.
- **Le changement de site est global** (`SiteSelector` header → `useCurrentSite.switchSite`
`PATCH /me/current-site` + `loadSidebar()` + `refreshNuxtData()`). `currentSite`
est un ref singleton de module. Les référentiels du ticket sont chargés en
`onMounted` **uniquement** (pas via `useAsyncData`) → ils ne se rechargent pas au
switch : **c'est le bug du point 2.**
- Le template PDF (`templates/logistique/weighing_ticket_print.html.twig`) est rendu
par Dompdf → **CSS 2.1 uniquement (pas de flexbox/grid)**, mise en page par tableaux.
## 3. Décisions (validées avec Tristan)
| Sujet | Décision |
|---|---|
| Définition « lié au site » | Tiers ayant ≥ 1 adresse rattachée au site sélectionné (via les adresses). |
| Portée du filtre | **Ticket de pesée seulement.** On ne modifie PAS le comportement des répertoires M1/M2 (déjà validés). On réutilise le param `?siteId[]=` existant côté front. |
| Switch de site avec tiers sélectionné | **Reset si absent** : après rechargement, si le tiers sélectionné n'est plus dans la liste du nouveau site, on vide sa valeur (le type de contrepartie reste). S'il y est encore, on le garde. |
| Contenu du cartouche PDF | **Nom seul** (pas de libellé « Client » / « Fournisseur » au-dessus). |
## 4. Conception
### 4.1 Point 1 — Cartouche tiers sur le bon de pesée (back + template)
**a. Résolution du nom — `WeighingTicket::getCounterpartyName(): ?string`**
Nouvelle méthode sur l'entité qui retourne, selon `counterpartyType` :
- `CLIENT``client?->getCompanyName()`
- `FOURNISSEUR``supplier?->getCompanyName()`
- `AUTRE``otherLabel`
- défaut → `null`
Rationale : garde le Twig « bête » (un seul `{{ ticket.counterpartyName }}`) et rend
la logique testable unitairement, sans toucher le provider ni le renderer.
**b. Template `weighing_ticket_print.html.twig`**
Passer le bloc d'en-tête en **table 2 colonnes** (contrainte Dompdf CSS 2.1) :
- colonne gauche (`width:auto`, `vertical-align:top`) : logo + identité société
(contenu **inchangé**) ;
- colonne droite (`text-align:right`, `vertical-align:top`) : un cartouche
`border:1px solid #000; padding:8px;` (largeur fixe, ~200px) contenant
`{{ ticket.counterpartyName }}` (nom seul, en gras).
Le reste du template (titre, table des pesées, poids net) est inchangé.
Cas `counterpartyName` null : en pratique l'impression a lieu après validation, où la
contrepartie est requise (groupe `finalize`). Par robustesse, si null → ne pas rendre
le cartouche (pas de cadre vide).
**c. Provider / renderer** : aucun changement (relations déjà fetch-jointes).
### 4.2 Point 2 — Listes filtrées par site + recharge au switch (front uniquement)
**a. `useWeighingTicketReferentials.ts`**
`load()` accepte un identifiant de site optionnel et l'injecte comme `siteId[]` dans
les requêtes `/clients` et `/suppliers` (en plus de `pagination=false`) :
- site fourni → `{ pagination: 'false', 'siteId[]': [siteId] }` ;
- site absent (`null`) → comportement actuel (liste complète, dégradé gracieux).
**b. Pages `weighing-tickets/new.vue` et `weighing-tickets/[id]/edit.vue`**
- récupèrent `currentSite` via `useCurrentSite()` ;
- `onMounted``referentials.load(currentSite.value?.id ?? null)` ;
- `watch(currentSite)` (sur l'id) → `referentials.load(newId)` puis **reset-si-absent** :
- si `form.clientIri` est défini et absent de `referentials.clients``form.clientIri = null` ;
- si `form.supplierIri` est défini et absent de `referentials.suppliers``form.supplierIri = null` ;
- `counterpartyType` et `otherLabel` ne sont pas touchés.
Note : le reset s'appuie sur les options (IRI `@id`) renvoyées par le référentiel ;
la comparaison se fait sur `value` (l'IRI Hydra).
**c. Cohérence avec la liste des tickets** : la liste `/weighing_tickets` est déjà
cloisonnée par site (provider M5). Filtrer les selects sur le site courant aligne la
saisie sur la liste.
## 5. Tests & vérification
| Niveau | Test | Contenu |
|---|---|---|
| Back (PHPUnit) | unitaire `WeighingTicket::getCounterpartyName()` | 3 cas : CLIENT → companyName, FOURNISSEUR → companyName, AUTRE → otherLabel ; + null si type absent. |
| Back | `WeighingTicketPrintApiTest` (existant) | reste vert (`%PDF`, content-type, disposition). |
| Front (Vitest) | `weighingTicketNew.spec.ts` / `weighingTicketEdit.spec.ts` | `load` passe `siteId[]` quand un site courant existe ; au changement de `currentSite` → rechargement + reset-si-absent du tiers sélectionné. |
Commandes : `make test` + `make nuxt-test` + `make php-cs-fixer-allow-risky`.
Pas de test E2E (règle d'or : Vitest privilégié).
## 6. Hors périmètre / non-objectifs
- Pas de modification du comportement des répertoires Clients / Fournisseurs (M1/M2).
- Pas de nouvelle permission RBAC, pas de migration, pas de changement de schéma.
- Pas de cloisonnement par site « global » sur `/clients` et `/suppliers` (rejeté :
on garde le filtre opt-in via `?siteId[]`).
- L'identité société du PDF reste fixe (décision ERP-192, ne change pas selon le site).
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useWeighingTicketReferentials } from '../useWeighingTicketReferentials'
const mockApiGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
/**
* Tests des référentiels Client/Fournisseur de l'écran ticket de pesée (M5).
* Contrat couvert (ERP-208) : `load(siteId)` filtre les deux endpoints par site
* courant via `siteId[]` ; sans site → listes complètes (param absent).
*/
describe('useWeighingTicketReferentials', () => {
beforeEach(() => {
mockApiGet.mockReset()
mockApiGet.mockResolvedValue({ member: [] })
})
it('passe siteId[] aux deux endpoints quand un site courant est fourni', async () => {
const { load } = useWeighingTicketReferentials()
await load(7)
const clientsCall = mockApiGet.mock.calls.find(c => c[0] === '/clients')
const suppliersCall = mockApiGet.mock.calls.find(c => c[0] === '/suppliers')
expect(clientsCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
expect(suppliersCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
})
it('ne passe pas siteId[] quand aucun site (liste complète)', async () => {
const { load } = useWeighingTicketReferentials()
await load(null)
const clientsCall = mockApiGet.mock.calls.find(c => c[0] === '/clients')
expect(clientsCall?.[1]).not.toHaveProperty('siteId[]')
expect(clientsCall?.[1]).toMatchObject({ pagination: 'false' })
})
it('mappe les membres Hydra en options { value: @id, label: companyName }', async () => {
mockApiGet.mockResolvedValue({ member: [{ '@id': '/api/clients/3', companyName: 'ACME' }] })
const { load, clients } = useWeighingTicketReferentials()
await load(7)
expect(clients.value).toEqual([{ value: '/api/clients/3', label: 'ACME' }])
})
})
@@ -32,11 +32,19 @@ export function useWeighingTicketReferentials() {
const clients = ref<RefOption[]>([]) const clients = ref<RefOption[]>([])
const suppliers = ref<RefOption[]>([]) const suppliers = ref<RefOption[]>([])
/** Récupère une collection complète (pagination désactivée) en Hydra. */ /**
async function fetchAll(url: string): Promise<PartyMember[]> { * Récupère une collection complète (pagination désactivée) en Hydra. Filtre par
* site courant si `siteId` est fourni (ERP-208) : un tiers est rattaché à un site
* via les sites de ses adresses — param `siteId[]` déjà géré par les providers M1/M2.
*/
async function fetchAll(url: string, siteId?: number | null): Promise<PartyMember[]> {
const query: Record<string, unknown> = { pagination: 'false' }
if (siteId !== null && siteId !== undefined) {
query['siteId[]'] = [siteId]
}
const res = await api.get<{ member?: PartyMember[] }>( const res = await api.get<{ member?: PartyMember[] }>(
url, url,
{ pagination: 'false' }, query,
{ headers: LD_JSON_HEADERS, toast: false }, { headers: LD_JSON_HEADERS, toast: false },
) )
return res.member ?? [] return res.member ?? []
@@ -45,14 +53,15 @@ export function useWeighingTicketReferentials() {
/** /**
* Charge en parallèle clients + fournisseurs (résilient : un référentiel en * Charge en parallèle clients + fournisseurs (résilient : un référentiel en
* échec — ex. 403 selon le rôle — laisse simplement son select vide sans * échec — ex. 403 selon le rôle — laisse simplement son select vide sans
* faire échouer l'autre). * faire échouer l'autre). `siteId` (site courant) filtre les listes par site
* (ERP-208) ; absent → listes complètes.
*/ */
async function load(): Promise<void> { async function load(siteId?: number | null): Promise<void> {
await Promise.allSettled([ await Promise.allSettled([
fetchAll('/clients').then((list) => { fetchAll('/clients', siteId).then((list) => {
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName })) clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
}), }),
fetchAll('/suppliers').then((list) => { fetchAll('/suppliers', siteId).then((list) => {
suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName })) suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName }))
}), }),
]) ])
@@ -8,12 +8,13 @@ const mockFetchTicket = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn()) const mockPatch = vi.hoisted(() => vi.fn())
const mockPush = vi.hoisted(() => vi.fn()) const mockPush = vi.hoisted(() => vi.fn())
const mockOpen = vi.hoisted(() => vi.fn()) const mockOpen = vi.hoisted(() => vi.fn())
const mockRefLoad = vi.hoisted(() => vi.fn())
vi.mock('~/modules/logistique/composables/useWeighingTicket', () => ({ vi.mock('~/modules/logistique/composables/useWeighingTicket', () => ({
useWeighingTicket: () => ({ fetchTicket: mockFetchTicket }), useWeighingTicket: () => ({ fetchTicket: mockFetchTicket }),
})) }))
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({ vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }), useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: mockRefLoad }),
})) }))
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({ vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }), useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
@@ -100,6 +101,7 @@ describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit
mockPatch.mockReset().mockResolvedValue({}) mockPatch.mockReset().mockResolvedValue({})
mockPush.mockReset() mockPush.mockReset()
mockOpen.mockReset() mockOpen.mockReset()
mockRefLoad.mockReset().mockResolvedValue(undefined)
}) })
it('charge le ticket au montage (pré-remplissage via hydrate)', async () => { it('charge le ticket au montage (pré-remplissage via hydrate)', async () => {
@@ -107,6 +109,12 @@ describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit
expect(mockFetchTicket).toHaveBeenCalledWith('9') expect(mockFetchTicket).toHaveBeenCalledWith('9')
}) })
it('filtre les référentiels sur le SITE DU TICKET, pas le site courant (ERP-208)', async () => {
await mountPage()
// DETAIL.site.id = 1 → les listes sont chargées pour le site du ticket (immuable).
expect(mockRefLoad).toHaveBeenCalledWith(1)
})
it('ticket validé : action principale « Enregistrer » + « Imprimer » (pas « Valider »)', async () => { it('ticket validé : action principale « Enregistrer » + « Imprimer » (pas « Valider »)', async () => {
const wrapper = await mountPage() const wrapper = await mountPage()
// DETAIL.status = VALIDATED → l'action principale s'intitule « Enregistrer ». // DETAIL.status = VALIDATED → l'action principale s'intitule « Enregistrer ».
@@ -7,9 +7,10 @@ const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn()) const mockPatch = vi.hoisted(() => vi.fn())
const mockPush = vi.hoisted(() => vi.fn()) const mockPush = vi.hoisted(() => vi.fn())
const mockOpen = vi.hoisted(() => vi.fn()) const mockOpen = vi.hoisted(() => vi.fn())
const mockRefLoad = vi.hoisted(() => vi.fn())
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({ vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }), useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: mockRefLoad }),
})) }))
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({ vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }), useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
@@ -23,6 +24,8 @@ vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('usePermissions', () => ({ can: () => true })) vi.stubGlobal('usePermissions', () => ({ can: () => true }))
vi.stubGlobal('navigateTo', vi.fn()) vi.stubGlobal('navigateTo', vi.fn())
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() })) vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
// Site courant (ERP-208) : id 7 → les référentiels doivent être chargés filtrés sur ce site.
vi.stubGlobal('useCurrentSite', () => ({ currentSite: ref({ id: 7, name: 'Site 7', color: '#000000' }) }))
globalThis.open = mockOpen globalThis.open = mockOpen
const NewPage = (await import('../weighing-tickets/new.vue')).default const NewPage = (await import('../weighing-tickets/new.vue')).default
@@ -70,6 +73,12 @@ describe('Écran Ajouter ticket de pesée (page /weighing-tickets/new)', () => {
mockPatch.mockReset().mockResolvedValue({}) mockPatch.mockReset().mockResolvedValue({})
mockPush.mockReset() mockPush.mockReset()
mockOpen.mockReset() mockOpen.mockReset()
mockRefLoad.mockReset().mockResolvedValue(undefined)
})
it('charge les référentiels filtrés sur le site courant au montage (ERP-208)', async () => {
await mountPage()
expect(mockRefLoad).toHaveBeenCalledWith(7)
}) })
it('un seul bouton « Valider » (pas de « Enregistrer » séparé)', async () => { it('un seul bouton « Valider » (pas de « Enregistrer » séparé)', async () => {
@@ -189,7 +189,7 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm' import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge' import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
import { useWeighingTicket } from '~/modules/logistique/composables/useWeighingTicket' import { useWeighingTicket, type WeighingTicketDetail } from '~/modules/logistique/composables/useWeighingTicket'
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials' import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks' import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
import { mapViolationsToRecord } from '~/shared/utils/api' import { mapViolationsToRecord } from '~/shared/utils/api'
@@ -404,12 +404,34 @@ function printTicket(): void {
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank') window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
} }
/**
* Garantit que la contrepartie DÉJÀ ENREGISTRÉE (hydratée depuis le ticket) reste
* affichée même si la liste filtrée par site ne la contient pas (ticket antérieur
* à ERP-208, droits restreints sur /clients, contrepartie hors site…) : on injecte
* son option plutôt que de la purger. Évite toute perte silencieuse de la
* contrepartie en édition (ERP-208, retour review).
*/
function ensureSelectedOptionPresent(detail: WeighingTicketDetail): void {
const client = detail.client
if (client && !referentials.clients.value.some(o => o.value === client['@id'])) {
referentials.clients.value.push({ value: client['@id'], label: client.companyName })
}
const supplier = detail.supplier
if (supplier && !referentials.suppliers.value.some(o => o.value === supplier['@id'])) {
referentials.suppliers.value.push({ value: supplier['@id'], label: supplier.companyName })
}
}
onMounted(async () => { onMounted(async () => {
referentials.load().catch(() => {})
try { try {
const detail = await fetchTicket(ticketId) const detail = await fetchTicket(ticketId)
ticketNumber.value = detail.number ?? '' ticketNumber.value = detail.number ?? ''
form.hydrate(detail) form.hydrate(detail)
// Listes filtrées sur le SITE DU TICKET (immuable, RG-5.09) — pas le site
// courant — et chargées APRÈS hydrate pour ne jamais purger la sélection
// existante (pas de race load/hydrate, ERP-208).
await referentials.load(detail.site?.id ?? null)
ensureSelectedOptionPresent(detail)
} }
catch { catch {
error.value = true error.value = true
@@ -176,7 +176,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm' import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge' import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials' import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
@@ -375,7 +375,28 @@ async function submitValidate(): Promise<void> {
} }
} }
const { currentSite } = useCurrentSite()
/**
* Recharge les référentiels Client/Fournisseur pour le site donné, puis purge le
* tiers sélectionné s'il n'appartient plus à la liste du nouveau site (ERP-208).
*/
async function reloadReferentials(siteId: number | null): Promise<void> {
await referentials.load(siteId)
if (form.clientIri.value && !referentials.clients.value.some(o => o.value === form.clientIri.value)) {
form.clientIri.value = null
}
if (form.supplierIri.value && !referentials.suppliers.value.some(o => o.value === form.supplierIri.value)) {
form.supplierIri.value = null
}
}
onMounted(() => { onMounted(() => {
referentials.load().catch(() => {}) reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
})
// Changement de site pendant la saisie → recharge les listes du nouveau site (ERP-208).
watch(() => currentSite.value?.id, (siteId) => {
reloadReferentials(siteId ?? null).catch(() => {})
}) })
</script> </script>
+1
View File
@@ -18,6 +18,7 @@ COPY config config/
COPY migrations migrations/ COPY migrations migrations/
COPY public public/ COPY public public/
COPY src src/ COPY src src/
COPY templates templates/
RUN composer dump-autoload --optimize --no-dev RUN composer dump-autoload --optimize --no-dev
+3 -1
View File
@@ -128,7 +128,9 @@ final class Version20260625110000 extends AbstractMigration
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(50) NOT NULL, code VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
states JSONB DEFAULT '[]'::jsonb NOT NULL, -- Pas de DEFAULT : un tableau vide violerait chk_product_states_not_empty
-- (RG-6.02). La colonne est toujours renseignee par l'app (Processor/ORM).
states JSONB NOT NULL,
manufactured BOOLEAN DEFAULT FALSE NOT NULL, manufactured BOOLEAN DEFAULT FALSE NOT NULL,
contains_molasses BOOLEAN DEFAULT FALSE NOT NULL, contains_molasses BOOLEAN DEFAULT FALSE NOT NULL,
category_id INT NOT NULL, category_id INT NOT NULL,
+7 -2
View File
@@ -151,7 +151,12 @@ class Product implements TimestampableInterface, BlamableInterface
* *
* @var list<string> * @var list<string>
*/ */
#[ORM\Column(type: 'json')] // jsonb (pas json) : aligne le mapping ORM sur la colonne JSONB creee par la
// migration (spec § 2.3 + CHECK chk_product_states_not_empty via
// jsonb_array_length). Sans `options: ['jsonb' => true]`, schema:update tente
// un ALTER states TYPE JSON qui casse le CHECK (jsonb_array_length(json) inconnu)
// et fait echouer make db-reset / test-db-setup.
#[ORM\Column(type: 'json', options: ['jsonb' => true])]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')] #[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')]
#[Assert\Choice( #[Assert\Choice(
choices: [self::STATE_PURCHASE, self::STATE_SALE, self::STATE_OTHER], choices: [self::STATE_PURCHASE, self::STATE_SALE, self::STATE_OTHER],
@@ -286,7 +291,7 @@ class Product implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
public function containsMolasses(): bool public function isContainsMolasses(): bool
{ {
return $this->containsMolasses; return $this->containsMolasses;
} }
@@ -7,6 +7,7 @@ namespace App\Module\Catalog\Domain\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\StorageTypeProvider;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageTypeRepository; use App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageTypeRepository;
use App\Module\Sites\Domain\Entity\Site; use App\Module\Sites\Domain\Entity\Site;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
@@ -38,16 +39,19 @@ use Symfony\Component\Serializer\Attribute\Groups;
*/ */
#[ApiResource( #[ApiResource(
operations: [ operations: [
// Tri label ASC et filtre ?siteId[]= portes par le StorageTypeProvider
// (ERP-201) : alimente le multi-select « Type de stockage » du formulaire
// produit, filtre par les sites selectionnes (RG-6.06). Pagination Hydra +
// echappatoire ?pagination=false (referentiel borne).
new GetCollection( new GetCollection(
security: "is_granted('catalog.products.view')", security: "is_granted('catalog.products.view')",
normalizationContext: ['groups' => ['storage_type:read']], normalizationContext: ['groups' => ['storage_type:read']],
// Tri alphabetique stable pour alimenter le multi-select du formulaire provider: StorageTypeProvider::class,
// produit (§ 4.2). Le filtre ?siteId[]= est branche en ERP-201.
order: ['label' => 'ASC'],
), ),
new Get( new Get(
security: "is_granted('catalog.products.view')", security: "is_granted('catalog.products.view')",
normalizationContext: ['groups' => ['storage_type:read']], normalizationContext: ['groups' => ['storage_type:read']],
provider: StorageTypeProvider::class,
), ),
], ],
)] )]
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Module\Catalog\Domain\Repository; namespace App\Module\Catalog\Domain\Repository;
use App\Module\Catalog\Domain\Entity\StorageType; use App\Module\Catalog\Domain\Entity\StorageType;
use Doctrine\ORM\QueryBuilder;
interface StorageTypeRepositoryInterface interface StorageTypeRepositoryInterface
{ {
@@ -12,10 +13,19 @@ interface StorageTypeRepositoryInterface
/** /**
* Tous les types de stockage tries par libelle (alimente le multi-select du * Tous les types de stockage tries par libelle (alimente le multi-select du
* formulaire produit — § 4.2). Le filtrage par site (?siteId[]=, RG-6.06) est * formulaire produit — § 4.2).
* branche cote provider en ERP-201.
* *
* @return list<StorageType> * @return list<StorageType>
*/ */
public function findAllOrderedByLabel(): array; public function findAllOrderedByLabel(): array;
/**
* QueryBuilder de la liste des types de stockage (consomme par le
* StorageTypeProvider) : tri `label ASC` (defaut spec § 4.2) et filtre
* optionnel `?siteId[]=` (RG-6.06) restreignant aux types disponibles sur
* AU MOINS UN des sites passes.
*
* @param list<int> $siteIds
*/
public function createListQueryBuilder(array $siteIds = []): QueryBuilder;
} }
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Catalog\Domain\Entity\StorageType;
use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use function is_int;
use function is_string;
/**
* Provider StorageType (referentiel lecture seule, ERP-201) :
* - LISTE : tri `label ASC` (defaut spec § 4.2), filtre `?siteId[]=` (RG-6.06 :
* types disponibles sur au moins un des sites passes) et collection PAGINEE
* Hydra (regle ABSOLUE n°13). Echappatoire `?pagination=false` respectee pour
* alimenter le multi-select « Type de stockage » du formulaire produit
* (referentiel borne — pagination_client_enabled).
* - ITEM : lookup simple par id.
*
* @implements ProviderInterface<StorageType>
*/
final class StorageTypeProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageTypeRepository')]
private readonly StorageTypeRepositoryInterface $repository,
private readonly Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|StorageType|null
{
if ($operation instanceof CollectionOperationInterface) {
$qb = $this->repository->createListQueryBuilder($this->readSiteIds($context));
// Echappatoire ?pagination=false : collection complete sans Paginator
// (alimentation du multi-select, referentiel borne).
if (!$this->pagination->isEnabled($operation, $context)) {
return $qb->getQuery()->getResult();
}
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
// Pas de fetch-join to-many (sites non serialisee) -> Paginator simple.
return new Paginator(new DoctrinePaginator($qb->getQuery()));
}
// Get unitaire.
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
return $this->repository->findById((int) $id);
}
/**
* Lit le filtre `?siteId[]=` : ids des sites coches (OR). Tolere une valeur
* scalaire unique (`?siteId=1`) ou un tableau. Ignore les entrees non numeriques.
*
* @return list<int>
*/
private function readSiteIds(array $context): array
{
$raw = $context['filters']['siteId'] ?? 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));
}
}
@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\Controller;
use App\Module\Catalog\Domain\Entity\Product;
use App\Module\Catalog\Domain\Entity\StorageType;
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use function in_array;
use function is_int;
use function is_string;
/**
* Export XLSX du catalogue produits (M6, spec-back § 4.5). Jumeau des controllers
* d'export ClientExportController (M1) / CarrierExportController (M4) — references
* en prose volontairement (pas de {@see} inter-module : violerait la regle
* ABSOLUE n°1).
*
* Controller Symfony custom (et non operation API Platform) car il produit un
* binaire de fichier, pas une representation Hydra. `priority: 1` est OBLIGATOIRE
* sur la route : sans cela API Platform capterait `/api/products/export.xlsx`
* comme l'item `GET /api/products/{id}.{_format}` (id="export", _format="xlsx")
* — cf. CLAUDE.md « controller custom sous /api ».
*
* Separation des responsabilites :
* - le COMMENT (generation du fichier) est delegue au service Shared
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
* - le QUOI vit ICI : selection des produits (MEMES filtres que
* `GET /api/products` via {@see ProductProvider}, deleguee a
* {@see ProductRepositoryInterface::createListQueryBuilder()} — l'export
* reflete exactement ce que l'utilisateur voit a l'ecran) et mapping metier
* des colonnes. Les produits soft-deleted (RG-6.09) sont toujours exclus, comme
* en liste (le M6 n'expose jamais le soft-delete, § 2.7).
*/
#[AsController]
final class ProductExportController
{
/**
* Libelles FR des etats (RG-6.02) pour la colonne « États ». L'ordre des cles
* fixe l'ordre d'affichage (Achat, Vendu, Autre) independamment de l'ordre de
* stockage en base.
*/
private const array STATE_LABELS = [
Product::STATE_PURCHASE => 'Achat',
Product::STATE_SALE => 'Vendu',
Product::STATE_OTHER => 'Autre',
];
public function __construct(
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')]
private readonly ProductRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
) {}
#[Route('/api/products/export.xlsx', name: 'catalog_products_export_xlsx', methods: ['GET'], priority: 1)]
#[IsGranted('catalog.products.view')]
public function __invoke(Request $request): Response
{
// Memes filtres que la vue liste (ProductProvider) pour que l'export
// reflete exactement ce que l'utilisateur voit a l'ecran : recherche
// (?search), categorie (?categoryId / ?categoryCode), etat (?state),
// sites (?siteId[]). includeDeleted reste false : le soft-delete n'est
// jamais expose au M6 (§ 2.7).
$search = $request->query->getString('search') ?: null;
$categoryId = $this->readIntOrNull($request->query->get('categoryId'));
$categoryCode = $request->query->getString('categoryCode') ?: null;
$state = $this->readState($request->query->get('state'));
$siteIds = $this->readIntList($request->query->all()['siteId'] ?? []);
/** @var list<Product> $products */
$products = $this->repository
->createListQueryBuilder(false, $search, $categoryId, $categoryCode, $state, $siteIds)
->getQuery()
->getResult()
;
$binary = $this->exporter->export(
'Catalogue produits',
$this->buildHeaders(),
$this->buildRows($products),
);
return $this->buildResponse($binary);
}
/**
* Colonnes de l'export (spec § 4.5).
*
* @return list<string>
*/
private function buildHeaders(): array
{
return [
'Numéro',
'Nom',
'États',
'Catégorie',
'Sites',
'Types de stockage',
'Fabriqué',
'Contient mélasse',
];
}
/**
* @param list<Product> $products
*
* @return iterable<list<null|scalar>>
*/
private function buildRows(array $products): iterable
{
foreach ($products as $product) {
yield [
$product->getCode(),
$product->getName(),
$this->formatStates($product),
$product->getCategory()?->getName(),
$this->formatSites($product),
$this->formatStorageTypes($product),
$product->isManufactured() ? 'Oui' : 'Non',
$product->isContainsMolasses() ? 'Oui' : 'Non',
];
}
}
/**
* Libelles FR des etats du produit, dans l'ordre canonique (Achat, Vendu,
* Autre), joints par virgule. Une valeur inattendue est ignoree.
*/
private function formatStates(Product $product): string
{
$states = $product->getStates();
$labels = [];
foreach (self::STATE_LABELS as $code => $label) {
if (in_array($code, $states, true)) {
$labels[] = $label;
}
}
return implode(', ', $labels);
}
/**
* Libelles des sites de disponibilite du produit, dedupliques, tries, joints
* par virgule.
*/
private function formatSites(Product $product): string
{
$names = [];
foreach ($product->getSites() as $site) {
// @var Site $site
$name = $site->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
return $this->joinSorted($names);
}
/**
* Libelles des types de stockage du produit, dedupliques, tries, joints par
* virgule.
*/
private function formatStorageTypes(Product $product): string
{
$labels = [];
foreach ($product->getStorageTypes() as $storageType) {
// @var StorageType $storageType
$label = $storageType->getLabel();
if (null !== $label && '' !== $label) {
$labels[$label] = true;
}
}
return $this->joinSorted($labels);
}
/**
* @param array<string, true> $names ensemble de libelles (cles)
*/
private function joinSorted(array $names): string
{
$list = array_keys($names);
sort($list);
return implode(', ', $list);
}
private function buildResponse(string $binary): Response
{
$filename = sprintf('catalogue-produits-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
$response = new Response($binary);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
return $response;
}
/**
* Lit le filtre `?state=` comme le ProductProvider : normalise en majuscules
* et n'accepte qu'une valeur de l'enum borne {PURCHASE, SALE, OTHER} ; toute
* autre valeur est ignoree (null).
*/
private function readState(mixed $raw): ?string
{
if (!is_string($raw) || '' === trim($raw)) {
return null;
}
$state = mb_strtoupper(trim($raw), 'UTF-8');
return in_array($state, array_keys(self::STATE_LABELS), true) ? $state : null;
}
/**
* Lit un identifiant entier positif unique (`?categoryId=`). Aligne sur
* ProductProvider (tolere int ou chaine numerique).
*/
private function readIntOrNull(mixed $raw): ?int
{
if (is_int($raw)) {
return $raw > 0 ? $raw : null;
}
return is_string($raw) && ctype_digit($raw) && (int) $raw > 0 ? (int) $raw : null;
}
/**
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
* ou liste, `?siteId[]=`). Aligne sur ProductProvider.
*
* @return list<int>
*/
private function readIntList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}
@@ -20,8 +20,9 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories * (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories
* prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport) ; le type * prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport) ; le type
* ADRESSE porte les categories des blocs adresse (Siege, Contact issues, * ADRESSE porte les categories des blocs adresse (Siege, Contact issues,
* Facturation, Livraison, Approvisionnement, Methaniseur). Chaque categorie porte * Facturation, Livraison, Approvisionnement, Methaniseur) ; le type PRODUIT porte
* un `code` stable. * les categories produit du catalogue (M6 ERP-201 : Cereales, Oleagineux, Aliments
* du betail, Engrais). Chaque categorie porte un `code` stable.
* Alimente le repertoire clients (ClientFixtures, module Commercial) avec des * Alimente le repertoire clients (ClientFixtures, module Commercial) avec des
* donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29 * donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
* (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2). * (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
@@ -88,6 +89,15 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
'Approvisionnement' => 'APPROVISIONNEMENT', 'Approvisionnement' => 'APPROVISIONNEMENT',
'Méthaniseur' => 'METHANISEUR', 'Méthaniseur' => 'METHANISEUR',
], ],
// M6 (ERP-201) : categories produit alimentant le select du formulaire
// produit (filtre ?typeCode=PRODUIT). Codes = slug MAJUSCULE deterministe
// (meme sortie que CategoryCodeGenerator). Provisoires, a affiner avec le metier.
'PRODUIT' => [
'Céréales' => 'CEREALES',
'Oléagineux' => 'OLEAGINEUX',
'Aliments du bétail' => 'ALIMENTS_DU_BETAIL',
'Engrais' => 'ENGRAIS',
],
]; ];
public function __construct( public function __construct(
@@ -29,6 +29,10 @@ use Doctrine\Persistence\ObjectManager;
* dediee au champ « Categorie » des blocs adresse (client + fournisseur). Mirroir * dediee au champ « Categorie » des blocs adresse (client + fournisseur). Mirroir
* de la migration Version20260625100000. * de la migration Version20260625100000.
* *
* M6 (ERP-201) : ajout du type PRODUIT (code PRODUIT, label « Produit »), taxonomie
* des categories produit du catalogue (Cereales, Oleagineux...). Mirroir du seed de
* la migration Version20260625110000 (ERP-198) : re-aligne dev/test apres purge.
*
* Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une * Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une
* entite managee par l ORM, donc le purger Doctrine la vide avant chaque * entite managee par l ORM, donc le purger Doctrine la vide avant chaque
* `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la * `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la
@@ -45,14 +49,15 @@ class CategoryTypeFixtures extends Fixture
/** /**
* Source unique des types : code technique => libelle FR. Doit rester aligne * Source unique des types : code technique => libelle FR. Doit rester aligne
* sur le seed des migrations Version20260602100000 (CLIENT), * sur le seed des migrations Version20260602100000 (CLIENT),
* Version20260605120000 (FOURNISSEUR), Version20260612080000 (PRESTATAIRE) et * Version20260605120000 (FOURNISSEUR), Version20260612080000 (PRESTATAIRE),
* Version20260625100000 (ADRESSE). * Version20260625100000 (ADRESSE) et Version20260625110000 (PRODUIT, ERP-198).
*/ */
private const TYPES = [ private const TYPES = [
'CLIENT' => 'Client', 'CLIENT' => 'Client',
'FOURNISSEUR' => 'Fournisseur', 'FOURNISSEUR' => 'Fournisseur',
'PRESTATAIRE' => 'Prestataire', 'PRESTATAIRE' => 'Prestataire',
'ADRESSE' => 'Adresse', 'ADRESSE' => 'Adresse',
'PRODUIT' => 'Produit',
]; ];
public function __construct( public function __construct(
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\DataFixtures;
use App\Module\Catalog\Domain\Entity\StorageType;
use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use App\Shared\Domain\Contract\SiteProviderInterface;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
/**
* Fixtures du module Catalog : seed du referentiel `storage_type` (M6).
*
* ⚠ PROVISOIRE (decision Matthieu 24/06, HP-M6-02) : codes, libelles ET mapping
* site ci-dessous sont a REVALIDER / RE-SEEDER quand Aurore livrera la liste et le
* mapping definitifs par site. La liste actuelle reprend les 10 valeurs de la
* maquette Figma (node 1503-34285) et les rattache PAR DEFAUT aux 3 sites
* (Chatellerault 86 / Saint-Jean 17 / Pommevic 82), faute de mapping reel.
*
* Pourquoi une fixture (et pas un seed de migration) : `storage_type` est une
* entite managee par l'ORM, donc le purger Doctrine la vide avant chaque
* `doctrine:fixtures:load`. Cette fixture re-aligne dev ET test (le referentiel
* doit exister pour alimenter le formulaire produit et les tests du filtre
* ?siteId[]= — ERP-203). Elle tourne dans TOUS les environnements (referentiel,
* pas une donnee de demo — miroir CategoryTypeFixtures).
*
* Idempotence : lookup par `code` parmi les types existants avant insertion
* (miroir CategoryTypeFixtures). `addSite()` est lui-meme idempotent (contains()).
* Rejouable sans doublon meme si le purger Doctrine est desactive.
*
* Depend de SitesFixtures : les 3 sites doivent etre seedes avant qu'on puisse y
* rattacher les types de stockage. Les sites sont resolus via le contrat Shared
* SiteProviderInterface (pas d'import du module Sites — regle ABSOLUE n°1).
*/
class StorageTypeFixtures extends Fixture implements DependentFixtureInterface
{
/**
* Seed PROVISOIRE (Figma node 1503-34285) : code MAJUSCULE stable => libelle FR.
* A re-seeder a reception de la liste Aurore (HP-M6-02).
*
* @var array<string, string>
*/
private const TYPES = [
'BOISSEAU' => 'Boisseau',
'BOISSEAU_DOSAGE' => 'Boisseau dosage',
'CASE' => 'Case',
'CELLULE' => 'Cellule',
'CONTAINER' => 'Container',
'CUVE_MELASSE' => 'Cuve mélasse',
'STOCKAGE_BIG_BAG' => 'Stockage big bag',
'STOCKAGE_PALETTE' => 'Stockage palette',
'TAS' => 'Tas',
'ZONE' => 'Zone',
];
/**
* Noms des 3 sites de rattachement PROVISOIRE (86 / 17 / 82). Le nom est la cle
* de lookup stable cote SitesFixtures.
*
* @var list<string>
*/
private const DEFAULT_SITE_NAMES = ['Chatellerault', 'Saint-Jean', 'Pommevic'];
public function __construct(
private readonly StorageTypeRepositoryInterface $storageTypeRepository,
private readonly SiteProviderInterface $siteProvider,
) {}
/**
* @return array<int, class-string>
*/
public function getDependencies(): array
{
return [SitesFixtures::class];
}
public function load(ObjectManager $manager): void
{
// Index des types deja presents par code, pour ne pas creer de doublon.
$existingByCode = [];
foreach ($this->storageTypeRepository->findAllOrderedByLabel() as $type) {
$existingByCode[$type->getCode()] = $type;
}
// Resolution des 3 sites par defaut via le contrat Shared (rattachement
// provisoire). Les objets resolus sont des Site managees (resolve_target_entities
// SiteInterface -> Site) : addSite() les accepte.
$defaultSites = [];
foreach (self::DEFAULT_SITE_NAMES as $name) {
$site = $this->siteProvider->findByName($name);
if (null !== $site) {
$defaultSites[] = $site;
}
}
foreach (self::TYPES as $code => $label) {
$storageType = $existingByCode[$code] ?? new StorageType();
$storageType->setCode($code);
$storageType->setLabel($label);
// Rattachement provisoire aux 3 sites (idempotent : addSite -> contains()).
foreach ($defaultSites as $site) {
$storageType->addSite($site);
}
$manager->persist($storageType);
}
$manager->flush();
}
}
@@ -7,6 +7,7 @@ namespace App\Module\Catalog\Infrastructure\Doctrine;
use App\Module\Catalog\Domain\Entity\StorageType; use App\Module\Catalog\Domain\Entity\StorageType;
use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface; use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
/** /**
@@ -31,4 +32,33 @@ class DoctrineStorageTypeRepository extends ServiceEntityRepository implements S
{ {
return $this->findBy([], ['label' => 'ASC']); return $this->findBy([], ['label' => 'ASC']);
} }
public function createListQueryBuilder(array $siteIds = []): QueryBuilder
{
// Tri alphabetique stable (multi-select du formulaire produit, § 4.2). La
// relation `sites` n'est PAS serialisee (storage_type:read ne la porte pas)
// -> pas d'eager-load : le filtre n'affecte pas la sortie, seulement la
// restriction des lignes.
$qb = $this->createQueryBuilder('st')
->orderBy('st.label', 'ASC')
;
// ?siteId[]= : type disponible sur AU MOINS UN des sites passes (OR, RG-6.06).
// Sous-requete EXISTS correlee (meme strategie que DoctrineCategoryRepository
// / DoctrineProductRepository) pour eviter les lignes dupliquees du JOIN.
if ([] !== $siteIds) {
$sub = $this->getEntityManager()->createQueryBuilder()
->select('1')
->from(StorageType::class, 'st_si')
->join('st_si.sites', 's_si')
->where('st_si = st')
->andWhere('s_si.id IN (:siteIds)')
;
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
->setParameter('siteIds', $siteIds)
;
}
return $qb;
}
} }
@@ -175,6 +175,15 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
/** Valide : contrepartie + immatriculation + 2 pesees OK, numero attribue (« Terminée »). */ /** Valide : contrepartie + immatriculation + 2 pesees OK, numero attribue (« Terminée »). */
public const string STATUS_VALIDATED = 'VALIDATED'; public const string STATUS_VALIDATED = 'VALIDATED';
/** Contrepartie « Client » (M1) — RG-5.03. */
public const string COUNTERPARTY_CLIENT = 'CLIENT';
/** Contrepartie « Fournisseur » (M2) — RG-5.03. */
public const string COUNTERPARTY_FOURNISSEUR = 'FOURNISSEUR';
/** Contrepartie « Autre » (libelle libre) — RG-5.03. */
public const string COUNTERPARTY_AUTRE = 'AUTRE';
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
@@ -195,7 +204,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
/** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — null tant que brouillon, requis a la validation. Pilote le champ associe obligatoire. */ /** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — null tant que brouillon, requis a la validation. Pilote le champ associe obligatoire. */
#[ORM\Column(name: 'counterparty_type', length: 12, nullable: true)] #[ORM\Column(name: 'counterparty_type', length: 12, nullable: true)]
#[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.', groups: ['finalize'])] #[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.', groups: ['finalize'])]
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')] #[Assert\Choice(choices: [self::COUNTERPARTY_CLIENT, self::COUNTERPARTY_FOURNISSEUR, self::COUNTERPARTY_AUTRE], message: 'Type de contrepartie invalide.')]
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])] #[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private ?string $counterpartyType = null; private ?string $counterpartyType = null;
@@ -313,7 +322,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
public function validateCounterpartyConsistency(ExecutionContextInterface $context): void public function validateCounterpartyConsistency(ExecutionContextInterface $context): void
{ {
switch ($this->counterpartyType) { switch ($this->counterpartyType) {
case 'CLIENT': case self::COUNTERPARTY_CLIENT:
if (null === $this->client) { if (null === $this->client) {
$context->buildViolation('Le client est obligatoire pour une contrepartie « Client ».') $context->buildViolation('Le client est obligatoire pour une contrepartie « Client ».')
->atPath('client') ->atPath('client')
@@ -323,7 +332,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
break; break;
case 'FOURNISSEUR': case self::COUNTERPARTY_FOURNISSEUR:
if (null === $this->supplier) { if (null === $this->supplier) {
$context->buildViolation('Le fournisseur est obligatoire pour une contrepartie « Fournisseur ».') $context->buildViolation('Le fournisseur est obligatoire pour une contrepartie « Fournisseur ».')
->atPath('supplier') ->atPath('supplier')
@@ -333,7 +342,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
break; break;
case 'AUTRE': case self::COUNTERPARTY_AUTRE:
if (null === $this->otherLabel || '' === trim($this->otherLabel)) { if (null === $this->otherLabel || '' === trim($this->otherLabel)) {
$context->buildViolation('Le libellé est obligatoire pour une contrepartie « Autre ».') $context->buildViolation('Le libellé est obligatoire pour une contrepartie « Autre ».')
->atPath('otherLabel') ->atPath('otherLabel')
@@ -458,6 +467,21 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
/**
* Nom du tiers à afficher (cartouche du bon de pesée PDF, ERP-208) : raison
* sociale du client/fournisseur ou libellé libre selon le type de contrepartie
* (RG-5.03). Null si aucune contrepartie cohérente (brouillon).
*/
public function getCounterpartyName(): ?string
{
return match ($this->counterpartyType) {
self::COUNTERPARTY_CLIENT => $this->client?->getCompanyName(),
self::COUNTERPARTY_FOURNISSEUR => $this->supplier?->getCompanyName(),
self::COUNTERPARTY_AUTRE => $this->otherLabel,
default => null,
};
}
public function getImmatriculation(): ?string public function getImmatriculation(): ?string
{ {
return $this->immatriculation; return $this->immatriculation;
@@ -27,6 +27,19 @@
.company-name { font-weight: bold; font-size: 12px; } .company-name { font-weight: bold; font-size: 12px; }
.company-line { font-size: 12px; } .company-line { font-size: 12px; }
/* En-tête 2 colonnes (Dompdf = CSS 2.1, pas de flex/grid) : identité
société à gauche, cartouche du tiers à droite (ERP-208). Largeurs
fixes par cellule + cartouche en bloc (pas d'inline-block/min-width,
mal supportés par Dompdf) : le cartouche occupe la colonne de droite
et un nom long passe à la ligne au lieu de déborder. */
.header { width: 100%; border-collapse: collapse; }
.header td { vertical-align: top; }
.header .h-left { width: 62%; }
.header .h-right { width: 38%; }
.party-box { border: 1px solid #000; padding: 8px 12px; }
.party-label { font-weight: bold; font-size: 14px; margin-bottom: 4px; }
.party-name { font-size: 11px; word-wrap: break-word; }
.title { font-size: 22px; font-weight: bold; margin: 22px 0 18px; } .title { font-size: 22px; font-weight: bold; margin: 22px 0 18px; }
/* Lignes des deux pesées : tableau sans bordure, colonnes alignées. */ /* Lignes des deux pesées : tableau sans bordure, colonnes alignées. */
@@ -41,13 +54,34 @@
</style> </style>
</head> </head>
<body> <body>
{% if logoSrc %} {# Libellé FR du type de contrepartie (couche de rendu, pas le Domain — ERP-208). #}
<div class="logo"><img src="{{ logoSrc }}" alt="LPC LIOT"></div> {% set counterpartyLabels = { 'CLIENT': 'Client', 'FOURNISSEUR': 'Fournisseur', 'AUTRE': 'Autre' } %}
{% endif %}
<div class="company-name">SA LIOT Châtellerault</div> <table class="header">
<div class="company-line">Email : lpc.contacts@lpc-liot.fr</div> <tr>
<div class="company-line">RCS Châtellerault B 339 505 612</div> <td class="h-left">
{% if logoSrc %}
<div class="logo"><img src="{{ logoSrc }}" alt="LPC LIOT"></div>
{% endif %}
<div class="company-name">SA LIOT Châtellerault</div>
<div class="company-line">Email : lpc.contacts@lpc-liot.fr</div>
<div class="company-line">RCS Châtellerault B 339 505 612</div>
</td>
{# Cartouche tiers (ERP-208) : type (libellé) + nom du client / fournisseur /
« autre ». Conditionné sur le TYPE : un brouillon sans type n'affiche rien ;
un type sans nom (cas limite) affiche au moins le libellé. #}
<td class="h-right">
{% if ticket.counterpartyType %}
<div class="party-box">
<div class="party-label">{{ counterpartyLabels[ticket.counterpartyType] ?? ticket.counterpartyType }} :</div>
{% if ticket.counterpartyName %}
<div class="party-name">{{ ticket.counterpartyName }}</div>
{% endif %}
</div>
{% endif %}
</td>
</tr>
</table>
<div class="title">Ticket de pesée</div> <div class="title">Ticket de pesée</div>
@@ -0,0 +1,276 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Catalog\Domain\Entity\Product;
use App\Module\Catalog\Domain\Entity\StorageType;
use App\Module\Sites\Domain\Entity\Site;
use DateTimeImmutable;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Classe de base des tests fonctionnels de l'entite Product (M6, module Catalog).
*
* Etend la base Catalog (factories Category / CategoryType + helpers d'auth) et
* ajoute ce qu'il faut pour exercer l'API produit de bout en bout :
* - `productType()` : recupere (ou cree) le CategoryType `PRODUIT`. Necessaire
* car le cleanup parent purge TOUS les category_type entre deux tests Catalog,
* donc le type seede par la migration M6 disparait : on le re-materialise a la
* volee pour que les POST passent RG-6.05.
* - `productCategory()` / `nonProductCategory()` : categories de test rattachees
* (ou non) au type PRODUIT.
* - `seedStorageType()` : type de stockage de test (prefixe code pour cleanup),
* rattachable a des sites precis (RG-6.06).
* - `siteByCode()` / `firstSite()` : sites fixtures (86 / 17 / 82).
* - `authView()` : user non-admin portant la permission `catalog.products.view`.
* - `validProductPayload()` : payload POST de reference (IRIs category/sites/
* storageTypes), surchargeable par cle.
* - `iri()` / `memberById()` : utilitaires Hydra.
*
* Cleanup : on purge les produits (toute la table — aucune fixture produit en
* env test) AVANT le parent, car product reference category / site / storage_type
* en FK ON DELETE RESTRICT (le parent supprime ensuite les categories/types). Les
* types de stockage de test (prefixe code) sont purges dans la foulee.
*
* @internal
*/
abstract class AbstractProductApiTestCase extends AbstractCatalogApiTestCase
{
protected const string LD = 'application/ld+json';
protected const string MERGE = 'application/merge-patch+json';
/** Code du type de categorie produit, seede par la migration M6 (§ 2.5). */
protected const string PRODUCT_TYPE_CODE = 'PRODUIT';
/** Prefixe des codes de StorageType seedes par ces tests (purge ciblee). */
protected const string TEST_STORAGE_TYPE_PREFIX = 'TESTPRD';
protected function tearDown(): void
{
$em = $this->getEm();
// Produits d'abord : ils referencent category / site / storage_type en FK
// RESTRICT, donc le parent ne pourrait pas purger les categories tant
// qu'un produit les pointe. Les jonctions product_site /
// product_storage_type cascadent au niveau base (ON DELETE CASCADE).
$em->createQuery('DELETE FROM '.Product::class)->execute();
// Types de stockage de test (prefixe code) — libere storage_type_site.
$em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix')
->setParameter('prefix', self::TEST_STORAGE_TYPE_PREFIX.'%')
->execute()
;
parent::tearDown();
}
/**
* Recupere le CategoryType `PRODUIT` (find-or-create). Le cleanup parent
* purge tous les category_type entre tests : on le recree au besoin pour que
* les POST produit satisfassent RG-6.05.
*/
protected function productType(): CategoryType
{
$em = $this->getEm();
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => self::PRODUCT_TYPE_CODE]);
if ($existing instanceof CategoryType) {
return $existing;
}
$type = new CategoryType();
$type->setCode(self::PRODUCT_TYPE_CODE);
$type->setLabel('Produit');
$em->persist($type);
$em->flush();
return $type;
}
/**
* Categorie de test rattachee au type PRODUIT (satisfait RG-6.05).
*/
protected function productCategory(?string $name = null): Category
{
// Nom laisse a null par defaut -> createCategory genere un nom aleatoire
// unique (uq_category_name_active impose LOWER(name) unique parmi les
// actives : deux categories de meme nom dans un test collisionneraient).
return $this->createCategory($name, $this->productType());
}
/**
* Categorie de test rattachee a un type NON-PRODUIT (viole RG-6.05).
*/
protected function nonProductCategory(): Category
{
return $this->createCategory(null, $this->createCategoryType());
}
/**
* Cree un type de stockage de test (code prefixe TESTPRD pour le cleanup),
* rattache aux sites passes (disponibilite — RG-6.06).
*/
protected function seedStorageType(string $label = 'Tas de test', Site ...$sites): StorageType
{
$em = $this->getEm();
$storageType = new StorageType();
$storageType->setCode($this->uniqueCode(self::TEST_STORAGE_TYPE_PREFIX));
$storageType->setLabel($label);
foreach ($sites as $site) {
$storageType->addSite($em->getReference(Site::class, (int) $site->getId()));
}
$em->persist($storageType);
$em->flush();
return $storageType;
}
protected function siteByCode(string $code): Site
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy(['code' => $code]);
self::assertInstanceOf(Site::class, $site, sprintf('Le site de code "%s" doit etre seede.', $code));
return $site;
}
protected function firstSite(): Site
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
self::assertInstanceOf(Site::class, $site, 'Un site fixture est requis (SitesFixtures).');
return $site;
}
/**
* Client non-admin portant seulement `catalog.products.view`.
*/
protected function authView(): Client
{
$creds = $this->createUserWithPermission('catalog.products.view');
return $this->authenticatedClient($creds['username'], $creds['password']);
}
/**
* Payload POST de reference : un produit valide (categorie PRODUIT, 1 site,
* 1 type de stockage disponible sur ce site). Surchargeable par cle via
* $overrides (ex: ['states' => ['SALE'], 'code' => 'X']).
*
* @param array<string, mixed> $overrides
*
* @return array<string, mixed>
*/
protected function validProductPayload(array $overrides = []): array
{
$site = $this->firstSite();
$storageType = $this->seedStorageType('Tas test', $site);
$category = $this->productCategory();
$base = [
'code' => $this->uniqueCode('TESTPRD'),
'name' => 'Produit test',
'states' => [Product::STATE_PURCHASE],
'manufactured' => false,
'containsMolasses' => false,
'category' => $this->iri('categories', (int) $category->getId()),
'sites' => [$this->iri('sites', (int) $site->getId())],
'storageTypes' => [$this->iri('storage_types', (int) $storageType->getId())],
];
return array_replace($base, $overrides);
}
/**
* Seede un produit directement via l'EM (bypass Processor/Validator). Utile
* pour disposer d'un id existant (RBAC item, PATCH) ou d'un produit
* soft-deleted (reutilisation de code — RG-6.01). La categorie / le site / le
* type de stockage manquants sont crees a la volee.
*
* @param list<string> $states
*/
protected function seedProductEntity(
?string $code = null,
array $states = [Product::STATE_PURCHASE],
?DateTimeImmutable $deletedAt = null,
?Site $site = null,
?StorageType $storageType = null,
?Category $category = null,
): Product {
$em = $this->getEm();
$site ??= $this->firstSite();
$product = new Product();
$product->setCode($code ?? $this->uniqueCode(self::TEST_STORAGE_TYPE_PREFIX));
$product->setName('Produit seed');
$product->setStates($states);
$product->setManufactured(false);
$product->setContainsMolasses(false);
$product->setCategory($category ?? $this->productCategory());
$product->addSite($em->getReference(Site::class, (int) $site->getId()));
$product->addStorageType($storageType ?? $this->seedStorageType('Seed', $site));
$product->setDeletedAt($deletedAt);
$em->persist($product);
$em->flush();
return $product;
}
/**
* Construit un IRI API Platform (`/api/{resource}/{id}`).
*/
protected function iri(string $resource, int $id): string
{
return sprintf('/api/%s/%d', $resource, $id);
}
/**
* Code unique de test (prefixe + nonce). Deja en MAJUSCULE : stable apres la
* normalisation serveur (trim + UPPER, RG-6.07).
*/
protected function uniqueCode(string $prefix): string
{
return $prefix.'_'.strtoupper(substr(bin2hex(random_bytes(5)), 0, 10));
}
/**
* Extrait les `propertyPath` des violations d'une reponse 422 (sans lever sur
* le statut non-2xx). Sert a verifier que le back identifie bien le champ
* fautif (contrat consomme par useFormErrors cote front).
*
* @return list<string>
*/
protected function violationPaths(ResponseInterface $response): array
{
$body = $response->toArray(false);
return array_values(array_map(
static fn (array $violation): string => (string) ($violation['propertyPath'] ?? ''),
$body['violations'] ?? [],
));
}
/**
* Retrouve un membre d'une collection Hydra par son id (ou null).
*
* @param array<string, mixed> $list
*
* @return null|array<string, mixed>
*/
protected function memberById(array $list, int $id): ?array
{
foreach ($list['member'] ?? [] as $member) {
if (($member['id'] ?? null) === $id) {
return $member;
}
}
return null;
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* RG-6.05 : la categorie d'un produit doit etre de type PRODUIT. Une categorie
* d'un autre type est rejetee en 422 (Assert\Callback, propertyPath `category`).
*
* @internal
*/
final class ProductCategoryTypeTest extends AbstractProductApiTestCase
{
public function testNonProductCategoryIsRejected(): void
{
$client = $this->createAdminClient();
$category = $this->nonProductCategory();
$response = $client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload([
'category' => $this->iri('categories', (int) $category->getId()),
]),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('category', $this->violationPaths($response));
}
public function testProductCategoryIsAccepted(): void
{
$client = $this->createAdminClient();
$category = $this->productCategory();
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload([
'category' => $this->iri('categories', (int) $category->getId()),
]),
]);
self::assertResponseStatusCodeSame(201);
}
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Product;
use DateTimeImmutable;
/**
* RG-6.01 : unicite GLOBALE du code produit parmi les ACTIFS.
*
* Couvre :
* - 409 sur doublon de code actif (pre-check deterministe du Processor) ;
* - normalisation (trim + UPPER, RG-6.07) prise en compte par l'unicite : un
* code casse / entoure d'espaces collisionne avec sa forme normalisee ;
* - reutilisation possible d'un code porte par un produit soft-deleted (l'index
* partiel uq_product_code_active ne contraint que les actifs).
*
* @internal
*/
final class ProductCodeUniquenessTest extends AbstractProductApiTestCase
{
public function testDuplicateActiveCodeReturns409(): void
{
$client = $this->createAdminClient();
$code = $this->uniqueCode('TESTPRD');
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(['code' => $code]),
]);
self::assertResponseStatusCodeSame(201);
// Meme code -> conflit (RG-6.01).
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(['code' => $code]),
]);
self::assertResponseStatusCodeSame(409);
}
public function testNormalizedCodeCollides(): void
{
$client = $this->createAdminClient();
$code = $this->uniqueCode('TESTPRD');
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(['code' => $code]),
]);
self::assertResponseStatusCodeSame(201);
// Variante minuscule + espaces : trim + UPPER serveur (RG-6.07) la ramene
// a la meme forme normalisee -> meme collision 409.
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(['code' => ' '.strtolower($code).' ']),
]);
self::assertResponseStatusCodeSame(409);
}
public function testSoftDeletedCodeCanBeReused(): void
{
$client = $this->createAdminClient();
$code = $this->uniqueCode('TESTPRD');
// Produit soft-deleted portant le code (seede directement, hors index actif).
$this->seedProductEntity(
code: $code,
states: [Product::STATE_PURCHASE],
deletedAt: new DateTimeImmutable(),
);
// Le meme code est libre cote actifs -> creation acceptee (201).
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(['code' => $code]),
]);
self::assertResponseStatusCodeSame(201);
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* RG-6.03 : « Fabrique » / « Contient de la melasse » saisissables uniquement si
* `states` contient SALE ; sinon forces `false` cote serveur (Processor), quoi
* que le client envoie.
*
* @internal
*/
final class ProductConditionalFieldsTest extends AbstractProductApiTestCase
{
public function testConditionalFieldsForcedFalseWithoutSale(): void
{
$client = $this->createAdminClient();
// Pas de SALE dans les etats mais champs conditionnels a true cote client.
$created = $client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload([
'states' => ['PURCHASE', 'OTHER'],
'manufactured' => true,
'containsMolasses' => true,
]),
])->toArray();
self::assertResponseStatusCodeSame(201);
// Le serveur a force les deux a false (RG-6.03).
self::assertFalse($created['manufactured']);
self::assertFalse($created['containsMolasses']);
}
public function testConditionalFieldsKeptWithSale(): void
{
$client = $this->createAdminClient();
$created = $client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload([
'states' => ['SALE'],
'manufactured' => true,
'containsMolasses' => true,
]),
])->toArray();
self::assertResponseStatusCodeSame(201);
// SALE present -> les valeurs saisies sont conservees.
self::assertTrue($created['manufactured']);
self::assertTrue($created['containsMolasses']);
}
public function testConditionalFieldsResetOnPatchRemovingSale(): void
{
$client = $this->createAdminClient();
$created = $client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload([
'states' => ['SALE'],
'manufactured' => true,
'containsMolasses' => true,
]),
])->toArray();
self::assertResponseStatusCodeSame(201);
// On retire SALE en PATCH -> les conditionnels doivent retomber a false.
$patched = $client->request('PATCH', '/api/products/'.$created['id'], [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['states' => ['PURCHASE']],
])->toArray();
self::assertResponseStatusCodeSame(200);
self::assertFalse($patched['manufactured']);
self::assertFalse($patched['containsMolasses']);
}
}
@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Entity\Product;
use App\Module\Catalog\Domain\Entity\StorageType;
use App\Module\Sites\Domain\Entity\Site;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX du catalogue produits (M6, § 4.5).
*
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes de
* colonnes), exclusion des produits soft-deleted par defaut (RG-6.09), respect
* des filtres ?search et ?state, peuplement des colonnes metier (etats joints,
* categorie, sites, types de stockage, fabrique / contient melasse), 403 sans
* catalog.products.view, 401 anonyme.
*
* @internal
*/
final class ProductExportControllerTest extends AbstractCatalogApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/products/export.xlsx';
/** Prefixe des codes de types de stockage seedes par ce test (cible du cleanup tearDown). */
private const string TEST_STORAGE_PREFIX = 'TEST_';
/**
* Purge des produits + types de stockage de test AVANT le cleanup parent :
* product reference category / site / storage_type en FK ON DELETE RESTRICT,
* donc les categories ne peuvent etre supprimees tant que des produits les
* referencent. La suppression des produits cascade les jonctions
* product_site / product_storage_type au niveau base (ON DELETE CASCADE).
*/
protected function tearDown(): void
{
$em = $this->getEm();
$em->createQuery('DELETE FROM '.Product::class)->execute();
$em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix')
->setParameter('prefix', self::TEST_STORAGE_PREFIX.'%')
->execute()
;
parent::tearDown();
}
public function testExportReturnsXlsxResponseWithHeaderRow(): void
{
$client = $this->createAdminClient();
$this->seedProduct('TEST_PRD_A', 'Export Alpha');
$response = $client->request('GET', self::EXPORT_URL);
self::assertResponseIsSuccessful();
$headers = $response->getHeaders(false);
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
$disposition = $headers['content-disposition'][0] ?? '';
self::assertStringContainsString('attachment; filename="catalogue-produits-', $disposition);
self::assertMatchesRegularExpression(
'/filename="catalogue-produits-\d{8}\.xlsx"/',
$disposition,
);
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
$grid = $this->gridFromResponse($response->getContent());
$headerCells = $grid[0];
self::assertSame('Numéro', $headerCells[0]);
self::assertSame('Nom', $headerCells[1]);
self::assertContains('États', $headerCells);
self::assertContains('Catégorie', $headerCells);
self::assertContains('Sites', $headerCells);
self::assertContains('Types de stockage', $headerCells);
self::assertContains('Fabriqué', $headerCells);
self::assertContains('Contient mélasse', $headerCells);
// Au moins une ligne de donnees (le produit seede).
self::assertContains('TEST_PRD_A', $this->codes($response->getContent()));
}
public function testExportExcludesSoftDeletedByDefault(): void
{
$client = $this->createAdminClient();
$this->seedProduct('TEST_PRD_ACTIVE', 'Active One');
$this->seedProduct('TEST_PRD_DELETED', 'Deleted One', deletedAt: new DateTimeImmutable());
$codes = $this->codes($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('TEST_PRD_ACTIVE', $codes);
self::assertNotContains('TEST_PRD_DELETED', $codes);
}
public function testExportRespectsSearchFilter(): void
{
$client = $this->createAdminClient();
$this->seedProduct('TEST_PRD_SRCH', 'Searchable Alpha');
$this->seedProduct('TEST_PRD_OTHER', 'Other Beta');
$codes = $this->codes(
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
);
self::assertContains('TEST_PRD_SRCH', $codes);
self::assertNotContains('TEST_PRD_OTHER', $codes);
}
public function testExportRespectsStateFilter(): void
{
$client = $this->createAdminClient();
$this->seedProduct('TEST_PRD_SALE', 'Sold One', [Product::STATE_SALE]);
$this->seedProduct('TEST_PRD_BUY', 'Bought One', [Product::STATE_PURCHASE]);
$codes = $this->codes(
$client->request('GET', self::EXPORT_URL.'?state=SALE')->getContent(),
);
self::assertContains('TEST_PRD_SALE', $codes);
self::assertNotContains('TEST_PRD_BUY', $codes);
}
public function testExportPopulatesAllBusinessColumns(): void
{
$client = $this->createAdminClient();
$site = $this->firstSite();
$storageType = $this->seedStorageType('TEST_STP', 'Tas de test');
$category = $this->createCategory('test_cat_export_produit');
$this->seedProduct(
'TEST_PRD_FULL',
'Complet',
[Product::STATE_PURCHASE, Product::STATE_SALE],
true,
true,
null,
$site,
$storageType,
$category,
);
$row = $this->rowForCode($client->request('GET', self::EXPORT_URL)->getContent(), 'TEST_PRD_FULL');
self::assertNotNull($row, 'Le produit seede est absent de l\'export.');
// 0 Numéro | 1 Nom | 2 États | 3 Catégorie | 4 Sites | 5 Types de stockage | 6 Fabriqué | 7 Contient mélasse
self::assertSame('TEST_PRD_FULL', $row[0]);
self::assertSame('Complet', $row[1]);
self::assertSame('Achat, Vendu', $row[2]);
self::assertSame((string) $category->getName(), $row[3]);
self::assertSame((string) $site->getName(), $row[4]);
self::assertSame('Tas de test', $row[5]);
self::assertSame('Oui', $row[6]);
self::assertSame('Oui', $row[7]);
}
public function testForbiddenWithoutProductsViewPermission(): void
{
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(403);
}
public function testUnauthorizedWhenAnonymous(): void
{
$client = self::createClient();
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(401);
}
/**
* Seede un produit complet (categorie + 1 site + 1 type de stockage par
* defaut). Les relations omises sont creees a la volee. Persistance directe
* via l'EM : on bypasse le Processor/Validator (non teste ici).
*
* @param list<string> $states
*/
private function seedProduct(
string $code,
string $name,
array $states = [Product::STATE_PURCHASE],
bool $manufactured = false,
bool $containsMolasses = false,
?DateTimeImmutable $deletedAt = null,
?Site $site = null,
?StorageType $storageType = null,
?Category $category = null,
): Product {
$em = $this->getEm();
$product = new Product();
$product->setCode($code);
$product->setName($name);
$product->setStates($states);
$product->setManufactured($manufactured);
$product->setContainsMolasses($containsMolasses);
$product->setCategory($category ?? $this->createCategory());
$product->addSite($site ?? $this->firstSite());
$product->addStorageType($storageType ?? $this->seedStorageType(self::TEST_STORAGE_PREFIX.strtoupper(substr(bin2hex(random_bytes(4)), 0, 8))));
$product->setDeletedAt($deletedAt);
$em->persist($product);
$em->flush();
return $product;
}
/**
* Cree un type de stockage de test (code prefixe TEST_ pour le cleanup).
*/
private function seedStorageType(string $code, string $label = 'Type de stockage de test'): StorageType
{
$em = $this->getEm();
$storageType = new StorageType();
$storageType->setCode($code);
$storageType->setLabel($label);
$em->persist($storageType);
$em->flush();
return $storageType;
}
/**
* Premier site seede (les sites existent en base de test, comme dans les
* autres tests d'export).
*/
private function firstSite(): Site
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
self::assertNotNull($site, 'Aucun site seede : impossible de seeder un produit.');
return $site;
}
/**
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
*
* @return array<int, array<int, mixed>>
*/
private function gridFromResponse(string $binary): array
{
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
self::assertIsString($tmp);
file_put_contents($tmp, $binary);
try {
return IOFactory::load($tmp)->getActiveSheet()->toArray();
} finally {
@unlink($tmp);
}
}
/**
* Extrait la colonne « Numéro » (1re colonne) des lignes de donnees.
*
* @return list<string>
*/
private function codes(string $binary): array
{
$grid = $this->gridFromResponse($binary);
$rows = array_slice($grid, 1); // saute l'en-tete
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
}
/**
* Renvoie la ligne de donnees dont la colonne « Numéro » vaut $code, ou null.
*
* @return null|array<int, mixed>
*/
private function rowForCode(string $binary, string $code): ?array
{
$grid = $this->gridFromResponse($binary);
foreach (array_slice($grid, 1) as $row) {
if ((string) ($row[0] ?? '') === $code) {
return $row;
}
}
return null;
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* RBAC du catalogue produit (M6, spec-back § 5.2 — admin-only, C7).
*
* La matrice est volontairement tres restrictive : seul l'Admin porte
* `catalog.products.view` / `.manage`. Les 4 personas metier MALIO (Bureau,
* Compta, Commerciale, Usine) n'ont AUCUNE permission produit -> 403 partout.
*
* On prouve aussi que l'acces n'est pas « admin only » par hasard mais bien
* porte par les permissions : un non-admin avec `view` lit (200) mais ne peut
* pas creer (403, refus au niveau securite avant denormalisation).
*
* Note : on ne teste pas « un non-admin avec `manage` cree un produit » — ce role
* n'existe dans aucun persona (catalogue admin-only) et un tel user ne pourrait
* de toute facon pas resoudre les IRI sites / categories / storage_types lors de
* la denormalisation (ces ressources portent leur propre controle d'acces). La
* creation par un porteur de `manage` est couverte par l'Admin (qui bypass).
*
* @internal
*/
final class ProductRBACMatrixTest extends AbstractProductApiTestCase
{
/** Personas metier sans permission produit (§ 5.2). */
private const array PERSONAS = ['Bureau', 'Compta', 'Commerciale', 'Usine'];
public function testAdminHasFullAccess(): void
{
$client = $this->createAdminClient();
$client->request('GET', '/api/products', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(),
]);
self::assertResponseStatusCodeSame(201);
}
public function testBusinessPersonasAreForbiddenEverywhere(): void
{
// Produit existant cible des operations item (seede par l'admin via l'EM).
$product = $this->seedProductEntity();
$id = (int) $product->getId();
foreach (self::PERSONAS as $persona) {
$client = $this->createPersonaClient($persona);
$client->request('GET', '/api/products', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403, $persona.' ne doit pas lister les produits.');
$client->request('GET', '/api/products/'.$id, ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403, $persona.' ne doit pas consulter un produit.');
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(),
]);
self::assertResponseStatusCodeSame(403, $persona.' ne doit pas creer de produit.');
$client->request('PATCH', '/api/products/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['name' => 'Renomme par '.$persona],
]);
self::assertResponseStatusCodeSame(403, $persona.' ne doit pas modifier un produit.');
}
}
public function testViewPermissionReadsButCannotManage(): void
{
$product = $this->seedProductEntity();
$client = $this->authView();
$client->request('GET', '/api/products', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
$client->request('GET', '/api/products/'.$product->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// view sans manage : creation refusee au niveau securite (403 avant que la
// denormalisation ne tente de resoudre les IRI -> pas de 400 parasite).
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(),
]);
self::assertResponseStatusCodeSame(403);
}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* Contrat de serialisation du produit (M6, spec-back § 4.0 / § 4.0.bis).
* Jumeau du test de contrat M5 WeighingTicketSerializationContractTest.
*
* Capture le JSON REEL (liste + detail) via un produit cree par l'API (POST reel,
* normalisation serveur reelle) et reverifie les pieges du RETEX M1 transposes au
* M6 :
* #1 : `category` sort en OBJET embarque (category:read), jamais en IRI nu.
* #2 : `sites` / `storageTypes` sortent en TABLEAUX d'OBJETS (site:read /
* storage_type:read), jamais en tableaux d'IRI.
* #3 : `states` = tableau de chaines ; `manufactured` / `containsMolasses`
* presents (booleens).
* #4 : `code` present (= « Numero » de la liste).
*
* REGLE D'OR : on asserte sur le CORPS JSON reel, jamais sur les annotations.
* DoD (§ 4.0.bis) : avec PRODUCT_DOD_DUMP positionnee, ecrit les corps liste +
* detail sous /tmp pour les coller dans la spec avant les ecrans front.
*
* @internal
*/
final class ProductSerializationContractTest extends AbstractProductApiTestCase
{
public function testListAndDetailSerializationContract(): void
{
$client = $this->createAdminClient();
// Produit cree par un POST reel (mix Achat + Vendu pour exercer les
// champs conditionnels au passage).
$created = $client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload([
'states' => ['PURCHASE', 'SALE'],
'manufactured' => true,
'containsMolasses' => true,
]),
])->toArray();
self::assertResponseStatusCodeSame(201);
$id = (int) $created['id'];
$code = (string) $created['code'];
$detail = $client->request('GET', '/api/products/'.$id, [
'headers' => ['Accept' => self::LD],
])->toArray();
$list = $client->request('GET', '/api/products?search='.$code, [
'headers' => ['Accept' => self::LD],
])->toArray();
// Enveloppe Hydra AP4 (member/totalItems sans prefixe hydra:).
self::assertArrayHasKey('member', $list);
self::assertArrayNotHasKey('hydra:member', $list);
$row = $this->memberById($list, $id);
self::assertNotNull($row, 'Le produit cree doit apparaitre dans la liste filtree.');
// === Piege #4 : code present (= « Numero ») + name ===
self::assertArrayHasKey('code', $row);
self::assertSame($code, $row['code']);
self::assertSame('Produit test', $row['name']);
// === Piege #1 : category en OBJET embarque (pas IRI nu) ===
self::assertIsArray($row['category'], 'category doit etre un objet embarque (category:read), pas un IRI nu.');
self::assertArrayHasKey('name', $row['category']);
// === Piege #3 : states tableau de chaines + booleens presents ===
self::assertSame(['PURCHASE', 'SALE'], $row['states']);
self::assertArrayHasKey('manufactured', $row);
self::assertArrayHasKey('containsMolasses', $row);
self::assertTrue($row['manufactured']);
self::assertTrue($row['containsMolasses']);
// === DETAIL : category embarque + sites / storageTypes en objets ===
self::assertIsArray($detail['category']);
self::assertArrayHasKey('name', $detail['category']);
// === Piege #2 : sites / storageTypes = tableaux d'OBJETS (pas IRI) ===
self::assertIsArray($detail['sites']);
self::assertNotEmpty($detail['sites']);
self::assertIsArray($detail['sites'][0], 'sites doit etre un tableau d\'objets (site:read), pas d\'IRI.');
self::assertArrayHasKey('name', $detail['sites'][0]);
self::assertIsArray($detail['storageTypes']);
self::assertNotEmpty($detail['storageTypes']);
self::assertIsArray($detail['storageTypes'][0], 'storageTypes doit etre un tableau d\'objets (storage_type:read), pas d\'IRI.');
self::assertArrayHasKey('label', $detail['storageTypes'][0]);
self::assertArrayHasKey('code', $detail['storageTypes'][0]);
self::assertSame(['PURCHASE', 'SALE'], $detail['states']);
$this->dumpDodIfRequested($list, $detail);
}
/**
* DoD (§ 4.0.bis) : ecrit les corps JSON reels sous /tmp si PRODUCT_DOD_DUMP
* est positionnee (sinon no-op). A coller dans spec-back.md § 4.0.bis.
*
* @param array<string, mixed> $list
* @param array<string, mixed> $detail
*/
private function dumpDodIfRequested(array $list, array $detail): void
{
if (false === getenv('PRODUCT_DOD_DUMP')) {
return;
}
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
file_put_contents('/tmp/product-dod-list.json', json_encode($list, $flags));
file_put_contents('/tmp/product-dod-detail.json', json_encode($detail, $flags));
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* RG-6.02 : `states` = multi-select ⊆ {PURCHASE, SALE, OTHER}, au moins 1 requis.
*
* Couvre :
* - tableau d'etats vide -> 422 (Assert\Count(min: 1)) sur le champ `states` ;
* - valeur hors enum -> 422 (Assert\Choice) sur le champ `states` ;
* - un seul etat valide -> 201 (borne basse acceptee).
*
* @internal
*/
final class ProductStatesValidationTest extends AbstractProductApiTestCase
{
public function testEmptyStatesIsRejected(): void
{
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(['states' => []]),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('states', $this->violationPaths($response));
}
public function testUnknownStateValueIsRejected(): void
{
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(['states' => ['PURCHASE', 'FOOBAR']]),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('states', $this->violationPaths($response));
}
public function testSingleValidStateIsAccepted(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(['states' => ['OTHER']]),
]);
self::assertResponseStatusCodeSame(201);
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* RG-6.06 : chaque type de stockage retenu doit etre disponible sur au moins un
* des sites selectionnes. Un type de stockage hors des sites du produit est
* rejete en 422 (Assert\Callback, propertyPath `storageTypes`).
*
* @internal
*/
final class ProductStorageTypeBySiteTest extends AbstractProductApiTestCase
{
public function testStorageTypeNotOnSelectedSitesIsRejected(): void
{
$client = $this->createAdminClient();
$siteA = $this->siteByCode('86');
$siteB = $this->siteByCode('17');
// Type de stockage disponible uniquement sur le site B...
$storageType = $this->seedStorageType('Cellule site B', $siteB);
// ... mais produit declare sur le site A seulement -> 422.
$response = $client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload([
'sites' => [$this->iri('sites', (int) $siteA->getId())],
'storageTypes' => [$this->iri('storage_types', (int) $storageType->getId())],
]),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('storageTypes', $this->violationPaths($response));
}
public function testStorageTypeOnSelectedSiteIsAccepted(): void
{
$client = $this->createAdminClient();
$siteA = $this->siteByCode('86');
$storageType = $this->seedStorageType('Tas site A', $siteA);
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload([
'sites' => [$this->iri('sites', (int) $siteA->getId())],
'storageTypes' => [$this->iri('storage_types', (int) $storageType->getId())],
]),
]);
self::assertResponseStatusCodeSame(201);
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Domain;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use PHPUnit\Framework\TestCase;
/**
* Couvre WeighingTicket::getCounterpartyName() (ERP-208) : nom du tiers affiché
* dans le cartouche du bon de pesée selon le type de contrepartie (RG-5.03).
*
* @internal
*/
final class WeighingTicketCounterpartyNameTest extends TestCase
{
public function testReturnsClientCompanyNameForClientCounterparty(): void
{
$client = new Client()->setCompanyName('Ferme du Pré');
$ticket = new WeighingTicket()->setCounterpartyType('CLIENT')->setClient($client);
self::assertSame('Ferme du Pré', $ticket->getCounterpartyName());
}
public function testReturnsSupplierCompanyNameForSupplierCounterparty(): void
{
$supplier = new Supplier()->setCompanyName('Coop Sud');
$ticket = new WeighingTicket()->setCounterpartyType('FOURNISSEUR')->setSupplier($supplier);
self::assertSame('Coop Sud', $ticket->getCounterpartyName());
}
public function testReturnsOtherLabelForOtherCounterparty(): void
{
$ticket = new WeighingTicket()->setCounterpartyType('AUTRE')->setOtherLabel('Particulier');
self::assertSame('Particulier', $ticket->getCounterpartyName());
}
public function testReturnsNullWhenNoCounterparty(): void
{
self::assertNull(new WeighingTicket()->getCounterpartyName());
}
}