Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6c556ca1b | |||
| 4207a4ae12 | |||
| fdd4394e99 | |||
| 8085f30077 | |||
| 817975e0b7 | |||
| efded9fd40 | |||
| 2e50a760c6 | |||
| 49e5e5548e | |||
| fd430bc123 | |||
| a6b48b1dd1 |
@@ -134,6 +134,16 @@ return [
|
||||
'module' => 'transport',
|
||||
'permission' => 'transport.carriers.view',
|
||||
],
|
||||
// Catalogue produit (M6, ERP-197). Place juste sous le repertoire
|
||||
// transporteurs (DECISION Matthieu 24/06). Admin-only : gate par
|
||||
// `catalog.products.view` et son module owner `catalog`.
|
||||
[
|
||||
'label' => 'sidebar.catalog.products',
|
||||
'to' => '/admin/products',
|
||||
'icon' => 'mdi:package-variant-closed',
|
||||
'module' => 'catalog',
|
||||
'permission' => 'catalog.products.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.core.roles',
|
||||
'to' => '/admin/roles',
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.148'
|
||||
app.version: '0.1.153'
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M6
|
||||
nom: "Catalogue produit"
|
||||
ecran: produits
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0.1
|
||||
date_redaction: 2026-06-24
|
||||
# Historique :
|
||||
# V0.1 (2026-06-24) — Spec back initiale. Restitution + précisions back du docx fonctionnel
|
||||
# « M6-produit-V0 » (V0, 15/06/2026, validation client en attente).
|
||||
# Décisions Matthieu (24/06) :
|
||||
# (1) Produit logé dans le module EXISTANT `Catalog` (pas de nouveau module) ;
|
||||
# item sidebar dans la section « Administration », sous « Répertoire transporteurs ».
|
||||
# (2) « Type de stockage » : référentiel minimal `StorageType` créé maintenant (provisoire,
|
||||
# en attendant la liste définitive d'Aurore), seedé avec la liste Figma (node 1503-34285).
|
||||
# (3) « Code produit » = « Numéro » de la liste : MÊME champ, saisi, UNIQUE global (409 doublon).
|
||||
# (4) « État du produit » : Achat / Vendu / Autre, multi-select, AU MOINS 1 requis
|
||||
# (corrige l'incohérence « Autre » vs « Aucun » du docx).
|
||||
# (5) PÉRIMÈTRE V0 = CRUD produit classique uniquement. Les onglets « Fournisseurs » et
|
||||
# « Clients » sont des PLACEHOLDERS « en cours de développement » (dépendent d'un module
|
||||
# Contrat inexistant) — hors périmètre, tracés HP-M6-01.
|
||||
|
||||
# === LIENS ===
|
||||
spec_front: ./spec-front.md
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1503-34285&p=f&m=dev"
|
||||
trace_fonctionnelle: "uploads/M6-produit-V0.pdf (V0, 15/06/2026, validation client en attente)"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_project_id: 6
|
||||
lesstime_taskgroup_id: 36 # M6 — Catalogue produit (ERP-197 → ERP-207)
|
||||
statut_global: pret_a_dev
|
||||
|
||||
# === DÉPENDANCES AMONT ===
|
||||
depend_de:
|
||||
- Catalog # Module hôte (REQUIRED). Category + CategoryType (ajout du type PRODUIT) + nouveau StorageType + Product
|
||||
- Sites # Site (relation ManyToMany product↔site) + filtrage des types de stockage par site
|
||||
- Core # User, Role, Permission, Audit, JWT
|
||||
- Shared # TimestampableBlamableTrait + Subscriber (ERP-52) + CategoryInterface
|
||||
---
|
||||
|
||||
# Spec back — Module 6 : Catalogue produit
|
||||
|
||||
## 1. Contexte
|
||||
|
||||
Cette spec **complète et précise** la [spec front V0.1](./spec-front.md) (docx `M6-produit-V0`, V0 du 15/06/2026, **validation client en attente**) avec tout ce qui touche au back : décisions d'archi, modèle de données, migration, API REST, RBAC, règles de gestion (RG-6.01 → RG-6.10), tests, hors-périmètre.
|
||||
|
||||
**Module cible** : module **EXISTANT `Catalog`** (`src/Module/Catalog/`) — DÉCISION Matthieu (24/06). Le docx parle d'un « Module 7 — Catalogue produit » rattaché à « l'Administration », mais le projet possède déjà un module `Catalog` (`ID = 'catalog'`, `REQUIRED = true`) qui porte `Category` / `CategoryType`. « Catalogue produit » y a sa place naturelle : on **n'ajoute pas de module**, on ajoute l'entité `Product` (+ le référentiel `StorageType`) au module `Catalog`. L'item de menu vit dans la section **Administration** de la sidebar, **sous « Répertoire transporteurs »** (cf. § 5.3).
|
||||
|
||||
> **RETEX obligatoire (M1→M5)** : ~80 % des frictions venaient du **contrat de sérialisation** (groupes / sous-ressources / embed), pas du métier. La section § 4.0 applique ce RETEX au M6. On réutilise le pattern Provider/Processor + normalisation serveur + Timestampable/Blamable + audit i18n + soft delete posé aux modules précédents, et la taxonomie `Category` codée (ERP-78).
|
||||
|
||||
**Dépendances déjà en place sur `develop`** :
|
||||
- `Catalog` → `Category` (taxonomie codée, soft delete, `CategoryInterface`) + `CategoryType` (référentiel statique, types CLIENT / FOURNISSEUR / PRESTATAIRE seedés). Le type **`PRODUIT` n'est PAS encore seedé** — le M6 l'ajoute (§ 2.5).
|
||||
- `Sites` → 3 sites Châtellerault (`code` 86) / Saint-Jean (17) / Pommevic (82) ; `Site.code` déjà mappé ; `SiteInterface`.
|
||||
- `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52).
|
||||
- `Core` → User, Role, Permission, Audit, JWT.
|
||||
|
||||
## 1.bis Remise en question du docx (incohérences relevées + résolutions)
|
||||
|
||||
> Le docx V0 est volontairement léger. Voici les points ambigus ou contradictoires relevés à la relecture, et la décision retenue (validée Matthieu 24/06). **Toute la spec qui suit applique ces décisions.**
|
||||
|
||||
| # | Point du docx | Problème | Décision retenue |
|
||||
|---|---|---|---|
|
||||
| C1 | « Module 7 — Catalogue produit » / « Module Administration » | Le projet n'a pas de « Module 7 » ni de module « Administration » ; un module `Catalog` existe déjà. | **Produit logé dans `Catalog`** ; item sidebar dans la **section Administration**, sous « Répertoire transporteurs » (§ 2.1 / § 5.3). |
|
||||
| C2 | Colonnes liste = `Nom`, `Numéro`, `Catégorie` ; formulaire = `Nom`, `Code produit`, `Catégorie` | « Numéro » (liste) vs « Code produit » (formulaire) : 2 noms pour quoi ? | **Même champ** : `code` (= « Numéro » = « Code produit »), **saisi**, **unique global**, 409 sur doublon (RG-6.01). La colonne liste « Numéro » affiche `code`. |
|
||||
| C3 | « État du produit » : Multi-select **obligatoire**, valeurs *Achat / Vendu / Autre* | Les onglets parlent de *Achat / Vendu / **Aucun*** → « Autre » ≠ « Aucun ». Et « obligatoire » + « Aucun » se contredisent. | **Enum `PURCHASE` / `SALE` / `OTHER`**, multi-select, **≥ 1 obligatoire** (RG-6.02). « Aucun » des onglets = « ni Achat ni Vendu » (donc `OTHER` seul). |
|
||||
| C4 | « Type de stockage » : « *liste fournie par Aurore* en fonction des sites » | Aucun référentiel de stockage n'existe en base ; la vraie liste est en attente. | **Référentiel minimal `StorageType` créé maintenant** (provisoire), seedé avec la liste Figma (node 1503-34285) ; options **filtrées par les sites sélectionnés** (RG-6.06, § 2.4). À re-seeder quand Aurore livre la liste/le mapping site définitifs (HP-M6-02). |
|
||||
| C5 | « Catégorie produit » : « Liste des catégories produit » | Le type `PRODUIT` n'est pas seedé ; aucune catégorie produit n'existe. | Le M6 **seede le `CategoryType` PRODUIT** + quelques `Category` produit, et le select est **filtré `?typeCode=PRODUIT`** (RG-6.05, § 2.5). |
|
||||
| C6 | « Fabriqué » / « Contient de la mélasse » : « apparaît si État = Vendu » | Comportement front only ? Que stocke-t-on si l'état n'est plus Vendu ? | Booléens **conditionnés à `SALE`** : saisis seulement si l'état contient `SALE`, sinon **forcés `false` serveur** (RG-6.03). |
|
||||
| C7 | RBAC : Admin = Tout, tous les autres rôles = Non | Très restrictif (admin-only). Confirmé ? | **Confirmé** : `catalog.products.view` / `.manage` attribués **au seul rôle Admin** (§ 5.2). |
|
||||
| C8 | Onglets « Fournisseurs » / « Clients » (contrats, prestation de triage, contrats TAF) | Référencent une notion de **Contrat** (client/fournisseur) **inexistante** dans le code. | **Hors périmètre V0** : onglets rendus en **placeholder « en cours de développement »** (comme les autres onglets non encore dev). Tracé HP-M6-01 (§ 9). |
|
||||
|
||||
## 2. Décisions d'archi
|
||||
|
||||
### 2.1 Entité `Product` dans le module `Catalog`
|
||||
|
||||
Ajout au module **`Catalog`** (pas de nouveau module — C1) :
|
||||
- Entité racine **`Product`** sous `src/Module/Catalog/Domain/Entity/Product.php`.
|
||||
- Référentiel **`StorageType`** sous `src/Module/Catalog/Domain/Entity/StorageType.php` (§ 2.4).
|
||||
- Permissions `catalog.products.view` / `catalog.products.manage` ajoutées à `CatalogModule::permissions()` (§ 5.1).
|
||||
- Pas de nouveau layer front (le module `catalog` n'a pas de layer dédié — les écrans admin du Catalog vivent dans le shell `frontend/app/` / `frontend/shared/`, comme `/admin/categories`). Route Nuxt : `/admin/products` (cf. spec-front).
|
||||
|
||||
**Référentiels cross-module consommés en relation ORM partagée (PAS d'import de logique)** — comme M2→M5 : `Product` référence `Site` (Sites) via une **relation ORM** (ManyToMany). Donnée de référence partagée, aucun service/repository d'un autre module appelé. `Category` et `StorageType` appartiennent au **même** module `Catalog` → relations internes classiques.
|
||||
|
||||
### 2.2 IDs — convention `INT` (alignée Catalog / Core)
|
||||
|
||||
`Product` et `StorageType` s'alignent sur la convention du module `Catalog` : **`INT GENERATED BY DEFAULT AS IDENTITY`**. Horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le `TimestampableBlamableTrait` mappe `datetime_immutable`).
|
||||
|
||||
### 2.3 État du produit — multi-valeur `states` (C3 / RG-6.02)
|
||||
|
||||
`états` est un **multi-select** : un produit peut être à la fois `PURCHASE` et `SALE`. Modélisation : colonne **`states JSONB NOT NULL DEFAULT '[]'`** (tableau de chaînes), valeurs autorisées `PURCHASE` / `SALE` / `OTHER`, **≥ 1** (Callback + CHECK de non-vacuité).
|
||||
|
||||
> **Alternative écartée** : 3 colonnes booléennes (`is_purchase`/`is_sale`/`is_other`). Plus simple à requêter mais s'éloigne de la sémantique « multi-select » et multiplie les colonnes. Le JSONB est retenu pour la fidélité au champ unique du docx ; si un besoin de filtrage SQL fin apparaît (HP), on bascule sur une table de jonction `product_state`.
|
||||
|
||||
Pilotage des champs conditionnels (RG-6.03) : `manufactured` et `containsMolasses` ne sont **saisissables que si `states` contient `SALE`** ; sinon forcés `false` côté serveur (Processor) — pas de state machine.
|
||||
|
||||
### 2.4 Référentiel `StorageType` (C4 / RG-6.06) — PROVISOIRE
|
||||
|
||||
> **Décision Matthieu (24/06)** : créer un **référentiel minimal** en attendant la liste/mapping définitifs d'Aurore. Seed = liste Figma (node 1503-34285).
|
||||
|
||||
- Entité `StorageType` (`Catalog`) : `id`, `code` (slug MAJUSCULE stable, unique), `label` (FR affiché), relation **`sites` ManyToMany → Site** (sur quels sites ce type de stockage est disponible).
|
||||
- **Seed initial (10 valeurs, Figma)** : Boisseau, Boisseau dosage, Case, Cellule, Container, Cuve mélasse, Stockage big bag, Stockage palette, Tas, Zone. **Provisoirement rattachés aux 3 sites** (86/17/82) tant qu'Aurore n'a pas précisé le mapping réel par site.
|
||||
- Le champ produit « Type de stockage » est un **multi-select filtré par les sites sélectionnés** dans le formulaire : `GET /api/storage_types?siteId[]=…` ne renvoie que les types disponibles sur ces sites (RG-6.06).
|
||||
- **Provisoire** : codes, libellés et mapping site sont à revalider/re-seeder à réception de la liste Aurore (HP-M6-02). Référentiel en **lecture seule** au M6 (pas de CRUD admin du StorageType — HP-M6-03).
|
||||
|
||||
### 2.5 Catégorie produit — type `PRODUIT` (C5 / RG-6.05)
|
||||
|
||||
- Le M6 **seede le `CategoryType` `PRODUIT`** (code `PRODUIT`, label « Produit ») : ajout dans **`CategoryTypeFixtures::TYPES`** ET dans une **migration de seed** (miroir dev/prod, comme CLIENT/FOURNISSEUR/PRESTATAIRE — cf. `CategoryTypeFixtures` docblock).
|
||||
- Le M6 seede aussi quelques **`Category` de type PRODUIT** (ex. provisoires : « Céréales », « Oléagineux », « Aliments du bétail », « Engrais ») pour alimenter le select. Codes auto-générés par `CategoryCodeGenerator` (slug MAJUSCULE stable).
|
||||
- `Product.category` = **ManyToOne `Category`** (obligatoire). Le select du formulaire est **filtré `?typeCode=PRODUIT`** (provider Category existant — filtre `typeCode` déjà supporté). Lecture du référentiel via `catalog.categories.read_ref` ou `.view` (déjà en place).
|
||||
|
||||
> **Garde-fou** : on **ne contraint pas** en base que `category` soit de type PRODUIT (le filtrage est applicatif via le select + une validation `#[Assert\Callback]` côté Processor qui rejette une catégorie non-PRODUIT en 422). Justification : éviter un couplage SQL fragile au référentiel type.
|
||||
|
||||
### 2.6 Audit & traces temporelles
|
||||
|
||||
Pattern Starseed standard (miroir M1→M5) :
|
||||
- `#[Auditable]` sur `Product`. Pas de champ sensible (password/token) → pas d'`#[AuditIgnore]`.
|
||||
- Audit des relations (`category`, `sites`, `storageTypes`) tracé automatiquement (ManyToMany inclus).
|
||||
- `Product implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait` (4 colonnes standard).
|
||||
- **Libellé i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter `audit.entity.catalog_product` dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)` = `catalog_product`).
|
||||
- `StorageType` = référentiel **statique** en lecture seule → **pas** de Timestampable/Blamable, **pas** `#[Auditable]` (whitelister dans `EntitiesAreTimestampableBlamableTest::EXCLUDED`, miroir `CategoryType`).
|
||||
|
||||
### 2.7 Soft delete préparé ; pas de Delete exposé au M6
|
||||
|
||||
Le docx M6 ne prévoit **ni archivage ni suppression** (actions = + Ajouter / Exporter / Filtrer). On **n'expose pas** de `Delete`. On prépare néanmoins une colonne `deleted_at` (soft delete technique) **non exposée** (cohérent avec `Category` et le pattern M5). Le provider exclut par défaut les produits soft-deleted.
|
||||
|
||||
## 3. Modèle de données
|
||||
|
||||
### 3.1 Diagramme
|
||||
|
||||
```
|
||||
+------------------+ +------------------------+
|
||||
| site (Sites) | | category_type (Catalog)| + seed type PRODUIT (§ 2.5)
|
||||
+------------------+ +------------------------+
|
||||
^ ^ ^
|
||||
| | | (ManyToMany existant)
|
||||
product_ | | storage_type_ |
|
||||
site | | site +------------------+
|
||||
| | | category | (type PRODUIT)
|
||||
+------------------+ +------------------+ +------------------+
|
||||
| product | | storage_type | ^
|
||||
| id (PK) | | id (PK) | | category_id (FK, NOT NULL)
|
||||
| code (UNIQUE) | | code (UNIQUE) |----------+
|
||||
| name | | label | (product.category ManyToOne)
|
||||
| states (JSONB) | +------------------+
|
||||
| manufactured | ^
|
||||
| contains_molasses| | product_storage_type (ManyToMany)
|
||||
| category_id (FK) |--------+
|
||||
| deleted_at |
|
||||
| created_at/by … |
|
||||
+------------------+
|
||||
^ ^
|
||||
| | product_site (ManyToMany) / product_storage_type (ManyToMany)
|
||||
+---+
|
||||
```
|
||||
|
||||
Tables de jonction : `product_site (product_id, site_id)`, `product_storage_type (product_id, storage_type_id)`, `storage_type_site (storage_type_id, site_id)`.
|
||||
|
||||
### 3.2 Migration Doctrine — SQL Postgres (illustratif)
|
||||
|
||||
Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/VersionYYYYMMDDHHMMSS.php` (postérieur aux migrations existantes).
|
||||
|
||||
> **Même justification qu'aux M1→M5** : FK cross-module (`user`, `site`, `category`) → le namespace modulaire casserait l'ordre sur `make db-reset` (exception racine de la règle ABSOLUE n°11).
|
||||
>
|
||||
> **Rappel règle ABSOLUE n°12** : chaque colonne créée DOIT recevoir son `COMMENT ON COLUMN` (FR, ≤ 200 car., sémantique + contrainte/RG). Les 4 colonnes Timestampable/Blamable passent par `addStandardTimestampableBlamableComments`.
|
||||
|
||||
```sql
|
||||
-- =====================================================================
|
||||
-- Référentiel des types de stockage (provisoire — § 2.4 / RG-6.06)
|
||||
-- =====================================================================
|
||||
CREATE TABLE storage_type (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
code VARCHAR(40) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX uq_storage_type_code ON storage_type (code);
|
||||
|
||||
CREATE TABLE storage_type_site (
|
||||
storage_type_id INT NOT NULL REFERENCES storage_type(id) ON DELETE CASCADE,
|
||||
site_id INT NOT NULL REFERENCES site(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (storage_type_id, site_id)
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- Table principale `product`
|
||||
-- =====================================================================
|
||||
CREATE TABLE product (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
code VARCHAR(50) NOT NULL, -- = « Numéro » liste, unique global (RG-6.01)
|
||||
name VARCHAR(255) NOT NULL,
|
||||
states JSONB NOT NULL DEFAULT '[]'::jsonb, -- PURCHASE|SALE|OTHER, >=1 (RG-6.02)
|
||||
manufactured BOOLEAN NOT NULL DEFAULT FALSE, -- saisi si SALE, sinon false (RG-6.03)
|
||||
contains_molasses BOOLEAN NOT NULL DEFAULT FALSE, -- saisi si SALE, sinon false (RG-6.03)
|
||||
category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT, -- type PRODUIT (RG-6.05)
|
||||
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE, -- soft delete, non exposé (§ 2.7)
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
CONSTRAINT chk_product_states_not_empty CHECK (jsonb_array_length(states) >= 1)
|
||||
);
|
||||
-- Unicité GLOBALE du code parmi les actifs (soft delete toléré) — index partiel.
|
||||
CREATE UNIQUE INDEX uq_product_code_active ON product (code) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_product_category ON product (category_id);
|
||||
CREATE INDEX idx_product_deleted_at ON product (deleted_at);
|
||||
CREATE INDEX idx_product_created_by ON product (created_by);
|
||||
CREATE INDEX idx_product_updated_by ON product (updated_by);
|
||||
|
||||
-- =====================================================================
|
||||
-- Jonctions produit ↔ sites / types de stockage
|
||||
-- =====================================================================
|
||||
CREATE TABLE product_site (
|
||||
product_id INT NOT NULL REFERENCES product(id) ON DELETE CASCADE,
|
||||
site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
|
||||
PRIMARY KEY (product_id, site_id)
|
||||
);
|
||||
CREATE TABLE product_storage_type (
|
||||
product_id INT NOT NULL REFERENCES product(id) ON DELETE CASCADE,
|
||||
storage_type_id INT NOT NULL REFERENCES storage_type(id) ON DELETE RESTRICT,
|
||||
PRIMARY KEY (product_id, storage_type_id)
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- Seed du type de catégorie PRODUIT (§ 2.5) — miroir CategoryTypeFixtures
|
||||
-- =====================================================================
|
||||
INSERT INTO category_type (code, label) VALUES ('PRODUIT', 'Produit')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
```
|
||||
|
||||
### 3.2.bis Commentaires SQL obligatoires (échantillon)
|
||||
|
||||
```php
|
||||
$this->addSql("COMMENT ON TABLE product IS 'Produits du catalogue (M6 Catalog) — état Achat/Vendu/Autre, sites de disponibilité, catégorie produit, types de stockage.'");
|
||||
$this->addSql("COMMENT ON COLUMN product.code IS 'Code produit (= « Numéro » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active.'");
|
||||
$this->addSql("COMMENT ON COLUMN product.states IS 'États du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02). Pilote les champs conditionnels.'");
|
||||
$this->addSql("COMMENT ON COLUMN product.manufactured IS '« Fabriqué » : saisi uniquement si states contient SALE, sinon forcé false serveur (RG-6.03).'");
|
||||
$this->addSql("COMMENT ON COLUMN product.contains_molasses IS '« Contient de la mélasse » : saisi uniquement si states contient SALE, sinon forcé false serveur (RG-6.03).'");
|
||||
$this->addSql("COMMENT ON COLUMN product.category_id IS 'Catégorie produit (FK category, type PRODUIT) — obligatoire, validée applicativement (RG-6.05).'");
|
||||
$this->addSql("COMMENT ON COLUMN product.deleted_at IS 'Horodatage de suppression logique (soft delete) — non exposé au M6 ; la liste exclut les produits supprimés (§ 2.7).'");
|
||||
$this->addSql("COMMENT ON TABLE storage_type IS 'Référentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Silo, Tas… (RG-6.06).'");
|
||||
$this->addSql("COMMENT ON COLUMN storage_type.code IS 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).'");
|
||||
$this->addSql("COMMENT ON COLUMN storage_type.label IS 'Libellé FR affiché du type de stockage (ex. « Cuve mélasse »).'");
|
||||
// + COMMENT ON COLUMN sur les tables de jonction (product_site, product_storage_type, storage_type_site)
|
||||
$this->addStandardTimestampableBlamableComments($schema, 'product');
|
||||
```
|
||||
|
||||
### 3.3 Entité `Product` — squelette (extrait)
|
||||
|
||||
Pattern jumeau de `Category` (`#[Auditable]`, `TimestampableBlamableTrait`, soft delete). **Chaque propriété affichée porte un read-group** (RETEX M1).
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\ProductProcessor;
|
||||
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\ProductProvider;
|
||||
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository;
|
||||
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagée (§ 2.1)
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('catalog.products.view')",
|
||||
normalizationContext: ['groups' => ['product:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||
provider: ProductProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('catalog.products.view')",
|
||||
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||
provider: ProductProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('catalog.products.manage')",
|
||||
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['product:write']],
|
||||
processor: ProductProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('catalog.products.manage')",
|
||||
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['product:write']],
|
||||
provider: ProductProvider::class,
|
||||
processor: ProductProcessor::class,
|
||||
),
|
||||
// Pas de Delete au M6 (docx) ; soft delete préparé non exposé (§ 2.7).
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineProductRepository::class)]
|
||||
#[ORM\Table(name: 'product')]
|
||||
#[Auditable]
|
||||
class Product implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
#[Groups(['product:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
/** Code produit (= « Numéro » liste), unique global, saisi (RG-6.01). */
|
||||
#[ORM\Column(length: 50)]
|
||||
#[Assert\NotBlank(message: 'Le code produit est obligatoire.')]
|
||||
#[Assert\Length(max: 50, maxMessage: 'Le code produit ne peut pas dépasser {{ limit }} caractères.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'Le nom du produit est obligatoire.')]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le nom du produit ne peut pas dépasser {{ limit }} caractères.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?string $name = null;
|
||||
|
||||
/** États (multi-select) ⊆ {PURCHASE, SALE, OTHER}, ≥ 1 (RG-6.02). */
|
||||
#[ORM\Column(type: 'json')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')]
|
||||
#[Assert\All([new Assert\Choice(choices: ['PURCHASE', 'SALE', 'OTHER'], message: 'État de produit invalide.')])]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private array $states = [];
|
||||
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private bool $manufactured = false; // saisi si SALE, sinon false (RG-6.03)
|
||||
|
||||
#[ORM\Column(name: 'contains_molasses', options: ['default' => false])]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private bool $containsMolasses = false; // saisi si SALE, sinon false (RG-6.03)
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Category::class)]
|
||||
#[ORM\JoinColumn(name: 'category_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
#[Assert\NotNull(message: 'La catégorie produit est obligatoire.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?Category $category = null; // type PRODUIT, validé Callback (RG-6.05)
|
||||
|
||||
/** @var Collection<int, Site> Sites de disponibilité (≥ 1, RG-6.04). */
|
||||
#[ORM\ManyToMany(targetEntity: Site::class)]
|
||||
#[ORM\JoinTable(name: 'product_site')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un site.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private Collection $sites;
|
||||
|
||||
/** @var Collection<int, StorageType> Types de stockage (≥ 1, filtrés par sites — RG-6.06). */
|
||||
#[ORM\ManyToMany(targetEntity: StorageType::class)]
|
||||
#[ORM\JoinTable(name: 'product_storage_type')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de stockage.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private Collection $storageTypes;
|
||||
|
||||
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
||||
private ?\DateTimeImmutable $deletedAt = null; // soft delete, non exposé (§ 2.7)
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->sites = new ArrayCollection();
|
||||
$this->storageTypes = new ArrayCollection();
|
||||
}
|
||||
|
||||
// RG-6.03 (champs conditionnels SALE) + RG-6.05 (catégorie de type PRODUIT)
|
||||
// + RG-6.06 (types de stockage ⊆ sites) : cohérence via #[Assert\Callback] (§ 7).
|
||||
// ... getters/setters ...
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠ `Site` appartient au module Sites — on consomme son read-group (`site:read`), **pas de logique inter-module** (§ 2.1). `Category` / `StorageType` sont dans le **même** module `Catalog`.
|
||||
|
||||
## 4. API REST (API Platform)
|
||||
|
||||
### 4.0 Contrat de sérialisation (RETEX M1 — section critique)
|
||||
|
||||
> **Leçon M1→M5** : pour **chaque champ affiché** (liste OU détail), les **3 maillons** : (a) groupe sur la propriété, (b) groupe dans le `normalizationContext` de l'opération, (c) read-group de l'entité imbriquée présent dans le contexte parent.
|
||||
|
||||
**Contexte par opération** :
|
||||
|
||||
| Opération | `normalizationContext` (groupes) |
|
||||
|---|---|
|
||||
| `GetCollection` (liste) | `product:read` + `category:read` + `site:read` + `storage_type:read` + `default:read` |
|
||||
| `Get` / `Post` / `Patch` (détail) | + `product:item:read` |
|
||||
|
||||
**LISTE — colonne datatable → maillons** (docx p.3 : Nom, Numéro, Catégorie) :
|
||||
|
||||
| Colonne affichée | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) |
|
||||
|---|---|---|---|
|
||||
| Nom | `name` ∈ `product:read` | ✅ | — |
|
||||
| Numéro | `code` ∈ `product:read` | ✅ | — |
|
||||
| Catégorie | `category` ∈ `product:read` (embed) | ✅ | `category:read` ✅ (affiche `category.name`) |
|
||||
|
||||
**DÉTAIL — maillons** : `states`, `manufactured`, `containsMolasses` ∈ `product:read` ; `sites` (embed `site:read`) + `storageTypes` (embed `storage_type:read`) ∈ `product:read` (ensembles **bornés** → embed autorisé, ne viole pas la règle n°13). Rien de spécifique en `product:item:read` au-delà des relations (tout le produit tient en liste) — `product:item:read` réservé si on ajoute des champs détail-only ultérieurement.
|
||||
|
||||
### 4.0.bis Réponse JSON de référence (DoD — CAPTURÉ sur l'API réelle, ERP-203)
|
||||
|
||||
> **Definition of Done** (miroir M2→M5) : créer un produit via `POST /api/products`, appeler `GET /api/products` (liste) ET `GET /api/products/{id}` (détail), **coller la réponse JSON réelle** ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON. Pièges re-testés : `category` en **objet embarqué** (pas IRI nu) ; `sites` / `storageTypes` en **tableaux d'objets** (pas tableaux d'IRI) ; `states` en tableau de chaînes ; `manufactured` / `containsMolasses` présents (booléens). `skip_null_values` actif → ne pas présumer la présence des champs null.
|
||||
>
|
||||
> **Capture réelle** (ERP-203) : produit créé par un `POST` réel puis relu, via `ProductSerializationContractTest` (régénérable : `PRODUCT_DOD_DUMP=1` → `/tmp/product-dod-{list,detail}.json`). Valeurs ci-dessous reformatées avec des libellés lisibles ; **les clés sont celles de la réponse réelle**. Écarts notables vs l'esquisse initiale, à connaître côté front :
|
||||
> - La **LISTE porte déjà `sites` + `storageTypes` embarqués** (la propriété `product:read` est dans le contexte liste ET détail) : pas besoin d'un appel détail pour les obtenir.
|
||||
> - `category` embarque **sa collection `categoryTypes`** (utile pour vérifier le type PRODUIT côté front, RG-6.05) **plus ses métadonnées d'audit** (`createdAt`/`updatedAt`/`createdBy`/`updatedBy`).
|
||||
> - `createdBy` / `updatedBy` (produit et catégorie) sortent en **IRI** (`/api/me` pour l'utilisateur courant), pas en objet User embarqué.
|
||||
> - chaque `site` embarque l'**adresse complète** (`street`, `postalCode`, `city`, `color`, `fullAddress` — groupe `site:read`).
|
||||
> - un `StorageType` n'expose que `id` / `code` / `label` (sa relation `sites` n'est pas sérialisée — § 2.4).
|
||||
|
||||
**`GET /api/products` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view`) :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"@context": "/api/contexts/Product",
|
||||
"@id": "/api/products",
|
||||
"@type": "Collection",
|
||||
"totalItems": 1,
|
||||
"member": [
|
||||
{
|
||||
"@id": "/api/products/34",
|
||||
"@type": "Product",
|
||||
"id": 34,
|
||||
"code": "BLE-TENDRE-01",
|
||||
"name": "Blé tendre",
|
||||
"states": ["PURCHASE", "SALE"],
|
||||
"manufactured": true,
|
||||
"containsMolasses": true,
|
||||
"category": {
|
||||
"@id": "/api/categories/12",
|
||||
"@type": "Category",
|
||||
"id": 12,
|
||||
"name": "Céréales",
|
||||
"code": "CEREALES",
|
||||
"categoryTypes": [
|
||||
{ "@id": "/api/category_types/5", "@type": "CategoryType", "id": 5, "code": "PRODUIT", "label": "Produit" }
|
||||
],
|
||||
"createdAt": "2026-06-25T12:09:27+02:00",
|
||||
"updatedAt": "2026-06-25T12:09:27+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me"
|
||||
},
|
||||
"sites": [
|
||||
{
|
||||
"@id": "/api/sites/1",
|
||||
"@type": "Site",
|
||||
"id": 1,
|
||||
"name": "Chatellerault",
|
||||
"code": "86",
|
||||
"street": "14 All. d'Argenson",
|
||||
"postalCode": "86100",
|
||||
"city": "Châtellerault",
|
||||
"color": "#056CF2",
|
||||
"createdAt": "2026-06-25T11:32:33+02:00",
|
||||
"updatedAt": "2026-06-25T11:32:33+02:00",
|
||||
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
|
||||
}
|
||||
],
|
||||
"storageTypes": [
|
||||
{ "@id": "/api/storage_types/9", "@type": "StorageType", "id": 9, "code": "TAS", "label": "Tas" }
|
||||
],
|
||||
"createdAt": "2026-06-25T12:09:28+02:00",
|
||||
"updatedAt": "2026-06-25T12:09:28+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me"
|
||||
}
|
||||
],
|
||||
"view": { "@id": "/api/products?search=BLE-TENDRE-01", "@type": "PartialCollectionView" }
|
||||
}
|
||||
```
|
||||
|
||||
**`GET /api/products/34` (DÉTAIL)** — **même structure** que la ligne de liste (les `sites` / `storageTypes` sont déjà embarqués en liste ; `product:item:read` est réservé à d'éventuels champs détail-only ultérieurs) :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"@context": "/api/contexts/Product",
|
||||
"@id": "/api/products/34",
|
||||
"@type": "Product",
|
||||
"id": 34,
|
||||
"code": "BLE-TENDRE-01",
|
||||
"name": "Blé tendre",
|
||||
"states": ["PURCHASE", "SALE"],
|
||||
"manufactured": true,
|
||||
"containsMolasses": true,
|
||||
"category": {
|
||||
"@id": "/api/categories/12",
|
||||
"@type": "Category",
|
||||
"id": 12,
|
||||
"name": "Céréales",
|
||||
"code": "CEREALES",
|
||||
"categoryTypes": [
|
||||
{ "@id": "/api/category_types/5", "@type": "CategoryType", "id": 5, "code": "PRODUIT", "label": "Produit" }
|
||||
],
|
||||
"createdAt": "2026-06-25T12:09:27+02:00",
|
||||
"updatedAt": "2026-06-25T12:09:27+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me"
|
||||
},
|
||||
"sites": [
|
||||
{
|
||||
"@id": "/api/sites/1",
|
||||
"@type": "Site",
|
||||
"id": 1,
|
||||
"name": "Chatellerault",
|
||||
"code": "86",
|
||||
"street": "14 All. d'Argenson",
|
||||
"postalCode": "86100",
|
||||
"city": "Châtellerault",
|
||||
"color": "#056CF2",
|
||||
"createdAt": "2026-06-25T11:32:33+02:00",
|
||||
"updatedAt": "2026-06-25T11:32:33+02:00",
|
||||
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
|
||||
}
|
||||
],
|
||||
"storageTypes": [
|
||||
{ "@id": "/api/storage_types/9", "@type": "StorageType", "id": 9, "code": "TAS", "label": "Tas" }
|
||||
],
|
||||
"createdAt": "2026-06-25T12:09:28+02:00",
|
||||
"updatedAt": "2026-06-25T12:09:28+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.1 Query params (LISTE)
|
||||
|
||||
| Param | Effet |
|
||||
|---|---|
|
||||
| `?page` / `?itemsPerPage` | pagination standard (10 / 25 / 50, défaut 10) |
|
||||
| `?search=` | recherche sur `code` et `name` |
|
||||
| `?categoryId=` ou `?categoryCode=` | filtre par catégorie (drawer « Filtrer », docx p.3) |
|
||||
| `?state=` | filtre par état (PURCHASE / SALE / OTHER) — drawer « Filtrer » |
|
||||
| `?siteId[]=` | filtre par site de disponibilité |
|
||||
| `?order[name]=asc` | tri (défaut : `name ASC`) |
|
||||
|
||||
Pagination obligatoire (règle ABSOLUE n°13) — provider ORM via `ApiPlatform\Doctrine\Orm\Paginator`, jamais d'array brut.
|
||||
|
||||
### 4.2 Référentiel `StorageType` — `GET /api/storage_types`
|
||||
|
||||
- Opérations : `GetCollection` + `Get` (lecture seule au M6 — § 2.4).
|
||||
- Sécurité : `is_granted('catalog.products.view')` (référentiel servant le formulaire produit). *(Si un autre rôle doit lire ce référentiel sans accès produit, ajouter une `read_ref` dédiée — non requis au M6 vu le RBAC admin-only.)*
|
||||
- **`?siteId[]=…`** : filtre les types disponibles sur les sites passés (alimente le multi-select « Type de stockage » filtré par les sites cochés — RG-6.06).
|
||||
- **`?pagination=false`** : échappatoire select (référentiel borné ≤ quelques dizaines) — règle frontend.
|
||||
- `normalizationContext: ['storage_type:read']` ; tri `label ASC`.
|
||||
|
||||
### 4.3 `POST /api/products` (création)
|
||||
|
||||
- Le client envoie : `code`, `name`, `states[]`, `manufactured`, `containsMolasses`, `category` (IRI), `sites[]` (IRI), `storageTypes[]` (IRI).
|
||||
- Le **Processor** (`ProductProcessor`) :
|
||||
1. Normalise `code` (trim + UPPER) et `name` (trim) — RG-6.07.
|
||||
2. Valide l'**unicité globale** du `code` parmi les actifs → **409** sur doublon (RG-6.01).
|
||||
3. Force `manufactured` / `containsMolasses` à `false` si `states` ne contient pas `SALE` (RG-6.03).
|
||||
4. Valide que `category` est de type **PRODUIT** (RG-6.05) et que `storageTypes ⊆` types disponibles sur les `sites` choisis (RG-6.06) → 422 sinon.
|
||||
- Réponse `201` avec le produit complet.
|
||||
|
||||
### 4.4 `PATCH /api/products/{id}` (modification)
|
||||
|
||||
- Mise à jour partielle, mêmes règles. Le **mode strict PATCH** s'applique (RETEX M1) : un champ hors-permission dans le payload = 403 global (ici un seul niveau `manage`, donc surface réduite).
|
||||
- Re-validation unicité `code` (en excluant le produit courant). Re-force des conditionnels (RG-6.03).
|
||||
|
||||
### 4.5 Export — `GET /api/products/export.xlsx`
|
||||
|
||||
- Exporte **toute la liste** des produits (docx : bouton « Exporter » → « Exporte toute la liste des produits »), filtres actifs appliqués.
|
||||
- Colonnes : Numéro (`code`), Nom, États (Achat/Vendu/Autre joints), Catégorie, Sites, Types de stockage, Fabriqué, Contient mélasse.
|
||||
- Génération via le helper XLSX standard projet (skill `xlsx`) — controller dédié (miroir `ClientExportController`) OU provider binaire ; **whitelisté pagination** (`EXCLUDED`) car export complet.
|
||||
|
||||
## 5. RBAC, module & sidebar
|
||||
|
||||
### 5.1 `CatalogModule::permissions()` — ajout
|
||||
|
||||
```php
|
||||
// Ajouts M6 (à insérer dans CatalogModule::permissions()) :
|
||||
['code' => 'catalog.products.view', 'label' => 'Voir les produits'],
|
||||
['code' => 'catalog.products.manage', 'label' => 'Gérer les produits (créer, éditer)'],
|
||||
```
|
||||
Synchronisation : `app:sync-permissions`.
|
||||
|
||||
### 5.2 Matrice rôle → permissions (docx p.3 — admin-only, C7)
|
||||
|
||||
| Rôle | `…products.view` | `…products.manage` |
|
||||
|---|:--:|:--:|
|
||||
| **Admin** | ✅ | ✅ |
|
||||
| **Bureau** | ❌ | ❌ |
|
||||
| **Compta** | ❌ | ❌ |
|
||||
| **Commerciale** | ❌ | ❌ |
|
||||
| **Usine** | ❌ | ❌ |
|
||||
|
||||
> Très restrictif : le Catalogue produit est **admin-only** (docx). Item sidebar masqué pour tous les autres rôles. (Si plus tard Bureau doit consulter, ajouter `catalog.products.view` à son rôle dans les 3 miroirs.)
|
||||
|
||||
### 5.3 Sidebar (`config/sidebar.php`)
|
||||
|
||||
Nouvel item dans la **section « Administration » existante**, placé **juste sous « Répertoire transporteurs »** (`/carriers`) — DÉCISION Matthieu (24/06) :
|
||||
|
||||
```php
|
||||
[
|
||||
'label' => 'sidebar.catalog.products',
|
||||
'to' => '/admin/products',
|
||||
'icon' => 'mdi:package-variant-closed',
|
||||
'module' => 'catalog',
|
||||
'permission' => 'catalog.products.view',
|
||||
],
|
||||
```
|
||||
|
||||
### 5.4 Règle ABSOLUE n°8 — 3 miroirs RBAC
|
||||
|
||||
Toute permission `catalog.products.*` doit être posée **simultanément** dans :
|
||||
1. `config/sidebar.php` (item + permission ci-dessus),
|
||||
2. `frontend/tests/e2e/_fixtures/personas.ts` (le persona **Admin** gagne `catalog.products.view/manage` + `expectedAdminLinks` ; les personas métier **ne** gagnent **rien**),
|
||||
3. `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` (miroir back du même persona Admin).
|
||||
|
||||
## 6. Normalisation serveur (RG-6.07)
|
||||
|
||||
`ProductFieldNormalizer` (miroir `CategoryProcessor` / `CarrierFieldNormalizer`), appelé par le Processor avant validation :
|
||||
- `code` → trim + UPPER (cohérent avec la stratégie de codes stables du Catalog).
|
||||
- `name` → trim (rejet 422 si vide après trim — RG-6.01/6.02 sur le name de Category, même garde-fou).
|
||||
|
||||
## 7. Règles de gestion (RG)
|
||||
|
||||
| RG | Source | Énoncé |
|
||||
|---|---|---|
|
||||
| **RG-6.01** | docx+back | `code` produit (= « Numéro » liste) obligatoire, **unique global** parmi les actifs, normalisé (trim/UPPER), **409** sur doublon. |
|
||||
| **RG-6.02** | docx+back | `states` = multi-select ⊆ {`PURCHASE`,`SALE`,`OTHER`}, **≥ 1** obligatoire (CHECK non-vide + `Assert\Count(min:1)`). |
|
||||
| **RG-6.03** | docx+back | « Fabriqué » et « Contient de la mélasse » saisissables **uniquement si `states` contient `SALE`** ; sinon forcés `false` serveur. |
|
||||
| **RG-6.04** | docx | `sites` (multi-select) obligatoire, **≥ 1** site. |
|
||||
| **RG-6.05** | docx+back | `category` obligatoire, limitée aux catégories de **type PRODUIT** (select filtré `?typeCode=PRODUIT` + validation Callback 422). |
|
||||
| **RG-6.06** | docx+back | `storageTypes` (multi-select) obligatoire, **≥ 1**, options **filtrées par les sites sélectionnés** ; référentiel `StorageType` **provisoire** (en attente Aurore). |
|
||||
| **RG-6.07** | back | Normalisation serveur : `code` trim+UPPER, `name` trim (§ 6). |
|
||||
| **RG-6.08** | back | « Modification » = même formulaire/mêmes règles que « Ajouter » ; bouton « Valider » → « Enregistrer » (docx p.7). Code & contraintes inchangés. |
|
||||
| **RG-6.09** | back | Liste : exclut par défaut les produits soft-deleted ; pas de Delete exposé (§ 2.7). |
|
||||
| **RG-6.10** | back | Onglets « Fournisseurs » / « Clients » = **hors périmètre V0** (placeholder), dépendent du module Contrat inexistant (HP-M6-01). |
|
||||
|
||||
Cohérence inter-champs (RG-6.03 / 6.05 / 6.06) implémentée via `#[Assert\Callback]` portant des messages FR + CHECK Postgres (non-vacuité `states`).
|
||||
|
||||
## 8. Tests (PHPUnit) — `make test`
|
||||
|
||||
- **`ProductSerializationContractTest`** : capture JSON liste + détail (DoD § 4.0.bis) ; `category`/`sites`/`storageTypes` embarqués (objets, pas IRI) ; `states` tableau ; booléens présents.
|
||||
- **`ProductCodeUniquenessTest`** : 409 sur doublon de `code` (actifs) ; réutilisation possible d'un code soft-deleted (index partiel).
|
||||
- **`ProductStatesValidationTest`** : ≥ 1 état (RG-6.02) ; valeurs hors enum rejetées.
|
||||
- **`ProductConditionalFieldsTest`** : `manufactured`/`containsMolasses` forcés `false` si pas `SALE` (RG-6.03).
|
||||
- **`ProductCategoryTypeTest`** : 422 si `category` n'est pas de type PRODUIT (RG-6.05).
|
||||
- **`ProductStorageTypeBySiteTest`** : 422 si un `storageType` n'est pas disponible sur les `sites` choisis (RG-6.06).
|
||||
- **RBAC** : Admin OK ; Bureau/Compta/Commerciale/Usine → 403 (view + manage).
|
||||
- **Architecture** (déjà en place, ne pas casser) : `ColumnsHaveSqlCommentTest`, `EntitiesAreTimestampableBlamableTest` (whitelister `StorageType`), `AuditableEntitiesHaveI18nLabelTest` (`catalog_product`), `CollectionsArePaginatedTest`, `EntityConstraintsHaveFrenchMessageTest`.
|
||||
|
||||
## 9. Hors périmètre (HP)
|
||||
|
||||
| Réf | Sujet |
|
||||
|---|---|
|
||||
| **HP-M6-01** | **Onglets « Fournisseurs » et « Clients »** du produit (liaison à des **contrats** client/fournisseur, « clients en prestation de triage », « contrats TAF »). Dépend d'un **module Contrat inexistant**. Rendus en **placeholder « en cours de développement »** au M6 (§ 1.bis C8, RG-6.10). À spécifier quand le module Contrat existera. |
|
||||
| **HP-M6-02** | Liste/mapping **définitifs des types de stockage par site** (fournis par Aurore). Re-seed du référentiel `StorageType` + révision du filtrage par site (§ 2.4). |
|
||||
| **HP-M6-03** | **CRUD admin du référentiel `StorageType`** (création/édition par un admin). Au M6 : lecture seule + seed. |
|
||||
| **HP-M6-04** | Archivage / suppression d'un produit (non prévu au docx — soft delete préparé mais non exposé, § 2.7). |
|
||||
| **HP-M6-05** | Contrainte SQL « `category` de type PRODUIT » au niveau base (au M6 : validation applicative seulement, § 2.5). |
|
||||
|
||||
## 10. Tickets Lesstime (à découper — back en tête)
|
||||
|
||||
| Ordre | Sujet | Tag |
|
||||
|---|---|---|
|
||||
| 0 | Permissions `catalog.products.view/manage` + sidebar (item sous Transporteurs) + 3 miroirs RBAC | Backend |
|
||||
| 1 | Migration : `storage_type` (+ jonction site) + `product` (+ jonctions) + seed type PRODUIT + COMMENT | Backend |
|
||||
| 2 | Entités `Product` + `StorageType` + Repositories + contrat sérialisation | Backend |
|
||||
| 3 | `ProductProvider` + `ProductProcessor` (unicité code, RG-6.03/6.05/6.06, normalisation) | Backend |
|
||||
| 4 | Référentiel `StorageType` exposé (`GetCollection` + filtre `?siteId[]`) + seed Figma + catégories PRODUIT | Backend |
|
||||
| 5 | Export XLSX | Backend |
|
||||
| 6 | Tests PHPUnit RG-6.01→6.10 + capture contrat JSON | Backend |
|
||||
| 7 | Page liste `/admin/products` (usePaginatedList) + drawer filtre + export | Frontend |
|
||||
| 8 | Écran Ajouter (champs conditionnels SALE, selects filtrés catégorie/stockage) | Frontend |
|
||||
| 9 | Écran Modification (« Enregistrer ») + onglets **placeholder « en cours de dev »** (Fournisseurs / Clients) | Frontend |
|
||||
| 10 | i18n + libellé audit (`catalog_product`) | Frontend |
|
||||
|
||||
## 📦 Tickets Lesstime générés
|
||||
|
||||
**TaskGroup Lesstime** : **#36 — M6 — Catalogue produit** (projet `ERP / Starseed`, projectId=6) — créé le 24/06/2026, 11 tickets au statut « Prêt à dev ». Back = **Matthieu**, Front = **Tristan**. Chaque ticket porte son prompt d'implémentation `.md` en pièce jointe (dossier `prompts/`).
|
||||
|
||||
| # | ERP | Ticket | Effort | Tag | Assigné |
|
||||
|---|---|---|---|---|---|
|
||||
| 1.1 | ERP-197 | Permissions catalog.products.* + sidebar + 3 miroirs RBAC | S | Backend | Matthieu |
|
||||
| 1.2 | ERP-198 | Migrer le schéma M6 (storage_type, product, jonctions, type PRODUIT) | M | Backend | Matthieu |
|
||||
| 1.3 | ERP-199 | Entités Product + StorageType + repositories + contrat sérialisation | M | Backend | Matthieu |
|
||||
| 1.4 | ERP-200 | ProductProvider + ProductProcessor (unicité code, RG-6.03/05/06) | L | Backend | Matthieu |
|
||||
| 1.5 | ERP-201 | Exposer le référentiel StorageType + seed Figma + catégories PRODUIT | M | Backend | Matthieu |
|
||||
| 1.6 | ERP-202 | Export XLSX des produits | S | Backend | Matthieu |
|
||||
| 1.7 | ERP-203 | Tests PHPUnit RG-6.01→6.10 + capture du contrat JSON | M | Backend | Matthieu |
|
||||
| 1.8 | ERP-204 | Page liste /admin/products (datatable, filtre, export) | M | Frontend | Tristan |
|
||||
| 1.9 | ERP-205 | Écran Ajouter un produit (champs conditionnels, selects filtrés) | L | Frontend | Tristan |
|
||||
| 1.10 | ERP-206 | Écran Modification + onglets placeholder (Fournisseurs/Clients) | M | Frontend | Tristan |
|
||||
| 1.11 | ERP-207 | i18n + libellé audit catalog_product | S | Frontend | Tristan |
|
||||
@@ -52,7 +52,8 @@
|
||||
"admin": "Sites"
|
||||
},
|
||||
"catalog": {
|
||||
"categories": "Gestion des catégories"
|
||||
"categories": "Gestion des catégories",
|
||||
"products": "Catalogue produit"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -183,6 +184,7 @@
|
||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||
},
|
||||
"accounting": {
|
||||
"infoTitle": "Informations",
|
||||
"siren": "SIREN",
|
||||
"accountNumber": "Numéro de compte",
|
||||
"tvaMode": "Mode de TVA",
|
||||
@@ -190,6 +192,7 @@
|
||||
"paymentDelay": "Délai de règlement",
|
||||
"paymentType": "Type de règlement",
|
||||
"bank": "Banque",
|
||||
"ribTitle": "RIB {n}",
|
||||
"ribLabel": "Libellé",
|
||||
"ribBic": "BIC",
|
||||
"ribIban": "IBAN",
|
||||
@@ -350,6 +353,7 @@
|
||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||
},
|
||||
"accounting": {
|
||||
"infoTitle": "Informations",
|
||||
"siren": "SIREN",
|
||||
"accountNumber": "Numéro de compte",
|
||||
"tvaMode": "Mode de TVA",
|
||||
@@ -441,6 +445,7 @@
|
||||
"categoryRequired": "Sélectionnez au moins une catégorie."
|
||||
},
|
||||
"contact": {
|
||||
"title": "Contact {n}",
|
||||
"lastName": "Nom",
|
||||
"firstName": "Prénom",
|
||||
"jobTitle": "Fonction",
|
||||
@@ -452,6 +457,7 @@
|
||||
"add": "Nouveau contact"
|
||||
},
|
||||
"address": {
|
||||
"title": "Adresse {n}",
|
||||
"sites": "Sites",
|
||||
"contacts": "Contact(s) rattaché(s)",
|
||||
"country": "Pays",
|
||||
@@ -465,6 +471,7 @@
|
||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||
},
|
||||
"accounting": {
|
||||
"infoTitle": "Informations",
|
||||
"siren": "SIREN",
|
||||
"accountNumber": "Numéro de compte",
|
||||
"tvaMode": "Mode de TVA",
|
||||
@@ -472,6 +479,7 @@
|
||||
"paymentDelay": "Délai de règlement",
|
||||
"paymentType": "Type de règlement",
|
||||
"bank": "Banque",
|
||||
"ribTitle": "RIB {n}",
|
||||
"ribLabel": "Libellé",
|
||||
"ribBic": "BIC",
|
||||
"ribIban": "IBAN",
|
||||
@@ -628,6 +636,7 @@
|
||||
"uploadFailed": "Le téléversement de la décharge a échoué."
|
||||
},
|
||||
"address": {
|
||||
"title": "Adresse",
|
||||
"country": "Pays",
|
||||
"postalCode": "Code postal",
|
||||
"city": "Ville",
|
||||
@@ -637,6 +646,7 @@
|
||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||
},
|
||||
"contact": {
|
||||
"title": "Contact {n}",
|
||||
"lastName": "Nom",
|
||||
"firstName": "Prénom",
|
||||
"jobTitle": "Fonction",
|
||||
@@ -654,6 +664,7 @@
|
||||
"confirm": "Supprimer"
|
||||
},
|
||||
"price": {
|
||||
"title": "Prix {n}",
|
||||
"direction": "Sens",
|
||||
"directionClient": "Client",
|
||||
"directionSupplier": "Fournisseur",
|
||||
@@ -806,6 +817,7 @@
|
||||
"core_permission": "Permission",
|
||||
"sites_site": "Site",
|
||||
"catalog_category": "Catégorie",
|
||||
"catalog_product": "Produit",
|
||||
"commercial_client": "Client",
|
||||
"commercial_clientaddress": "Adresse client",
|
||||
"commercial_clientcontact": "Contact client",
|
||||
|
||||
@@ -1,203 +1,211 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||
(pas de bordure sous le dernier bloc). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Usage de l'adresse : Select unique (plus simple pour l'utilisateur)
|
||||
remplacant les 3 cases. Les options encodent les combinaisons valides
|
||||
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
|
||||
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
|
||||
<!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire +
|
||||
exclusivite prospect) -> affichee sous le select Type d'adresse. -->
|
||||
<MalioSelect
|
||||
:model-value="addressType"
|
||||
:options="addressTypeOptions"
|
||||
:label="t('commercial.clients.form.address.addressType')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.isProspect"
|
||||
@update:model-value="onAddressTypeChange"
|
||||
/>
|
||||
|
||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-1.10). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('commercial.clients.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif). Consultation : masque si aucun (ERP-193). -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('commercial.clients.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Email(s) de facturation : visible/obligatoire seulement si Facturation
|
||||
(RG-1.11). Le « + » revele un 2e email optionnel (max 2, pendant du
|
||||
telephone secondaire) qui coule dans la grille. Sinon un filler comble
|
||||
la colonne pour que Categorie reparte au debut de la ligne suivante. -->
|
||||
<MalioInputEmail
|
||||
v-if="isBillingEmailRequired(model)"
|
||||
:model-value="model.billingEmail"
|
||||
:label="t('commercial.clients.form.address.billingEmail')"
|
||||
:required="!readonly && !disabled"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.billingEmail"
|
||||
:addable="!model.hasSecondaryBillingEmail && !readonly"
|
||||
:add-button-label="t('commercial.clients.form.address.addBillingEmail')"
|
||||
@update:model-value="(v: string) => update('billingEmail', v)"
|
||||
@add="revealSecondaryBillingEmail"
|
||||
/>
|
||||
<!-- Filler : aligne la suite de la grille (Categorie au debut de ligne).
|
||||
Inutile en consultation masquee (la grille se recompose sans les
|
||||
champs vides, ERP-193). -->
|
||||
<div v-else-if="!hideEmpty" aria-hidden="true" />
|
||||
|
||||
<MalioInputEmail
|
||||
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
|
||||
:model-value="model.billingEmailSecondary"
|
||||
:label="t('commercial.clients.form.address.billingEmailSecondary')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.billingEmailSecondary"
|
||||
@update:model-value="(v: string) => update('billingEmailSecondary', v)"
|
||||
/>
|
||||
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.categoryIris"
|
||||
:options="categoryOptions"
|
||||
:label="t('commercial.clients.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('commercial.clients.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.postalCode"
|
||||
:label="t('commercial.clients.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Si la BAN est
|
||||
indisponible, bascule en saisie libre — recuperable : re-saisir le
|
||||
code postal relance la recherche et repasse en select au succes. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('commercial.clients.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.city"
|
||||
:label="t('commercial.clients.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Adresse + Adresse complementaire sur 2 colonnes : on wrappe car
|
||||
MalioInputText/Autocomplete (inheritAttrs:false) renvoient `class`
|
||||
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
|
||||
le col-span-2, le champ le remplit (w-full). -->
|
||||
<div class="col-span-2">
|
||||
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple
|
||||
seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas
|
||||
sa valeur liee, il n'afficherait rien en readonly). allow-create :
|
||||
si la BAN ne propose rien (ou erreur), le texte saisi est CONSERVE au
|
||||
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
|
||||
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
<!-- Grille 4 colonnes des champs de l'adresse. -->
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Usage de l'adresse : Select unique (plus simple pour l'utilisateur)
|
||||
remplacant les 3 cases. Les options encodent les combinaisons valides
|
||||
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
|
||||
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
|
||||
<!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire +
|
||||
exclusivite prospect) -> affichee sous le select Type d'adresse. -->
|
||||
<MalioSelect
|
||||
:model-value="addressType"
|
||||
:options="addressTypeOptions"
|
||||
:label="t('commercial.clients.form.address.addressType')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('commercial.clients.form.address.streetNotFound')"
|
||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
:error="errors?.isProspect"
|
||||
@update:model-value="onAddressTypeChange"
|
||||
/>
|
||||
|
||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-1.10). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('commercial.clients.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif). Consultation : masque si aucun (ERP-193). -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('commercial.clients.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Email(s) de facturation : visible/obligatoire seulement si Facturation
|
||||
(RG-1.11). Le « + » revele un 2e email optionnel (max 2, pendant du
|
||||
telephone secondaire) qui coule dans la grille. Sinon un filler comble
|
||||
la colonne pour que Categorie reparte au debut de la ligne suivante. -->
|
||||
<MalioInputEmail
|
||||
v-if="isBillingEmailRequired(model)"
|
||||
:model-value="model.billingEmail"
|
||||
:label="t('commercial.clients.form.address.billingEmail')"
|
||||
:required="!readonly && !disabled"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.billingEmail"
|
||||
:addable="!model.hasSecondaryBillingEmail && !readonly"
|
||||
:add-button-label="t('commercial.clients.form.address.addBillingEmail')"
|
||||
@update:model-value="(v: string) => update('billingEmail', v)"
|
||||
@add="revealSecondaryBillingEmail"
|
||||
/>
|
||||
<!-- Filler : aligne la suite de la grille (Categorie au debut de ligne).
|
||||
Inutile en consultation masquee (la grille se recompose sans les
|
||||
champs vides, ERP-193). -->
|
||||
<div v-else-if="!hideEmpty" aria-hidden="true" />
|
||||
|
||||
<MalioInputEmail
|
||||
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
|
||||
:model-value="model.billingEmailSecondary"
|
||||
:label="t('commercial.clients.form.address.billingEmailSecondary')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.billingEmailSecondary"
|
||||
@update:model-value="(v: string) => update('billingEmailSecondary', v)"
|
||||
/>
|
||||
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.categoryIris"
|
||||
:options="categoryOptions"
|
||||
:label="t('commercial.clients.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('commercial.clients.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.postalCode"
|
||||
:label="t('commercial.clients.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Si la BAN est
|
||||
indisponible, bascule en saisie libre — recuperable : re-saisir le
|
||||
code postal relance la recherche et repasse en select au succes. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('commercial.clients.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('commercial.clients.form.address.streetComplement')"
|
||||
:model-value="model.city"
|
||||
:label="t('commercial.clients.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Adresse + Adresse complementaire sur 2 colonnes : on wrappe car
|
||||
MalioInputText/Autocomplete (inheritAttrs:false) renvoient `class`
|
||||
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
|
||||
le col-span-2, le champ le remplit (w-full). -->
|
||||
<div class="col-span-2">
|
||||
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple
|
||||
seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas
|
||||
sa valeur liee, il n'afficherait rien en readonly). allow-create :
|
||||
si la BAN ne propose rien (ou erreur), le texte saisi est CONSERVE au
|
||||
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
|
||||
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('commercial.clients.form.address.streetNotFound')"
|
||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('commercial.clients.form.address.streetComplement')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -230,6 +238,8 @@ const props = defineProps<{
|
||||
/** Pays disponibles (France par defaut). */
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||
last?: boolean
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
|
||||
@@ -1,84 +1,93 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
|
||||
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('commercial.clients.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('commercial.clients.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.clients.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||
(pas de bordure sous le dernier bloc). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
|
||||
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Grille 4 colonnes des champs du contact. -->
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('commercial.clients.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('commercial.clients.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.clients.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('commercial.clients.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('commercial.clients.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('commercial.clients.form.contact.addPhone')"
|
||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('commercial.clients.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('commercial.clients.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('commercial.clients.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('commercial.clients.form.contact.addPhone')"
|
||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('commercial.clients.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -98,6 +107,8 @@ const props = defineProps<{
|
||||
title: string
|
||||
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */
|
||||
removable?: boolean
|
||||
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||
last?: boolean
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
|
||||
@@ -1,189 +1,198 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : modal de confirmation cote parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||
(pas de bordure sous le dernier bloc). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||
<!-- Suppression : modal de confirmation cote parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant
|
||||
l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath
|
||||
`addressType`) s'affiche via la prop native :error de MalioSelect. -->
|
||||
<MalioSelect
|
||||
:model-value="model.addressType"
|
||||
:options="addressTypeOptions"
|
||||
:label="t('commercial.suppliers.form.address.addressType')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.addressType"
|
||||
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
|
||||
/>
|
||||
<!-- Grille 4 colonnes des champs de l'adresse. -->
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant
|
||||
l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath
|
||||
`addressType`) s'affiche via la prop native :error de MalioSelect. -->
|
||||
<MalioSelect
|
||||
:model-value="model.addressType"
|
||||
:options="addressTypeOptions"
|
||||
:label="t('commercial.suppliers.form.address.addressType')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.addressType"
|
||||
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
|
||||
/>
|
||||
|
||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-2.06). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('commercial.suppliers.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif). -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('commercial.suppliers.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
|
||||
porte ici l'email de facturation, absent cote fournisseur). Inutile en
|
||||
consultation masquee (la grille se recompose sans les champs vides). -->
|
||||
<div v-if="!hideEmpty" aria-hidden="true" />
|
||||
|
||||
<!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.categoryIris"
|
||||
:options="categoryOptions"
|
||||
:label="t('commercial.suppliers.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('commercial.suppliers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.postalCode"
|
||||
:label="t('commercial.suppliers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('commercial.suppliers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.city"
|
||||
:label="t('commercial.suppliers.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('commercial.suppliers.form.address.street')"
|
||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-2.06). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('commercial.suppliers.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')"
|
||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif). -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('commercial.suppliers.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
|
||||
porte ici l'email de facturation, absent cote fournisseur). Inutile en
|
||||
consultation masquee (la grille se recompose sans les champs vides). -->
|
||||
<div v-if="!hideEmpty" aria-hidden="true" />
|
||||
|
||||
<!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.categoryIris"
|
||||
:options="categoryOptions"
|
||||
:label="t('commercial.suppliers.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('commercial.suppliers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.postalCode"
|
||||
:label="t('commercial.suppliers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('commercial.suppliers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('commercial.suppliers.form.address.street')"
|
||||
:model-value="model.city"
|
||||
:label="t('commercial.suppliers.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('commercial.suppliers.form.address.streetComplement')"
|
||||
:mask="ADDRESS_MASK"
|
||||
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('commercial.suppliers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')"
|
||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('commercial.suppliers.form.address.street')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('commercial.suppliers.form.address.streetComplement')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bennes : stepper (specifique fournisseur, defaut 0). En consultation, 0
|
||||
reste affiche (valeur saisie) ; seul un champ vide serait masque. -->
|
||||
<MalioInputNumber
|
||||
v-if="!hideEmpty || isFilled(model.bennes)"
|
||||
:model-value="model.bennes"
|
||||
:label="t('commercial.suppliers.form.address.bennes')"
|
||||
:min="0"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
:error="errors?.bennes"
|
||||
@update:model-value="(v: string) => update('bennes', v)"
|
||||
/>
|
||||
|
||||
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur).
|
||||
Consultation : masquee si non cochee (ERP-193). -->
|
||||
<MalioCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.triageProvider)"
|
||||
id="address-triage-provider"
|
||||
:label="t('commercial.suppliers.form.address.triageProvider')"
|
||||
:model-value="model.triageProvider"
|
||||
group-class="self-center"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: boolean) => update('triageProvider', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bennes : stepper (specifique fournisseur, defaut 0). En consultation, 0
|
||||
reste affiche (valeur saisie) ; seul un champ vide serait masque. -->
|
||||
<MalioInputNumber
|
||||
v-if="!hideEmpty || isFilled(model.bennes)"
|
||||
:model-value="model.bennes"
|
||||
:label="t('commercial.suppliers.form.address.bennes')"
|
||||
:min="0"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.bennes"
|
||||
@update:model-value="(v: string) => update('bennes', v)"
|
||||
/>
|
||||
|
||||
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur).
|
||||
Consultation : masquee si non cochee (ERP-193). -->
|
||||
<MalioCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.triageProvider)"
|
||||
id="address-triage-provider"
|
||||
:label="t('commercial.suppliers.form.address.triageProvider')"
|
||||
:model-value="model.triageProvider"
|
||||
group-class="self-center"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: boolean) => update('triageProvider', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -210,6 +219,8 @@ const props = defineProps<{
|
||||
/** Pays disponibles (France par defaut). */
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||
last?: boolean
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
|
||||
@@ -1,83 +1,92 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('commercial.suppliers.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('commercial.suppliers.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||
(pas de bordure sous le dernier bloc). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Grille 4 colonnes des champs du contact. -->
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('commercial.suppliers.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('commercial.suppliers.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('commercial.suppliers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('commercial.suppliers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('commercial.suppliers.form.contact.addPhone')"
|
||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('commercial.suppliers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('commercial.suppliers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('commercial.suppliers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('commercial.suppliers.form.contact.addPhone')"
|
||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('commercial.suppliers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -96,6 +105,8 @@ const props = defineProps<{
|
||||
title: string
|
||||
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-2.13). */
|
||||
removable?: boolean
|
||||
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||
last?: boolean
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
|
||||
@@ -77,4 +77,23 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
|
||||
// Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal).
|
||||
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
|
||||
})
|
||||
|
||||
it('separe les categories CLIENT (formulaire) des categories ADRESSE (blocs adresse)', async () => {
|
||||
// Le mock distingue les deux appels /categories par leur filtre typeCode.
|
||||
mockGet.mockImplementation((url: string, query?: Record<string, unknown>) => {
|
||||
if (url === '/categories' && query?.typeCode === 'CLIENT') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/categories/1', code: 'SECTEUR', name: 'Secteur' }] })
|
||||
}
|
||||
if (url === '/categories' && query?.typeCode === 'ADRESSE') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/categories/9', code: 'SIEGE', name: 'Siège' }] })
|
||||
}
|
||||
return Promise.resolve({ member: [] })
|
||||
})
|
||||
|
||||
const refs = useClientReferentials()
|
||||
await refs.loadCommon()
|
||||
|
||||
expect(refs.categories.value).toEqual([{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' }])
|
||||
expect(refs.addressCategories.value).toEqual([{ value: '/api/categories/9', label: 'Siège', code: 'SIEGE' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,16 @@ describe('useSupplierReferentials', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('charge les categories d\'adresse filtrees sur le type ADRESSE', async () => {
|
||||
await useSupplierReferentials().loadCommon()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/categories',
|
||||
expect.objectContaining({ pagination: 'false', typeCode: 'ADRESSE' }),
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('mappe les categories en options { value: IRI, label: name, code }', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/categories') {
|
||||
|
||||
@@ -68,6 +68,9 @@ export function useClientReferentials() {
|
||||
const api = useApi()
|
||||
|
||||
const categories = ref<CategoryOption[]>([])
|
||||
// Taxonomie dediee aux blocs adresse (type ADRESSE), distincte des categories
|
||||
// CLIENT du formulaire principal.
|
||||
const addressCategories = ref<CategoryOption[]>([])
|
||||
const sites = ref<RefOption[]>([])
|
||||
const tvaModes = ref<RefOption[]>([])
|
||||
const paymentDelays = ref<RefOption[]>([])
|
||||
@@ -109,6 +112,9 @@ export function useClientReferentials() {
|
||||
// de type CLIENT (pas FOURNISSEUR) -> on filtre la collection cote API.
|
||||
fetchAll<CategoryMember>('/categories', { typeCode: 'CLIENT' })
|
||||
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
|
||||
// Categories des blocs adresse : taxonomie dediee type ADRESSE.
|
||||
fetchAll<CategoryMember>('/categories', { typeCode: 'ADRESSE' })
|
||||
.then((cats) => { addressCategories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
|
||||
fetchAll<SiteMember>('/sites')
|
||||
// Libelle = numero de departement (2 premiers chiffres du code
|
||||
// postal du site), ex: 86100 -> « 86 ». Le code postal est deja
|
||||
@@ -151,6 +157,7 @@ export function useClientReferentials() {
|
||||
|
||||
return {
|
||||
categories,
|
||||
addressCategories,
|
||||
sites,
|
||||
tvaModes,
|
||||
paymentDelays,
|
||||
|
||||
@@ -62,6 +62,9 @@ export function useSupplierReferentials() {
|
||||
const api = useApi()
|
||||
|
||||
const categories = ref<CategoryOption[]>([])
|
||||
// Taxonomie dediee aux blocs adresse (type ADRESSE), distincte des categories
|
||||
// FOURNISSEUR du formulaire principal.
|
||||
const addressCategories = ref<CategoryOption[]>([])
|
||||
const sites = ref<RefOption[]>([])
|
||||
const tvaModes = ref<RefOption[]>([])
|
||||
const paymentDelays = ref<RefOption[]>([])
|
||||
@@ -97,6 +100,9 @@ export function useSupplierReferentials() {
|
||||
// categories de type FOURNISSEUR (RG-2.10) -> on filtre cote API.
|
||||
fetchAll<CategoryMember>('/categories', { typeCode: 'FOURNISSEUR' })
|
||||
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
|
||||
// Categories des blocs adresse : taxonomie dediee type ADRESSE.
|
||||
fetchAll<CategoryMember>('/categories', { typeCode: 'ADRESSE' })
|
||||
.then((cats) => { addressCategories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
|
||||
fetchAll<SiteMember>('/sites')
|
||||
// Libelle = numero de departement (2 premiers chiffres du code
|
||||
// postal du site), ex: 86100 -> « 86 ».
|
||||
@@ -121,6 +127,7 @@ export function useSupplierReferentials() {
|
||||
|
||||
return {
|
||||
categories,
|
||||
addressCategories,
|
||||
sites,
|
||||
tvaModes,
|
||||
paymentDelays,
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||
<!-- Onglet Information -->
|
||||
<template #information>
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
|
||||
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
|
||||
coussin de chaque cote). -->
|
||||
@@ -178,6 +178,7 @@
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:last="index === contacts.length - 1"
|
||||
:disabled="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -210,6 +211,7 @@
|
||||
:key="address.id ?? `new-${index}`"
|
||||
:model-value="address"
|
||||
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
||||
:last="index === addresses.length - 1"
|
||||
:category-options="addressCategoryOptions"
|
||||
:site-options="siteOptions"
|
||||
:contact-options="contactOptions"
|
||||
@@ -244,8 +246,10 @@
|
||||
editable uniquement si accounting.manage). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="accounting.siren"
|
||||
:label="t('commercial.clients.form.accounting.siren')"
|
||||
@@ -314,21 +318,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). -->
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13).
|
||||
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
|
||||
<div
|
||||
v-for="(rib, index) in visibleRibs"
|
||||
:key="rib.id ?? `new-${index}`"
|
||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
class="pb-[20px]"
|
||||
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||
@@ -469,9 +479,6 @@ import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
const SIREN_MASK = '#########'
|
||||
const EMPLOYEES_MASK = '#######'
|
||||
|
||||
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
|
||||
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const toast = useToast()
|
||||
@@ -563,15 +570,17 @@ function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[
|
||||
return [...primary, ...extra.filter(o => !seen.has(o.value))]
|
||||
}
|
||||
|
||||
const embedCategoryOptions = computed<CategoryOption[]>(() => {
|
||||
const fromClient = categoryOptionsOf(client.value?.categories)
|
||||
const fromAddresses = (client.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))
|
||||
return mergeOptions(fromClient, fromAddresses)
|
||||
})
|
||||
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value))
|
||||
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
|
||||
// Categories du formulaire principal (type CLIENT) : referentiel UNION categories
|
||||
// embarquees du client (fallback si le referentiel n'est pas chargeable).
|
||||
const embedClientCategoryOptions = computed<CategoryOption[]>(() => categoryOptionsOf(client.value?.categories))
|
||||
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedClientCategoryOptions.value))
|
||||
// Categories des blocs adresse (type ADRESSE) : referentiel dedie UNION categories
|
||||
// embarquees des adresses (fallback meme fonction qu'au-dessus).
|
||||
const embedAddressCategoryOptions = computed<CategoryOption[]>(() =>
|
||||
mergeOptions([], (client.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))),
|
||||
)
|
||||
const addressCategoryOptions = computed(() =>
|
||||
mainCategoryOptions.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
|
||||
mergeOptions(referentials.addressCategories.value, embedAddressCategoryOptions.value),
|
||||
)
|
||||
|
||||
const embedSiteOptions = computed<RefOption[]>(() =>
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||
<!-- Onglet Information -->
|
||||
<template #information>
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
|
||||
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
|
||||
coussin de chaque cote). -->
|
||||
@@ -156,6 +156,7 @@
|
||||
:key="contact.id ?? index"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
:last="index === contacts.length - 1"
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
@@ -170,6 +171,7 @@
|
||||
:key="view.draft.id ?? index"
|
||||
:model-value="view.draft"
|
||||
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
||||
:last="index === addressViews.length - 1"
|
||||
:category-options="view.categoryOptions"
|
||||
:site-options="allSiteOptions"
|
||||
:contact-options="contactOptions"
|
||||
@@ -183,8 +185,10 @@
|
||||
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': ribs.length > 0 }">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="isFilled(accounting.siren)"
|
||||
:model-value="accounting.siren"
|
||||
@@ -239,13 +243,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB (0..n), lecture seule. -->
|
||||
<!-- Blocs RIB (0..n), lecture seule.
|
||||
Titre « RIB N », filet de separation sauf sous le dernier. -->
|
||||
<div
|
||||
v-for="(rib, index) in ribs"
|
||||
:key="rib.id ?? index"
|
||||
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
class="pb-[20px]"
|
||||
:class="{ 'border-b border-black': index !== ribs.length - 1 }"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="isFilled(rib.label)"
|
||||
:model-value="rib.label"
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<!-- Onglet Information -->
|
||||
<template #information>
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs, dont
|
||||
le champ de 40px est centre dans un conteneur h-12 (~4px de
|
||||
coussin en HAUT et en BAS). Sans pb-1, le textarea descend ~4px
|
||||
@@ -177,6 +177,7 @@
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:last="index === contacts.length - 1"
|
||||
:disabled="isValidated('contact')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -209,6 +210,7 @@
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
||||
:last="index === addresses.length - 1"
|
||||
:category-options="addressCategoryOptions"
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
@@ -242,8 +244,10 @@
|
||||
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="accounting.siren"
|
||||
:label="t('commercial.clients.form.accounting.siren')"
|
||||
@@ -312,22 +316,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). -->
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13).
|
||||
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
|
||||
<div
|
||||
v-for="(rib, index) in visibleRibs"
|
||||
:key="index"
|
||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
class="pb-[20px]"
|
||||
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
|
||||
>
|
||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||
@@ -446,9 +456,6 @@ const SIREN_MASK = '#########'
|
||||
// Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7).
|
||||
const EMPLOYEES_MASK = '#######'
|
||||
|
||||
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
|
||||
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const toast = useToast()
|
||||
@@ -806,10 +813,8 @@ async function submitContacts(): Promise<void> {
|
||||
const addresses = ref<AddressFormDraft[]>([emptyAddress()])
|
||||
const addressDegradedNotified = ref(false)
|
||||
|
||||
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
|
||||
const addressCategoryOptions = computed(() =>
|
||||
referentials.categories.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
|
||||
)
|
||||
// Categories autorisees sur une adresse : taxonomie dediee type ADRESSE.
|
||||
const addressCategoryOptions = computed(() => referentials.addressCategories.value)
|
||||
|
||||
// Contacts deja crees, rattachables a une adresse (M2M, via leur IRI).
|
||||
const contactOptions = computed<RefOption[]>(() =>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||
<!-- Onglet Information -->
|
||||
<template #information>
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs. -->
|
||||
<MalioInputTextArea
|
||||
v-model="information.description"
|
||||
@@ -147,6 +147,7 @@
|
||||
:model-value="contact"
|
||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:last="index === contacts.length - 1"
|
||||
:disabled="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -179,7 +180,8 @@
|
||||
:key="address.id ?? `new-${index}`"
|
||||
:model-value="address"
|
||||
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
|
||||
:category-options="mainCategoryOptions"
|
||||
:last="index === addresses.length - 1"
|
||||
:category-options="addressCategoryOptions"
|
||||
:site-options="siteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
@@ -213,8 +215,10 @@
|
||||
editable uniquement si accounting.manage). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="accounting.siren"
|
||||
:label="t('commercial.suppliers.form.accounting.siren')"
|
||||
@@ -283,21 +287,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). -->
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08).
|
||||
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
|
||||
<div
|
||||
v-for="(rib, index) in visibleRibs"
|
||||
:key="rib.id ?? `new-${index}`"
|
||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
class="pb-[20px]"
|
||||
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('commercial.suppliers.form.accounting.ribLabel')"
|
||||
@@ -526,15 +536,18 @@ function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[
|
||||
return [...primary, ...extra.filter(o => !seen.has(o.value))]
|
||||
}
|
||||
|
||||
// Categories issues de l'embed (fournisseur + adresses), role-independantes.
|
||||
const embedCategoryOptions = computed<CategoryOption[]>(() => {
|
||||
const fromSupplier = categoryOptionsOf(supplier.value?.categories)
|
||||
const fromAddresses = (supplier.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))
|
||||
return mergeOptions(fromSupplier, fromAddresses)
|
||||
})
|
||||
// Toutes les categories de type FOURNISSEUR sont autorisees, sur le bloc principal
|
||||
// comme sur une adresse (pas de restriction Distributeur/Courtier comme au M1 — RG-2.10).
|
||||
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value))
|
||||
// Categories du formulaire principal (type FOURNISSEUR) : referentiel UNION
|
||||
// categories embarquees du fournisseur (fallback si referentiel non chargeable).
|
||||
const embedSupplierCategoryOptions = computed<CategoryOption[]>(() => categoryOptionsOf(supplier.value?.categories))
|
||||
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedSupplierCategoryOptions.value))
|
||||
// Categories des blocs adresse (type ADRESSE) : referentiel dedie UNION categories
|
||||
// embarquees des adresses (meme logique de fallback).
|
||||
const embedAddressCategoryOptions = computed<CategoryOption[]>(() =>
|
||||
mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))),
|
||||
)
|
||||
const addressCategoryOptions = computed(() =>
|
||||
mergeOptions(referentials.addressCategories.value, embedAddressCategoryOptions.value),
|
||||
)
|
||||
|
||||
const embedSiteOptions = computed<RefOption[]>(() =>
|
||||
mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => siteOptionsOf(a.sites))),
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||
<!-- Onglet Information -->
|
||||
<template #information>
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
|
||||
sur les inputs (champ 40px centre dans un h-12). -->
|
||||
<MalioInputTextArea
|
||||
@@ -137,6 +137,7 @@
|
||||
:key="contact.id ?? index"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||
:last="index === contacts.length - 1"
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
@@ -151,6 +152,7 @@
|
||||
:key="view.draft.id ?? index"
|
||||
:model-value="view.draft"
|
||||
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
|
||||
:last="index === addressViews.length - 1"
|
||||
:category-options="view.categoryOptions"
|
||||
:site-options="allSiteOptions"
|
||||
:contact-options="contactOptions"
|
||||
@@ -164,8 +166,10 @@
|
||||
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': ribs.length > 0 }">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="isFilled(accounting.siren)"
|
||||
:model-value="accounting.siren"
|
||||
@@ -220,13 +224,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB (0..n), lecture seule. -->
|
||||
<!-- Blocs RIB (0..n), lecture seule.
|
||||
Titre « RIB N », filet de separation sauf sous le dernier. -->
|
||||
<div
|
||||
v-for="(rib, index) in ribs"
|
||||
:key="rib.id ?? index"
|
||||
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
class="pb-[20px]"
|
||||
:class="{ 'border-b border-black': index !== ribs.length - 1 }"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="isFilled(rib.label)"
|
||||
:model-value="rib.label"
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<!-- Onglet Information -->
|
||||
<template #information>
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputTextArea
|
||||
v-model="information.description"
|
||||
:label="t('commercial.suppliers.form.information.description')"
|
||||
@@ -145,6 +145,7 @@
|
||||
:model-value="contact"
|
||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:last="index === contacts.length - 1"
|
||||
:disabled="isValidated('contacts')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -177,7 +178,8 @@
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
|
||||
:category-options="referentials.categories.value"
|
||||
:last="index === addresses.length - 1"
|
||||
:category-options="referentials.addressCategories.value"
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
@@ -210,8 +212,10 @@
|
||||
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="accounting.siren"
|
||||
:label="t('commercial.suppliers.form.accounting.siren')"
|
||||
@@ -280,21 +284,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). -->
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08).
|
||||
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
|
||||
<div
|
||||
v-for="(rib, index) in visibleRibs"
|
||||
:key="index"
|
||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
class="pb-[20px]"
|
||||
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('commercial.suppliers.form.accounting.ribLabel')"
|
||||
|
||||
@@ -1,131 +1,140 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : modal de confirmation cote parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.siteIris)"
|
||||
:model-value="model.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('technique.providers.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('technique.providers.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
v-if="!hideEmpty || isFilled(model.country)"
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('technique.providers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.postalCode)"
|
||||
:model-value="model.postalCode"
|
||||
:label="t('technique.providers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('technique.providers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
|
||||
:model-value="model.city"
|
||||
:label="t('technique.providers.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('technique.providers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('technique.providers.form.address.streetNotFound')"
|
||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('technique.providers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||
(pas de bordure sous le dernier bloc). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||
<!-- Suppression : modal de confirmation cote parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||
<!-- Grille 4 colonnes des champs de l'adresse. -->
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.siteIris)"
|
||||
:model-value="model.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('technique.providers.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('technique.providers.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
v-if="!hideEmpty || isFilled(model.country)"
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('technique.providers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('technique.providers.form.address.streetComplement')"
|
||||
v-if="!hideEmpty || isFilled(model.postalCode)"
|
||||
:model-value="model.postalCode"
|
||||
:label="t('technique.providers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('technique.providers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
|
||||
:model-value="model.city"
|
||||
:label="t('technique.providers.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('technique.providers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('technique.providers.form.address.streetNotFound')"
|
||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('technique.providers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('technique.providers.form.address.streetComplement')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -143,6 +152,8 @@ const POSTAL_CODE_MASK = '#####'
|
||||
const props = defineProps<{
|
||||
/** Brouillon de l'adresse (v-model). */
|
||||
modelValue: ProviderAddressFormDraft
|
||||
/** Titre du bloc (ex: « Adresse 1 »). */
|
||||
title: string
|
||||
/** Sites Starseed disponibles. */
|
||||
siteOptions: RefOption[]
|
||||
/** Contacts deja saisis, rattachables a l'adresse. */
|
||||
@@ -150,6 +161,8 @@ const props = defineProps<{
|
||||
/** Pays disponibles (France par defaut). */
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||
last?: boolean
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
|
||||
@@ -1,84 +1,93 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
non supprimable (1er bloc) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('technique.providers.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('technique.providers.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('technique.providers.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||
(pas de bordure sous le dernier bloc). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
non supprimable (1er bloc) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Grille 4 colonnes des champs du contact. -->
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('technique.providers.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('technique.providers.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('technique.providers.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('technique.providers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('technique.providers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('technique.providers.form.contact.addPhone')"
|
||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('technique.providers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('technique.providers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('technique.providers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('technique.providers.form.contact.addPhone')"
|
||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('technique.providers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -93,8 +102,12 @@ const PHONE_MASK = '## ## ## ## ##'
|
||||
const props = defineProps<{
|
||||
/** Brouillon du contact (v-model). */
|
||||
modelValue: ProviderContactFormDraft
|
||||
/** Titre du bloc (ex: « Contact 1 »). */
|
||||
title: string
|
||||
/** Affiche l'icone de suppression (1er bloc non supprimable). */
|
||||
removable?: boolean
|
||||
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||
last?: boolean
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
|
||||
@@ -46,6 +46,7 @@ function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<str
|
||||
return mount(ProviderAddressBlock, {
|
||||
props: {
|
||||
modelValue: { ...emptyProviderAddress(), ...overrides },
|
||||
title: 'Adresse 1',
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
|
||||
@@ -29,6 +29,7 @@ function mountBlock(errors?: Record<string, string>) {
|
||||
return mount(ProviderContactBlock, {
|
||||
props: {
|
||||
modelValue: emptyProviderContact(),
|
||||
title: 'Contact 1',
|
||||
...(errors ? { errors } : {}),
|
||||
},
|
||||
global: {
|
||||
|
||||
@@ -72,7 +72,9 @@
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:last="index === contacts.length - 1"
|
||||
:disabled="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -104,6 +106,8 @@
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:title="t('technique.providers.form.address.title', { n: index + 1 })"
|
||||
:last="index === addresses.length - 1"
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
@@ -136,8 +140,10 @@
|
||||
<!-- Onglet Comptabilite (present si accounting.view ; editable si manage). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="accounting.siren"
|
||||
:label="t('technique.providers.form.accounting.siren')"
|
||||
@@ -206,21 +212,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). -->
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08).
|
||||
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
|
||||
<div
|
||||
v-for="(rib, index) in visibleRibs"
|
||||
:key="index"
|
||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
class="pb-[20px]"
|
||||
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('technique.providers.form.accounting.ribLabel')"
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
|
||||
:last="index === contacts.length - 1"
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
@@ -94,6 +96,8 @@
|
||||
v-for="(view, index) in addressViews"
|
||||
:key="index"
|
||||
:model-value="view.draft"
|
||||
:title="t('technique.providers.form.address.title', { n: index + 1 })"
|
||||
:last="index === addressViews.length - 1"
|
||||
:site-options="view.siteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptionsFor(view.draft.country)"
|
||||
@@ -108,8 +112,10 @@
|
||||
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText v-if="isFilled(accounting.siren)" :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" disabled />
|
||||
<MalioInputText v-if="isFilled(accounting.accountNumber)" :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" disabled />
|
||||
<MalioSelect v-if="isFilled(accounting.tvaModeIri)" :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" disabled empty-option-label="" />
|
||||
@@ -120,13 +126,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB (uniquement si type de reglement = LCR). -->
|
||||
<!-- Blocs RIB (uniquement si type de reglement = LCR).
|
||||
Titre « RIB N », filet de separation sauf sous le dernier. -->
|
||||
<div
|
||||
v-for="(rib, index) in visibleRibs"
|
||||
:key="index"
|
||||
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
class="pb-[20px]"
|
||||
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText v-if="isFilled(rib.label)" :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" disabled />
|
||||
<MalioInputText v-if="isFilled(rib.bic)" :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" disabled />
|
||||
<MalioInputText v-if="isFilled(rib.iban)" :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" disabled />
|
||||
|
||||
@@ -73,7 +73,9 @@
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:last="index === contacts.length - 1"
|
||||
:disabled="isValidated('contact')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -108,6 +110,8 @@
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:title="t('technique.providers.form.address.title', { n: index + 1 })"
|
||||
:last="index === addresses.length - 1"
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
@@ -139,8 +143,10 @@
|
||||
<!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si manage). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="accounting.siren"
|
||||
:label="t('technique.providers.form.accounting.siren')"
|
||||
@@ -210,21 +216,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). -->
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08).
|
||||
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
|
||||
<div
|
||||
v-for="(rib, index) in visibleRibs"
|
||||
:key="index"
|
||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
class="pb-[20px]"
|
||||
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('technique.providers.form.accounting.ribLabel')"
|
||||
|
||||
@@ -1,103 +1,113 @@
|
||||
<template>
|
||||
<!-- Adresse UNIQUE par transporteur (ERP-172) : un seul bloc, jamais supprimable. -->
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Pays : prerempli « France » (RG-4.05). -->
|
||||
<MalioSelect
|
||||
v-if="!hideEmpty || isFilled(model.country)"
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('transport.carriers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.country"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.postalCode)"
|
||||
:model-value="model.postalCode"
|
||||
:label="t('transport.carriers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('transport.carriers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
|
||||
:model-value="model.city"
|
||||
:label="t('transport.carriers.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Filler : aligne le debut de la ligne suivante sur la grille. Inutile en
|
||||
consultation masquee (la grille se recompose sans les champs vides). -->
|
||||
<div v-if="!hideEmpty" aria-hidden="true" />
|
||||
|
||||
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('transport.carriers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('transport.carriers.form.address.streetNotFound')"
|
||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('transport.carriers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||
(pas de bordure sous le dernier bloc). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||
<!-- En-tete : titre du bloc, en noir (adresse unique, sans suppression). -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||
</div>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.streetComplement)"
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('transport.carriers.form.address.streetComplement')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
<!-- Grille 4 colonnes des champs de l'adresse. -->
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Pays : prerempli « France » (RG-4.05). -->
|
||||
<MalioSelect
|
||||
v-if="!hideEmpty || isFilled(model.country)"
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('transport.carriers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.country"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.postalCode)"
|
||||
:model-value="model.postalCode"
|
||||
:label="t('transport.carriers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('transport.carriers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
|
||||
:model-value="model.city"
|
||||
:label="t('transport.carriers.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Filler : aligne le debut de la ligne suivante sur la grille. Inutile en
|
||||
consultation masquee (la grille se recompose sans les champs vides). -->
|
||||
<div v-if="!hideEmpty" aria-hidden="true" />
|
||||
|
||||
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('transport.carriers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('transport.carriers.form.address.streetNotFound')"
|
||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('transport.carriers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.streetComplement)"
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('transport.carriers.form.address.streetComplement')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -118,8 +128,12 @@ const POSTAL_CODE_MASK = '#####'
|
||||
const props = defineProps<{
|
||||
/** Brouillon de l'adresse (v-model). */
|
||||
modelValue: CarrierAddressFormDraft
|
||||
/** Titre du bloc (ex: « Adresse 1 »). */
|
||||
title: string
|
||||
/** Pays disponibles (France par defaut). */
|
||||
countryOptions: RefOption[]
|
||||
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||
last?: boolean
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
|
||||
@@ -1,84 +1,93 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si
|
||||
non supprimable (1er bloc) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('transport.carriers.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('transport.carriers.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('transport.carriers.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false)
|
||||
renvoie `class` sur l'input interne, pas sur la cellule de grille. -->
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('transport.carriers.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||
(pas de bordure sous le dernier bloc). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||
<!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si
|
||||
non supprimable (1er bloc) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('transport.carriers.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Grille 4 colonnes des champs du contact. -->
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('transport.carriers.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('transport.carriers.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false)
|
||||
renvoie `class` sur l'input interne, pas sur la cellule de grille. -->
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('transport.carriers.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('transport.carriers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<!-- Téléphone principal + bouton « + » révélant le 2e numéro (max 2). -->
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('transport.carriers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('transport.carriers.form.contact.addPhone')"
|
||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<!-- 2e numéro : révélé à la demande (max 2 téléphones — RG-4.08). -->
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('transport.carriers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('transport.carriers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<!-- Téléphone principal + bouton « + » révélant le 2e numéro (max 2). -->
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('transport.carriers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('transport.carriers.form.contact.addPhone')"
|
||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<!-- 2e numéro : révélé à la demande (max 2 téléphones — RG-4.08). -->
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('transport.carriers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -93,8 +102,12 @@ const PHONE_MASK = '## ## ## ## ##'
|
||||
const props = defineProps<{
|
||||
/** Brouillon du contact (v-model). */
|
||||
modelValue: CarrierContactFormDraft
|
||||
/** Titre du bloc (ex: « Contact 1 »). */
|
||||
title: string
|
||||
/** Affiche l'icône de suppression (1er bloc non supprimable). */
|
||||
removable?: boolean
|
||||
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||
last?: boolean
|
||||
/** Bloc en lecture seule (onglet validé). */
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
|
||||
@@ -1,190 +1,199 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : modal de confirmation côté parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('transport.carriers.form.price.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<!-- RG-4.09 : sens du prix (CLIENT / FOURNISSEUR) en colonne 1 / ligne 1, radios
|
||||
EN LIGNE (horizontaux), centrés sur la hauteur de champ (h-12) comme la
|
||||
case « Affréter ». Pas de label de groupe. -->
|
||||
<div>
|
||||
<div class="flex h-12 items-center gap-6">
|
||||
<MalioRadioButton
|
||||
:model-value="model.direction"
|
||||
:name="`price-direction-${uid}`"
|
||||
value="CLIENT"
|
||||
:label="t('transport.carriers.form.price.directionClient')"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="onDirectionChange"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
:model-value="model.direction"
|
||||
:name="`price-direction-${uid}`"
|
||||
value="FOURNISSEUR"
|
||||
:label="t('transport.carriers.form.price.directionSupplier')"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="onDirectionChange"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors?.direction" class="ml-[2px] text-xs text-m-danger">{{ errors.direction }}</p>
|
||||
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||
(pas de bordure sous le dernier bloc), aligne sur les blocs contact / adresse. -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||
<!-- Suppression : modal de confirmation côté parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('transport.carriers.form.price.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Branche CLIENT (RG-4.10). -->
|
||||
<template v-if="model.direction === 'CLIENT'">
|
||||
<MalioSelect
|
||||
:model-value="model.clientIri"
|
||||
:options="clientOptions"
|
||||
:label="t('transport.carriers.form.price.client')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.client"
|
||||
@update:model-value="onClientChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="model.clientDeliveryAddressIri"
|
||||
:options="clientAddressOptions"
|
||||
:label="t('transport.carriers.form.price.clientDeliveryAddress')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.clientDeliveryAddress"
|
||||
@update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="model.departureSiteIri"
|
||||
:options="siteOptions"
|
||||
:label="t('transport.carriers.form.price.departureSite')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.departureSite"
|
||||
@update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Branche FOURNISSEUR (RG-4.11). -->
|
||||
<template v-else-if="model.direction === 'FOURNISSEUR'">
|
||||
<MalioSelect
|
||||
:model-value="model.supplierIri"
|
||||
:options="supplierOptions"
|
||||
:label="t('transport.carriers.form.price.supplier')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.supplier"
|
||||
@update:model-value="onSupplierChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="model.supplierSupplyAddressIri"
|
||||
:options="supplierAddressOptions"
|
||||
:label="t('transport.carriers.form.price.supplierSupplyAddress')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.supplierSupplyAddress"
|
||||
@update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="model.deliverySiteIri"
|
||||
:options="siteOptions"
|
||||
:label="t('transport.carriers.form.price.deliverySite')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.deliverySite"
|
||||
@update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Communs (visibles dès qu'un sens est choisi). -->
|
||||
<template v-if="model.direction !== null">
|
||||
<!-- Contenant : Benne / Fond mouvant (radios centrés h-12, pas de label). -->
|
||||
<!-- Grille 4 colonnes des champs du prix. -->
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- RG-4.09 : sens du prix (CLIENT / FOURNISSEUR) en colonne 1 / ligne 1, radios
|
||||
EN LIGNE (horizontaux), centrés sur la hauteur de champ (h-12) comme la
|
||||
case « Affréter ». Pas de label de groupe. -->
|
||||
<div>
|
||||
<div class="flex h-12 items-center gap-4">
|
||||
<div class="flex h-12 items-center gap-6">
|
||||
<MalioRadioButton
|
||||
:model-value="model.containerType"
|
||||
:name="`price-container-${uid}`"
|
||||
value="BENNE"
|
||||
:label="t('transport.carriers.containerType.BENNE')"
|
||||
:model-value="model.direction"
|
||||
:name="`price-direction-${uid}`"
|
||||
value="CLIENT"
|
||||
:label="t('transport.carriers.form.price.directionClient')"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
||||
@update:model-value="onDirectionChange"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
:model-value="model.containerType"
|
||||
:name="`price-container-${uid}`"
|
||||
value="FOND_MOUVANT"
|
||||
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
||||
:model-value="model.direction"
|
||||
:name="`price-direction-${uid}`"
|
||||
value="FOURNISSEUR"
|
||||
:label="t('transport.carriers.form.price.directionSupplier')"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
||||
@update:model-value="onDirectionChange"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors?.containerType" class="ml-[2px] text-xs text-m-danger">{{ errors.containerType }}</p>
|
||||
<p v-if="errors?.direction" class="ml-[2px] text-xs text-m-danger">{{ errors.direction }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Tarification : Forfait / Tonne (radios centrés h-12, pas de label). -->
|
||||
<div>
|
||||
<div class="flex h-12 items-center gap-4">
|
||||
<MalioRadioButton
|
||||
:model-value="model.pricingUnit"
|
||||
:name="`price-unit-${uid}`"
|
||||
value="FORFAIT"
|
||||
:label="t('transport.carriers.form.price.pricingForfait')"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
:model-value="model.pricingUnit"
|
||||
:name="`price-unit-${uid}`"
|
||||
value="TONNE"
|
||||
:label="t('transport.carriers.form.price.pricingTonne')"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
||||
/>
|
||||
<!-- Branche CLIENT (RG-4.10). -->
|
||||
<template v-if="model.direction === 'CLIENT'">
|
||||
<MalioSelect
|
||||
:model-value="model.clientIri"
|
||||
:options="clientOptions"
|
||||
:label="t('transport.carriers.form.price.client')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.client"
|
||||
@update:model-value="onClientChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="model.clientDeliveryAddressIri"
|
||||
:options="clientAddressOptions"
|
||||
:label="t('transport.carriers.form.price.clientDeliveryAddress')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.clientDeliveryAddress"
|
||||
@update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="model.departureSiteIri"
|
||||
:options="siteOptions"
|
||||
:label="t('transport.carriers.form.price.departureSite')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.departureSite"
|
||||
@update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Branche FOURNISSEUR (RG-4.11). -->
|
||||
<template v-else-if="model.direction === 'FOURNISSEUR'">
|
||||
<MalioSelect
|
||||
:model-value="model.supplierIri"
|
||||
:options="supplierOptions"
|
||||
:label="t('transport.carriers.form.price.supplier')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.supplier"
|
||||
@update:model-value="onSupplierChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="model.supplierSupplyAddressIri"
|
||||
:options="supplierAddressOptions"
|
||||
:label="t('transport.carriers.form.price.supplierSupplyAddress')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.supplierSupplyAddress"
|
||||
@update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="model.deliverySiteIri"
|
||||
:options="siteOptions"
|
||||
:label="t('transport.carriers.form.price.deliverySite')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.deliverySite"
|
||||
@update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Communs (visibles dès qu'un sens est choisi). -->
|
||||
<template v-if="model.direction !== null">
|
||||
<!-- Contenant : Benne / Fond mouvant (radios centrés h-12, pas de label). -->
|
||||
<div>
|
||||
<div class="flex h-12 items-center gap-4">
|
||||
<MalioRadioButton
|
||||
:model-value="model.containerType"
|
||||
:name="`price-container-${uid}`"
|
||||
value="BENNE"
|
||||
:label="t('transport.carriers.containerType.BENNE')"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
:model-value="model.containerType"
|
||||
:name="`price-container-${uid}`"
|
||||
value="FOND_MOUVANT"
|
||||
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors?.containerType" class="ml-[2px] text-xs text-m-danger">{{ errors.containerType }}</p>
|
||||
</div>
|
||||
<p v-if="errors?.pricingUnit" class="ml-[2px] text-xs text-m-danger">{{ errors.pricingUnit }}</p>
|
||||
</div>
|
||||
|
||||
<MalioInputAmount
|
||||
:model-value="model.price"
|
||||
:label="t('transport.carriers.form.price.price')"
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.price"
|
||||
@update:model-value="(v: string) => update('price', v)"
|
||||
/>
|
||||
<!-- Tarification : Forfait / Tonne (radios centrés h-12, pas de label). -->
|
||||
<div>
|
||||
<div class="flex h-12 items-center gap-4">
|
||||
<MalioRadioButton
|
||||
:model-value="model.pricingUnit"
|
||||
:name="`price-unit-${uid}`"
|
||||
value="FORFAIT"
|
||||
:label="t('transport.carriers.form.price.pricingForfait')"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
:model-value="model.pricingUnit"
|
||||
:name="`price-unit-${uid}`"
|
||||
value="TONNE"
|
||||
:label="t('transport.carriers.form.price.pricingTonne')"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors?.pricingUnit" class="ml-[2px] text-xs text-m-danger">{{ errors.pricingUnit }}</p>
|
||||
</div>
|
||||
|
||||
<MalioSelect
|
||||
:model-value="model.priceState"
|
||||
:options="priceStateOptions"
|
||||
:label="t('transport.carriers.form.price.priceState')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.priceState"
|
||||
@update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))"
|
||||
/>
|
||||
</template>
|
||||
<MalioInputAmount
|
||||
:model-value="model.price"
|
||||
:label="t('transport.carriers.form.price.price')"
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.price"
|
||||
@update:model-value="(v: string) => update('price', v)"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
:model-value="model.priceState"
|
||||
:options="priceStateOptions"
|
||||
:label="t('transport.carriers.form.price.priceState')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.priceState"
|
||||
@update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -200,6 +209,10 @@ interface SelectOption {
|
||||
const props = defineProps<{
|
||||
/** Brouillon du prix (v-model). */
|
||||
modelValue: CarrierPriceFormDraft
|
||||
/** Titre du bloc (ex: « Prix 1 »). */
|
||||
title: string
|
||||
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||
last?: boolean
|
||||
/** Clients disponibles (IRI en value). */
|
||||
clientOptions: SelectOption[]
|
||||
/** Fournisseurs disponibles (IRI en value). */
|
||||
|
||||
@@ -56,6 +56,7 @@ function mountBlock(overrides: Record<string, unknown> = {}) {
|
||||
return mount(CarrierAddressBlock, {
|
||||
props: {
|
||||
modelValue: { ...emptyCarrierAddress(), ...overrides },
|
||||
title: 'Adresse 1',
|
||||
countryOptions: [{ value: 'France', label: 'France' }],
|
||||
},
|
||||
global: {
|
||||
|
||||
@@ -143,6 +143,8 @@
|
||||
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
|
||||
<CarrierAddressBlock
|
||||
:model-value="address"
|
||||
:title="t('transport.carriers.form.address.title')"
|
||||
:last="true"
|
||||
:country-options="countryOptions"
|
||||
:errors="addressErrors"
|
||||
@update:model-value="(v) => address = v"
|
||||
@@ -160,7 +162,9 @@
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:last="index === contacts.length - 1"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
@@ -178,10 +182,12 @@
|
||||
v-for="(price, index) in prices"
|
||||
:key="index"
|
||||
:model-value="price"
|
||||
:title="t('transport.carriers.form.price.title', { n: index + 1 })"
|
||||
:client-options="clientOptions"
|
||||
:supplier-options="supplierOptions"
|
||||
:site-options="siteOptions"
|
||||
removable
|
||||
:last="index === prices.length - 1"
|
||||
:errors="priceErrors[index]"
|
||||
@update:model-value="(v) => prices[index] = v"
|
||||
@remove="askRemovePrice(index)"
|
||||
|
||||
@@ -123,6 +123,8 @@
|
||||
<!-- Adresse UNIQUE (ERP-172). -->
|
||||
<CarrierAddressBlock
|
||||
:model-value="address"
|
||||
:title="t('transport.carriers.form.address.title')"
|
||||
:last="true"
|
||||
:country-options="countryOptionsFor(address.country)"
|
||||
disabled
|
||||
hide-empty
|
||||
@@ -136,6 +138,8 @@
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
|
||||
:last="index === contacts.length - 1"
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
|
||||
@@ -180,6 +180,8 @@
|
||||
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
|
||||
<CarrierAddressBlock
|
||||
:model-value="address"
|
||||
:title="t('transport.carriers.form.address.title')"
|
||||
:last="true"
|
||||
:country-options="countryOptions"
|
||||
:disabled="isQualimat || isValidated('addresses')"
|
||||
:errors="addressErrors"
|
||||
@@ -207,7 +209,9 @@
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:last="index === contacts.length - 1"
|
||||
:disabled="isValidated('contacts')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -240,11 +244,13 @@
|
||||
v-for="(price, index) in prices"
|
||||
:key="index"
|
||||
:model-value="price"
|
||||
:title="t('transport.carriers.form.price.title', { n: index + 1 })"
|
||||
:client-options="clientOptions"
|
||||
:supplier-options="supplierOptions"
|
||||
:site-options="siteOptions"
|
||||
:removable="!isValidated('prices')"
|
||||
:disabled="isValidated('prices')"
|
||||
:last="index === prices.length - 1"
|
||||
:errors="priceErrors[index]"
|
||||
@update:model-value="(v) => prices[index] = v"
|
||||
@remove="askRemovePrice(index)"
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface Persona {
|
||||
// sidebar-visibility pour driver la matrice. Les valeurs correspondent
|
||||
// aux slugs de route (`/admin/<slug>`), volontairement stables quand
|
||||
// la copie/i18n change.
|
||||
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories'>
|
||||
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories' | 'products'>
|
||||
}
|
||||
|
||||
const SHARED_PASSWORD = 'e2e-secret'
|
||||
@@ -47,7 +47,7 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
password: SHARED_PASSWORD,
|
||||
isAdmin: true,
|
||||
permissions: [],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'],
|
||||
},
|
||||
'user-full': {
|
||||
key: 'user-full',
|
||||
@@ -65,6 +65,12 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
'sites.bypass_scope',
|
||||
'catalog.categories.view',
|
||||
'catalog.categories.manage',
|
||||
// Catalogue produit (M6, ERP-197). Admin-only (matrice docx p.3) :
|
||||
// mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE
|
||||
// n°7). L'item vit dans la section Administration sur la route
|
||||
// `/admin/products` -> ajoute le lien `products` a expectedAdminLinks.
|
||||
'catalog.products.view',
|
||||
'catalog.products.manage',
|
||||
// Commercial — Repertoire clients (M1). Mappe ici sur le persona
|
||||
// "tout" en attendant les vrais roles metier (bureau/compta/
|
||||
// commerciale/usine) seedes par ERP-74. Pas de nouveau persona
|
||||
@@ -110,7 +116,7 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
'logistique.weighing_tickets.view',
|
||||
'logistique.weighing_tickets.manage',
|
||||
],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'],
|
||||
},
|
||||
'user-readonly': {
|
||||
key: 'user-readonly',
|
||||
@@ -155,4 +161,4 @@ export function getPersona(key: PersonaKey): Persona {
|
||||
return personas[key]
|
||||
}
|
||||
|
||||
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'audit-log'] as const
|
||||
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'] as const
|
||||
|
||||
@@ -18,6 +18,7 @@ COPY config config/
|
||||
COPY migrations migrations/
|
||||
COPY public public/
|
||||
COPY src src/
|
||||
COPY templates templates/
|
||||
|
||||
RUN composer dump-autoload --optimize --no-dev
|
||||
|
||||
|
||||
@@ -233,6 +233,7 @@ test-db-setup:
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_carrier_name_active ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_product_code_active ON product (code) WHERE deleted_at IS NULL"
|
||||
|
||||
fixtures:
|
||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Taxonomie ADRESSE (module Catalog) — categories du champ « Categorie » des blocs adresse.
|
||||
*
|
||||
* Contexte : jusqu'ici le multi-select « Categorie » des blocs adresse reutilisait
|
||||
* la taxonomie CLIENT (M1, codes DISTRIBUTEUR/COURTIER blacklistes par RG-1.29) ou
|
||||
* FOURNISSEUR (M2, RG-2.10). On introduit un type dedie ADRESSE : les blocs adresse
|
||||
* client (ClientAddress) et fournisseur (SupplierAddress) ne referencent plus que
|
||||
* des `Category` rattachees au type ADRESSE (validation whitelist par type).
|
||||
*
|
||||
* Cette migration :
|
||||
* 1. cree le `category_type` ADRESSE (code ADRESSE, label « Adresse ») ;
|
||||
* 2. seede 6 `Category` rattachees a ce type via la jonction ManyToMany
|
||||
* `category_category_type` (modele courant depuis Version20260608120000 ;
|
||||
* la colonne ManyToOne `category.category_type_id` n'existe plus).
|
||||
*
|
||||
* Aucune colonne creee/modifiee -> pas de `COMMENT ON COLUMN` (regle ABSOLUE n°12) :
|
||||
* la migration ne fait que des INSERT de donnees de reference.
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
|
||||
* garantit l'ordre par timestamp avant les migrations modulaires sur base vide.
|
||||
*
|
||||
* Idempotence : `INSERT ... ON CONFLICT (code) DO NOTHING` pour le type,
|
||||
* `INSERT ... SELECT ... WHERE NOT EXISTS` pour chaque categorie et chaque ligne
|
||||
* de jonction (aligne sur le pattern PRESTATAIRE / Version20260612080000). En prod
|
||||
* la table `category` est vide (aucune fixture metier) ; en dev/test le purger
|
||||
* Doctrine vide `category` / `category_type` avant les fixtures qui reproduisent le
|
||||
* meme etat final (CategoryTypeFixtures / CategoryFixtures etendus a ADRESSE).
|
||||
*/
|
||||
final class Version20260625100000 extends AbstractMigration
|
||||
{
|
||||
/**
|
||||
* Categories de demonstration du type ADRESSE : nom => code stable. Le code est
|
||||
* la cle metier (slug MAJUSCULE du nom, miroir du CategoryCodeGenerator) et reste
|
||||
* unique parmi les actifs (uq_category_code). Le nom est unique GLOBALEMENT parmi
|
||||
* les actifs (uq_category_name_active) : aucune collision avec les categories
|
||||
* deja seedees (CLIENT / FOURNISSEUR / PRESTATAIRE).
|
||||
*/
|
||||
private const array ADDRESS_CATEGORIES = [
|
||||
'Siège' => 'SIEGE',
|
||||
'Contact issues' => 'CONTACT_ISSUES',
|
||||
'Facturation' => 'FACTURATION',
|
||||
'Livraison' => 'LIVRAISON',
|
||||
'Approvisionnement' => 'APPROVISIONNEMENT',
|
||||
'Méthaniseur' => 'METHANISEUR',
|
||||
];
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Taxonomie ADRESSE : cree le CategoryType ADRESSE + seed des categories adresse (Siege, Contact issues, Facturation, Livraison, Approvisionnement, Methaniseur).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// 1. Type ADRESSE (idempotent via l'index unique uq_category_type_code).
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO category_type (code, label) VALUES ('ADRESSE', 'Adresse')
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
SQL);
|
||||
|
||||
foreach (self::ADDRESS_CATEGORIES as $name => $code) {
|
||||
// 2a. Categorie sous ADRESSE (si le code est libre parmi les actifs).
|
||||
// created_at/updated_at NOT NULL -> NOW() ; le blame reste null
|
||||
// (seed hors contexte HTTP, libelle « Systeme » cote front).
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO category (name, code, created_at, updated_at)
|
||||
SELECT :name, :code, NOW(), NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL
|
||||
)
|
||||
SQL, ['name' => $name, 'code' => $code]);
|
||||
|
||||
// 2b. Jonction M2M categorie <-> type ADRESSE (modele courant).
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO category_category_type (category_id, category_type_id)
|
||||
SELECT c.id, ct.id
|
||||
FROM category c
|
||||
CROSS JOIN category_type ct
|
||||
WHERE c.code = :code AND c.deleted_at IS NULL
|
||||
AND ct.code = 'ADRESSE'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM category_category_type cct
|
||||
WHERE cct.category_id = c.id AND cct.category_type_id = ct.id
|
||||
)
|
||||
SQL, ['code' => $code]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Best-effort : on retire d'abord les categories seedees (par code) — la FK
|
||||
// category_category_type est ON DELETE CASCADE cote category, donc les lignes
|
||||
// de jonction partent avec —, puis le type s'il n'est plus reference.
|
||||
$this->addSql(
|
||||
'DELETE FROM category WHERE code IN (:codes) '
|
||||
.'AND id IN (SELECT category_id FROM category_category_type cct '
|
||||
."JOIN category_type ct ON ct.id = cct.category_type_id WHERE ct.code = 'ADRESSE')",
|
||||
['codes' => array_values(self::ADDRESS_CATEGORIES)],
|
||||
['codes' => ArrayParameterType::STRING],
|
||||
);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM category_type
|
||||
WHERE code = 'ADRESSE'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM category_category_type cct WHERE cct.category_type_id = category_type.id
|
||||
)
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* M6 — Catalogue produit (ERP-198) : creation du schema BDD du module.
|
||||
*
|
||||
* Objets crees (spec-back § 3.2) :
|
||||
* - storage_type : referentiel PROVISOIRE des types de stockage (en attente de la
|
||||
* liste definitive d'Aurore — § 2.4 / RG-6.06). Lecture seule au M6.
|
||||
* - storage_type_site : jonction M2M storage_type <-> site (sur quels sites un type
|
||||
* de stockage est disponible — alimente le filtrage du multi-select par site).
|
||||
* - product : table principale (code unique global parmi les actifs, etats
|
||||
* multi-valeur JSONB, champs conditionnels SALE, categorie de type PRODUIT,
|
||||
* soft-delete prepare + Timestampable/Blamable).
|
||||
* - product_site : jonction M2M product <-> site (sites de disponibilite, RG-6.04).
|
||||
* - product_storage_type : jonction M2M product <-> storage_type (RG-6.06).
|
||||
*
|
||||
* Seed : ajout du `category_type` PRODUIT (miroir CategoryTypeFixtures, comme
|
||||
* CLIENT/FOURNISSEUR/PRESTATAIRE/ADRESSE — § 2.5). Les `Category` de type PRODUIT et
|
||||
* le seed Figma du referentiel storage_type suivent au ticket ERP-201.
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
|
||||
* la table product porte des FK cross-module (user, site, category). Le tri par
|
||||
* timestamp au sein du namespace racine garantit l'ordre apres la creation de ces
|
||||
* tables sur base vide ; un namespace modulaire casserait `make db-reset` (cf.
|
||||
* Version20260617150000 pour le M5).
|
||||
*
|
||||
* Convention IDs (spec § 2.2) : `INT GENERATED BY DEFAULT AS IDENTITY`,
|
||||
* horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le TimestampableBlamableTrait mappe
|
||||
* `datetime_immutable`). Chaque colonne porte son `COMMENT ON COLUMN` (regle n°12).
|
||||
*
|
||||
* NB schema:update (test-db-setup) : product / storage_type et leurs jonctions seront
|
||||
* mappes en ORM au ticket suivant (entites Product + StorageType, ERP-199). D'ici la,
|
||||
* `schema:update --force` les drope sur la base de TEST uniquement (sans impact :
|
||||
* aucun test ne les reference encore, et dev/prod ne lancent jamais schema:update).
|
||||
* Leurs descriptions seront ajoutees a ColumnCommentsCatalog au ticket entites (comme
|
||||
* weighing_ticket : migration ERP-182, catalogue ERP-183).
|
||||
*/
|
||||
final class Version20260625110000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-198 (M6) : storage_type (+ jonction site) + product (+ jonctions site/stockage) + seed category_type PRODUIT.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->createStorageType();
|
||||
$this->createStorageTypeSite();
|
||||
$this->createProduct();
|
||||
$this->createProductSite();
|
||||
$this->createProductStorageType();
|
||||
$this->seedCategoryTypeProduit();
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Ordre inverse des dependances FK.
|
||||
$this->addSql('DROP TABLE IF EXISTS product_storage_type');
|
||||
$this->addSql('DROP TABLE IF EXISTS product_site');
|
||||
$this->addSql('DROP TABLE IF EXISTS product');
|
||||
$this->addSql('DROP TABLE IF EXISTS storage_type_site');
|
||||
$this->addSql('DROP TABLE IF EXISTS storage_type');
|
||||
// Retrait du type seede (best-effort : echoue si des categories le referencent
|
||||
// encore — attendu, le down sert au dev sur base saine).
|
||||
$this->addSql("DELETE FROM category_type WHERE code = 'PRODUIT'");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Referentiel des types de stockage (PROVISOIRE) — § 2.4 / RG-6.06
|
||||
// =================================================================
|
||||
|
||||
private function createStorageType(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE storage_type (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
code VARCHAR(40) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE UNIQUE INDEX uq_storage_type_code ON storage_type (code)');
|
||||
|
||||
$this->comment('storage_type', '_table', 'Referentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Cellule, Tas, Cuve melasse… (RG-6.06). Lecture seule au M6.');
|
||||
$this->comment('storage_type', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('storage_type', 'code', 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).');
|
||||
$this->comment('storage_type', 'label', 'Libelle FR affiche du type de stockage (ex. « Cuve melasse »).');
|
||||
}
|
||||
|
||||
private function createStorageTypeSite(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE storage_type_site (
|
||||
storage_type_id INT NOT NULL,
|
||||
site_id INT NOT NULL,
|
||||
PRIMARY KEY (storage_type_id, site_id),
|
||||
CONSTRAINT fk_storage_type_site_type
|
||||
FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_storage_type_site_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_storage_type_site_site ON storage_type_site (site_id)');
|
||||
|
||||
$this->comment('storage_type_site', '_table', 'Jointure M2M storage_type <-> site (Sites) — sites sur lesquels un type de stockage est disponible (alimente le filtrage du multi-select par site, RG-6.06).');
|
||||
$this->comment('storage_type_site', 'storage_type_id', 'FK -> storage_type.id, ON DELETE CASCADE — type de stockage disponible.');
|
||||
$this->comment('storage_type_site', 'site_id', 'FK -> site.id, ON DELETE CASCADE — site ou le type de stockage est disponible.');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Table principale `product`
|
||||
// =================================================================
|
||||
|
||||
private function createProduct(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE product (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
code VARCHAR(50) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
-- Pas de DEFAULT : un tableau vide violerait chk_product_states_not_empty
|
||||
-- (RG-6.02). La colonne est toujours renseignee par l'app (Processor/ORM).
|
||||
states JSONB NOT NULL,
|
||||
manufactured BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
contains_molasses BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_product_states_not_empty
|
||||
CHECK (jsonb_array_length(states) >= 1),
|
||||
CONSTRAINT fk_product_category
|
||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_product_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_product_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
// Unicite GLOBALE du code parmi les actifs (soft-delete tolere) — index partiel.
|
||||
$this->addSql('CREATE UNIQUE INDEX uq_product_code_active ON product (code) WHERE deleted_at IS NULL');
|
||||
$this->addSql('CREATE INDEX idx_product_category ON product (category_id)');
|
||||
$this->addSql('CREATE INDEX idx_product_deleted_at ON product (deleted_at)');
|
||||
$this->addSql('CREATE INDEX idx_product_created_by ON product (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_product_updated_by ON product (updated_by)');
|
||||
|
||||
$this->comment('product', '_table', 'Produits du catalogue (M6 Catalog) — etat Achat/Vendu/Autre, sites de disponibilite, categorie produit, types de stockage.');
|
||||
$this->comment('product', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('product', 'code', 'Code produit (= « Numero » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active. Normalise serveur (trim/UPPER).');
|
||||
$this->comment('product', 'name', 'Nom du produit (≤ 255). Normalise serveur (trim).');
|
||||
$this->comment('product', 'states', 'Etats du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02, chk_product_states_not_empty). Pilote les champs conditionnels.');
|
||||
$this->comment('product', 'manufactured', '« Fabrique » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).');
|
||||
$this->comment('product', 'contains_molasses', '« Contient de la melasse » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).');
|
||||
$this->comment('product', 'category_id', 'Categorie produit (FK -> category.id, ON DELETE RESTRICT) — type PRODUIT, obligatoire, validee applicativement (RG-6.05).');
|
||||
$this->comment('product', 'deleted_at', 'Horodatage du soft-delete technique — non expose au M6 ; la liste exclut les produits supprimes (§ 2.7). Null = ligne active.');
|
||||
$this->addTimestampableBlamableComments('product');
|
||||
}
|
||||
|
||||
private function createProductSite(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE product_site (
|
||||
product_id INT NOT NULL,
|
||||
site_id INT NOT NULL,
|
||||
PRIMARY KEY (product_id, site_id),
|
||||
CONSTRAINT fk_product_site_product
|
||||
FOREIGN KEY (product_id) REFERENCES product (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_product_site_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_product_site_site ON product_site (site_id)');
|
||||
|
||||
$this->comment('product_site', '_table', 'Jointure M2M product <-> site (Sites) — sites de disponibilite du produit (>= 1 obligatoire, RG-6.04).');
|
||||
$this->comment('product_site', 'product_id', 'FK -> product.id, ON DELETE CASCADE — produit concerne.');
|
||||
$this->comment('product_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site de disponibilite rattache au produit.');
|
||||
}
|
||||
|
||||
private function createProductStorageType(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE product_storage_type (
|
||||
product_id INT NOT NULL,
|
||||
storage_type_id INT NOT NULL,
|
||||
PRIMARY KEY (product_id, storage_type_id),
|
||||
CONSTRAINT fk_product_storage_type_product
|
||||
FOREIGN KEY (product_id) REFERENCES product (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_product_storage_type_type
|
||||
FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_product_storage_type_type ON product_storage_type (storage_type_id)');
|
||||
|
||||
$this->comment('product_storage_type', '_table', 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, filtres par les sites selectionnes, RG-6.06).');
|
||||
$this->comment('product_storage_type', 'product_id', 'FK -> product.id, ON DELETE CASCADE — produit concerne.');
|
||||
$this->comment('product_storage_type', 'storage_type_id', 'FK -> storage_type.id, ON DELETE RESTRICT — type de stockage rattache au produit.');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Seed du type de categorie PRODUIT (§ 2.5) — miroir CategoryTypeFixtures
|
||||
// =================================================================
|
||||
|
||||
private function seedCategoryTypeProduit(): void
|
||||
{
|
||||
// Idempotent via l'index unique uq_category_type_code (comme CLIENT/FOURNISSEUR/
|
||||
// PRESTATAIRE/ADRESSE). Les Category de type PRODUIT suivent en ERP-201.
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO category_type (code, label) VALUES ('PRODUIT', 'Produit')
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
SQL);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Helpers (identiques au M5 Version20260617150000)
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
|
||||
* en reutilisant le catalogue partage (source unique, ERP-67).
|
||||
*/
|
||||
private function addTimestampableBlamableComments(string $table): void
|
||||
{
|
||||
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
|
||||
$this->comment($table, $column, $description);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN`
|
||||
* en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement d apostrophe.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Application\Service;
|
||||
|
||||
/**
|
||||
* Normalisation serveur des champs texte d'un Product, appliquee par le
|
||||
* ProductProcessor AVANT l'unicite du code et la persistance (RG-6.07, spec-back
|
||||
* M6 § 6). Jumeau du CarrierFieldNormalizer (M4), recentre sur les deux champs
|
||||
* texte du produit.
|
||||
*
|
||||
* - code : trim + UPPER (cohérent avec la stratégie de codes stables du Catalog —
|
||||
* le code produit fait office de cle metier saisie, unique global parmi les
|
||||
* actifs RG-6.01).
|
||||
* - name : trim simple (pas de changement de casse — libelle affiche).
|
||||
*
|
||||
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide apres
|
||||
* trim devient null. En pratique le ProductProcessor n'appelle ces methodes
|
||||
* qu'apres validation (NotBlank deja joue par API Platform), donc le code et le
|
||||
* name sont non vides a ce stade — le retour null reste un garde-fou.
|
||||
*/
|
||||
final class ProductFieldNormalizer
|
||||
{
|
||||
/**
|
||||
* Code produit en majuscules (RG-6.07) : " ble-01 " -> "BLE-01". Conserve
|
||||
* null tel quel ; une chaine vide apres trim devient null (c'est l'Assert\NotBlank
|
||||
* de l'entite qui rejette le vide, pas le normalizer).
|
||||
*/
|
||||
public function normalizeCode(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : mb_strtoupper($value, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nom du produit trimme (RG-6.07), sans changement de casse. Une chaine vide
|
||||
* apres trim devient null.
|
||||
*/
|
||||
public function normalizeName(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : $value;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,10 @@ final class CatalogModule
|
||||
// sans donner l'acces d'administration `.view` (qui ouvre la page Catalogue
|
||||
// dans la sidebar). Accordee aux roles metier via la matrice RBAC § 2.7.
|
||||
['code' => 'catalog.categories.read_ref', 'label' => 'Lire le referentiel categories (transverse, lecture seule)'],
|
||||
// Catalogue produit (M6, ERP-197) : admin-only (matrice docx p.3, C7).
|
||||
// Item sidebar dans la section Administration, sous « Repertoire transporteurs ».
|
||||
['code' => 'catalog.products.view', 'label' => 'Voir les produits'],
|
||||
['code' => 'catalog.products.manage', 'label' => 'Gérer les produits (créer, éditer)'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
<?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;
|
||||
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 DateTimeImmutable;
|
||||
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;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
use function in_array;
|
||||
|
||||
/**
|
||||
* Produit du catalogue (M6 Catalog) — entite racine du module produit, jumelle de
|
||||
* Category (#[Auditable], TimestampableBlamable, soft-delete) cote pattern et de
|
||||
* Carrier (M4) / WeighingTicket (M5) cote contrat de serialisation (RETEX M1,
|
||||
* 3 maillons — spec § 4.0).
|
||||
*
|
||||
* Contrat de serialisation :
|
||||
* - LISTE (product:read + category:read + site:read + storage_type:read +
|
||||
* default:read) : code (« Numero »), name, states, manufactured,
|
||||
* containsMolasses, category embarquee, createdAt/updatedAt (via default:read).
|
||||
* - DETAIL (+ product:item:read) : ajoute sites + storageTypes embarques
|
||||
* (ensembles bornes -> embed autorise, ne viole pas la regle n°13). Le groupe
|
||||
* product:item:read est reserve pour d'eventuels champs detail-only ulterieurs.
|
||||
*
|
||||
* Regles de gestion (renvoyees au Processor/Provider, ERP-200) :
|
||||
* - RG-6.01 : `code` unique global parmi les actifs, normalise serveur (trim/UPPER),
|
||||
* 409 sur doublon (index partiel uq_product_code_active).
|
||||
* - RG-6.02 : `states` = sous-ensemble non vide de {PURCHASE, SALE, OTHER}.
|
||||
* - RG-6.03 : `manufactured` / `containsMolasses` saisis uniquement si states
|
||||
* contient SALE, sinon forces false serveur.
|
||||
* - RG-6.04 : `sites` >= 1.
|
||||
* - RG-6.05 : `category` de type PRODUIT (validee applicativement, Callback ERP-200).
|
||||
* - RG-6.06 : `storageTypes` >= 1, filtres par les sites selectionnes.
|
||||
*
|
||||
* Soft-delete prepare via `deletedAt` (non expose au M6, § 2.7) : pas de Delete
|
||||
* dans les operations, la liste exclut les produits supprimes (Provider, ERP-200).
|
||||
*
|
||||
* Les RG inter-champs (RG-6.03/6.05/6.06) et l'unicite du code passent par le
|
||||
* Processor + une contrainte d'entite Assert\Callback en ERP-200 (chaque 422
|
||||
* porte un propertyPath exploitable par useFormErrors — mapping inline, ERP-101).
|
||||
*
|
||||
* NB : `Site` appartient au module Sites, consomme en relation ORM partagee
|
||||
* (§ 2.1) — on reutilise son read-group `site:read`, sans logique inter-module.
|
||||
* `Category` et `StorageType` sont dans le meme module Catalog.
|
||||
*
|
||||
* @see ProductProvider Lecture (liste paginee filtree soft-delete + item) — ERP-200.
|
||||
* @see ProductProcessor Ecriture (normalisation, unicite code, RG-6.03/05/06) — ERP-200.
|
||||
*/
|
||||
#[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 prepare non expose (§ 2.7).
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineProductRepository::class)]
|
||||
#[ORM\Table(name: 'product')]
|
||||
// Index nommes pour matcher la migration (cf. Category). L'index unique partiel
|
||||
// `uq_product_code_active` (code WHERE deleted_at IS NULL — unicite GLOBALE du
|
||||
// code parmi les actifs, RG-6.01) reste possede par la seule migration :
|
||||
// Doctrine ORM ne sait pas exprimer un index partiel via attribut.
|
||||
#[ORM\Index(name: 'idx_product_category', columns: ['category_id'])]
|
||||
#[ORM\Index(name: 'idx_product_deleted_at', columns: ['deleted_at'])]
|
||||
#[ORM\Index(name: 'idx_product_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_product_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Product implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
// === Timestampable + Blamable ===
|
||||
// Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs
|
||||
// getters/setters viennent du Trait Shared, remplies automatiquement par le
|
||||
// TimestampableBlamableSubscriber au prePersist / preUpdate.
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/** Etats du produit (RG-6.02) — valeurs autorisees de la colonne JSONB `states`. */
|
||||
public const string STATE_PURCHASE = 'PURCHASE';
|
||||
public const string STATE_SALE = 'SALE';
|
||||
public const string STATE_OTHER = 'OTHER';
|
||||
|
||||
/** Code de type de categorie autorise pour un produit (RG-6.05). */
|
||||
private const string PRODUCT_CATEGORY_TYPE_CODE = 'PRODUIT';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['product:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
// Code produit (= « Numero » de la liste), saisi, unique global parmi les
|
||||
// actifs (RG-6.01). Normalise serveur (trim/UPPER) par le ProductProcessor.
|
||||
#[ORM\Column(length: 50)]
|
||||
#[Assert\NotBlank(message: 'Le code produit est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 50, maxMessage: 'Le code produit ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'Le nom du produit est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le nom du produit ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?string $name = null;
|
||||
|
||||
/**
|
||||
* Etats du produit (multi-select), sous-ensemble non vide de
|
||||
* {PURCHASE, SALE, OTHER} (RG-6.02). Stocke en JSONB (tableau de chaines),
|
||||
* non-vacuite garantie aussi par le CHECK chk_product_states_not_empty.
|
||||
*
|
||||
* Validation des valeurs via Assert\Choice(multiple: true) plutot que
|
||||
* Assert\All([Choice]) : equivalent fonctionnel, et seul Choice est gere par
|
||||
* le garde-fou EntityConstraintsHaveFrenchMessageTest.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
// jsonb (pas json) : aligne le mapping ORM sur la colonne JSONB creee par la
|
||||
// migration (spec § 2.3 + CHECK chk_product_states_not_empty via
|
||||
// jsonb_array_length). Sans `options: ['jsonb' => true]`, schema:update tente
|
||||
// un ALTER states TYPE JSON qui casse le CHECK (jsonb_array_length(json) inconnu)
|
||||
// et fait echouer make db-reset / test-db-setup.
|
||||
#[ORM\Column(type: 'json', options: ['jsonb' => true])]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')]
|
||||
#[Assert\Choice(
|
||||
choices: [self::STATE_PURCHASE, self::STATE_SALE, self::STATE_OTHER],
|
||||
multiple: true,
|
||||
message: 'État de produit invalide.',
|
||||
multipleMessage: 'État de produit invalide.',
|
||||
)]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private array $states = [];
|
||||
|
||||
// « Fabrique » : saisi uniquement si states contient SALE, sinon force false
|
||||
// serveur (RG-6.03).
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private bool $manufactured = false;
|
||||
|
||||
// « Contient de la melasse » : saisi uniquement si states contient SALE,
|
||||
// sinon force false serveur (RG-6.03).
|
||||
#[ORM\Column(name: 'contains_molasses', options: ['default' => false])]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private bool $containsMolasses = false;
|
||||
|
||||
// Categorie produit (obligatoire). Limitee aux categories de type PRODUIT,
|
||||
// validee applicativement (RG-6.05, Callback ERP-200). FK ON DELETE RESTRICT :
|
||||
// une categorie referencee par un produit ne peut etre supprimee.
|
||||
#[ORM\ManyToOne(targetEntity: Category::class)]
|
||||
#[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
#[Assert\NotNull(message: 'La catégorie produit est obligatoire.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?Category $category = null;
|
||||
|
||||
/**
|
||||
* Sites de disponibilite du produit (>= 1, RG-6.04). Relation ORM partagee
|
||||
* vers Site (module Sites, § 2.1). Cote inverse en ON DELETE RESTRICT : un
|
||||
* site reference par un produit ne peut etre supprime.
|
||||
*
|
||||
* @var Collection<int, Site>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Site::class)]
|
||||
#[ORM\JoinTable(name: 'product_site')]
|
||||
#[ORM\JoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un site.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private Collection $sites;
|
||||
|
||||
/**
|
||||
* Types de stockage du produit (>= 1, RG-6.06), filtres par les sites
|
||||
* selectionnes (provider StorageType, ERP-201). Cote inverse en ON DELETE
|
||||
* RESTRICT : un type de stockage reference par un produit ne peut etre supprime.
|
||||
*
|
||||
* @var Collection<int, StorageType>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: StorageType::class)]
|
||||
#[ORM\JoinTable(name: 'product_storage_type')]
|
||||
#[ORM\JoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'storage_type_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de stockage.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private Collection $storageTypes;
|
||||
|
||||
/**
|
||||
* Soft-delete technique : null = actif, valeur = supprime logiquement le {date}.
|
||||
* Non expose au M6 (§ 2.7, aucun groupe) : prepare pour une future suppression
|
||||
* (HP-M6-04). La liste exclut par defaut les produits supprimes (Provider).
|
||||
*/
|
||||
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
||||
private ?DateTimeImmutable $deletedAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->sites = new ArrayCollection();
|
||||
$this->storageTypes = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCode(): ?string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function setCode(string $code): static
|
||||
{
|
||||
$this->code = $code;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getStates(): array
|
||||
{
|
||||
return $this->states;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $states
|
||||
*/
|
||||
public function setStates(array $states): static
|
||||
{
|
||||
$this->states = $states;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isManufactured(): bool
|
||||
{
|
||||
return $this->manufactured;
|
||||
}
|
||||
|
||||
public function setManufactured(bool $manufactured): static
|
||||
{
|
||||
$this->manufactured = $manufactured;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isContainsMolasses(): bool
|
||||
{
|
||||
return $this->containsMolasses;
|
||||
}
|
||||
|
||||
public function setContainsMolasses(bool $containsMolasses): static
|
||||
{
|
||||
$this->containsMolasses = $containsMolasses;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCategory(): ?Category
|
||||
{
|
||||
return $this->category;
|
||||
}
|
||||
|
||||
public function setCategory(?Category $category): static
|
||||
{
|
||||
$this->category = $category;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Site>
|
||||
*/
|
||||
public function getSites(): Collection
|
||||
{
|
||||
return $this->sites;
|
||||
}
|
||||
|
||||
public function addSite(Site $site): static
|
||||
{
|
||||
if (!$this->sites->contains($site)) {
|
||||
$this->sites->add($site);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSite(Site $site): static
|
||||
{
|
||||
$this->sites->removeElement($site);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, StorageType>
|
||||
*/
|
||||
public function getStorageTypes(): Collection
|
||||
{
|
||||
return $this->storageTypes;
|
||||
}
|
||||
|
||||
public function addStorageType(StorageType $storageType): static
|
||||
{
|
||||
if (!$this->storageTypes->contains($storageType)) {
|
||||
$this->storageTypes->add($storageType);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeStorageType(StorageType $storageType): static
|
||||
{
|
||||
$this->storageTypes->removeElement($storageType);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.05 : la categorie d'un produit doit etre de type PRODUIT. Validee
|
||||
* applicativement (pas de contrainte SQL au referentiel, § 2.5) via Callback
|
||||
* + ->atPath('category') pour que la 422 porte un propertyPath consommable par
|
||||
* useFormErrors (mapping inline, ERP-101). Le NotNull gere l'absence : on ne
|
||||
* leve que si une categorie est presente ET non-PRODUIT.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCategoryIsProductType(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (null === $this->category) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!in_array(self::PRODUCT_CATEGORY_TYPE_CODE, $this->category->getCategoryTypeCodes(), true)) {
|
||||
$context->buildViolation('La catégorie sélectionnée doit être de type Produit.')
|
||||
->atPath('category')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.06 : chaque type de stockage choisi doit etre disponible sur AU MOINS UN
|
||||
* des sites choisis (intersection non vide). Validee via Callback +
|
||||
* ->atPath('storageTypes'). On ne croise que si les deux collections sont non
|
||||
* vides : leur absence est deja couverte par les Assert\Count(min: 1) dedies.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateStorageTypesAvailableOnSelectedSites(ExecutionContextInterface $context): void
|
||||
{
|
||||
if ($this->sites->isEmpty() || $this->storageTypes->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensemble des ids de sites selectionnes (lookup O(1)).
|
||||
$selectedSiteIds = [];
|
||||
foreach ($this->sites as $site) {
|
||||
$selectedSiteIds[$site->getId()] = true;
|
||||
}
|
||||
|
||||
foreach ($this->storageTypes as $storageType) {
|
||||
$available = false;
|
||||
foreach ($storageType->getSites() as $storageTypeSite) {
|
||||
if (isset($selectedSiteIds[$storageTypeSite->getId()])) {
|
||||
$available = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$available) {
|
||||
$context->buildViolation('Le type de stockage « {{ label }} » n\'est disponible sur aucun des sites sélectionnés.')
|
||||
->setParameter('{{ label }}', (string) $storageType->getLabel())
|
||||
->atPath('storageTypes')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\StorageTypeProvider;
|
||||
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageTypeRepository;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Type de stockage : referentiel PROVISOIRE classifiant ou un produit peut etre
|
||||
* stocke (ex: TAS, CELLULE, CUVE_MELASSE). Cree au M6 en attendant la liste et
|
||||
* le mapping site definitifs d'Aurore (HP-M6-02) ; seede avec la liste Figma
|
||||
* (node 1503-34285) au ticket ERP-201.
|
||||
*
|
||||
* Relation `sites` (ManyToMany -> Site) : sites sur lesquels ce type de stockage
|
||||
* est disponible. Sert au filtrage du multi-select « Type de stockage » par les
|
||||
* sites selectionnes dans le formulaire produit (RG-6.06). Non serialisee au M6
|
||||
* (le filtrage est applique cote provider en ERP-201).
|
||||
*
|
||||
* Lecture seule au M6 : seules les operations GetCollection et Get sont exposees
|
||||
* (CRUD admin = hors perimetre HP-M6-03), sous la permission `catalog.products.view`
|
||||
* (referentiel servant le formulaire produit — § 4.2).
|
||||
*
|
||||
* Referentiel statique : pas de Timestampable/Blamable ni `#[Auditable]`
|
||||
* (whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED, miroir
|
||||
* CategoryType — cree par migration/seed, pas pilote utilisateur). Le groupe
|
||||
* `storage_type:read` est porte par chaque propriete affichee pour que le type
|
||||
* soit embarque dans la reponse d'un Product (cf. .claude/rules/backend.md
|
||||
* § Serialization).
|
||||
*/
|
||||
#[ApiResource(
|
||||
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(
|
||||
security: "is_granted('catalog.products.view')",
|
||||
normalizationContext: ['groups' => ['storage_type:read']],
|
||||
provider: StorageTypeProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('catalog.products.view')",
|
||||
normalizationContext: ['groups' => ['storage_type:read']],
|
||||
provider: StorageTypeProvider::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineStorageTypeRepository::class)]
|
||||
#[ORM\Table(name: 'storage_type')]
|
||||
// Contrainte d'unicite nommee pour matcher la migration (cf. CategoryType).
|
||||
#[ORM\UniqueConstraint(name: 'uq_storage_type_code', columns: ['code'])]
|
||||
class StorageType
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['storage_type:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 40)]
|
||||
#[Groups(['storage_type:read'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['storage_type:read'])]
|
||||
private ?string $label = null;
|
||||
|
||||
/**
|
||||
* Sites sur lesquels ce type de stockage est disponible (RG-6.06). Non
|
||||
* exposee en serialisation au M6 : sert uniquement au filtrage `?siteId[]=`
|
||||
* du referentiel (branche en ERP-201).
|
||||
*
|
||||
* @var Collection<int, Site>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Site::class)]
|
||||
#[ORM\JoinTable(name: 'storage_type_site')]
|
||||
#[ORM\JoinColumn(name: 'storage_type_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
private Collection $sites;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->sites = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCode(): ?string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function setCode(string $code): static
|
||||
{
|
||||
$this->code = $code;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Site>
|
||||
*/
|
||||
public function getSites(): Collection
|
||||
{
|
||||
return $this->sites;
|
||||
}
|
||||
|
||||
public function addSite(Site $site): static
|
||||
{
|
||||
if (!$this->sites->contains($site)) {
|
||||
$this->sites->add($site);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSite(Site $site): static
|
||||
{
|
||||
$this->sites->removeElement($site);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Domain\Repository;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Product;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
interface ProductRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Product;
|
||||
|
||||
public function save(Product $product): void;
|
||||
|
||||
/**
|
||||
* Vrai si un produit actif (deleted_at IS NULL) porte deja ce code.
|
||||
* `$excludeId` exclut un produit precis du test (cas PATCH). Garantit
|
||||
* l'unicite GLOBALE du code parmi les actifs (RG-6.01, index partiel
|
||||
* uq_product_code_active). Un code reutilisable apres soft-delete (le test
|
||||
* ignore les supprimes).
|
||||
*/
|
||||
public function existsActiveByCode(string $code, ?int $excludeId = null): bool;
|
||||
|
||||
/**
|
||||
* QueryBuilder de la liste produits (consomme par le ProductProvider) : exclut
|
||||
* par defaut les soft-deleted (RG-6.09), trie par name ASC (defaut spec § 4.1)
|
||||
* et applique les filtres optionnels du drawer « Filtrer » :
|
||||
* - `$search` : recherche partielle case-insensitive sur `code` + `name`.
|
||||
* - `$categoryId` : restreint a une categorie precise (par id).
|
||||
* - `$categoryCode` : restreint a une categorie precise (par code stable).
|
||||
* - `$state` : appartenance a la colonne JSONB `states` (PURCHASE|SALE|OTHER).
|
||||
* - `$siteIds` : produit disponible sur AU MOINS UN des sites passes.
|
||||
*
|
||||
* @param list<int> $siteIds
|
||||
*/
|
||||
public function createListQueryBuilder(
|
||||
bool $includeDeleted = false,
|
||||
?string $search = null,
|
||||
?int $categoryId = null,
|
||||
?string $categoryCode = null,
|
||||
?string $state = null,
|
||||
array $siteIds = [],
|
||||
): QueryBuilder;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Domain\Repository;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
interface StorageTypeRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?StorageType;
|
||||
|
||||
/**
|
||||
* Tous les types de stockage tries par libelle (alimente le multi-select du
|
||||
* formulaire produit — § 4.2).
|
||||
*
|
||||
* @return list<StorageType>
|
||||
*/
|
||||
public function findAllOrderedByLabel(): array;
|
||||
|
||||
/**
|
||||
* QueryBuilder de la liste des types de stockage (consomme par le
|
||||
* StorageTypeProvider) : tri `label ASC` (defaut spec § 4.2) et filtre
|
||||
* optionnel `?siteId[]=` (RG-6.06) restreignant aux types disponibles sur
|
||||
* AU MOINS UN des sites passes.
|
||||
*
|
||||
* @param list<int> $siteIds
|
||||
*/
|
||||
public function createListQueryBuilder(array $siteIds = []): QueryBuilder;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Catalog\Application\Service\ProductFieldNormalizer;
|
||||
use App\Module\Catalog\Domain\Entity\Product;
|
||||
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Throwable;
|
||||
|
||||
use function in_array;
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture du produit (M6, POST / PATCH). Cf. spec-back M6 § 4.3 /
|
||||
* § 4.4 + RG-6.01 / RG-6.03 / RG-6.07. Jumeau du CategoryProcessor (409 doublon)
|
||||
* et du CarrierProcessor (normalisation serveur).
|
||||
*
|
||||
* Sequence (POST / PATCH) :
|
||||
* 1. Normalisation serveur (RG-6.07) via ProductFieldNormalizer : code trim+UPPER,
|
||||
* name trim. Jouee AVANT l'unicite et la persistance ; la validation
|
||||
* (NotBlank/Length + Callback RG-6.05/6.06) a deja joue cote API Platform sur
|
||||
* la saisie brute.
|
||||
* 2. RG-6.03 : champs conditionnels SALE. Si `states` ne contient pas SALE,
|
||||
* `manufactured` et `containsMolasses` sont forces false serveur (ils ne sont
|
||||
* saisissables que si l'etat contient SALE).
|
||||
* 3. RG-6.01 : unicite GLOBALE du `code` parmi les actifs. Pre-check deterministe
|
||||
* (excluant le produit courant en PATCH) -> 409 ; l'index partiel
|
||||
* uq_product_code_active reste le filet anti-race au flush.
|
||||
* 4. Persistance via le persist_processor Doctrine ORM.
|
||||
*
|
||||
* Mode strict PATCH (RETEX M1) : la security d'operation exige deja
|
||||
* `catalog.products.manage` pour TOUS les champs ecrivables (un seul niveau de
|
||||
* permission au M6 — § 5.2 admin-only). Il n'existe donc aucun champ « hors-permission »
|
||||
* a re-gater finement (contrairement a l'archivage Carrier RG-4.14 ou au split
|
||||
* comptable Client RG-1.28) : le 403 global est porte par la security d'operation,
|
||||
* pas par un guard de champ ici.
|
||||
*
|
||||
* Les RG inter-champs RG-6.05 (categorie de type PRODUIT) et RG-6.06 (types de
|
||||
* stockage disponibles sur les sites choisis) sont portees par des Assert\Callback
|
||||
* + ->atPath() sur l'entite Product (jouees par API Platform AVANT ce processor),
|
||||
* pour que chaque 422 porte un propertyPath consommable par useFormErrors (mapping
|
||||
* inline, pas un toast — convention ERP-101).
|
||||
*
|
||||
* @implements ProcessorInterface<Product, Product>
|
||||
*/
|
||||
final class ProductProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly ProductFieldNormalizer $normalizer,
|
||||
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')]
|
||||
private readonly ProductRepositoryInterface $repository,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof Product) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
// 1. RG-6.07 : normalisation serveur (code trim+UPPER, name trim).
|
||||
$this->normalize($data);
|
||||
|
||||
// 2. RG-6.03 : si l'etat ne contient pas SALE, les champs conditionnels
|
||||
// « Fabrique » / « Contient de la melasse » sont forces false serveur.
|
||||
if (!in_array(Product::STATE_SALE, $data->getStates(), true)) {
|
||||
$data->setManufactured(false);
|
||||
$data->setContainsMolasses(false);
|
||||
}
|
||||
|
||||
// 3. RG-6.01 : unicite GLOBALE du code parmi les actifs (exclut le produit
|
||||
// courant en PATCH). Pre-check explicite -> 409 deterministe.
|
||||
$code = (string) $data->getCode();
|
||||
if ('' !== $code && $this->repository->existsActiveByCode($code, $data->getId())) {
|
||||
throw $this->duplicateCodeConflict($code);
|
||||
}
|
||||
|
||||
// 4. Persistance, avec filet anti-race sur l'index partiel.
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// Insertion concurrente du meme code entre le pre-check et le flush
|
||||
// (collision sur uq_product_code_active — unicite parmi les actifs).
|
||||
throw $this->duplicateCodeConflict($code, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisation serveur du produit (RG-6.07). Les setters ne sont touches que si
|
||||
* une valeur est presente, pour ne jamais ecraser l'existant lors d'un PATCH
|
||||
* partiel. Les casts (string) sont surs : NotBlank a deja rejete le vide en amont.
|
||||
*/
|
||||
private function normalize(Product $data): void
|
||||
{
|
||||
if (null !== $data->getCode()) {
|
||||
$data->setCode((string) $this->normalizer->normalizeCode($data->getCode()));
|
||||
}
|
||||
|
||||
if (null !== $data->getName()) {
|
||||
$data->setName((string) $this->normalizer->normalizeName($data->getName()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.01 : 409 sur doublon de code produit. Le front mappe ce conflit sur le
|
||||
* champ `code` (setError('code', ...) + toast — convention useFormErrors ERP-101
|
||||
* / useCategoryForm RG-1.07) : le propertyPath exploitable est `code`.
|
||||
*/
|
||||
private function duplicateCodeConflict(string $code, ?Throwable $previous = null): ConflictHttpException
|
||||
{
|
||||
return new ConflictHttpException(
|
||||
sprintf('Le code produit « %s » est déjà utilisé par un autre produit.', $code),
|
||||
$previous,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?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\Product;
|
||||
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
use function in_array;
|
||||
use function is_int;
|
||||
use function is_string;
|
||||
|
||||
/**
|
||||
* Provider Product (lecture, ERP-200) :
|
||||
* - LISTE : exclut par defaut les produits soft-deleted (RG-6.09), trie par
|
||||
* name ASC (defaut spec § 4.1), applique les filtres du drawer « Filtrer »
|
||||
* (?search, ?categoryId / ?categoryCode, ?state, ?siteId[]) et renvoie une
|
||||
* collection PAGINEE Hydra (regle ABSOLUE n°13 : jamais d'array brut sur une
|
||||
* operation de collection — on enveloppe le QueryBuilder dans le Paginator ORM).
|
||||
* Echappatoire ?pagination=false respectee (alimentation d'un select).
|
||||
* - ITEM : recharge le produit puis renvoie null (404) s'il est soft-deleted —
|
||||
* le soft-delete n'est jamais expose au M6 (§ 2.7), aucun flag includeDeleted.
|
||||
*
|
||||
* @implements ProviderInterface<Product>
|
||||
*/
|
||||
final class ProductProvider implements ProviderInterface
|
||||
{
|
||||
/** Etats valides du filtre ?state= (enum borne, RG-6.02). */
|
||||
private const array VALID_STATES = [Product::STATE_PURCHASE, Product::STATE_SALE, Product::STATE_OTHER];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')]
|
||||
private readonly ProductRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Product|null
|
||||
{
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
// includeDeleted toujours false : le soft-delete n'est pas expose au M6.
|
||||
$qb = $this->repository->createListQueryBuilder(
|
||||
false,
|
||||
$this->readSearch($context),
|
||||
$this->readCategoryId($context),
|
||||
$this->readCategoryCode($context),
|
||||
$this->readState($context),
|
||||
$this->readSiteIds($context),
|
||||
);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator.
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
// Branche paginee standard : offset/limit via Pagination, enveloppe dans
|
||||
// le Paginator ORM (fetchJoinCollection: true pour compter correctement
|
||||
// malgre les fetch-joins to-many sites/storageTypes du QueryBuilder).
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
|
||||
}
|
||||
|
||||
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$product = $this->repository->findById((int) $id);
|
||||
if (null === $product) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// § 2.7 : un produit soft-deleted n'est jamais expose (404).
|
||||
if (null !== $product->getDeletedAt()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?search=` (recherche partielle code + name). Renvoie la valeur
|
||||
* trimmee ou null si absente / vide.
|
||||
*/
|
||||
private function readSearch(array $context): ?string
|
||||
{
|
||||
$raw = $context['filters']['search'] ?? null;
|
||||
|
||||
if (!is_string($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = trim($raw);
|
||||
|
||||
return '' === $raw ? null : $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?categoryId=` (drawer « Filtrer »). Renvoie l'id entier ou null
|
||||
* si absent / non numerique.
|
||||
*/
|
||||
private function readCategoryId(array $context): ?int
|
||||
{
|
||||
$raw = $context['filters']['categoryId'] ?? null;
|
||||
|
||||
if (is_int($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
return is_string($raw) && ctype_digit($raw) ? (int) $raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?categoryCode=` (drawer « Filtrer »). Renvoie le code trimme ou
|
||||
* null si absent / vide.
|
||||
*/
|
||||
private function readCategoryCode(array $context): ?string
|
||||
{
|
||||
$raw = $context['filters']['categoryCode'] ?? null;
|
||||
|
||||
if (!is_string($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = trim($raw);
|
||||
|
||||
return '' === $raw ? null : $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?state=` (PURCHASE / SALE / OTHER). Normalise en majuscules et
|
||||
* n'accepte qu'une valeur de l'enum borne ; toute autre valeur est ignoree (null).
|
||||
*/
|
||||
private function readState(array $context): ?string
|
||||
{
|
||||
$raw = $context['filters']['state'] ?? null;
|
||||
|
||||
if (!is_string($raw) || '' === trim($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$state = mb_strtoupper(trim($raw), 'UTF-8');
|
||||
|
||||
return in_array($state, self::VALID_STATES, true) ? $state : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?siteId[]=` : ids des sites coches (OR). Tolere une valeur
|
||||
* scalaire unique (`?siteId=1`) ou un tableau. Ignore les entrees non numeriques.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readSiteIds(array $context): array
|
||||
{
|
||||
$raw = $context['filters']['siteId'] ?? null;
|
||||
|
||||
if (null === $raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$ids = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_int($value) || (is_string($value) && ctype_digit($value))) {
|
||||
$ids[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
use function is_int;
|
||||
use function is_string;
|
||||
|
||||
/**
|
||||
* Provider StorageType (referentiel lecture seule, ERP-201) :
|
||||
* - LISTE : tri `label ASC` (defaut spec § 4.2), filtre `?siteId[]=` (RG-6.06 :
|
||||
* types disponibles sur au moins un des sites passes) et collection PAGINEE
|
||||
* Hydra (regle ABSOLUE n°13). Echappatoire `?pagination=false` respectee pour
|
||||
* alimenter le multi-select « Type de stockage » du formulaire produit
|
||||
* (referentiel borne — pagination_client_enabled).
|
||||
* - ITEM : lookup simple par id.
|
||||
*
|
||||
* @implements ProviderInterface<StorageType>
|
||||
*/
|
||||
final class StorageTypeProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageTypeRepository')]
|
||||
private readonly StorageTypeRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|StorageType|null
|
||||
{
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
$qb = $this->repository->createListQueryBuilder($this->readSiteIds($context));
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||
// (alimentation du multi-select, referentiel borne).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
// Pas de fetch-join to-many (sites non serialisee) -> Paginator simple.
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery()));
|
||||
}
|
||||
|
||||
// Get unitaire.
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->repository->findById((int) $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?siteId[]=` : ids des sites coches (OR). Tolere une valeur
|
||||
* scalaire unique (`?siteId=1`) ou un tableau. Ignore les entrees non numeriques.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readSiteIds(array $context): array
|
||||
{
|
||||
$raw = $context['filters']['siteId'] ?? null;
|
||||
|
||||
if (null === $raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$ids = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_int($value) || (is_string($value) && ctype_digit($value))) {
|
||||
$ids[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Product;
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
use function in_array;
|
||||
use function is_int;
|
||||
use function is_string;
|
||||
|
||||
/**
|
||||
* Export XLSX du catalogue produits (M6, spec-back § 4.5). Jumeau des controllers
|
||||
* d'export ClientExportController (M1) / CarrierExportController (M4) — references
|
||||
* en prose volontairement (pas de {@see} inter-module : violerait la regle
|
||||
* ABSOLUE n°1).
|
||||
*
|
||||
* Controller Symfony custom (et non operation API Platform) car il produit un
|
||||
* binaire de fichier, pas une representation Hydra. `priority: 1` est OBLIGATOIRE
|
||||
* sur la route : sans cela API Platform capterait `/api/products/export.xlsx`
|
||||
* comme l'item `GET /api/products/{id}.{_format}` (id="export", _format="xlsx")
|
||||
* — cf. CLAUDE.md « controller custom sous /api ».
|
||||
*
|
||||
* Separation des responsabilites :
|
||||
* - le COMMENT (generation du fichier) est delegue au service Shared
|
||||
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
|
||||
* - le QUOI vit ICI : selection des produits (MEMES filtres que
|
||||
* `GET /api/products` via {@see ProductProvider}, deleguee a
|
||||
* {@see ProductRepositoryInterface::createListQueryBuilder()} — l'export
|
||||
* reflete exactement ce que l'utilisateur voit a l'ecran) et mapping metier
|
||||
* des colonnes. Les produits soft-deleted (RG-6.09) sont toujours exclus, comme
|
||||
* en liste (le M6 n'expose jamais le soft-delete, § 2.7).
|
||||
*/
|
||||
#[AsController]
|
||||
final class ProductExportController
|
||||
{
|
||||
/**
|
||||
* Libelles FR des etats (RG-6.02) pour la colonne « États ». L'ordre des cles
|
||||
* fixe l'ordre d'affichage (Achat, Vendu, Autre) independamment de l'ordre de
|
||||
* stockage en base.
|
||||
*/
|
||||
private const array STATE_LABELS = [
|
||||
Product::STATE_PURCHASE => 'Achat',
|
||||
Product::STATE_SALE => 'Vendu',
|
||||
Product::STATE_OTHER => 'Autre',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')]
|
||||
private readonly ProductRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
) {}
|
||||
|
||||
#[Route('/api/products/export.xlsx', name: 'catalog_products_export_xlsx', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('catalog.products.view')]
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
// Memes filtres que la vue liste (ProductProvider) pour que l'export
|
||||
// reflete exactement ce que l'utilisateur voit a l'ecran : recherche
|
||||
// (?search), categorie (?categoryId / ?categoryCode), etat (?state),
|
||||
// sites (?siteId[]). includeDeleted reste false : le soft-delete n'est
|
||||
// jamais expose au M6 (§ 2.7).
|
||||
$search = $request->query->getString('search') ?: null;
|
||||
$categoryId = $this->readIntOrNull($request->query->get('categoryId'));
|
||||
$categoryCode = $request->query->getString('categoryCode') ?: null;
|
||||
$state = $this->readState($request->query->get('state'));
|
||||
$siteIds = $this->readIntList($request->query->all()['siteId'] ?? []);
|
||||
|
||||
/** @var list<Product> $products */
|
||||
$products = $this->repository
|
||||
->createListQueryBuilder(false, $search, $categoryId, $categoryCode, $state, $siteIds)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
'Catalogue produits',
|
||||
$this->buildHeaders(),
|
||||
$this->buildRows($products),
|
||||
);
|
||||
|
||||
return $this->buildResponse($binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes de l'export (spec § 4.5).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Numéro',
|
||||
'Nom',
|
||||
'États',
|
||||
'Catégorie',
|
||||
'Sites',
|
||||
'Types de stockage',
|
||||
'Fabriqué',
|
||||
'Contient mélasse',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Product> $products
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(array $products): iterable
|
||||
{
|
||||
foreach ($products as $product) {
|
||||
yield [
|
||||
$product->getCode(),
|
||||
$product->getName(),
|
||||
$this->formatStates($product),
|
||||
$product->getCategory()?->getName(),
|
||||
$this->formatSites($product),
|
||||
$this->formatStorageTypes($product),
|
||||
$product->isManufactured() ? 'Oui' : 'Non',
|
||||
$product->isContainsMolasses() ? 'Oui' : 'Non',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelles FR des etats du produit, dans l'ordre canonique (Achat, Vendu,
|
||||
* Autre), joints par virgule. Une valeur inattendue est ignoree.
|
||||
*/
|
||||
private function formatStates(Product $product): string
|
||||
{
|
||||
$states = $product->getStates();
|
||||
|
||||
$labels = [];
|
||||
foreach (self::STATE_LABELS as $code => $label) {
|
||||
if (in_array($code, $states, true)) {
|
||||
$labels[] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
return implode(', ', $labels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelles des sites de disponibilite du produit, dedupliques, tries, joints
|
||||
* par virgule.
|
||||
*/
|
||||
private function formatSites(Product $product): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($product->getSites() as $site) {
|
||||
// @var Site $site
|
||||
$name = $site->getName();
|
||||
if (null !== $name && '' !== $name) {
|
||||
$names[$name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelles des types de stockage du produit, dedupliques, tries, joints par
|
||||
* virgule.
|
||||
*/
|
||||
private function formatStorageTypes(Product $product): string
|
||||
{
|
||||
$labels = [];
|
||||
foreach ($product->getStorageTypes() as $storageType) {
|
||||
// @var StorageType $storageType
|
||||
$label = $storageType->getLabel();
|
||||
if (null !== $label && '' !== $label) {
|
||||
$labels[$label] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($labels);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $names ensemble de libelles (cles)
|
||||
*/
|
||||
private function joinSorted(array $names): string
|
||||
{
|
||||
$list = array_keys($names);
|
||||
sort($list);
|
||||
|
||||
return implode(', ', $list);
|
||||
}
|
||||
|
||||
private function buildResponse(string $binary): Response
|
||||
{
|
||||
$filename = sprintf('catalogue-produits-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
|
||||
|
||||
$response = new Response($binary);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?state=` comme le ProductProvider : normalise en majuscules
|
||||
* et n'accepte qu'une valeur de l'enum borne {PURCHASE, SALE, OTHER} ; toute
|
||||
* autre valeur est ignoree (null).
|
||||
*/
|
||||
private function readState(mixed $raw): ?string
|
||||
{
|
||||
if (!is_string($raw) || '' === trim($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$state = mb_strtoupper(trim($raw), 'UTF-8');
|
||||
|
||||
return in_array($state, array_keys(self::STATE_LABELS), true) ? $state : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un identifiant entier positif unique (`?categoryId=`). Aligne sur
|
||||
* ProductProvider (tolere int ou chaine numerique).
|
||||
*/
|
||||
private function readIntOrNull(mixed $raw): ?int
|
||||
{
|
||||
if (is_int($raw)) {
|
||||
return $raw > 0 ? $raw : null;
|
||||
}
|
||||
|
||||
return is_string($raw) && ctype_digit($raw) && (int) $raw > 0 ? (int) $raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
|
||||
* ou liste, `?siteId[]=`). Aligne sur ProductProvider.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readIntList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
|
||||
$out[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,11 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
* a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte
|
||||
* taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs
|
||||
* (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories
|
||||
* prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport). Chaque
|
||||
* categorie porte un `code` stable.
|
||||
* prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport) ; le type
|
||||
* ADRESSE porte les categories des blocs adresse (Siege, Contact issues,
|
||||
* Facturation, Livraison, Approvisionnement, Methaniseur) ; le type PRODUIT porte
|
||||
* les categories produit du catalogue (M6 ERP-201 : Cereales, Oleagineux, Aliments
|
||||
* du betail, Engrais). Chaque categorie porte un `code` stable.
|
||||
* Alimente le repertoire clients (ClientFixtures, module Commercial) avec des
|
||||
* donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
|
||||
* (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
|
||||
@@ -78,6 +81,23 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
|
||||
'Nettoyage' => 'NETTOYAGE',
|
||||
'Transport' => 'TRANSPORT',
|
||||
],
|
||||
'ADRESSE' => [
|
||||
'Siège' => 'SIEGE',
|
||||
'Contact issues' => 'CONTACT_ISSUES',
|
||||
'Facturation' => 'FACTURATION',
|
||||
'Livraison' => 'LIVRAISON',
|
||||
'Approvisionnement' => 'APPROVISIONNEMENT',
|
||||
'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(
|
||||
|
||||
@@ -25,6 +25,14 @@ use Doctrine\Persistence\ObjectManager;
|
||||
* taxonomie distincte des prestataires (Maintenance industrielle, Nettoyage,
|
||||
* Transport). Mirroir de la migration Version20260612080000.
|
||||
*
|
||||
* ADRESSE : ajout du type ADRESSE (code ADRESSE, label « Adresse »), taxonomie
|
||||
* dediee au champ « Categorie » des blocs adresse (client + fournisseur). Mirroir
|
||||
* 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
|
||||
* 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
|
||||
@@ -41,12 +49,15 @@ class CategoryTypeFixtures extends Fixture
|
||||
/**
|
||||
* Source unique des types : code technique => libelle FR. Doit rester aligne
|
||||
* sur le seed des migrations Version20260602100000 (CLIENT),
|
||||
* Version20260605120000 (FOURNISSEUR) et Version20260612080000 (PRESTATAIRE).
|
||||
* Version20260605120000 (FOURNISSEUR), Version20260612080000 (PRESTATAIRE),
|
||||
* Version20260625100000 (ADRESSE) et Version20260625110000 (PRODUIT, ERP-198).
|
||||
*/
|
||||
private const TYPES = [
|
||||
'CLIENT' => 'Client',
|
||||
'FOURNISSEUR' => 'Fournisseur',
|
||||
'PRESTATAIRE' => 'Prestataire',
|
||||
'ADRESSE' => 'Adresse',
|
||||
'PRODUIT' => 'Produit',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface;
|
||||
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
|
||||
use App\Shared\Domain\Contract\SiteProviderInterface;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
/**
|
||||
* Fixtures du module Catalog : seed du referentiel `storage_type` (M6).
|
||||
*
|
||||
* ⚠ PROVISOIRE (decision Matthieu 24/06, HP-M6-02) : codes, libelles ET mapping
|
||||
* site ci-dessous sont a REVALIDER / RE-SEEDER quand Aurore livrera la liste et le
|
||||
* mapping definitifs par site. La liste actuelle reprend les 10 valeurs de la
|
||||
* maquette Figma (node 1503-34285) et les rattache PAR DEFAUT aux 3 sites
|
||||
* (Chatellerault 86 / Saint-Jean 17 / Pommevic 82), faute de mapping reel.
|
||||
*
|
||||
* Pourquoi une fixture (et pas un seed de migration) : `storage_type` est une
|
||||
* entite managee par l'ORM, donc le purger Doctrine la vide avant chaque
|
||||
* `doctrine:fixtures:load`. Cette fixture re-aligne dev ET test (le referentiel
|
||||
* doit exister pour alimenter le formulaire produit et les tests du filtre
|
||||
* ?siteId[]= — ERP-203). Elle tourne dans TOUS les environnements (referentiel,
|
||||
* pas une donnee de demo — miroir CategoryTypeFixtures).
|
||||
*
|
||||
* Idempotence : lookup par `code` parmi les types existants avant insertion
|
||||
* (miroir CategoryTypeFixtures). `addSite()` est lui-meme idempotent (contains()).
|
||||
* Rejouable sans doublon meme si le purger Doctrine est desactive.
|
||||
*
|
||||
* Depend de SitesFixtures : les 3 sites doivent etre seedes avant qu'on puisse y
|
||||
* rattacher les types de stockage. Les sites sont resolus via le contrat Shared
|
||||
* SiteProviderInterface (pas d'import du module Sites — regle ABSOLUE n°1).
|
||||
*/
|
||||
class StorageTypeFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
/**
|
||||
* Seed PROVISOIRE (Figma node 1503-34285) : code MAJUSCULE stable => libelle FR.
|
||||
* A re-seeder a reception de la liste Aurore (HP-M6-02).
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const TYPES = [
|
||||
'BOISSEAU' => 'Boisseau',
|
||||
'BOISSEAU_DOSAGE' => 'Boisseau dosage',
|
||||
'CASE' => 'Case',
|
||||
'CELLULE' => 'Cellule',
|
||||
'CONTAINER' => 'Container',
|
||||
'CUVE_MELASSE' => 'Cuve mélasse',
|
||||
'STOCKAGE_BIG_BAG' => 'Stockage big bag',
|
||||
'STOCKAGE_PALETTE' => 'Stockage palette',
|
||||
'TAS' => 'Tas',
|
||||
'ZONE' => 'Zone',
|
||||
];
|
||||
|
||||
/**
|
||||
* Noms des 3 sites de rattachement PROVISOIRE (86 / 17 / 82). Le nom est la cle
|
||||
* de lookup stable cote SitesFixtures.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const DEFAULT_SITE_NAMES = ['Chatellerault', 'Saint-Jean', 'Pommevic'];
|
||||
|
||||
public function __construct(
|
||||
private readonly StorageTypeRepositoryInterface $storageTypeRepository,
|
||||
private readonly SiteProviderInterface $siteProvider,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, class-string>
|
||||
*/
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return [SitesFixtures::class];
|
||||
}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Index des types deja presents par code, pour ne pas creer de doublon.
|
||||
$existingByCode = [];
|
||||
foreach ($this->storageTypeRepository->findAllOrderedByLabel() as $type) {
|
||||
$existingByCode[$type->getCode()] = $type;
|
||||
}
|
||||
|
||||
// Resolution des 3 sites par defaut via le contrat Shared (rattachement
|
||||
// provisoire). Les objets resolus sont des Site managees (resolve_target_entities
|
||||
// SiteInterface -> Site) : addSite() les accepte.
|
||||
$defaultSites = [];
|
||||
foreach (self::DEFAULT_SITE_NAMES as $name) {
|
||||
$site = $this->siteProvider->findByName($name);
|
||||
if (null !== $site) {
|
||||
$defaultSites[] = $site;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (self::TYPES as $code => $label) {
|
||||
$storageType = $existingByCode[$code] ?? new StorageType();
|
||||
$storageType->setCode($code);
|
||||
$storageType->setLabel($label);
|
||||
|
||||
// Rattachement provisoire aux 3 sites (idempotent : addSite -> contains()).
|
||||
foreach ($defaultSites as $site) {
|
||||
$storageType->addSite($site);
|
||||
}
|
||||
|
||||
$manager->persist($storageType);
|
||||
}
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Product;
|
||||
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Product>
|
||||
*/
|
||||
class DoctrineProductRepository extends ServiceEntityRepository implements ProductRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Product::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Product
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(Product $product): void
|
||||
{
|
||||
$this->getEntityManager()->persist($product);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function existsActiveByCode(string $code, ?int $excludeId = null): bool
|
||||
{
|
||||
$qb = $this->createQueryBuilder('p')
|
||||
->select('1')
|
||||
->andWhere('p.code = :code')
|
||||
->andWhere('p.deletedAt IS NULL')
|
||||
->setParameter('code', $code)
|
||||
->setMaxResults(1)
|
||||
;
|
||||
|
||||
if (null !== $excludeId) {
|
||||
$qb->andWhere('p.id != :excludeId')->setParameter('excludeId', $excludeId);
|
||||
}
|
||||
|
||||
return [] !== $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function createListQueryBuilder(
|
||||
bool $includeDeleted = false,
|
||||
?string $search = null,
|
||||
?int $categoryId = null,
|
||||
?string $categoryCode = null,
|
||||
?string $state = null,
|
||||
array $siteIds = [],
|
||||
): QueryBuilder {
|
||||
// Eager-load des relations embarquees en liste (product:read) pour eviter
|
||||
// un N+1 par produit : category (ManyToOne, sur), sites et storageTypes
|
||||
// (ManyToMany BORNES — embed autorise, ne viole pas la regle n°13). Le
|
||||
// provider enveloppe la requete dans un Paginator(fetchJoinCollection: true),
|
||||
// compatible avec ces fetch-joins to-many (comptage par sous-requete d'ids).
|
||||
$qb = $this->createQueryBuilder('p')
|
||||
->leftJoin('p.category', 'cat')->addSelect('cat')
|
||||
->leftJoin('p.sites', 's')->addSelect('s')
|
||||
->leftJoin('p.storageTypes', 'stp')->addSelect('stp')
|
||||
->orderBy('p.name', 'ASC')
|
||||
;
|
||||
|
||||
// RG-6.09 : la liste exclut par defaut les produits soft-deleted.
|
||||
if (!$includeDeleted) {
|
||||
$qb->andWhere('p.deletedAt IS NULL');
|
||||
}
|
||||
|
||||
// ?search= : recherche partielle case-insensitive sur code + name. Les
|
||||
// metacaracteres LIKE (%, _, \) sont echappes pour rester litteraux. Les
|
||||
// deux LIKE sont parenthese pour ne pas casser la precedence AND/OR avec
|
||||
// les autres filtres (AND lie plus fort que OR en DQL).
|
||||
if (null !== $search && '' !== trim($search)) {
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||
$qb->andWhere('(LOWER(p.code) LIKE :search OR LOWER(p.name) LIKE :search)')
|
||||
->setParameter('search', $pattern)
|
||||
;
|
||||
}
|
||||
|
||||
// ?categoryId= : filtre par categorie precise (id).
|
||||
if (null !== $categoryId) {
|
||||
$qb->andWhere('cat.id = :categoryId')->setParameter('categoryId', $categoryId);
|
||||
}
|
||||
|
||||
// ?categoryCode= : filtre par categorie precise (code stable).
|
||||
if (null !== $categoryCode && '' !== trim($categoryCode)) {
|
||||
$qb->andWhere('cat.code = :categoryCode')->setParameter('categoryCode', trim($categoryCode));
|
||||
}
|
||||
|
||||
// ?state= : appartenance a la colonne JSONB `states`. DQL ne sait pas
|
||||
// exprimer la containment jsonb -> on resout les ids matchant en SQL natif
|
||||
// (operateur @>), puis on contraint le QueryBuilder. Ids vides -> condition
|
||||
// toujours fausse (aucun produit), sans casser le reste de la requete.
|
||||
if (null !== $state) {
|
||||
$stateIds = $this->matchingStateIds($state);
|
||||
if ([] === $stateIds) {
|
||||
$qb->andWhere('1 = 0');
|
||||
} else {
|
||||
$qb->andWhere('p.id IN (:stateIds)')->setParameter('stateIds', $stateIds);
|
||||
}
|
||||
}
|
||||
|
||||
// ?siteId[]= : produit disponible sur AU MOINS UN des sites passes (OR).
|
||||
// Sous-requete EXISTS correlee pour ne PAS restreindre la collection sites
|
||||
// eager-loadee `s` (sinon les autres sites du produit disparaitraient du
|
||||
// JSON) et eviter les lignes dupliquees (cf. DoctrineCategoryRepository).
|
||||
if ([] !== $siteIds) {
|
||||
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('1')
|
||||
->from(Product::class, 'p_si')
|
||||
->join('p_si.sites', 's_si')
|
||||
->where('p_si = p')
|
||||
->andWhere('s_si.id IN (:siteIds)')
|
||||
;
|
||||
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
|
||||
->setParameter('siteIds', $siteIds)
|
||||
;
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids des produits dont la colonne JSONB `states` contient l'etat donne, via
|
||||
* l'operateur de containment Postgres `@>`. L'etat est borne a l'enum
|
||||
* {PURCHASE, SALE, OTHER} en amont (ProductProvider) — pas de saisie libre ici.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function matchingStateIds(string $state): array
|
||||
{
|
||||
$rows = $this->getEntityManager()->getConnection()
|
||||
->executeQuery(
|
||||
'SELECT id FROM product WHERE states @> CAST(:state AS JSONB)',
|
||||
['state' => (string) json_encode([$state])],
|
||||
)
|
||||
->fetchFirstColumn()
|
||||
;
|
||||
|
||||
return array_map(static fn (mixed $id): int => (int) $id, $rows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<StorageType>
|
||||
*/
|
||||
class DoctrineStorageTypeRepository extends ServiceEntityRepository implements StorageTypeRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, StorageType::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?StorageType
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<StorageType>
|
||||
*/
|
||||
public function findAllOrderedByLabel(): array
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
* - sites : SiteInterface (module Sites) via resolve_target_entities
|
||||
* - contacts : ClientContact (meme module)
|
||||
* - categories : CategoryInterface (module Catalog) via resolve_target_entities
|
||||
* — codes DISTRIBUTEUR/COURTIER interdits (RG-1.29, validateCategoryCodes, ERP-78)
|
||||
* — type ADRESSE attendu (validateCategoryType)
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable/Blamable.
|
||||
*
|
||||
@@ -96,11 +96,11 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/**
|
||||
* RG-1.29 (ERP-78) : ces codes de categorie decrivent une relation entre
|
||||
* clients (distributeur / courtier) et n'ont pas de sens sur une adresse.
|
||||
* Toute autre categorie du type CLIENT est autorisee.
|
||||
* Seules les categories PORTANT ce type sont autorisees sur une adresse client.
|
||||
* S'appuie sur CategoryInterface::getCategoryTypeCodes() (multi-type — pas
|
||||
* d'import du module Catalog, regle ABSOLUE n°1).
|
||||
*/
|
||||
private const array FORBIDDEN_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER'];
|
||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'ADRESSE';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
@@ -215,7 +215,7 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
|
||||
private Collection $contacts;
|
||||
|
||||
// Au moins une categorie est obligatoire sur une adresse (spec-front § Adresse).
|
||||
// RG-1.29 : categories de code DISTRIBUTEUR/COURTIER interdites (validateCategoryCodes).
|
||||
// Categories de type ADRESSE uniquement (validateCategoryType).
|
||||
/** @var Collection<int, CategoryInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||
#[ORM\JoinTable(name: 'client_address_category')]
|
||||
@@ -335,20 +335,19 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.29 (ERP-78) : une adresse interdit les categories de code
|
||||
* DISTRIBUTEUR / COURTIER — elles decrivent une relation entre clients
|
||||
* (RG-1.03) et n'ont pas de sens sur une adresse physique -> 422 avec
|
||||
* violation sur le champ `categories`. Toute autre categorie (type unique
|
||||
* CLIENT) est acceptee. S'appuie sur CategoryInterface::getCode() (pas
|
||||
* d'import du module Catalog — regle ABSOLUE n°1).
|
||||
* Toute categorie posee sur une adresse client doit etre de type ADRESSE ->
|
||||
* sinon 422 avec violation sur le champ `categories`. S'appuie sur
|
||||
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
||||
* acceptee des qu'elle PORTE le type ADRESSE ; pas d'import du module Catalog,
|
||||
* regle ABSOLUE n°1).
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCategoryCodes(ExecutionContextInterface $context): void
|
||||
public function validateCategoryType(ExecutionContextInterface $context): void
|
||||
{
|
||||
foreach ($this->categories as $category) {
|
||||
if ($category instanceof CategoryInterface
|
||||
&& in_array($category->getCode(), self::FORBIDDEN_CATEGORY_CODES, true)) {
|
||||
$context->buildViolation('Type de catégorie non autorisé sur une adresse.')
|
||||
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
|
||||
$context->buildViolation('Type de catégorie non autorisé (ADRESSE attendu).')
|
||||
->atPath('categories')
|
||||
->addViolation()
|
||||
;
|
||||
|
||||
@@ -40,7 +40,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
* un site obligatoire (RG-2.06, Assert\Count). Site n'a pas de `code`.
|
||||
* - contacts : SupplierContact (meme module).
|
||||
* - categories : CategoryInterface (module Catalog) via resolve_target_entities —
|
||||
* type FOURNISSEUR attendu (RG-2.10, Assert\Callback validateCategoryType).
|
||||
* type ADRESSE attendu (Assert\Callback validateCategoryType).
|
||||
*
|
||||
* Embarquee sous `supplier.addresses` au detail (groupe supplier:item:read,
|
||||
* maillon (a)).
|
||||
@@ -110,11 +110,11 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
|
||||
public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU'];
|
||||
|
||||
/**
|
||||
* RG-2.10 : seules les categories PORTANT ce type sont autorisees sur une
|
||||
* adresse fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes()
|
||||
* (pas d'import du module Catalog — regle ABSOLUE n°1).
|
||||
* Seules les categories PORTANT ce type sont autorisees sur une adresse
|
||||
* fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes() (pas
|
||||
* d'import du module Catalog — regle ABSOLUE n°1).
|
||||
*/
|
||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
|
||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'ADRESSE';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
@@ -208,8 +208,8 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
private Collection $contacts;
|
||||
|
||||
// RG-2.10 : au moins une categorie de type FOURNISSEUR par adresse (le type est
|
||||
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites).
|
||||
// Au moins une categorie de type ADRESSE par adresse (le type est controle par
|
||||
// validateCategoryType ; le minimum par Assert\Count, miroir sites).
|
||||
/** @var Collection<int, CategoryInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||
#[ORM\JoinTable(name: 'supplier_address_category')]
|
||||
@@ -227,12 +227,12 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-2.10 : toute categorie posee sur une adresse fournisseur doit etre de
|
||||
* type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
|
||||
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
|
||||
* Toute categorie posee sur une adresse fournisseur doit etre de type ADRESSE
|
||||
* -> sinon 422 avec violation sur le champ `categories` (propertyPath aligne
|
||||
* ERP-101, message FR ERP-107). S'appuie sur
|
||||
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
||||
* acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module
|
||||
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
|
||||
* acceptee des qu'elle PORTE le type ADRESSE ; pas d'import du module Catalog,
|
||||
* regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCategoryType(ExecutionContextInterface $context): void
|
||||
@@ -240,7 +240,7 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
|
||||
foreach ($this->categories as $category) {
|
||||
if ($category instanceof CategoryInterface
|
||||
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
|
||||
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
|
||||
$context->buildViolation('Type de catégorie non autorisé (ADRESSE attendu).')
|
||||
->atPath('categories')
|
||||
->addViolation()
|
||||
;
|
||||
|
||||
@@ -55,8 +55,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
|
||||
* restent null (« Systeme » cote front), c'est attendu. Les donnees respectent
|
||||
* les CHECK BDD ET les validators applicatifs (exclusivite Prospect, billingEmail
|
||||
* ssi facturation, aucune categorie de code DISTRIBUTEUR/COURTIER sur une adresse
|
||||
* — RG-1.29, ERP-78).
|
||||
* ssi facturation, categories de type ADRESSE sur les adresses).
|
||||
*
|
||||
* Depend de CategoryFixtures (categories), SitesFixtures (sites) et
|
||||
* CommercialReferentialFixtures (referentiels comptables Bank / PaymentType).
|
||||
@@ -116,7 +115,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
);
|
||||
if ($gsoIsNew) {
|
||||
$this->addContact($gso, 'Paul', 'Garnier', 'Directeur commercial', '05 56 10 20 30', null, 'paul.garnier@distrib-gso.fr');
|
||||
$this->addAddress($gso, ['Pommevic'], '82400', 'Pommevic', '1 Av. Jean Duquesne', isDelivery: true, categoryNames: ['Transport/Logistique']);
|
||||
$this->addAddress($gso, ['Pommevic'], '82400', 'Pommevic', '1 Av. Jean Duquesne', isDelivery: true, categoryNames: ['Livraison']);
|
||||
}
|
||||
|
||||
// Courtier reference par d'autres clients.
|
||||
@@ -140,7 +139,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
$dubois->setPaymentType($this->paymentType($manager, 'VIREMENT'));
|
||||
$dubois->setBank($this->bank($manager, 'SG'));
|
||||
$this->addContact($dubois, 'Jean', 'Dubois', 'Gérant', '05 49 00 00 01', null, 'jean.dubois@menuiserie-dubois.fr');
|
||||
$this->addAddress($dubois, ['Chatellerault'], '86100', 'Châtellerault', '12 rue de l\'Atelier', isDelivery: true, categoryNames: ['BTP']);
|
||||
$this->addAddress($dubois, ['Chatellerault'], '86100', 'Châtellerault', '12 rue de l\'Atelier', isDelivery: true, categoryNames: ['Livraison']);
|
||||
}
|
||||
|
||||
// === Dependant d'un distributeur (RG-1.03) ===
|
||||
@@ -176,7 +175,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
if ($isNew) {
|
||||
$transports->setPaymentType($this->paymentType($manager, 'LCR'));
|
||||
$this->addContact($transports, null, 'Bernard', 'Responsable exploitation', '05 56 12 13 14', null, 'expl@transports-rapides.fr');
|
||||
$this->addAddress($transports, ['Saint-Jean'], '17400', 'Fontenet', '2 zone industrielle', isDelivery: true, categoryNames: ['Transport/Logistique']);
|
||||
$this->addAddress($transports, ['Saint-Jean'], '17400', 'Fontenet', '2 zone industrielle', isDelivery: true, categoryNames: ['Approvisionnement']);
|
||||
$this->addRib($transports, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
|
||||
$this->addRib($transports, 'Compte secondaire', 'SOGEFRPPXXX', 'FR7630006000011234567890189', 1);
|
||||
}
|
||||
@@ -192,9 +191,9 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
// Prospect : exclusif de livraison/facturation (sans billingEmail).
|
||||
$this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '1 avenue de la Prospection', isProspect: true, position: 0);
|
||||
// Livraison.
|
||||
$this->addAddress($industries, ['Saint-Jean'], '17400', 'Fontenet', '4 rue de la Livraison', isDelivery: true, categoryNames: ['Industrie'], position: 1);
|
||||
$this->addAddress($industries, ['Saint-Jean'], '17400', 'Fontenet', '4 rue de la Livraison', isDelivery: true, categoryNames: ['Livraison'], position: 1);
|
||||
// Facturation : billingEmail obligatoire.
|
||||
$this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '7 boulevard des Factures', isBilling: true, billingEmail: 'Compta@Industries-Vertes.FR', position: 2);
|
||||
$this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '7 boulevard des Factures', isBilling: true, billingEmail: 'Compta@Industries-Vertes.FR', categoryNames: ['Facturation'], position: 2);
|
||||
}
|
||||
|
||||
// === 3 contacts dont un avec telephone secondaire (RG-1.05/1.02) ===
|
||||
@@ -249,7 +248,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
$holding->setDirectorName('Antoine Lefèvre');
|
||||
$holding->setProfitAmount('1250000.00');
|
||||
$this->addContact($holding, 'Antoine', 'Lefèvre', 'PDG', '05 56 51 52 53', null, 'antoine.lefevre@holding-premium.fr');
|
||||
$this->addAddress($holding, ['Pommevic'], '82400', 'Pommevic', '1 allée des Investisseurs', isDelivery: true, categoryNames: ['Industrie']);
|
||||
$this->addAddress($holding, ['Pommevic'], '82400', 'Pommevic', '1 allée des Investisseurs', isDelivery: true, categoryNames: ['Siège']);
|
||||
}
|
||||
|
||||
// === Multi-categories M2M ===
|
||||
@@ -260,7 +259,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
);
|
||||
if ($isNew) {
|
||||
$this->addContact($conglo, 'Hélène', 'Faure', 'Directrice générale', '05 49 61 62 63', null, 'helene.faure@conglomerat-multi.fr');
|
||||
$this->addAddress($conglo, ['Chatellerault', 'Saint-Jean'], '86100', 'Châtellerault', '20 rue des Activités', isDelivery: true, categoryNames: ['BTP', 'Services']);
|
||||
$this->addAddress($conglo, ['Chatellerault', 'Saint-Jean'], '86100', 'Châtellerault', '20 rue des Activités', isDelivery: true, categoryNames: ['Livraison', 'Approvisionnement']);
|
||||
}
|
||||
|
||||
// === Prospect seul ===
|
||||
@@ -282,7 +281,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
);
|
||||
if ($isNew) {
|
||||
$this->addContact($association, null, 'Caron', 'Président', '05 49 81 82 83', null, 'president@asso-riverains.fr');
|
||||
$this->addAddress($association, ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village', isDelivery: true, categoryNames: ['Association']);
|
||||
$this->addAddress($association, ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village', isDelivery: true, categoryNames: ['Contact issues']);
|
||||
}
|
||||
|
||||
$manager->flush();
|
||||
@@ -359,10 +358,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
/**
|
||||
* Ajoute une adresse au client (cascade persist via Client.addresses). Les
|
||||
* donnees respectent les validators : exclusivite Prospect, billingEmail ssi
|
||||
* facturation, aucune categorie de code DISTRIBUTEUR/COURTIER (RG-1.29).
|
||||
* facturation, categories de type ADRESSE uniquement.
|
||||
*
|
||||
* @param list<string> $siteNames au moins un site (RG-1.10)
|
||||
* @param list<string> $categoryNames categories hors DISTRIBUTEUR/COURTIER (RG-1.29)
|
||||
* @param list<string> $categoryNames categories de type ADRESSE (Siege, Livraison...)
|
||||
*/
|
||||
private function addAddress(
|
||||
Client $client,
|
||||
|
||||
@@ -186,6 +186,10 @@ final class SeedE2ECommand extends Command
|
||||
'sites.bypass_scope',
|
||||
'catalog.categories.view',
|
||||
'catalog.categories.manage',
|
||||
// Catalogue produit (M6, ERP-197). Admin-only (matrice docx
|
||||
// p.3) : mappe sur le persona "tout". Miroir de personas.ts.
|
||||
'catalog.products.view',
|
||||
'catalog.products.manage',
|
||||
// Commercial — Repertoire clients (M1). Mappe ici sur le
|
||||
// persona "tout" en attendant les vrais roles metier
|
||||
// (bureau/compta/commerciale/usine) seedes par ERP-74.
|
||||
|
||||
@@ -28,9 +28,7 @@ interface CategoryInterface
|
||||
* entre environnements) ni importer la classe concrete Category (regle
|
||||
* ABSOLUE n°1). Pilote, cote M1 Commercial :
|
||||
* - RG-1.03 : un distributor doit referencer un client portant la categorie
|
||||
* de code DISTRIBUTEUR (resp. COURTIER pour broker) ;
|
||||
* - RG-1.29 : une adresse interdit les categories de code DISTRIBUTEUR /
|
||||
* COURTIER (relations entre clients, pas des attributs d'adresse).
|
||||
* de code DISTRIBUTEUR (resp. COURTIER pour broker).
|
||||
*/
|
||||
public function getCode(): ?string;
|
||||
|
||||
@@ -38,9 +36,10 @@ interface CategoryInterface
|
||||
* Codes des types de categorie rattaches (CategoryType::code), tableau vide
|
||||
* si aucun. Depuis le passage en ManyToMany, une categorie peut porter
|
||||
* plusieurs types : un module tiers teste l'appartenance via
|
||||
* `in_array($code, $category->getCategoryTypeCodes(), true)`. Pilote, cote
|
||||
* M2 Commercial, la RG-2.10 (une categorie de fournisseur doit etre de type
|
||||
* FOURNISSEUR).
|
||||
* `in_array($code, $category->getCategoryTypeCodes(), true)`. Pilote la
|
||||
* RG-2.10 (une categorie de fournisseur doit etre de type FOURNISSEUR) et la
|
||||
* validation des blocs adresse (categories de type ADRESSE uniquement, client
|
||||
* comme fournisseur).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
|
||||
@@ -271,9 +271,9 @@ final class ColumnCommentsCatalog
|
||||
],
|
||||
|
||||
'client_address_category' => [
|
||||
'_table' => 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).',
|
||||
'_table' => 'Jointure M2M client_address <-> category — categories d adresse de type ADRESSE uniquement.',
|
||||
'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.',
|
||||
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).',
|
||||
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type ADRESSE.',
|
||||
],
|
||||
|
||||
'client_rib' => [
|
||||
@@ -360,9 +360,9 @@ final class ColumnCommentsCatalog
|
||||
],
|
||||
|
||||
'supplier_address_category' => [
|
||||
'_table' => 'Jointure M2M supplier_address <-> category — categories d adresse de type FOURNISSEUR (RG-2.10).',
|
||||
'_table' => 'Jointure M2M supplier_address <-> category — categories d adresse de type ADRESSE.',
|
||||
'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.',
|
||||
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type FOURNISSEUR (RG-2.10).',
|
||||
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type ADRESSE.',
|
||||
],
|
||||
|
||||
'supplier_rib' => [
|
||||
@@ -575,6 +575,47 @@ final class ColumnCommentsCatalog
|
||||
'status' => 'Cycle de vie (ERP-193) : DRAFT (« En attente », pesee enregistree sans contrepartie/immat) ou VALIDATED (« Terminée », valide avec numero). chk_wt_status. Defaut DRAFT.',
|
||||
'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
// M6 Catalog (ERP-199) — tables desormais mappees par les entites
|
||||
// Product / StorageType : schema:update (test) les recree sans COMMENT
|
||||
// -> app:apply-column-comments les rejoue depuis ce catalogue. Strings
|
||||
// identiques aux COMMENT de la migration Version20260625110000 (ERP-198).
|
||||
'storage_type' => [
|
||||
'_table' => 'Referentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Cellule, Tas, Cuve melasse… (RG-6.06). Lecture seule au M6.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).',
|
||||
'label' => 'Libelle FR affiche du type de stockage (ex. « Cuve melasse »).',
|
||||
],
|
||||
|
||||
'storage_type_site' => [
|
||||
'_table' => 'Jointure M2M storage_type <-> site (Sites) — sites sur lesquels un type de stockage est disponible (alimente le filtrage du multi-select par site, RG-6.06).',
|
||||
'storage_type_id' => 'FK -> storage_type.id, ON DELETE CASCADE — type de stockage disponible.',
|
||||
'site_id' => 'FK -> site.id, ON DELETE CASCADE — site ou le type de stockage est disponible.',
|
||||
],
|
||||
|
||||
'product' => [
|
||||
'_table' => 'Produits du catalogue (M6 Catalog) — etat Achat/Vendu/Autre, sites de disponibilite, categorie produit, types de stockage.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code produit (= « Numero » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active. Normalise serveur (trim/UPPER).',
|
||||
'name' => 'Nom du produit (≤ 255). Normalise serveur (trim).',
|
||||
'states' => 'Etats du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02, chk_product_states_not_empty). Pilote les champs conditionnels.',
|
||||
'manufactured' => '« Fabrique » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).',
|
||||
'contains_molasses' => '« Contient de la melasse » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).',
|
||||
'category_id' => 'Categorie produit (FK -> category.id, ON DELETE RESTRICT) — type PRODUIT, obligatoire, validee applicativement (RG-6.05).',
|
||||
'deleted_at' => 'Horodatage du soft-delete technique — non expose au M6 ; la liste exclut les produits supprimes (§ 2.7). Null = ligne active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'product_site' => [
|
||||
'_table' => 'Jointure M2M product <-> site (Sites) — sites de disponibilite du produit (>= 1 obligatoire, RG-6.04).',
|
||||
'product_id' => 'FK -> product.id, ON DELETE CASCADE — produit concerne.',
|
||||
'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site de disponibilite rattache au produit.',
|
||||
],
|
||||
|
||||
'product_storage_type' => [
|
||||
'_table' => 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, filtres par les sites selectionnes, RG-6.06).',
|
||||
'product_id' => 'FK -> product.id, ON DELETE CASCADE — produit concerne.',
|
||||
'storage_type_id' => 'FK -> storage_type.id, ON DELETE RESTRICT — type de stockage rattache au produit.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\Country;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||
@@ -55,6 +56,10 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||
* - CategoryType : referentiel statique (codes de typage des categories),
|
||||
* pas de besoin de tracabilite user-driven (cree par migration/seed,
|
||||
* pas pilote utilisateur au M0). Cf. spec-back § 2.8.bis + RG-1.17.
|
||||
* - StorageType (M6, ERP-199) : referentiel PROVISOIRE des types de stockage
|
||||
* (en attente liste Aurore — HP-M6-02), cree par migration + seede (ERP-201),
|
||||
* lecture seule au M6. Pas de tracabilite user-driven, meme justification que
|
||||
* CategoryType. Cf. spec-back M6 § 2.4 + § 2.6.
|
||||
* - TvaMode / PaymentDelay / PaymentType / Bank (M1 Commercial) : referentiels
|
||||
* comptables statiques (id/code/label/position), seedes par migration +
|
||||
* CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de
|
||||
@@ -75,6 +80,7 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||
Permission::class,
|
||||
Site::class,
|
||||
CategoryType::class,
|
||||
StorageType::class,
|
||||
TvaMode::class,
|
||||
PaymentDelay::class,
|
||||
PaymentType::class,
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Catalog\Domain\Entity\Product;
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Classe de base des tests fonctionnels de l'entite Product (M6, module Catalog).
|
||||
*
|
||||
* Etend la base Catalog (factories Category / CategoryType + helpers d'auth) et
|
||||
* ajoute ce qu'il faut pour exercer l'API produit de bout en bout :
|
||||
* - `productType()` : recupere (ou cree) le CategoryType `PRODUIT`. Necessaire
|
||||
* car le cleanup parent purge TOUS les category_type entre deux tests Catalog,
|
||||
* donc le type seede par la migration M6 disparait : on le re-materialise a la
|
||||
* volee pour que les POST passent RG-6.05.
|
||||
* - `productCategory()` / `nonProductCategory()` : categories de test rattachees
|
||||
* (ou non) au type PRODUIT.
|
||||
* - `seedStorageType()` : type de stockage de test (prefixe code pour cleanup),
|
||||
* rattachable a des sites precis (RG-6.06).
|
||||
* - `siteByCode()` / `firstSite()` : sites fixtures (86 / 17 / 82).
|
||||
* - `authView()` : user non-admin portant la permission `catalog.products.view`.
|
||||
* - `validProductPayload()` : payload POST de reference (IRIs category/sites/
|
||||
* storageTypes), surchargeable par cle.
|
||||
* - `iri()` / `memberById()` : utilitaires Hydra.
|
||||
*
|
||||
* Cleanup : on purge les produits (toute la table — aucune fixture produit en
|
||||
* env test) AVANT le parent, car product reference category / site / storage_type
|
||||
* en FK ON DELETE RESTRICT (le parent supprime ensuite les categories/types). Les
|
||||
* types de stockage de test (prefixe code) sont purges dans la foulee.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class AbstractProductApiTestCase extends AbstractCatalogApiTestCase
|
||||
{
|
||||
protected const string LD = 'application/ld+json';
|
||||
protected const string MERGE = 'application/merge-patch+json';
|
||||
|
||||
/** Code du type de categorie produit, seede par la migration M6 (§ 2.5). */
|
||||
protected const string PRODUCT_TYPE_CODE = 'PRODUIT';
|
||||
|
||||
/** Prefixe des codes de StorageType seedes par ces tests (purge ciblee). */
|
||||
protected const string TEST_STORAGE_TYPE_PREFIX = 'TESTPRD';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
// Produits d'abord : ils referencent category / site / storage_type en FK
|
||||
// RESTRICT, donc le parent ne pourrait pas purger les categories tant
|
||||
// qu'un produit les pointe. Les jonctions product_site /
|
||||
// product_storage_type cascadent au niveau base (ON DELETE CASCADE).
|
||||
$em->createQuery('DELETE FROM '.Product::class)->execute();
|
||||
|
||||
// Types de stockage de test (prefixe code) — libere storage_type_site.
|
||||
$em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix')
|
||||
->setParameter('prefix', self::TEST_STORAGE_TYPE_PREFIX.'%')
|
||||
->execute()
|
||||
;
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere le CategoryType `PRODUIT` (find-or-create). Le cleanup parent
|
||||
* purge tous les category_type entre tests : on le recree au besoin pour que
|
||||
* les POST produit satisfassent RG-6.05.
|
||||
*/
|
||||
protected function productType(): CategoryType
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => self::PRODUCT_TYPE_CODE]);
|
||||
if ($existing instanceof CategoryType) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$type = new CategoryType();
|
||||
$type->setCode(self::PRODUCT_TYPE_CODE);
|
||||
$type->setLabel('Produit');
|
||||
$em->persist($type);
|
||||
$em->flush();
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorie de test rattachee au type PRODUIT (satisfait RG-6.05).
|
||||
*/
|
||||
protected function productCategory(?string $name = null): Category
|
||||
{
|
||||
// Nom laisse a null par defaut -> createCategory genere un nom aleatoire
|
||||
// unique (uq_category_name_active impose LOWER(name) unique parmi les
|
||||
// actives : deux categories de meme nom dans un test collisionneraient).
|
||||
return $this->createCategory($name, $this->productType());
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorie de test rattachee a un type NON-PRODUIT (viole RG-6.05).
|
||||
*/
|
||||
protected function nonProductCategory(): Category
|
||||
{
|
||||
return $this->createCategory(null, $this->createCategoryType());
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un type de stockage de test (code prefixe TESTPRD pour le cleanup),
|
||||
* rattache aux sites passes (disponibilite — RG-6.06).
|
||||
*/
|
||||
protected function seedStorageType(string $label = 'Tas de test', Site ...$sites): StorageType
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
$storageType = new StorageType();
|
||||
$storageType->setCode($this->uniqueCode(self::TEST_STORAGE_TYPE_PREFIX));
|
||||
$storageType->setLabel($label);
|
||||
foreach ($sites as $site) {
|
||||
$storageType->addSite($em->getReference(Site::class, (int) $site->getId()));
|
||||
}
|
||||
|
||||
$em->persist($storageType);
|
||||
$em->flush();
|
||||
|
||||
return $storageType;
|
||||
}
|
||||
|
||||
protected function siteByCode(string $code): Site
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy(['code' => $code]);
|
||||
self::assertInstanceOf(Site::class, $site, sprintf('Le site de code "%s" doit etre seede.', $code));
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
protected function firstSite(): Site
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
|
||||
self::assertInstanceOf(Site::class, $site, 'Un site fixture est requis (SitesFixtures).');
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client non-admin portant seulement `catalog.products.view`.
|
||||
*/
|
||||
protected function authView(): Client
|
||||
{
|
||||
$creds = $this->createUserWithPermission('catalog.products.view');
|
||||
|
||||
return $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload POST de reference : un produit valide (categorie PRODUIT, 1 site,
|
||||
* 1 type de stockage disponible sur ce site). Surchargeable par cle via
|
||||
* $overrides (ex: ['states' => ['SALE'], 'code' => 'X']).
|
||||
*
|
||||
* @param array<string, mixed> $overrides
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function validProductPayload(array $overrides = []): array
|
||||
{
|
||||
$site = $this->firstSite();
|
||||
$storageType = $this->seedStorageType('Tas test', $site);
|
||||
$category = $this->productCategory();
|
||||
|
||||
$base = [
|
||||
'code' => $this->uniqueCode('TESTPRD'),
|
||||
'name' => 'Produit test',
|
||||
'states' => [Product::STATE_PURCHASE],
|
||||
'manufactured' => false,
|
||||
'containsMolasses' => false,
|
||||
'category' => $this->iri('categories', (int) $category->getId()),
|
||||
'sites' => [$this->iri('sites', (int) $site->getId())],
|
||||
'storageTypes' => [$this->iri('storage_types', (int) $storageType->getId())],
|
||||
];
|
||||
|
||||
return array_replace($base, $overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un produit directement via l'EM (bypass Processor/Validator). Utile
|
||||
* pour disposer d'un id existant (RBAC item, PATCH) ou d'un produit
|
||||
* soft-deleted (reutilisation de code — RG-6.01). La categorie / le site / le
|
||||
* type de stockage manquants sont crees a la volee.
|
||||
*
|
||||
* @param list<string> $states
|
||||
*/
|
||||
protected function seedProductEntity(
|
||||
?string $code = null,
|
||||
array $states = [Product::STATE_PURCHASE],
|
||||
?DateTimeImmutable $deletedAt = null,
|
||||
?Site $site = null,
|
||||
?StorageType $storageType = null,
|
||||
?Category $category = null,
|
||||
): Product {
|
||||
$em = $this->getEm();
|
||||
$site ??= $this->firstSite();
|
||||
|
||||
$product = new Product();
|
||||
$product->setCode($code ?? $this->uniqueCode(self::TEST_STORAGE_TYPE_PREFIX));
|
||||
$product->setName('Produit seed');
|
||||
$product->setStates($states);
|
||||
$product->setManufactured(false);
|
||||
$product->setContainsMolasses(false);
|
||||
$product->setCategory($category ?? $this->productCategory());
|
||||
$product->addSite($em->getReference(Site::class, (int) $site->getId()));
|
||||
$product->addStorageType($storageType ?? $this->seedStorageType('Seed', $site));
|
||||
$product->setDeletedAt($deletedAt);
|
||||
|
||||
$em->persist($product);
|
||||
$em->flush();
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit un IRI API Platform (`/api/{resource}/{id}`).
|
||||
*/
|
||||
protected function iri(string $resource, int $id): string
|
||||
{
|
||||
return sprintf('/api/%s/%d', $resource, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Code unique de test (prefixe + nonce). Deja en MAJUSCULE : stable apres la
|
||||
* normalisation serveur (trim + UPPER, RG-6.07).
|
||||
*/
|
||||
protected function uniqueCode(string $prefix): string
|
||||
{
|
||||
return $prefix.'_'.strtoupper(substr(bin2hex(random_bytes(5)), 0, 10));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les `propertyPath` des violations d'une reponse 422 (sans lever sur
|
||||
* le statut non-2xx). Sert a verifier que le back identifie bien le champ
|
||||
* fautif (contrat consomme par useFormErrors cote front).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function violationPaths(ResponseInterface $response): array
|
||||
{
|
||||
$body = $response->toArray(false);
|
||||
|
||||
return array_values(array_map(
|
||||
static fn (array $violation): string => (string) ($violation['propertyPath'] ?? ''),
|
||||
$body['violations'] ?? [],
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrouve un membre d'une collection Hydra par son id (ou null).
|
||||
*
|
||||
* @param array<string, mixed> $list
|
||||
*
|
||||
* @return null|array<string, mixed>
|
||||
*/
|
||||
protected function memberById(array $list, int $id): ?array
|
||||
{
|
||||
foreach ($list['member'] ?? [] as $member) {
|
||||
if (($member['id'] ?? null) === $id) {
|
||||
return $member;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
|
||||
/**
|
||||
* Tests du seed de la taxonomie ADRESSE cote API.
|
||||
*
|
||||
* Le multi-select « Categorie » des blocs adresse (client + fournisseur) consomme
|
||||
* `GET /api/categories?typeCode=ADRESSE`. Ce test prouve que :
|
||||
* - le filtre `?typeCode=ADRESSE` ne renvoie QUE les categories du type ADRESSE
|
||||
* (aucune fuite de categorie d'un autre type) ;
|
||||
* - chaque membre renvoye porte bien le type ADRESSE dans `categoryTypes`.
|
||||
*
|
||||
* NB : la base de test est purgee de toute categorie / type entre chaque test
|
||||
* (cf. AbstractCatalogApiTestCase::cleanupCatalogTestData), donc le type et les
|
||||
* categories ADRESSE sont materialises ici (et non lus depuis le seed de la
|
||||
* migration / fixture, qui ne survit pas a la purge). On valide ainsi le contrat
|
||||
* du filtre sur le code reel `ADRESSE`. La presence du seed apres un
|
||||
* `make db-reset` reel est, elle, verifiee par l'idempotence des fixtures.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryAdresseSeedTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
/**
|
||||
* Categories de demonstration seedees par la migration / fixture ADRESSE.
|
||||
*/
|
||||
private const array ADDRESS_CATEGORIES = [
|
||||
'Siège',
|
||||
'Contact issues',
|
||||
'Facturation',
|
||||
'Livraison',
|
||||
'Approvisionnement',
|
||||
'Méthaniseur',
|
||||
];
|
||||
|
||||
public function testTypeCodeAdresseReturnsOnlyAddressCategories(): void
|
||||
{
|
||||
$addressType = $this->getOrCreateAdresseType();
|
||||
foreach (self::ADDRESS_CATEGORIES as $name) {
|
||||
$this->createCategory($name, $addressType);
|
||||
}
|
||||
|
||||
// Bruit : un type + une categorie d'un autre type ne doivent PAS fuiter.
|
||||
$noiseType = $this->createCategoryType('TEST_CLIENT', 'Test Client');
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'noise', $noiseType);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories?typeCode=ADRESSE&pagination=false');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$members = $response->toArray()['member'];
|
||||
$names = array_map(static fn (array $m): string => $m['name'], $members);
|
||||
sort($names);
|
||||
|
||||
$expected = self::ADDRESS_CATEGORIES;
|
||||
sort($expected);
|
||||
self::assertSame(
|
||||
$expected,
|
||||
$names,
|
||||
'Le filtre ?typeCode=ADRESSE doit ne renvoyer QUE les categories du type ADRESSE.',
|
||||
);
|
||||
|
||||
// Chaque categorie remontee doit PORTER le type ADRESSE.
|
||||
foreach ($members as $member) {
|
||||
self::assertContains('ADRESSE', array_column($member['categoryTypes'], 'code'));
|
||||
}
|
||||
}
|
||||
|
||||
public function testTypeCodeAdresseKeepsHydraPagination(): void
|
||||
{
|
||||
$addressType = $this->getOrCreateAdresseType();
|
||||
$this->createCategory('Siège', $addressType);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories?typeCode=ADRESSE');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$data = $response->toArray();
|
||||
self::assertArrayHasKey('totalItems', $data, 'Le filtre ne doit pas casser la pagination Hydra.');
|
||||
self::assertArrayHasKey('member', $data);
|
||||
|
||||
foreach ($data['member'] as $member) {
|
||||
self::assertContains('ADRESSE', array_column($member['categoryTypes'], 'code'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere le type ADRESSE reel, ou le cree s'il est absent. Le code `ADRESSE`
|
||||
* est seede par CategoryTypeFixtures (present en debut de suite), mais le
|
||||
* cleanup purge tous les `category_type` entre les tests : selon l'ordre
|
||||
* d'execution, le type peut donc exister ou non. Le get-or-create rend le test
|
||||
* robuste sans dependre du seed ni le dupliquer.
|
||||
*/
|
||||
private function getOrCreateAdresseType(): CategoryType
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'ADRESSE']);
|
||||
|
||||
if ($existing instanceof CategoryType) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
return $this->createCategoryType('ADRESSE', 'Adresse');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
/**
|
||||
* RG-6.05 : la categorie d'un produit doit etre de type PRODUIT. Une categorie
|
||||
* d'un autre type est rejetee en 422 (Assert\Callback, propertyPath `category`).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProductCategoryTypeTest extends AbstractProductApiTestCase
|
||||
{
|
||||
public function testNonProductCategoryIsRejected(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$category = $this->nonProductCategory();
|
||||
|
||||
$response = $client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload([
|
||||
'category' => $this->iri('categories', (int) $category->getId()),
|
||||
]),
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertContains('category', $this->violationPaths($response));
|
||||
}
|
||||
|
||||
public function testProductCategoryIsAccepted(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$category = $this->productCategory();
|
||||
|
||||
$client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload([
|
||||
'category' => $this->iri('categories', (int) $category->getId()),
|
||||
]),
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Product;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* RG-6.01 : unicite GLOBALE du code produit parmi les ACTIFS.
|
||||
*
|
||||
* Couvre :
|
||||
* - 409 sur doublon de code actif (pre-check deterministe du Processor) ;
|
||||
* - normalisation (trim + UPPER, RG-6.07) prise en compte par l'unicite : un
|
||||
* code casse / entoure d'espaces collisionne avec sa forme normalisee ;
|
||||
* - reutilisation possible d'un code porte par un produit soft-deleted (l'index
|
||||
* partiel uq_product_code_active ne contraint que les actifs).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProductCodeUniquenessTest extends AbstractProductApiTestCase
|
||||
{
|
||||
public function testDuplicateActiveCodeReturns409(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$code = $this->uniqueCode('TESTPRD');
|
||||
|
||||
$client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload(['code' => $code]),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// Meme code -> conflit (RG-6.01).
|
||||
$client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload(['code' => $code]),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
}
|
||||
|
||||
public function testNormalizedCodeCollides(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$code = $this->uniqueCode('TESTPRD');
|
||||
|
||||
$client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload(['code' => $code]),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// Variante minuscule + espaces : trim + UPPER serveur (RG-6.07) la ramene
|
||||
// a la meme forme normalisee -> meme collision 409.
|
||||
$client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload(['code' => ' '.strtolower($code).' ']),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
}
|
||||
|
||||
public function testSoftDeletedCodeCanBeReused(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$code = $this->uniqueCode('TESTPRD');
|
||||
|
||||
// Produit soft-deleted portant le code (seede directement, hors index actif).
|
||||
$this->seedProductEntity(
|
||||
code: $code,
|
||||
states: [Product::STATE_PURCHASE],
|
||||
deletedAt: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
// Le meme code est libre cote actifs -> creation acceptee (201).
|
||||
$client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload(['code' => $code]),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
/**
|
||||
* RG-6.03 : « Fabrique » / « Contient de la melasse » saisissables uniquement si
|
||||
* `states` contient SALE ; sinon forces `false` cote serveur (Processor), quoi
|
||||
* que le client envoie.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProductConditionalFieldsTest extends AbstractProductApiTestCase
|
||||
{
|
||||
public function testConditionalFieldsForcedFalseWithoutSale(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
// Pas de SALE dans les etats mais champs conditionnels a true cote client.
|
||||
$created = $client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload([
|
||||
'states' => ['PURCHASE', 'OTHER'],
|
||||
'manufactured' => true,
|
||||
'containsMolasses' => true,
|
||||
]),
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
// Le serveur a force les deux a false (RG-6.03).
|
||||
self::assertFalse($created['manufactured']);
|
||||
self::assertFalse($created['containsMolasses']);
|
||||
}
|
||||
|
||||
public function testConditionalFieldsKeptWithSale(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$created = $client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload([
|
||||
'states' => ['SALE'],
|
||||
'manufactured' => true,
|
||||
'containsMolasses' => true,
|
||||
]),
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
// SALE present -> les valeurs saisies sont conservees.
|
||||
self::assertTrue($created['manufactured']);
|
||||
self::assertTrue($created['containsMolasses']);
|
||||
}
|
||||
|
||||
public function testConditionalFieldsResetOnPatchRemovingSale(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$created = $client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload([
|
||||
'states' => ['SALE'],
|
||||
'manufactured' => true,
|
||||
'containsMolasses' => true,
|
||||
]),
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// On retire SALE en PATCH -> les conditionnels doivent retomber a false.
|
||||
$patched = $client->request('PATCH', '/api/products/'.$created['id'], [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['states' => ['PURCHASE']],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertFalse($patched['manufactured']);
|
||||
self::assertFalse($patched['containsMolasses']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Catalog\Domain\Entity\Product;
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use DateTimeImmutable;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'export XLSX du catalogue produits (M6, § 4.5).
|
||||
*
|
||||
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes de
|
||||
* colonnes), exclusion des produits soft-deleted par defaut (RG-6.09), respect
|
||||
* des filtres ?search et ?state, peuplement des colonnes metier (etats joints,
|
||||
* categorie, sites, types de stockage, fabrique / contient melasse), 403 sans
|
||||
* catalog.products.view, 401 anonyme.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProductExportControllerTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
private const string EXPORT_URL = '/api/products/export.xlsx';
|
||||
|
||||
/** Prefixe des codes de types de stockage seedes par ce test (cible du cleanup tearDown). */
|
||||
private const string TEST_STORAGE_PREFIX = 'TEST_';
|
||||
|
||||
/**
|
||||
* Purge des produits + types de stockage de test AVANT le cleanup parent :
|
||||
* product reference category / site / storage_type en FK ON DELETE RESTRICT,
|
||||
* donc les categories ne peuvent etre supprimees tant que des produits les
|
||||
* referencent. La suppression des produits cascade les jonctions
|
||||
* product_site / product_storage_type au niveau base (ON DELETE CASCADE).
|
||||
*/
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$em->createQuery('DELETE FROM '.Product::class)->execute();
|
||||
$em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix')
|
||||
->setParameter('prefix', self::TEST_STORAGE_PREFIX.'%')
|
||||
->execute()
|
||||
;
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testExportReturnsXlsxResponseWithHeaderRow(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProduct('TEST_PRD_A', 'Export Alpha');
|
||||
|
||||
$response = $client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$headers = $response->getHeaders(false);
|
||||
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
|
||||
|
||||
$disposition = $headers['content-disposition'][0] ?? '';
|
||||
self::assertStringContainsString('attachment; filename="catalogue-produits-', $disposition);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/filename="catalogue-produits-\d{8}\.xlsx"/',
|
||||
$disposition,
|
||||
);
|
||||
|
||||
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
|
||||
$grid = $this->gridFromResponse($response->getContent());
|
||||
$headerCells = $grid[0];
|
||||
self::assertSame('Numéro', $headerCells[0]);
|
||||
self::assertSame('Nom', $headerCells[1]);
|
||||
self::assertContains('États', $headerCells);
|
||||
self::assertContains('Catégorie', $headerCells);
|
||||
self::assertContains('Sites', $headerCells);
|
||||
self::assertContains('Types de stockage', $headerCells);
|
||||
self::assertContains('Fabriqué', $headerCells);
|
||||
self::assertContains('Contient mélasse', $headerCells);
|
||||
|
||||
// Au moins une ligne de donnees (le produit seede).
|
||||
self::assertContains('TEST_PRD_A', $this->codes($response->getContent()));
|
||||
}
|
||||
|
||||
public function testExportExcludesSoftDeletedByDefault(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProduct('TEST_PRD_ACTIVE', 'Active One');
|
||||
$this->seedProduct('TEST_PRD_DELETED', 'Deleted One', deletedAt: new DateTimeImmutable());
|
||||
|
||||
$codes = $this->codes($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('TEST_PRD_ACTIVE', $codes);
|
||||
self::assertNotContains('TEST_PRD_DELETED', $codes);
|
||||
}
|
||||
|
||||
public function testExportRespectsSearchFilter(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProduct('TEST_PRD_SRCH', 'Searchable Alpha');
|
||||
$this->seedProduct('TEST_PRD_OTHER', 'Other Beta');
|
||||
|
||||
$codes = $this->codes(
|
||||
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
|
||||
);
|
||||
|
||||
self::assertContains('TEST_PRD_SRCH', $codes);
|
||||
self::assertNotContains('TEST_PRD_OTHER', $codes);
|
||||
}
|
||||
|
||||
public function testExportRespectsStateFilter(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProduct('TEST_PRD_SALE', 'Sold One', [Product::STATE_SALE]);
|
||||
$this->seedProduct('TEST_PRD_BUY', 'Bought One', [Product::STATE_PURCHASE]);
|
||||
|
||||
$codes = $this->codes(
|
||||
$client->request('GET', self::EXPORT_URL.'?state=SALE')->getContent(),
|
||||
);
|
||||
|
||||
self::assertContains('TEST_PRD_SALE', $codes);
|
||||
self::assertNotContains('TEST_PRD_BUY', $codes);
|
||||
}
|
||||
|
||||
public function testExportPopulatesAllBusinessColumns(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$site = $this->firstSite();
|
||||
$storageType = $this->seedStorageType('TEST_STP', 'Tas de test');
|
||||
$category = $this->createCategory('test_cat_export_produit');
|
||||
|
||||
$this->seedProduct(
|
||||
'TEST_PRD_FULL',
|
||||
'Complet',
|
||||
[Product::STATE_PURCHASE, Product::STATE_SALE],
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
$site,
|
||||
$storageType,
|
||||
$category,
|
||||
);
|
||||
|
||||
$row = $this->rowForCode($client->request('GET', self::EXPORT_URL)->getContent(), 'TEST_PRD_FULL');
|
||||
self::assertNotNull($row, 'Le produit seede est absent de l\'export.');
|
||||
|
||||
// 0 Numéro | 1 Nom | 2 États | 3 Catégorie | 4 Sites | 5 Types de stockage | 6 Fabriqué | 7 Contient mélasse
|
||||
self::assertSame('TEST_PRD_FULL', $row[0]);
|
||||
self::assertSame('Complet', $row[1]);
|
||||
self::assertSame('Achat, Vendu', $row[2]);
|
||||
self::assertSame((string) $category->getName(), $row[3]);
|
||||
self::assertSame((string) $site->getName(), $row[4]);
|
||||
self::assertSame('Tas de test', $row[5]);
|
||||
self::assertSame('Oui', $row[6]);
|
||||
self::assertSame('Oui', $row[7]);
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutProductsViewPermission(): void
|
||||
{
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testUnauthorizedWhenAnonymous(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un produit complet (categorie + 1 site + 1 type de stockage par
|
||||
* defaut). Les relations omises sont creees a la volee. Persistance directe
|
||||
* via l'EM : on bypasse le Processor/Validator (non teste ici).
|
||||
*
|
||||
* @param list<string> $states
|
||||
*/
|
||||
private function seedProduct(
|
||||
string $code,
|
||||
string $name,
|
||||
array $states = [Product::STATE_PURCHASE],
|
||||
bool $manufactured = false,
|
||||
bool $containsMolasses = false,
|
||||
?DateTimeImmutable $deletedAt = null,
|
||||
?Site $site = null,
|
||||
?StorageType $storageType = null,
|
||||
?Category $category = null,
|
||||
): Product {
|
||||
$em = $this->getEm();
|
||||
|
||||
$product = new Product();
|
||||
$product->setCode($code);
|
||||
$product->setName($name);
|
||||
$product->setStates($states);
|
||||
$product->setManufactured($manufactured);
|
||||
$product->setContainsMolasses($containsMolasses);
|
||||
$product->setCategory($category ?? $this->createCategory());
|
||||
$product->addSite($site ?? $this->firstSite());
|
||||
$product->addStorageType($storageType ?? $this->seedStorageType(self::TEST_STORAGE_PREFIX.strtoupper(substr(bin2hex(random_bytes(4)), 0, 8))));
|
||||
$product->setDeletedAt($deletedAt);
|
||||
|
||||
$em->persist($product);
|
||||
$em->flush();
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un type de stockage de test (code prefixe TEST_ pour le cleanup).
|
||||
*/
|
||||
private function seedStorageType(string $code, string $label = 'Type de stockage de test'): StorageType
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
$storageType = new StorageType();
|
||||
$storageType->setCode($code);
|
||||
$storageType->setLabel($label);
|
||||
|
||||
$em->persist($storageType);
|
||||
$em->flush();
|
||||
|
||||
return $storageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Premier site seede (les sites existent en base de test, comme dans les
|
||||
* autres tests d'export).
|
||||
*/
|
||||
private function firstSite(): Site
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
|
||||
self::assertNotNull($site, 'Aucun site seede : impossible de seeder un produit.');
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function gridFromResponse(string $binary): array
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
|
||||
self::assertIsString($tmp);
|
||||
file_put_contents($tmp, $binary);
|
||||
|
||||
try {
|
||||
return IOFactory::load($tmp)->getActiveSheet()->toArray();
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la colonne « Numéro » (1re colonne) des lignes de donnees.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function codes(string $binary): array
|
||||
{
|
||||
$grid = $this->gridFromResponse($binary);
|
||||
$rows = array_slice($grid, 1); // saute l'en-tete
|
||||
|
||||
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renvoie la ligne de donnees dont la colonne « Numéro » vaut $code, ou null.
|
||||
*
|
||||
* @return null|array<int, mixed>
|
||||
*/
|
||||
private function rowForCode(string $binary, string $code): ?array
|
||||
{
|
||||
$grid = $this->gridFromResponse($binary);
|
||||
foreach (array_slice($grid, 1) as $row) {
|
||||
if ((string) ($row[0] ?? '') === $code) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
/**
|
||||
* RBAC du catalogue produit (M6, spec-back § 5.2 — admin-only, C7).
|
||||
*
|
||||
* La matrice est volontairement tres restrictive : seul l'Admin porte
|
||||
* `catalog.products.view` / `.manage`. Les 4 personas metier MALIO (Bureau,
|
||||
* Compta, Commerciale, Usine) n'ont AUCUNE permission produit -> 403 partout.
|
||||
*
|
||||
* On prouve aussi que l'acces n'est pas « admin only » par hasard mais bien
|
||||
* porte par les permissions : un non-admin avec `view` lit (200) mais ne peut
|
||||
* pas creer (403, refus au niveau securite avant denormalisation).
|
||||
*
|
||||
* Note : on ne teste pas « un non-admin avec `manage` cree un produit » — ce role
|
||||
* n'existe dans aucun persona (catalogue admin-only) et un tel user ne pourrait
|
||||
* de toute facon pas resoudre les IRI sites / categories / storage_types lors de
|
||||
* la denormalisation (ces ressources portent leur propre controle d'acces). La
|
||||
* creation par un porteur de `manage` est couverte par l'Admin (qui bypass).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProductRBACMatrixTest extends AbstractProductApiTestCase
|
||||
{
|
||||
/** Personas metier sans permission produit (§ 5.2). */
|
||||
private const array PERSONAS = ['Bureau', 'Compta', 'Commerciale', 'Usine'];
|
||||
|
||||
public function testAdminHasFullAccess(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('GET', '/api/products', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
$client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload(),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testBusinessPersonasAreForbiddenEverywhere(): void
|
||||
{
|
||||
// Produit existant cible des operations item (seede par l'admin via l'EM).
|
||||
$product = $this->seedProductEntity();
|
||||
$id = (int) $product->getId();
|
||||
|
||||
foreach (self::PERSONAS as $persona) {
|
||||
$client = $this->createPersonaClient($persona);
|
||||
|
||||
$client->request('GET', '/api/products', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403, $persona.' ne doit pas lister les produits.');
|
||||
|
||||
$client->request('GET', '/api/products/'.$id, ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403, $persona.' ne doit pas consulter un produit.');
|
||||
|
||||
$client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload(),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403, $persona.' ne doit pas creer de produit.');
|
||||
|
||||
$client->request('PATCH', '/api/products/'.$id, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['name' => 'Renomme par '.$persona],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403, $persona.' ne doit pas modifier un produit.');
|
||||
}
|
||||
}
|
||||
|
||||
public function testViewPermissionReadsButCannotManage(): void
|
||||
{
|
||||
$product = $this->seedProductEntity();
|
||||
$client = $this->authView();
|
||||
|
||||
$client->request('GET', '/api/products', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
$client->request('GET', '/api/products/'.$product->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// view sans manage : creation refusee au niveau securite (403 avant que la
|
||||
// denormalisation ne tente de resoudre les IRI -> pas de 400 parasite).
|
||||
$client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload(),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
/**
|
||||
* Contrat de serialisation du produit (M6, spec-back § 4.0 / § 4.0.bis).
|
||||
* Jumeau du test de contrat M5 WeighingTicketSerializationContractTest.
|
||||
*
|
||||
* Capture le JSON REEL (liste + detail) via un produit cree par l'API (POST reel,
|
||||
* normalisation serveur reelle) et reverifie les pieges du RETEX M1 transposes au
|
||||
* M6 :
|
||||
* #1 : `category` sort en OBJET embarque (category:read), jamais en IRI nu.
|
||||
* #2 : `sites` / `storageTypes` sortent en TABLEAUX d'OBJETS (site:read /
|
||||
* storage_type:read), jamais en tableaux d'IRI.
|
||||
* #3 : `states` = tableau de chaines ; `manufactured` / `containsMolasses`
|
||||
* presents (booleens).
|
||||
* #4 : `code` present (= « Numero » de la liste).
|
||||
*
|
||||
* REGLE D'OR : on asserte sur le CORPS JSON reel, jamais sur les annotations.
|
||||
* DoD (§ 4.0.bis) : avec PRODUCT_DOD_DUMP positionnee, ecrit les corps liste +
|
||||
* detail sous /tmp pour les coller dans la spec avant les ecrans front.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProductSerializationContractTest extends AbstractProductApiTestCase
|
||||
{
|
||||
public function testListAndDetailSerializationContract(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
// Produit cree par un POST reel (mix Achat + Vendu pour exercer les
|
||||
// champs conditionnels au passage).
|
||||
$created = $client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload([
|
||||
'states' => ['PURCHASE', 'SALE'],
|
||||
'manufactured' => true,
|
||||
'containsMolasses' => true,
|
||||
]),
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$id = (int) $created['id'];
|
||||
$code = (string) $created['code'];
|
||||
|
||||
$detail = $client->request('GET', '/api/products/'.$id, [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
])->toArray();
|
||||
$list = $client->request('GET', '/api/products?search='.$code, [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
])->toArray();
|
||||
|
||||
// Enveloppe Hydra AP4 (member/totalItems sans prefixe hydra:).
|
||||
self::assertArrayHasKey('member', $list);
|
||||
self::assertArrayNotHasKey('hydra:member', $list);
|
||||
|
||||
$row = $this->memberById($list, $id);
|
||||
self::assertNotNull($row, 'Le produit cree doit apparaitre dans la liste filtree.');
|
||||
|
||||
// === Piege #4 : code present (= « Numero ») + name ===
|
||||
self::assertArrayHasKey('code', $row);
|
||||
self::assertSame($code, $row['code']);
|
||||
self::assertSame('Produit test', $row['name']);
|
||||
|
||||
// === Piege #1 : category en OBJET embarque (pas IRI nu) ===
|
||||
self::assertIsArray($row['category'], 'category doit etre un objet embarque (category:read), pas un IRI nu.');
|
||||
self::assertArrayHasKey('name', $row['category']);
|
||||
|
||||
// === Piege #3 : states tableau de chaines + booleens presents ===
|
||||
self::assertSame(['PURCHASE', 'SALE'], $row['states']);
|
||||
self::assertArrayHasKey('manufactured', $row);
|
||||
self::assertArrayHasKey('containsMolasses', $row);
|
||||
self::assertTrue($row['manufactured']);
|
||||
self::assertTrue($row['containsMolasses']);
|
||||
|
||||
// === DETAIL : category embarque + sites / storageTypes en objets ===
|
||||
self::assertIsArray($detail['category']);
|
||||
self::assertArrayHasKey('name', $detail['category']);
|
||||
|
||||
// === Piege #2 : sites / storageTypes = tableaux d'OBJETS (pas IRI) ===
|
||||
self::assertIsArray($detail['sites']);
|
||||
self::assertNotEmpty($detail['sites']);
|
||||
self::assertIsArray($detail['sites'][0], 'sites doit etre un tableau d\'objets (site:read), pas d\'IRI.');
|
||||
self::assertArrayHasKey('name', $detail['sites'][0]);
|
||||
|
||||
self::assertIsArray($detail['storageTypes']);
|
||||
self::assertNotEmpty($detail['storageTypes']);
|
||||
self::assertIsArray($detail['storageTypes'][0], 'storageTypes doit etre un tableau d\'objets (storage_type:read), pas d\'IRI.');
|
||||
self::assertArrayHasKey('label', $detail['storageTypes'][0]);
|
||||
self::assertArrayHasKey('code', $detail['storageTypes'][0]);
|
||||
|
||||
self::assertSame(['PURCHASE', 'SALE'], $detail['states']);
|
||||
|
||||
$this->dumpDodIfRequested($list, $detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* DoD (§ 4.0.bis) : ecrit les corps JSON reels sous /tmp si PRODUCT_DOD_DUMP
|
||||
* est positionnee (sinon no-op). A coller dans spec-back.md § 4.0.bis.
|
||||
*
|
||||
* @param array<string, mixed> $list
|
||||
* @param array<string, mixed> $detail
|
||||
*/
|
||||
private function dumpDodIfRequested(array $list, array $detail): void
|
||||
{
|
||||
if (false === getenv('PRODUCT_DOD_DUMP')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
||||
file_put_contents('/tmp/product-dod-list.json', json_encode($list, $flags));
|
||||
file_put_contents('/tmp/product-dod-detail.json', json_encode($detail, $flags));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
/**
|
||||
* RG-6.02 : `states` = multi-select ⊆ {PURCHASE, SALE, OTHER}, au moins 1 requis.
|
||||
*
|
||||
* Couvre :
|
||||
* - tableau d'etats vide -> 422 (Assert\Count(min: 1)) sur le champ `states` ;
|
||||
* - valeur hors enum -> 422 (Assert\Choice) sur le champ `states` ;
|
||||
* - un seul etat valide -> 201 (borne basse acceptee).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProductStatesValidationTest extends AbstractProductApiTestCase
|
||||
{
|
||||
public function testEmptyStatesIsRejected(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload(['states' => []]),
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertContains('states', $this->violationPaths($response));
|
||||
}
|
||||
|
||||
public function testUnknownStateValueIsRejected(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload(['states' => ['PURCHASE', 'FOOBAR']]),
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertContains('states', $this->violationPaths($response));
|
||||
}
|
||||
|
||||
public function testSingleValidStateIsAccepted(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload(['states' => ['OTHER']]),
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
/**
|
||||
* RG-6.06 : chaque type de stockage retenu doit etre disponible sur au moins un
|
||||
* des sites selectionnes. Un type de stockage hors des sites du produit est
|
||||
* rejete en 422 (Assert\Callback, propertyPath `storageTypes`).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProductStorageTypeBySiteTest extends AbstractProductApiTestCase
|
||||
{
|
||||
public function testStorageTypeNotOnSelectedSitesIsRejected(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$siteA = $this->siteByCode('86');
|
||||
$siteB = $this->siteByCode('17');
|
||||
|
||||
// Type de stockage disponible uniquement sur le site B...
|
||||
$storageType = $this->seedStorageType('Cellule site B', $siteB);
|
||||
|
||||
// ... mais produit declare sur le site A seulement -> 422.
|
||||
$response = $client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload([
|
||||
'sites' => [$this->iri('sites', (int) $siteA->getId())],
|
||||
'storageTypes' => [$this->iri('storage_types', (int) $storageType->getId())],
|
||||
]),
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertContains('storageTypes', $this->violationPaths($response));
|
||||
}
|
||||
|
||||
public function testStorageTypeOnSelectedSiteIsAccepted(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$siteA = $this->siteByCode('86');
|
||||
$storageType = $this->seedStorageType('Tas site A', $siteA);
|
||||
|
||||
$client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validProductPayload([
|
||||
'sites' => [$this->iri('sites', (int) $siteA->getId())],
|
||||
'storageTypes' => [$this->iri('storage_types', (int) $storageType->getId())],
|
||||
]),
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,10 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
|
||||
protected const string TEST_CATEGORY_PREFIX = 'test_cli_cat_';
|
||||
|
||||
/**
|
||||
* Codes pilotant les RG (RG-1.03 distributor/broker, RG-1.29 adresse) : ils
|
||||
* doivent matcher exactement, donc createCategory() les fetch-or-create par
|
||||
* code. Les autres codes sont traites comme de simples libelles generiques et
|
||||
* produisent une categorie a code UNIQUE (cf. createCategory).
|
||||
* Codes pilotant les RG (RG-1.03 distributor/broker) : ils doivent matcher
|
||||
* exactement, donc createCategory() les fetch-or-create par code. Les autres
|
||||
* codes sont traites comme de simples libelles generiques et produisent une
|
||||
* categorie a code UNIQUE (cf. createCategory).
|
||||
*/
|
||||
private const array RG_EXACT_CODES = ['DISTRIBUTEUR', 'COURTIER'];
|
||||
|
||||
@@ -75,6 +75,47 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
|
||||
return $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere (ou cree) le type ADRESSE (categories des blocs adresse). Idempotent
|
||||
* via l'unicite de category_type.code. Laisse en place au tearDown.
|
||||
*/
|
||||
protected function addressCategoryType(): CategoryType
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'ADRESSE']);
|
||||
if (null !== $existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$type = new CategoryType();
|
||||
$type->setCode('ADRESSE');
|
||||
$type->setLabel('Adresse');
|
||||
$em->persist($type);
|
||||
$em->flush();
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree une Category de test de type ADRESSE (autorisee sur un bloc adresse).
|
||||
* Code UNIQUE (suffixe aleatoire) : les categories d'adresse ne pilotent aucune
|
||||
* RG par code, deux appels produisent donc deux categories distinctes.
|
||||
*/
|
||||
protected function createAddressCategory(): Category
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
|
||||
$category = new Category();
|
||||
$category->setName(self::TEST_CATEGORY_PREFIX.'adresse_'.$suffix);
|
||||
$category->setCode('ADRESSE_'.strtoupper($suffix));
|
||||
$category->addCategoryType($this->addressCategoryType());
|
||||
$em->persist($category);
|
||||
$em->flush();
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree une Category de test sous le type unique CLIENT (ERP-78).
|
||||
*
|
||||
|
||||
@@ -134,8 +134,8 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
|
||||
* Seede un fournisseur COMPLET (sans passer par l'API — validations
|
||||
* applicatives non rejouees mais CHECK BDD respectes) : onglet Information
|
||||
* rempli, bloc comptable non nul (SIREN + refs), >= 1 RIB, >= 1 adresse
|
||||
* multi-sites (>= 2 sites, triageProvider=true) avec >= 1 categorie
|
||||
* FOURNISSEUR, >= 1 contact, >= 1 categorie sur le fournisseur. Sert de socle
|
||||
* multi-sites (>= 2 sites, triageProvider=true) avec >= 1 categorie de type
|
||||
* ADRESSE, >= 1 contact, >= 1 categorie FOURNISSEUR sur le fournisseur. Sert de socle
|
||||
* au contrat de serialisation et a la DoD (§ 4.0.bis).
|
||||
*
|
||||
* @param string $paymentTypeCode code du type de reglement a poser (defaut LCR,
|
||||
@@ -202,7 +202,9 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
|
||||
foreach ($sites as $site) {
|
||||
$address->addSite($site);
|
||||
}
|
||||
$address->addCategory($this->supplierCategory('NEGOCIANT'));
|
||||
// Categorie de bloc adresse : type ADRESSE (et non FOURNISSEUR — celui-ci
|
||||
// reste sur le bloc principal du fournisseur).
|
||||
$address->addCategory($this->createAddressCategory());
|
||||
$address->addContact($contact);
|
||||
$supplier->addAddress($address);
|
||||
$em->persist($address);
|
||||
|
||||
@@ -15,8 +15,8 @@ use App\Module\Sites\Domain\Entity\Site;
|
||||
* - RG-1.06 / RG-1.07 / RG-1.08 : exclusivite is_prospect vs
|
||||
* is_delivery / is_billing ;
|
||||
* - RG-1.11 : billing_email obligatoire ssi is_billing ;
|
||||
* - RG-1.29 (ERP-78) : les categories de code DISTRIBUTEUR / COURTIER sont
|
||||
* interdites sur une adresse (-> 422) ; toute autre categorie est acceptee.
|
||||
* - categorie d'adresse : seules les categories de type ADRESSE sont acceptees
|
||||
* (-> 422 sinon), au moins une est obligatoire.
|
||||
*
|
||||
* Depuis ERP-76, ces regles sont portees par des Assert\Callback sur l'entite
|
||||
* ClientAddress (mirror applicatif des CHECK Postgres) : la combinaison invalide
|
||||
@@ -170,7 +170,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Non Billing Empty Email');
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
$category = $this->createAddressCategory();
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
@@ -197,7 +197,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Billing Two Emails');
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
$category = $this->createAddressCategory();
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
@@ -225,7 +225,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Secondary Email Non Billing');
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
$category = $this->createAddressCategory();
|
||||
|
||||
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
@@ -246,15 +246,16 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.29 : poster une categorie de type DISTRIBUTEUR sur une adresse -> 422
|
||||
* avec violation sur le champ `categories`.
|
||||
* Une categorie qui n'est PAS de type ADRESSE (ici une categorie CLIENT) est
|
||||
* refusee sur une adresse -> 422 avec violation sur le champ `categories`.
|
||||
*/
|
||||
public function testAddressRejectsDistributorCategory(): void
|
||||
public function testAddressRejectsNonAddressCategory(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Distributor Cat');
|
||||
$category = $this->createCategory('DISTRIBUTEUR');
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Non Address Cat');
|
||||
// Categorie de type CLIENT (et non ADRESSE) -> doit etre refusee sur l'adresse.
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
@@ -270,70 +271,20 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertStringContainsString(
|
||||
'Type de catégorie non autorisé sur une adresse.',
|
||||
'Type de catégorie non autorisé (ADRESSE attendu).',
|
||||
(string) $client->getResponse()->getContent(false),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.29 : poster une categorie de type COURTIER sur une adresse -> 422.
|
||||
* Une categorie de type ADRESSE est acceptee sur une adresse -> 201.
|
||||
*/
|
||||
public function testAddressRejectsBrokerCategory(): void
|
||||
public function testAddressAcceptsAddressCategory(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Broker Cat');
|
||||
$category = $this->createCategory('COURTIER');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isDelivery' => true,
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.29 : une categorie de type SECTEUR est autorisee sur une adresse.
|
||||
*/
|
||||
public function testAddressAcceptsSectorCategory(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Sector Cat');
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isDelivery' => true,
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.29 : une categorie de type AUTRE est autorisee sur une adresse.
|
||||
*/
|
||||
public function testAddressAcceptsOtherCategory(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Other Cat');
|
||||
$category = $this->createCategory('AUTRE');
|
||||
$seed = $this->seedClient('Address Address Cat');
|
||||
$category = $this->createAddressCategory();
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
@@ -385,7 +336,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address No Type');
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
$category = $this->createAddressCategory();
|
||||
|
||||
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
@@ -413,7 +364,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Broker Type');
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
$category = $this->createAddressCategory();
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
@@ -435,7 +386,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Distributor Type');
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
$category = $this->createAddressCategory();
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
@@ -462,7 +413,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Broker Mix');
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
$category = $this->createAddressCategory();
|
||||
|
||||
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
|
||||
@@ -203,7 +203,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Host');
|
||||
$siteIri = $this->firstSiteIri();
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
$category = $this->createAddressCategory();
|
||||
|
||||
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
@@ -276,7 +276,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Addr Multi');
|
||||
$siteIri = $this->firstSiteIri();
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
$category = $this->createAddressCategory();
|
||||
$this->seedAddress($seed, 'Bordeaux');
|
||||
$this->seedAddress($seed, 'Lyon');
|
||||
|
||||
@@ -305,7 +305,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$siteIri = $this->firstSiteIri();
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
$category = $this->createAddressCategory();
|
||||
|
||||
$client->request('POST', '/api/clients/999999/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
|
||||
@@ -106,7 +106,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Address Host');
|
||||
$category = $this->supplierCategory('NEGOCIANT');
|
||||
$category = $this->createAddressCategory();
|
||||
|
||||
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
@@ -174,7 +174,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Address Incoherent');
|
||||
$category = $this->supplierCategory('NEGOCIANT');
|
||||
$category = $this->createAddressCategory();
|
||||
|
||||
// RG-2.05 : pas de controle strict de coherence CP/ville cote serveur.
|
||||
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
||||
@@ -222,7 +222,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Address Types');
|
||||
$siteIri = $this->firstSiteIri();
|
||||
$category = $this->supplierCategory('NEGOCIANT');
|
||||
$category = $this->createAddressCategory();
|
||||
|
||||
foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) {
|
||||
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
||||
@@ -240,12 +240,12 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testPostAddressWithNonFournisseurCategoryReturns422(): void
|
||||
public function testPostAddressWithNonAddressCategoryReturns422(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Address Bad Cat');
|
||||
// categorie de type CLIENT -> interdite sur une adresse fournisseur.
|
||||
// categorie de type CLIENT (et non ADRESSE) -> interdite sur une adresse.
|
||||
$clientTypedCategory = $this->createCategory('SECTEUR');
|
||||
|
||||
$response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
||||
@@ -260,7 +260,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-2.10 -> 422 rattachee a categories.
|
||||
// Categorie hors type ADRESSE -> 422 rattachee a categories.
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user