Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac86500266 | |||
| bc14e3893b | |||
| 5409c79d1d | |||
| e9f8b0bc45 |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.154'
|
app.version: '0.1.151'
|
||||||
|
|||||||
@@ -1,692 +0,0 @@
|
|||||||
---
|
|
||||||
# === 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 |
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
# 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. ✓
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
# 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).
|
|
||||||
-44
@@ -1,44 +0,0 @@
|
|||||||
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,19 +32,11 @@ 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. */
|
||||||
* Récupère une collection complète (pagination désactivée) en Hydra. Filtre par
|
async function fetchAll(url: string): Promise<PartyMember[]> {
|
||||||
* 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,
|
||||||
query,
|
{ pagination: 'false' },
|
||||||
{ headers: LD_JSON_HEADERS, toast: false },
|
{ headers: LD_JSON_HEADERS, toast: false },
|
||||||
)
|
)
|
||||||
return res.member ?? []
|
return res.member ?? []
|
||||||
@@ -53,15 +45,14 @@ 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). `siteId` (site courant) filtre les listes par site
|
* faire échouer l'autre).
|
||||||
* (ERP-208) ; absent → listes complètes.
|
|
||||||
*/
|
*/
|
||||||
async function load(siteId?: number | null): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
fetchAll('/clients', siteId).then((list) => {
|
fetchAll('/clients').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', siteId).then((list) => {
|
fetchAll('/suppliers').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,13 +8,12 @@ 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: mockRefLoad }),
|
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }),
|
||||||
}))
|
}))
|
||||||
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' }),
|
||||||
@@ -101,7 +100,6 @@ 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 () => {
|
||||||
@@ -109,12 +107,6 @@ 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,10 +7,9 @@ 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: mockRefLoad }),
|
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }),
|
||||||
}))
|
}))
|
||||||
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' }),
|
||||||
@@ -24,8 +23,6 @@ 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
|
||||||
@@ -73,12 +70,6 @@ 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, type WeighingTicketDetail } from '~/modules/logistique/composables/useWeighingTicket'
|
import { useWeighingTicket } 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,34 +404,12 @@ 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, watch } 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 { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||||
@@ -375,28 +375,7 @@ 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(() => {
|
||||||
reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
|
referentials.load().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>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -128,9 +128,7 @@ 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,
|
||||||
-- Pas de DEFAULT : un tableau vide violerait chk_product_states_not_empty
|
states JSONB DEFAULT '[]'::jsonb NOT NULL,
|
||||||
-- (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,
|
||||||
|
|||||||
@@ -151,12 +151,7 @@ class Product implements TimestampableInterface, BlamableInterface
|
|||||||
*
|
*
|
||||||
* @var list<string>
|
* @var list<string>
|
||||||
*/
|
*/
|
||||||
// jsonb (pas json) : aligne le mapping ORM sur la colonne JSONB creee par la
|
#[ORM\Column(type: 'json')]
|
||||||
// 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],
|
||||||
@@ -291,7 +286,7 @@ class Product implements TimestampableInterface, BlamableInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isContainsMolasses(): bool
|
public function containsMolasses(): bool
|
||||||
{
|
{
|
||||||
return $this->containsMolasses;
|
return $this->containsMolasses;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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;
|
||||||
@@ -39,19 +38,16 @@ 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']],
|
||||||
provider: StorageTypeProvider::class,
|
// Tri alphabetique stable pour alimenter le multi-select du formulaire
|
||||||
|
// 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,7 +5,6 @@ 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
|
||||||
{
|
{
|
||||||
@@ -13,19 +12,10 @@ 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).
|
* formulaire produit — § 4.2). Le filtrage par site (?siteId[]=, RG-6.06) est
|
||||||
|
* 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
<?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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
<?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,9 +20,8 @@ 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) ; le type PRODUIT porte
|
* Facturation, Livraison, Approvisionnement, Methaniseur). Chaque categorie porte
|
||||||
* les categories produit du catalogue (M6 ERP-201 : Cereales, Oleagineux, Aliments
|
* un `code` stable.
|
||||||
* 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).
|
||||||
@@ -89,15 +88,6 @@ 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,10 +29,6 @@ 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
|
||||||
@@ -49,15 +45,14 @@ 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),
|
* Version20260605120000 (FOURNISSEUR), Version20260612080000 (PRESTATAIRE) et
|
||||||
* Version20260625100000 (ADRESSE) et Version20260625110000 (PRODUIT, ERP-198).
|
* Version20260625100000 (ADRESSE).
|
||||||
*/
|
*/
|
||||||
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(
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
<?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,7 +7,6 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,33 +31,4 @@ 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,15 +175,6 @@ 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]
|
||||||
@@ -204,7 +195,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: [self::COUNTERPARTY_CLIENT, self::COUNTERPARTY_FOURNISSEUR, self::COUNTERPARTY_AUTRE], message: 'Type de contrepartie invalide.')]
|
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', '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;
|
||||||
|
|
||||||
@@ -322,7 +313,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 self::COUNTERPARTY_CLIENT:
|
case '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')
|
||||||
@@ -332,7 +323,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case self::COUNTERPARTY_FOURNISSEUR:
|
case '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')
|
||||||
@@ -342,7 +333,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case self::COUNTERPARTY_AUTRE:
|
case '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')
|
||||||
@@ -467,21 +458,6 @@ 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,19 +27,6 @@
|
|||||||
.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. */
|
||||||
@@ -54,34 +41,13 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{# Libellé FR du type de contrepartie (couche de rendu, pas le Domain — ERP-208). #}
|
{% if logoSrc %}
|
||||||
{% set counterpartyLabels = { 'CLIENT': 'Client', 'FOURNISSEUR': 'Fournisseur', 'AUTRE': 'Autre' } %}
|
<div class="logo"><img src="{{ logoSrc }}" alt="LPC LIOT"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<table class="header">
|
<div class="company-name">SA LIOT Châtellerault</div>
|
||||||
<tr>
|
<div class="company-line">Email : lpc.contacts@lpc-liot.fr</div>
|
||||||
<td class="h-left">
|
<div class="company-line">RCS Châtellerault B 339 505 612</div>
|
||||||
{% 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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<?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']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
<?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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user