Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 311e758dea | |||
| 9f96d1c40d | |||
| 836f177ff9 | |||
| 1d91b4dea9 | |||
| c402418937 | |||
| 2866fb8865 | |||
| 0ed131ce57 | |||
| a948eed9b6 | |||
| fc78f434d1 | |||
| 53e19d61ac | |||
| ece8146c03 | |||
| 58589e93d0 | |||
| e0d59962d6 | |||
| 3ce40a707f | |||
| 9613857650 | |||
| 2a0918bbfe | |||
| 8e31e1759c | |||
| 4824690923 | |||
| fceb1e0e83 |
@@ -74,3 +74,53 @@ Exemple : pour qu'`User.profile` soit embarque au lieu d'un lien IRI sous le gro
|
||||
## PostgreSQL
|
||||
|
||||
- Noms de colonnes toujours en **minuscules** dans le SQL brut (commun a tous les projets MALIO)
|
||||
|
||||
## Migrations Doctrine
|
||||
|
||||
### Documentation SQL obligatoire (`COMMENT ON COLUMN`)
|
||||
|
||||
**Toute migration qui cree ou modifie une colonne d'une table metier doit poser un `COMMENT ON COLUMN` decrivant le champ.** La description est stockee dans `pg_description` et visible dans tous les outils d'admin BDD (DBeaver, DataGrip, pgAdmin), sans avoir a lire les annotations PHP.
|
||||
|
||||
**Format de la description** :
|
||||
- En francais
|
||||
- ≤ 200 caracteres
|
||||
- Semantique du champ — contraintes / lien RG si pertinent
|
||||
- Pour les colonnes d'identifiant ou FK, mentionner la cible
|
||||
|
||||
Exemples :
|
||||
|
||||
```php
|
||||
// Migration : creation d'une colonne avec son commentaire dans la meme migration
|
||||
$this->addSql("ALTER TABLE client ADD COLUMN siren VARCHAR(9) DEFAULT NULL");
|
||||
$this->addSql("COMMENT ON COLUMN client.siren IS 'SIREN (9 chiffres) — identifiant legal entreprise. Unique parmi non-archives (RG-1.15).'");
|
||||
|
||||
// Cas FK : preciser la cible
|
||||
$this->addSql("COMMENT ON COLUMN client.legal_form_id IS 'Reference forme juridique (SARL, SAS, SA...) — FK -> legal_form.id, ON DELETE RESTRICT.'");
|
||||
|
||||
// Cas booleen : preciser le sens et la valeur par defaut
|
||||
$this->addSql("COMMENT ON COLUMN user.is_admin IS 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.'");
|
||||
|
||||
// Bonus : decrire la table elle-meme
|
||||
$this->addSql("COMMENT ON TABLE client IS 'Repertoire clients (M1 Commercial) — entites archivables.'");
|
||||
```
|
||||
|
||||
### Helper Timestampable/Blamable
|
||||
|
||||
Les 4 colonnes `created_at`, `updated_at`, `created_by`, `updated_by` ajoutees par `TimestampableBlamableTrait` recoivent une description **standardisee** via le helper centralise pour eviter la duplication. Helper a creer ou appeler :
|
||||
|
||||
```php
|
||||
// Dans la migration, apres avoir ajoute les 4 colonnes :
|
||||
$this->addStandardTimestampableBlamableComments($schema, 'client');
|
||||
```
|
||||
|
||||
L'implementation du helper applique :
|
||||
- `created_at` : « Horodatage de creation de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). »
|
||||
- `updated_at` : « Horodatage de derniere modification de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). »
|
||||
- `created_by` : « ID de l'utilisateur ayant cree la ligne — null pour les creations hors HTTP (CLI, migration, fixture). FK -> user.id, ON DELETE SET NULL. »
|
||||
- `updated_by` : « ID de l'utilisateur ayant modifie la ligne en dernier — null pour les modifications hors HTTP. FK -> user.id, ON DELETE SET NULL. »
|
||||
|
||||
### Garde-fou architecture
|
||||
|
||||
`tests/Architecture/ColumnsHaveSqlCommentTest` parcourt `information_schema.columns` filtre sur le schema `public` et echoue si **une seule colonne** n'a pas de `col_description`. Seules les tables system (`doctrine_migration_versions`) et la whitelist `EXCLUDED_TABLES` explicite (commentaire de justification + ticket Lesstime ouvert pour le retrofit) sont tolerees.
|
||||
|
||||
Conclusion : si tu crees une colonne sans poser son `COMMENT ON COLUMN`, `make test` casse en CI.
|
||||
|
||||
@@ -73,12 +73,23 @@ jobs:
|
||||
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
|
||||
|
||||
- name: Bootstrap test database
|
||||
# Aligne sur la cible `test-db-setup` du makefile : apres
|
||||
# `schema:update --force`, on RECREE manuellement l'index unique
|
||||
# partiel `uq_category_name_type_active` car Doctrine ORM ne sait
|
||||
# pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE
|
||||
# deleted_at IS NULL) et `schema:update` les considere comme
|
||||
# orphelins et les DROP — collisions non detectees, tests d'unicite
|
||||
# qui attendent 409 recoivent 201.
|
||||
run: |
|
||||
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
|
||||
php bin/console doctrine:migrations:migrate --env=test --no-interaction
|
||||
php bin/console doctrine:schema:update --env=test --force --no-interaction
|
||||
# Rejoue le catalogue COMMENT ON apres schema:update (cf. ERP-67) :
|
||||
# schema:update drop les commentaires des tables managees par l'ORM.
|
||||
php bin/console app:apply-column-comments --env=test --no-interaction
|
||||
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
||||
php bin/console app:sync-permissions --env=test --no-interaction
|
||||
php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: php -d memory_limit=512M vendor/bin/phpunit
|
||||
|
||||
@@ -24,6 +24,7 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md
|
||||
9. **Jamais commit sans demande explicite** de l'utilisateur ; jamais force push sans confirmation.
|
||||
10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement.
|
||||
11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison.
|
||||
12. **Toujours documenter chaque colonne BDD via `COMMENT ON COLUMN`** dans la migration qui la cree ou la modifie. Description en francais, courte (≤ 200 caracteres), explique la semantique metier + contraintes implicites (unicite partielle, FK importante, lien RG). Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` echoue si une colonne `public` n'a pas de description (`col_description IS NULL`). Details et exemples : @.claude/rules/backend.md § Migrations Doctrine.
|
||||
|
||||
## Conventions
|
||||
@.claude/rules/architecture.md
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
use App\Module\Catalog\CatalogModule;
|
||||
use App\Module\Commercial\CommercialModule;
|
||||
use App\Module\Core\CoreModule;
|
||||
use App\Module\Sites\SitesModule;
|
||||
@@ -9,4 +10,5 @@ return [
|
||||
CoreModule::class,
|
||||
CommercialModule::class,
|
||||
SitesModule::class,
|
||||
CatalogModule::class,
|
||||
];
|
||||
|
||||
@@ -37,6 +37,10 @@ doctrine:
|
||||
# Permet a Shared de referencer UserInterface dans ses ORM mappings sans
|
||||
# importer la classe concrete du module Core (cf. spec-back M0 § 2.8).
|
||||
Symfony\Component\Security\Core\User\UserInterface: App\Module\Core\Domain\Entity\User
|
||||
# Cible des ManyToMany Client.categories / ClientAddress.categories (M1).
|
||||
# Permet au module Commercial de referencer une Category via le contrat
|
||||
# Shared sans importer la classe concrete du module Catalog (regle n°1).
|
||||
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
|
||||
mappings:
|
||||
Core:
|
||||
type: attribute
|
||||
@@ -66,6 +70,16 @@ doctrine:
|
||||
dir: '%kernel.project_dir%/src/Module/Catalog/Domain/Entity'
|
||||
prefix: 'App\Module\Catalog\Domain\Entity'
|
||||
alias: Catalog
|
||||
# Mapping inconditionnel du module Commercial (meme logique que Catalog) :
|
||||
# les tables (client, sous-collections, referentiels comptables) creees
|
||||
# par la migration M1 (Version20260601000000) doivent etre connues de
|
||||
# l'ORM. L'activation fonctionnelle passe par config/modules.php.
|
||||
Commercial:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity'
|
||||
prefix: 'App\Module\Commercial\Domain\Entity'
|
||||
alias: Commercial
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
|
||||
@@ -83,6 +83,13 @@ return [
|
||||
'module' => 'sites',
|
||||
'permission' => 'sites.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.catalog.categories',
|
||||
'to' => '/admin/categories',
|
||||
'icon' => 'mdi:tag-multiple-outline',
|
||||
'module' => 'catalog',
|
||||
'permission' => 'catalog.categories.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.core.audit_log',
|
||||
'to' => '/admin/audit-log',
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.43'
|
||||
app.version: '0.1.54'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M1
|
||||
nom: "Répertoire clients"
|
||||
ecran: repertoire-clients
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0
|
||||
date_redaction: 2026-05-28
|
||||
|
||||
# === LIENS ===
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898"
|
||||
regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.04, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13, RG-1.14, RG-1.15, RG-1.16, RG-1.17, RG-1.18, RG-1.19, RG-1.20, RG-1.21, RG-1.22, RG-1.23, RG-1.24, RG-1.25, RG-1.26, RG-1.27, RG-1.28, RG-1.29]
|
||||
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||
lien_spec_back: ./spec-back.md
|
||||
|
||||
# === VALIDATION CLIENT #1 ===
|
||||
client_validation_1:
|
||||
statut: validee
|
||||
date: 2026-05-22
|
||||
canal: ecrit
|
||||
valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet"
|
||||
resume: "Module 1 — Répertoire clients. Page d'entrée Commercial. Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Information / Contact / Adresse / Comptabilité (Transport, Statistiques, Rapports, Échanges = placeholders blancs)."
|
||||
trace_archivee: "uploads/4a1b026f-M1-reportoire-clients.docx (V0 d'origine .docx)"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_taskgroup_id: 23
|
||||
lesstime_project_id: 6
|
||||
statut_global: en_dev
|
||||
---
|
||||
|
||||
# Module 1 — Répertoire clients (V0 front)
|
||||
|
||||
> **Origine** : spec front V0 livrée le 22/05/2026 (`M1-reportoire-clients.docx`). Restitution Markdown pour intégration au workflow MALIO. Le contenu original n'est pas modifié — toute précision et toute décision (en particulier côté back) vit dans [`spec-back.md`](./spec-back.md).
|
||||
|
||||
## But
|
||||
|
||||
Permettre aux utilisateurs Starseed (selon rôle) de gérer le **répertoire des clients** de l'organisation : consultation, création, modification, archivage. Cette page est la **porte d'entrée du module Commercial**.
|
||||
|
||||
## Accès
|
||||
|
||||
- **Depuis** : menu principal → section **Commercial** → entrée « Répertoire clients »
|
||||
- **Rôles autorisés** :
|
||||
|
||||
| Rôle | Consultation | Création / Modification | Archivage |
|
||||
|---|---|---|---|
|
||||
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
|
||||
| **Bureau** | ✅ Tout | ✅ Tout sauf onglet Comptabilité | ❌ |
|
||||
| **Compta** | ✅ Tout | ✅ Onglet Comptabilité uniquement | ❌ |
|
||||
| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ |
|
||||
| **Usine** | ❌ | ❌ | ❌ |
|
||||
|
||||
> **Note** : aligné sur le docx d'origine — Compta édite uniquement l'onglet Comptabilité (champs SIREN / TVA / Délai de règlement / Type de règlement / Banque / RIBs). Compta ne peut pas **créer** un client (pas de droit `manage` général), mais peut éditer la partie comptable d'un client existant créé par Admin ou Bureau.
|
||||
|
||||
## Navigation
|
||||
|
||||
L'écran est la page d'entrée du module **Commercial**. Titre : « **Répertoire clients** ».
|
||||
|
||||
- Affichage principal : un **datatable** listant tous les clients **actifs** de l'organisation (les clients archivés sont masqués par défaut — filtre UI dédié pour les voir).
|
||||
- **Clic sur une ligne** → bascule sur l'écran **Consultation client** (page dédiée, pas un drawer — cf. maquette Figma).
|
||||
- **Bouton « + Ajouter »** (en haut à droite) → bascule sur l'écran **Ajouter un client**.
|
||||
- **Bouton « Exporter »** (en haut à droite) → télécharge un **fichier XLSX** des clients **affichés** (cf. filtre actif). Format détaillé dans [`spec-back.md` § Export](./spec-back.md).
|
||||
|
||||
## Datatable du Répertoire
|
||||
|
||||
Composant : `<MalioDataTable>`. Colonnes (à raffiner avec Tristan en revue maquette) :
|
||||
|
||||
| Colonne | Source | Tri |
|
||||
|---|---|---|
|
||||
| **Nom entreprise** | `client.companyName` | ASC par défaut |
|
||||
| **Contact principal** | `firstName + lastName` | Oui |
|
||||
| **Téléphone principal** | `phonePrimary` (formaté `XX XX XX XX XX`) | Non |
|
||||
| **Email principal** | `email` | Oui |
|
||||
| **Catégories** | liste des codes catégories séparés par `,` | Non |
|
||||
| **Site(s)** | sites rattachés à au moins une adresse (badges colorés) | Non |
|
||||
|
||||
> **Filtre archivés** : toggle UI en haut du datatable. Désactivé par défaut. État local (pas dans l'URL — cf. règle ABSOLUE Starseed n°6).
|
||||
|
||||
> **Pagination** : front via `<MalioDataTable>` (volumétrie cible faible — quelques centaines). Tri serveur `companyName ASC` par défaut.
|
||||
|
||||
## Écran « Ajouter un client »
|
||||
|
||||
Création par **onglets successifs avec validation incrémentale** : pour pouvoir passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant**, et les champs de l'onglet validé passent en lecture seule + bouton « Valider » désactivé (disabled).
|
||||
|
||||
### Formulaire principal (pré-onglets)
|
||||
|
||||
C'est le 1er bloc à remplir. Sans validation de ce formulaire, les onglets ne sont pas accessibles.
|
||||
|
||||
| Champ | Type composant | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom du client (Entreprise)** | `<MalioInputText>` | Oui | RG-1.18 (normalisation UPPERCASE serveur) |
|
||||
| **Nom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
|
||||
| **Prénom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de l'API ; M2M Client ↔ Category |
|
||||
| **Téléphone principal** | `<MalioInputText>` (masque tel) | Oui | RG-1.02 + RG-1.20 (format `XX XX XX XX XX`) |
|
||||
| **Téléphone secondaire** | `<MalioInputText>` (masque tel) | Non | Apparaît au clic sur le bouton `+` (RG-1.02). Max 2 — bouton `+` disparaît une fois rempli. |
|
||||
| **Email** | `<MalioInputText>` type email | Oui | RG-1.21 (lowercase) |
|
||||
| **Distributeur / Courtier** | `<MalioSelect>` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. |
|
||||
| **Nom du distributeur** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du distributeur ». Liste = clients ayant ≥ 1 catégorie de type `DISTRIBUTEUR`. RG-1.03. |
|
||||
| **Nom du courtier** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du courtier ». Liste = clients ayant ≥ 1 catégorie de type `COURTIER`. RG-1.03. |
|
||||
| **Prestation de triage** | `<MalioCheckbox>` | Non | — |
|
||||
|
||||
**Action** : « Valider » (`<MalioButton>`) → POST `/api/clients` ([`spec-back.md` § 4.3](./spec-back.md)). Si succès, on passe automatiquement à l'onglet « Information ».
|
||||
|
||||
### Onglet « Information »
|
||||
|
||||
Saisir les informations de l'entreprise.
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Description** | `<MalioInputTextArea>` | Conditionnel | RG-1.04 (obligatoire pour rôle Commerciale) |
|
||||
| **Concurrents** | `<MalioInputText>` | Conditionnel | RG-1.04 |
|
||||
| **Date de création** (de l'entreprise) | `<input type="date">` (exception Malio — pas de composant date couvert) | Conditionnel | RG-1.04 |
|
||||
| **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-1.04 |
|
||||
| **CA €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
|
||||
| **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-1.04 |
|
||||
| **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
|
||||
|
||||
**Action** : « Valider » → PATCH partiel `/api/clients/{id}` (groupe `client:write:information`).
|
||||
|
||||
### Onglet « Contact »
|
||||
|
||||
Saisir un ou plusieurs contacts associés au client. Le 1er bloc est **pré-rempli** depuis les champs du formulaire principal (Nom, Prénom, Téléphone, Email — édition autorisée).
|
||||
|
||||
**Bloc Contact** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom** | `<MalioInputText>` | Conditionnel | RG-1.05 + RG-1.19 (Capitalize) |
|
||||
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-1.05 + RG-1.19 (Capitalize) |
|
||||
| **Fonction** | `<MalioInputText>` | Non | — |
|
||||
| **Téléphone** (x1, +1 possible) | `<MalioInputText>` | Non | RG-1.20 (format) |
|
||||
| **Email** | `<MalioInputText>` type email | Non | RG-1.21 (lowercase) |
|
||||
|
||||
**RG-1.14 (renforcement validée par Tristan le 28/05)** : **au moins 1 bloc Contact valide** (au moins Nom OU Prénom rempli) est obligatoire pour valider l'onglet. Donc l'onglet Contact ne peut pas être finalisé vide.
|
||||
|
||||
**Actions** :
|
||||
- « + Nouveau contact » : ajoute un bloc. Bouton **désactivé tant que le bloc précédent n'a pas Prénom OU Nom rempli** (RG-1.05).
|
||||
- « Supprimer » (icône) sur un bloc : modal de confirmation (`<MalioButton>` Annuler / Confirmer). Si Oui → suppression du bloc.
|
||||
- « Valider » → PATCH `/api/clients/{id}/contacts` (création/mise à jour de la collection).
|
||||
|
||||
### Onglet « Adresse »
|
||||
|
||||
Saisir une ou plusieurs adresses du client, rattachées à un ou plusieurs sites Starseed (Châtellerault 86 / Saint-Jean 17 / Pommevic 82) et à des contacts.
|
||||
|
||||
**Bloc Adresse** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Prospect** | `<MalioCheckbox>` | Non | RG-1.06 — masque Adresse de livraison + Facturation si coché |
|
||||
| **Adresse de livraison** | `<MalioCheckbox>` | Non | RG-1.07 — masque Prospect si coché |
|
||||
| **Facturation** | `<MalioCheckbox>` | Non | RG-1.08 — masque Prospect si coché ; affiche le champ Email (RG-1.11) |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de **type SECTEUR + AUTRE** uniquement (cf. décision Q5 — DISTRIBUTEUR et COURTIER qualifient une relation entre clients, pas un lieu) |
|
||||
| **Pays** | `<MalioSelect>` | Oui | Préremplie « France » |
|
||||
| **Code postal** | `<MalioInputText>` (masque numérique) | Oui | RG-1.09 — déclenche autocomplete ville via BAN |
|
||||
| **Ville** | `<MalioSelect>` | Oui | RG-1.09 — alimentée par api-adresse.data.gouv.fr suivant le CP |
|
||||
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-1.09 — autocomplete BAN |
|
||||
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
|
||||
| **Sites Starseed** | `<MalioSelectCheckbox>` (multi-checkbox 86 / 17 / 82) | Oui | RG-1.10 — ≥ 1 site obligatoire |
|
||||
| **Contact(s) rattaché(s)** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
|
||||
| **Email (facturation)** | `<MalioInputText>` type email | Conditionnel | RG-1.11 — visible/obligatoire uniquement si « Facturation » coché |
|
||||
|
||||
**Actions** :
|
||||
- « + Nouvelle Adresse » : ajoute un bloc identique.
|
||||
- « Supprimer » : modal de confirmation puis suppression.
|
||||
- « Valider » → PATCH `/api/clients/{id}/addresses`.
|
||||
|
||||
### Onglet « Transport »
|
||||
|
||||
🚧 **Placeholder blanc au M1.** Frame vide. Aucun champ. Aucun bouton de validation. L'utilisateur passe automatiquement à l'onglet suivant. **Pas de mention « En cours »** — c'est juste blanc (décision Tristan 28/05).
|
||||
|
||||
### Onglet « Comptabilité »
|
||||
|
||||
⚠ **Accessible aux rôles avec `commercial.clients.accounting.manage`** (Admin + Compta au M1). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer cet onglet** (champs SIREN / N° compte / TVA / Délai / Type de règlement / Banque / RIBs) — cf. décision Q1, aligné docx. Compta ne peut pas créer un client (pas de `manage` général).
|
||||
|
||||
**Champs comptables** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | Format 9 chiffres. **Pas d'unicité** (décision Q4) |
|
||||
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
|
||||
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` |
|
||||
| **N° de TVA** | `<MalioInputText>` | Oui | — |
|
||||
| **Délai de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_delays` |
|
||||
| **Type de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_types` |
|
||||
| **Banque** | `<MalioSelect>` | Conditionnel | RG-1.12 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks`. |
|
||||
|
||||
**Bloc RIB** (0..n blocs, présence obligatoire conditionnée par RG-1.13) :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 |
|
||||
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 — `#[AuditIgnore]` (champ sensible) |
|
||||
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 — `#[AuditIgnore]` (champ sensible) |
|
||||
|
||||
**Actions** :
|
||||
- « + RIB » : ajoute un bloc.
|
||||
- « Supprimer » (icône) : modal de confirmation.
|
||||
- « Valider » → PATCH `/api/clients/{id}/accounting`.
|
||||
|
||||
### Onglets « Statistiques » / « Rapports » / « Échanges »
|
||||
|
||||
🚧 **Placeholders blancs au M1.** Mêmes règles que Transport (frames vides, pas de validation).
|
||||
|
||||
## Écran « Consultation client »
|
||||
|
||||
Tous les champs en **lecture seule**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans bouton `+` pour ajouter des blocs Contact / Adresse / RIB.
|
||||
|
||||
- **Flèche retour** (à gauche) → revient au Répertoire.
|
||||
- **Bouton « Modifier »** (à droite, visible si l'utilisateur a la permission `commercial.clients.manage`) → bascule sur l'écran Modification.
|
||||
- **Bouton « Archiver »** (à droite, visible **uniquement pour Admin** via permission `commercial.clients.archive`) → ouvre une modal de confirmation, puis PATCH `/api/clients/{id}` `{ "isArchived": true }`. Le client passe en archivé (cf. flag `is_archived`).
|
||||
|
||||
> Le client archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé. Décision validée Tristan 28/05.
|
||||
|
||||
### Onglets affichés en consultation
|
||||
|
||||
Mêmes onglets qu'en création, **plus** les 4 placeholders blancs. L'utilisateur navigue librement entre les onglets (pas de séquence forcée en consultation).
|
||||
|
||||
## Écran « Modification client »
|
||||
|
||||
Comportement identique à l'écran Ajouter sauf :
|
||||
- **Pas de formulaire principal** (les champs principaux sont édités via les onglets correspondants).
|
||||
- Les champs sont **pré-remplis** avec les valeurs actuelles.
|
||||
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
|
||||
- Les onglets pour lesquels l'utilisateur n'a **pas** la permission `manage` restent en lecture seule (pas de bouton Valider, pas d'icône suppression de bloc).
|
||||
- Les onglets placeholders restent inaccessibles à l'édition (blancs).
|
||||
|
||||
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||
|
||||
- **Datatable** : `<MalioDataTable>` (Répertoire)
|
||||
- **Input texte** : `<MalioInputText>`
|
||||
- **Input numérique** : `<MalioInputNumber>`
|
||||
- **Input montant** : `<MalioInputAmount>` (CA, Résultat)
|
||||
- **TextArea** : `<MalioInputTextArea>` (Description)
|
||||
- **Select simple** : `<MalioSelect>` (Pays, Ville, distributeur/courtier, refs comptables)
|
||||
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
|
||||
- **Checkbox** : `<MalioCheckbox>` (Prospect, Adresse livraison, Facturation, Prestation de triage)
|
||||
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
|
||||
- **Toasts** : standards via `useApi()`
|
||||
|
||||
**Exceptions autorisées** (à commenter `// TODO migrer quand Malio couvre`) :
|
||||
- `<input type="date">` pour « Date de création » (composant `MalioDate` non couvert)
|
||||
- Modal de confirmation : composant à confirmer côté équipe front (probablement `<MalioModal>` ou un wrapper à créer dans `frontend/shared/`)
|
||||
|
||||
## Règles de formatage et normalisation
|
||||
|
||||
Le serveur normalise systématiquement (cf. RG-1.18 à RG-1.21 dans [`spec-back.md`](./spec-back.md)) :
|
||||
|
||||
| Champ | Normalisation serveur | Affichage front |
|
||||
|---|---|---|
|
||||
| Nom entreprise (`companyName`) | UPPERCASE intégral | UPPERCASE |
|
||||
| Nom + Prénom contact | Capitalize (1ère lettre majuscule + reste minuscule) | identique |
|
||||
| Téléphone (`phonePrimary`, `phoneSecondary`, contact phones) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) |
|
||||
| Email | lowercase intégral | identique |
|
||||
|
||||
> **Le front ne fait pas la normalisation** — il envoie la valeur saisie, le serveur normalise puis renvoie la valeur normalisée. L'UI affiche immédiatement la valeur normalisée renvoyée par l'API. Cohérent avec le pattern `useApi()`.
|
||||
|
||||
## API adresse postale
|
||||
|
||||
Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse.data.gouv.fr** (Base Adresse Nationale, gratuite, française).
|
||||
|
||||
- Composable dédié `useAddressAutocomplete()` (à créer en M1).
|
||||
- Appel HTTP **direct depuis le front** (CORS OK), pas de proxy back.
|
||||
- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions adresse.
|
||||
- Cas dégradé : si l'API ne répond pas (offline, timeout), le champ Ville devient un `<MalioInputText>` libre éditable + toast d'avertissement. Validation serveur acceptera la saisie libre.
|
||||
|
||||
## Points laissés ouverts par la V0 (résolus côté back)
|
||||
|
||||
| # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) |
|
||||
|---|---|---|
|
||||
| 1 | Catégorie en multi-select non clarifiée (1 ou n par client) | **M2M `client_category`** validée. CategoryType seedé avec `DISTRIBUTEUR`, `COURTIER`, `SECTEUR`, `AUTRE` (HP-3 du M0 levé). |
|
||||
| 2 | Distributeur / Courtier : liste de quoi ? | **Auto-référence Client** via 2 FK nullables `distributor_id` et `broker_id` (cf. RG-1.03). Une seule des deux est remplie à la fois. |
|
||||
| 3 | Onglet « Comptabilité » : qui édite ? | **Admin et Compta** peuvent éditer l'onglet Comptabilité (`commercial.clients.accounting.manage`). Bureau / Commerciale ne voient pas l'onglet. Compta ne peut pas créer un client (pas de `manage` global), mais peut éditer la partie comptable d'un client existant. |
|
||||
| 4 | Workflow par onglet | **Sauvegarde incrémentale**. POST formulaire principal crée le `Client` (status implicite « actif »). Chaque onglet validé = PATCH partiel par groupe de sérialisation dédié. Pas d'état « draft ». |
|
||||
| 5 | Onglets « À venir » | **Placeholders blancs** (frames vides, pas de message). Ré-activables sans rebuild quand les modules associés arriveront. |
|
||||
| 6 | Archive vs soft delete | **Flag `is_archived` séparé de `deleted_at`**. Archive ≠ delete : un client archivé est masqué par défaut mais reste en BDD éditable (Admin seul). Filtres UI distincts. Soft delete = HP M2. |
|
||||
| 7 | Unicité métier | **Nom d'entreprise uniquement** (case-insensitive, parmi non-archivés) — décision Q4. SIREN et email NON uniques. Index partiel Postgres `uq_client_company_name_active`. Doublon de nom → 409 Conflict. |
|
||||
| 8 | Téléphones (max 2) | **2 colonnes plates** `phone_primary` + `phone_secondary`. Pas de table séparée. |
|
||||
| 9 | API code postal | **api-adresse.data.gouv.fr** (BAN). Appel direct front via composable dédié. Cas dégradé : saisie libre + toast. |
|
||||
| 10 | Référentiels comptables | **4 entités CRUD-ables** (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`) seedées au M1, CRUD admin futur (HP-M2). |
|
||||
| 11 | Format de l'export | **XLSX uniquement** au M1. CSV à étudier en HP. |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime générés
|
||||
|
||||
**TaskGroup Lesstime** : à créer — `M1 — Répertoire clients` (projet `ERP / Starseed`, projectId=6).
|
||||
|
||||
> Détail complet, table des tickets et action manuelle dans Lesstime → voir [`spec-back.md § Tickets Lesstime générés`](./spec-back.md#-tickets-lesstime-générés).
|
||||
@@ -1,9 +1,17 @@
|
||||
<!--
|
||||
Valeurs en dur issues de la maquette Figma (design Starseed) :
|
||||
- sidebar depliee : 232px (w-[232px], repli laisse par defaut 72px)
|
||||
- marge horizontale du contenu sur desktop : 170px (xl:px-[170px])
|
||||
- bande blanche sticky sous la navbar : 47px (h-[47px])
|
||||
A faire evoluer uniquement avec une mise a jour de maquette.
|
||||
-->
|
||||
<template>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<MalioSidebar
|
||||
v-model="ui.sidebarCollapsed"
|
||||
:sections="translatedSections"
|
||||
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
|
||||
>
|
||||
<template #logo>
|
||||
<img src="/LOGO_MALIO.png" alt="Malio"/>
|
||||
@@ -16,10 +24,10 @@
|
||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<SiteSelector v-if="showSiteSelector"/>
|
||||
<main
|
||||
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-[170px]">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12"/>
|
||||
class="pointer-events-none sticky top-0 z-30 h-[47px] flex-shrink-0 bg-white"/>
|
||||
<slot/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
},
|
||||
"sites": {
|
||||
"admin": "Sites"
|
||||
},
|
||||
"catalog": {
|
||||
"categories": "Gestion des catégories"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -85,12 +88,19 @@
|
||||
},
|
||||
"empty": "Aucune activité enregistrée",
|
||||
"no_results": "Aucun résultat pour ces filtres",
|
||||
"error": {
|
||||
"title": "Erreur",
|
||||
"message": "Impossible de charger le journal d'audit. Vérifiez les filtres ou réessayez."
|
||||
},
|
||||
"timeline": {
|
||||
"empty": "Aucun historique",
|
||||
"load_more": "Voir plus"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser",
|
||||
"date_range": "Date à date",
|
||||
"date_from": "Du",
|
||||
"date_to": "Au",
|
||||
"entity_type": "Type d'entité",
|
||||
@@ -220,6 +230,39 @@
|
||||
"updated": "Site mis à jour avec succès",
|
||||
"deleted": "Site supprimé avec succès"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"title": "Gestion des catégories",
|
||||
"newCategory": "Ajouter",
|
||||
"editCategory": "Modifier la catégorie",
|
||||
"createCategory": "Créer une catégorie",
|
||||
"viewCategory": "Détail de la catégorie",
|
||||
"noCategories": "Aucune catégorie pour l'instant.",
|
||||
"table": {
|
||||
"name": "Nom",
|
||||
"type": "Type"
|
||||
},
|
||||
"form": {
|
||||
"name": "Nom",
|
||||
"type": "Type de catégorie",
|
||||
"typePlaceholder": "Sélectionner un type"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Le nom est obligatoire.",
|
||||
"nameLength": "Le nom doit faire entre 2 et 120 caractères.",
|
||||
"typeRequired": "Le type de catégorie est obligatoire."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Supprimer la catégorie",
|
||||
"message": "Êtes-vous sûr de vouloir supprimer la catégorie \"{name}\" ? Cette action est irréversible."
|
||||
},
|
||||
"toast": {
|
||||
"created": "Catégorie créée avec succès",
|
||||
"updated": "Catégorie mise à jour avec succès",
|
||||
"deleted": "Catégorie supprimée avec succès",
|
||||
"duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.",
|
||||
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<MalioModal
|
||||
:model-value="modelValue"
|
||||
modal-class="max-w-md"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold text-neutral-900">
|
||||
{{ t('admin.categories.delete.title') }}
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<p class="text-sm text-neutral-600">
|
||||
{{ t('admin.categories.delete.message', { name: categoryName }) }}
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="secondary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
:disabled="loading"
|
||||
@click="emit('confirm')"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
categoryName: string
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
confirm: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
drawer-class="w-full max-w-lg"
|
||||
header-class="border-b border-black"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-2xl font-bold">
|
||||
{{ headerLabel }}
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
|
||||
<!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim).
|
||||
Erreur miroir client + erreurs server-side (422) mappees sur ce champ. -->
|
||||
<MalioInputText
|
||||
v-model="form.name.value"
|
||||
:label="t('admin.categories.form.name')"
|
||||
input-class="w-full"
|
||||
:max-length="120"
|
||||
:error="form.errors.value.name"
|
||||
required
|
||||
/>
|
||||
|
||||
<!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
|
||||
number (categoryType id) ; conversion en IRI au moment du save
|
||||
par le composable useCategoryForm. -->
|
||||
<MalioSelect
|
||||
v-model="form.categoryTypeId.value"
|
||||
:options="typeOptions"
|
||||
:label="t('admin.categories.form.type')"
|
||||
:empty-option-label="t('admin.categories.form.typePlaceholder')"
|
||||
:error="form.errors.value.categoryType"
|
||||
:disabled="loadingTypes"
|
||||
/>
|
||||
|
||||
<!-- Erreur transverse (typiquement reseau / 5xx) — separe des
|
||||
erreurs de validation par champ. -->
|
||||
<p v-if="form.errors.value._global" class="text-sm text-red-600">
|
||||
{{ form.errors.value._global }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<!-- Footer fixe : depuis 1.7.1 le slot #footer est un frere du body
|
||||
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
v-if="canShowDelete"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
button-class="w-[150px]"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-[150px]"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canShowSave"
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-[150px]"
|
||||
:disabled="form.submitting.value || loadingTypes"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Category } from '~/modules/catalog/types/category'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { can } = usePermissions()
|
||||
const { types, loadingTypes, fetchTypes } = useCategoriesAdmin()
|
||||
// Instance dediee de form pour ce drawer — state isole (cf. useCategoryForm
|
||||
// n'est pas singleton, contrairement a useCategoriesAdmin).
|
||||
const form = useCategoryForm()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
category: Category | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
/**
|
||||
* Mode du drawer (dérivé du composable `useCategoryForm`) :
|
||||
* - 'create' : pas de category prop, formulaire vide, POST au save.
|
||||
* - 'view' : category prop set, formulaire pre-rempli, save MASQUE
|
||||
* jusqu'a ce que l'utilisateur modifie un champ.
|
||||
* - 'edit' : category prop set et formulaire « dirty » (au moins un
|
||||
* champ different de l'original), PATCH au save.
|
||||
*/
|
||||
type DrawerMode = 'create' | 'view' | 'edit'
|
||||
|
||||
const isCreateMode = computed(() => props.category === null)
|
||||
|
||||
const mode = computed<DrawerMode>(() => {
|
||||
if (isCreateMode.value) return 'create'
|
||||
return form.isDirty.value ? 'edit' : 'view'
|
||||
})
|
||||
|
||||
const headerLabel = computed(() => {
|
||||
if (mode.value === 'create') return t('admin.categories.createCategory')
|
||||
if (mode.value === 'edit') return t('admin.categories.editCategory')
|
||||
return t('admin.categories.viewCategory')
|
||||
})
|
||||
|
||||
// Le bouton Supprimer n'est visible qu'en consultation/edition d'une categorie
|
||||
// existante et seulement pour les users ayant la permission manage. En mode
|
||||
// creation on affiche un bouton Annuler a la place.
|
||||
const canShowDelete = computed(
|
||||
() => !isCreateMode.value && can('catalog.categories.manage'),
|
||||
)
|
||||
|
||||
// Save : visible en creation, ou en edition (apres modification d'un champ).
|
||||
// Masque en view tant que rien n'a change.
|
||||
const canShowSave = computed(
|
||||
() => mode.value === 'create' || mode.value === 'edit',
|
||||
)
|
||||
|
||||
const typeOptions = computed(() =>
|
||||
types.value.map(ct => ({
|
||||
label: ct.label,
|
||||
value: ct.id,
|
||||
})),
|
||||
)
|
||||
|
||||
// Re-initialise le form quand la categorie selectionnee change (clic sur une
|
||||
// autre ligne sans fermer le drawer entre-temps).
|
||||
watch(() => props.category, (cat) => {
|
||||
form.loadFrom(cat)
|
||||
}, { immediate: true })
|
||||
|
||||
// A chaque ouverture du drawer : reload du form + refresh des types (au cas
|
||||
// ou un type aurait ete ajoute en arriere-plan depuis le dernier fetch — pas
|
||||
// d'optimisation cache au M0, le referentiel est petit).
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
form.loadFrom(props.category)
|
||||
fetchTypes()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Sauvegarde : delegue au composable (POST en mode create, PATCH en mode
|
||||
* edit). Le toast succes + mapping erreur 409/422 est gere par le composable.
|
||||
* En cas de succes, on ferme le drawer et on previent le parent pour qu'il
|
||||
* refresh la liste.
|
||||
*/
|
||||
async function handleSave(): Promise<void> {
|
||||
let result: Category | null = null
|
||||
if (mode.value === 'create') {
|
||||
result = await form.submitCreate()
|
||||
} else if (mode.value === 'edit' && props.category) {
|
||||
result = await form.submitUpdate(props.category.id)
|
||||
}
|
||||
if (result) {
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
|
||||
// Mock du store auth : useCategoriesAdmin s'auto-enregistre via
|
||||
// `onAuthSessionCleared(...)` au chargement du module. On stubbe pour
|
||||
// eviter de charger Pinia et la vraie store (pas necessaire ici).
|
||||
vi.mock('~/shared/stores/auth', () => ({
|
||||
onAuthSessionCleared: vi.fn(),
|
||||
}))
|
||||
|
||||
// Le client API est un auto-import Nuxt. On le remplace par un stub
|
||||
// global pour intercepter les appels et controler les reponses dans
|
||||
// chaque test (cf. pattern utilise dans useCurrentSite.spec.ts).
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: mockGet,
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
|
||||
// Import APRES vi.mock / vi.stubGlobal : le module n'est evalue qu'a
|
||||
// ce moment-la, donc le mock auth est bien actif au top-level.
|
||||
const { useCategoriesAdmin } = await import('../useCategoriesAdmin')
|
||||
|
||||
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
|
||||
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
||||
|
||||
const CAT_A: Category = {
|
||||
id: 10,
|
||||
name: 'Vis',
|
||||
categoryType: TYPE_VENTE,
|
||||
deletedAt: null,
|
||||
createdAt: '2026-01-01T10:00:00+00:00',
|
||||
updatedAt: '2026-01-01T10:00:00+00:00',
|
||||
createdBy: null,
|
||||
updatedBy: null,
|
||||
}
|
||||
const CAT_B: Category = {
|
||||
id: 11,
|
||||
name: 'Boulons',
|
||||
categoryType: TYPE_VENTE,
|
||||
deletedAt: null,
|
||||
createdAt: '2026-01-02T10:00:00+00:00',
|
||||
updatedAt: '2026-01-02T10:00:00+00:00',
|
||||
createdBy: null,
|
||||
updatedBy: null,
|
||||
}
|
||||
|
||||
function makeHydra<T>(items: T[]): HydraCollection<T> {
|
||||
return {
|
||||
totalItems: items.length,
|
||||
member: items,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useCategoriesAdmin', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
// Reset systematique du state singleton entre tests : sans ca,
|
||||
// les categories chargees dans un test fuiteraient dans le suivant.
|
||||
const { resetCategoriesAdmin } = useCategoriesAdmin()
|
||||
resetCategoriesAdmin()
|
||||
})
|
||||
|
||||
describe('fetchAll', () => {
|
||||
it('appelle GET /categories avec itemsPerPage=999 par defaut', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
|
||||
const { fetchAll } = useCategoriesAdmin()
|
||||
|
||||
await fetchAll()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/categories',
|
||||
{ itemsPerPage: 999 },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('peuple categories.value depuis le champ Hydra member', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra([CAT_A, CAT_B]))
|
||||
const { fetchAll, categories } = useCategoriesAdmin()
|
||||
|
||||
await fetchAll()
|
||||
|
||||
expect(categories.value).toEqual([CAT_A, CAT_B])
|
||||
})
|
||||
|
||||
it('exclut les soft-deleted par defaut (pas de query includeDeleted)', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
|
||||
const { fetchAll } = useCategoriesAdmin()
|
||||
|
||||
await fetchAll()
|
||||
|
||||
const queryArg = mockGet.mock.calls[0]?.[1] as Record<string, unknown>
|
||||
expect(queryArg).not.toHaveProperty('includeDeleted')
|
||||
})
|
||||
|
||||
it('ajoute includeDeleted=true quand demande explicitement', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
|
||||
const { fetchAll } = useCategoriesAdmin()
|
||||
|
||||
await fetchAll(true)
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/categories',
|
||||
{ itemsPerPage: 999, includeDeleted: 'true' },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('passe loading a true pendant la requete et false apres', async () => {
|
||||
let resolveRequest: (v: HydraCollection<Category>) => void = () => {}
|
||||
mockGet.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||
)
|
||||
const { fetchAll, loading } = useCategoriesAdmin()
|
||||
|
||||
const pending = fetchAll()
|
||||
expect(loading.value).toBe(true)
|
||||
|
||||
resolveRequest(makeHydra<Category>([]))
|
||||
await pending
|
||||
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('peuple error.value et vide categories en cas d echec', async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error('Network down'))
|
||||
const { fetchAll, categories, error, loading } = useCategoriesAdmin()
|
||||
// Pre-charge volontairement quelque chose pour verifier la purge.
|
||||
categories.value = [CAT_A]
|
||||
|
||||
await fetchAll()
|
||||
|
||||
expect(categories.value).toEqual([])
|
||||
expect(error.value).toBe('Network down')
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('gere une reponse sans champ member (fallback tableau vide)', async () => {
|
||||
mockGet.mockResolvedValueOnce({
|
||||
totalItems: 0,
|
||||
} as unknown as HydraCollection<Category>)
|
||||
const { fetchAll, categories } = useCategoriesAdmin()
|
||||
|
||||
await fetchAll()
|
||||
|
||||
expect(categories.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchTypes', () => {
|
||||
it('appelle GET /category_types avec itemsPerPage=999', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([]))
|
||||
const { fetchTypes } = useCategoriesAdmin()
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/category_types',
|
||||
{ itemsPerPage: 999 },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('peuple types.value depuis le champ Hydra member', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra([TYPE_VENTE, TYPE_ACHAT]))
|
||||
const { fetchTypes, types } = useCategoriesAdmin()
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(types.value).toEqual([TYPE_VENTE, TYPE_ACHAT])
|
||||
})
|
||||
|
||||
it('peuple error.value et vide types en cas d echec', async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error('500'))
|
||||
const { fetchTypes, types, error, loadingTypes } = useCategoriesAdmin()
|
||||
types.value = [TYPE_VENTE]
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(types.value).toEqual([])
|
||||
expect(error.value).toContain('500')
|
||||
expect(loadingTypes.value).toBe(false)
|
||||
})
|
||||
|
||||
it('passe loadingTypes a true pendant la requete et false apres', async () => {
|
||||
let resolveRequest: (v: HydraCollection<CategoryType>) => void = () => {}
|
||||
mockGet.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||
)
|
||||
const { fetchTypes, loadingTypes } = useCategoriesAdmin()
|
||||
|
||||
const pending = fetchTypes()
|
||||
expect(loadingTypes.value).toBe(true)
|
||||
|
||||
resolveRequest(makeHydra<CategoryType>([]))
|
||||
await pending
|
||||
|
||||
expect(loadingTypes.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetCategoriesAdmin', () => {
|
||||
it('vide categories, types, loading, loadingTypes et error', () => {
|
||||
const { resetCategoriesAdmin, categories, types, loading, loadingTypes, error }
|
||||
= useCategoriesAdmin()
|
||||
// Pre-peuple le state pour verifier la purge effective.
|
||||
categories.value = [CAT_A]
|
||||
types.value = [TYPE_VENTE]
|
||||
loading.value = true
|
||||
loadingTypes.value = true
|
||||
error.value = 'oops'
|
||||
|
||||
resetCategoriesAdmin()
|
||||
|
||||
expect(categories.value).toEqual([])
|
||||
expect(types.value).toEqual([])
|
||||
expect(loading.value).toBe(false)
|
||||
expect(loadingTypes.value).toBe(false)
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('singleton', () => {
|
||||
it('deux appels a useCategoriesAdmin() partagent la meme ref categories', () => {
|
||||
const a = useCategoriesAdmin()
|
||||
const b = useCategoriesAdmin()
|
||||
|
||||
// Les fonctions sont reinstanciees a chaque appel mais les refs
|
||||
// doivent etre rigoureusement les memes (state au niveau module).
|
||||
expect(a.categories).toBe(b.categories)
|
||||
expect(a.types).toBe(b.types)
|
||||
expect(a.loading).toBe(b.loading)
|
||||
})
|
||||
|
||||
it('une mutation via une instance est visible depuis une autre instance', () => {
|
||||
const a = useCategoriesAdmin()
|
||||
const b = useCategoriesAdmin()
|
||||
|
||||
a.categories.value = [CAT_A]
|
||||
|
||||
expect(b.categories.value).toEqual([CAT_A])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,454 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||
import { useCategoryForm } from '../useCategoryForm'
|
||||
|
||||
// Stubs des auto-imports Nuxt consommes par le composable.
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
const mockPost = vi.hoisted(() => vi.fn())
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
const mockDelete = vi.hoisted(() => vi.fn())
|
||||
const mockToastSuccess = vi.hoisted(() => vi.fn())
|
||||
const mockToastError = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: mockGet,
|
||||
post: mockPost,
|
||||
put: vi.fn(),
|
||||
patch: mockPatch,
|
||||
delete: mockDelete,
|
||||
}))
|
||||
vi.stubGlobal('useToast', () => ({
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
}))
|
||||
// useI18n.t : on renvoie la cle telle quelle (pratique pour asserter dessus).
|
||||
// Quand le composable passe des params (ex: doublon), on les serialise pour
|
||||
// pouvoir verifier que l'interpolation a bien recu le bon nom.
|
||||
vi.stubGlobal('useI18n', () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}::${JSON.stringify(params)}` : key,
|
||||
}))
|
||||
|
||||
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
|
||||
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
||||
|
||||
const CAT: Category = {
|
||||
id: 42,
|
||||
name: 'Vis',
|
||||
categoryType: TYPE_VENTE,
|
||||
deletedAt: null,
|
||||
createdAt: '2026-01-01T10:00:00+00:00',
|
||||
updatedAt: '2026-01-01T10:00:00+00:00',
|
||||
createdBy: null,
|
||||
updatedBy: null,
|
||||
}
|
||||
|
||||
describe('useCategoryForm', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
mockDelete.mockReset()
|
||||
mockToastSuccess.mockReset()
|
||||
mockToastError.mockReset()
|
||||
})
|
||||
|
||||
describe('loadFrom', () => {
|
||||
it('pre-remplit le formulaire depuis une categorie existante', () => {
|
||||
const form = useCategoryForm()
|
||||
|
||||
form.loadFrom(CAT)
|
||||
|
||||
expect(form.name.value).toBe('Vis')
|
||||
expect(form.categoryTypeId.value).toBe(1)
|
||||
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||
})
|
||||
|
||||
it('vide le formulaire en mode creation (null)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'old'
|
||||
form.categoryTypeId.value = 99
|
||||
|
||||
form.loadFrom(null)
|
||||
|
||||
expect(form.name.value).toBe('')
|
||||
expect(form.categoryTypeId.value).toBeNull()
|
||||
})
|
||||
|
||||
it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
|
||||
const form = useCategoryForm()
|
||||
|
||||
form.loadFrom(CAT)
|
||||
|
||||
expect(form.isDirty.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDirty', () => {
|
||||
it('passe a true des qu une valeur diverge du snapshot initial', () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
expect(form.isDirty.value).toBe(false)
|
||||
|
||||
form.name.value = 'Vis modifie'
|
||||
|
||||
expect(form.isDirty.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validate', () => {
|
||||
it('signale une erreur si name est vide (RG-1.02)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ''
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
|
||||
})
|
||||
|
||||
it('signale erreur si name est whitespace-only (trim → vide)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ' '
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
|
||||
})
|
||||
|
||||
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'A'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
|
||||
})
|
||||
|
||||
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'A'.repeat(121)
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
|
||||
})
|
||||
|
||||
it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = null
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.categoryType).toBe('admin.categories.validation.typeRequired')
|
||||
})
|
||||
|
||||
it('passe quand name et categoryType sont valides', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||
})
|
||||
|
||||
it('reinitialise les erreurs avant chaque validation', () => {
|
||||
const form = useCategoryForm()
|
||||
// Erreur prealable.
|
||||
form.errors.value._global = 'erreur ancienne'
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
form.validate()
|
||||
|
||||
expect(form.errors.value._global).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitCreate', () => {
|
||||
it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => {
|
||||
mockPost.mockResolvedValueOnce(CAT)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ' Vis '
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/categories',
|
||||
{ name: 'Vis', categoryType: '/api/category_types/1' },
|
||||
{ toast: false },
|
||||
)
|
||||
expect(result).toEqual(CAT)
|
||||
})
|
||||
|
||||
it('ne declenche aucun appel API si la validation client echoue', async () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ''
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('declenche un toast de succes en cas de creation reussie', async () => {
|
||||
mockPost.mockResolvedValueOnce(CAT)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||
title: 'Succès',
|
||||
message: 'admin.categories.toast.created',
|
||||
})
|
||||
})
|
||||
|
||||
it('mappe un 409 (RG-1.07) sur errors.name + toast erreur avec le nom', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: { status: 409, _data: {} },
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(result).toBeNull()
|
||||
// La cle est interpolee avec le nom soumis : on retrouve "Vis" dans
|
||||
// les params i18n (stub serialise les params).
|
||||
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
|
||||
expect(form.errors.value.name).toContain('"name":"Vis"')
|
||||
expect(mockToastError).toHaveBeenCalledTimes(1)
|
||||
const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string }
|
||||
expect(toastArg.message).toContain('Vis')
|
||||
})
|
||||
|
||||
it('mappe un 422 violations sur les champs concernes (errors.name)', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: {
|
||||
violations: [
|
||||
{ propertyPath: 'name', message: 'name should not be blank.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(form.errors.value.name).toBe('name should not be blank.')
|
||||
// Pas de toast quand on a mappe les violations : l erreur est
|
||||
// affichee inline sous le champ concerne.
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mappe aussi hydra:violations (negociation de format alternative)', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: {
|
||||
'hydra:violations': [
|
||||
{ propertyPath: 'categoryType', message: 'Type invalide.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
expect(form.errors.value.categoryType).toBe('Type invalide.')
|
||||
})
|
||||
|
||||
it('fallback en erreur globale + toast si le status n est ni 409 ni 422', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: { status: 500, _data: { 'hydra:description': 'Boom server' } },
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
expect(form.errors.value._global).toBe('Boom server')
|
||||
expect(mockToastError).toHaveBeenCalledWith({
|
||||
title: 'Erreur',
|
||||
message: 'Boom server',
|
||||
})
|
||||
})
|
||||
|
||||
it('passe submitting a true pendant la requete et a false apres', async () => {
|
||||
let resolveRequest: (v: Category) => void = () => {}
|
||||
mockPost.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||
)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const pending = form.submitCreate()
|
||||
expect(form.submitting.value).toBe(true)
|
||||
|
||||
resolveRequest(CAT)
|
||||
await pending
|
||||
|
||||
expect(form.submitting.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitUpdate', () => {
|
||||
it('appelle PATCH /categories/{id} uniquement avec les champs modifies', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'Vis V2' // categoryTypeId inchange
|
||||
|
||||
await form.submitUpdate(42)
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/categories/42',
|
||||
{ name: 'Vis V2' }, // pas de categoryType car non modifie
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('envoie categoryType en IRI quand seul le type a change', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT })
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.categoryTypeId.value = 2
|
||||
|
||||
await form.submitUpdate(42)
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/categories/42',
|
||||
{ categoryType: '/api/category_types/2' },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('court-circuite l appel API si aucun champ n a change', async () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
// Aucune modification — isDirty=false, patch payload vide.
|
||||
|
||||
const result = await form.submitUpdate(42)
|
||||
|
||||
expect(mockPatch).not.toHaveBeenCalled()
|
||||
expect(result).toBeNull()
|
||||
expect(form.submitting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('declenche un toast de succes au PATCH reussi', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'Vis V2'
|
||||
|
||||
await form.submitUpdate(42)
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||
title: 'Succès',
|
||||
message: 'admin.categories.toast.updated',
|
||||
})
|
||||
})
|
||||
|
||||
it('mappe le 409 sur errors.name en mode update aussi', async () => {
|
||||
mockPatch.mockRejectedValueOnce({
|
||||
response: { status: 409, _data: {} },
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'Doublon'
|
||||
|
||||
const result = await form.submitUpdate(42)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
|
||||
expect(form.errors.value.name).toContain('"name":"Doublon"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitDelete', () => {
|
||||
it('appelle DELETE /categories/{id} et declenche un toast succes', async () => {
|
||||
mockDelete.mockResolvedValueOnce(undefined)
|
||||
const form = useCategoryForm()
|
||||
|
||||
const ok = await form.submitDelete(42)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false })
|
||||
expect(ok).toBe(true)
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||
title: 'Succès',
|
||||
message: 'admin.categories.toast.deleted',
|
||||
})
|
||||
})
|
||||
|
||||
it('retourne false et toast erreur en cas d echec', async () => {
|
||||
mockDelete.mockRejectedValueOnce({
|
||||
response: { status: 500, _data: { detail: 'down' } },
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
|
||||
const ok = await form.submitDelete(42)
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value._global).toBe('down')
|
||||
expect(mockToastError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
it('vide le formulaire et les erreurs', () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'edit'
|
||||
form.errors.value._global = 'erreur'
|
||||
form.submitting.value = true
|
||||
|
||||
form.reset()
|
||||
|
||||
expect(form.name.value).toBe('')
|
||||
expect(form.categoryTypeId.value).toBeNull()
|
||||
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||
expect(form.submitting.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isolation', () => {
|
||||
it('deux instances useCategoryForm() ont des states independants', () => {
|
||||
const a = useCategoryForm()
|
||||
const b = useCategoryForm()
|
||||
|
||||
a.name.value = 'A'
|
||||
b.name.value = 'B'
|
||||
|
||||
expect(a.name.value).toBe('A')
|
||||
expect(b.name.value).toBe('B')
|
||||
// Les refs sont distinctes (pas singleton — chaque drawer son state).
|
||||
expect(a.name).not.toBe(b.name)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Composable d'administration des categories (M0 — Gestion des categories).
|
||||
*
|
||||
* Centralise le chargement et le state des deux ressources lues par la page
|
||||
* `/admin/categories` : la liste des categories et le referentiel
|
||||
* CategoryType (utilise dans le select du drawer).
|
||||
*
|
||||
* State singleton au niveau module (meme convention que `useSidebar` /
|
||||
* `useModules` / `useAuditLog`) : reset automatique au logout via
|
||||
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md : « composables
|
||||
* avec state singleton doivent etre reinitialises au logout »), et reset
|
||||
* explicite expose via `resetCategoriesAdmin()` appele depuis
|
||||
* `modules/core/pages/logout.vue`.
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||
|
||||
/**
|
||||
* Dette M0 : pas de pagination serveur sur les ressources Catalog (volumetrie
|
||||
* cible ≤ 300). On force une page geante via `itemsPerPage` pour recuperer
|
||||
* toute la liste en un coup. A basculer en pagination serveur quand la
|
||||
* volumetrie reelle depassera ce plafond — meme pattern que sites.vue.
|
||||
*/
|
||||
const HYDRA_NO_PAGINATION = 999
|
||||
|
||||
// State singleton — partage entre tous les composants qui appellent le
|
||||
// composable dans la meme session. Les refs sont declarees au niveau module
|
||||
// (pas dans la fonction `useCategoriesAdmin()`) pour eviter qu'une nouvelle
|
||||
// instance soit creee a chaque appel.
|
||||
const categories = ref<Category[]>([])
|
||||
const types = ref<CategoryType[]>([])
|
||||
const loading = ref(false)
|
||||
const loadingTypes = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
function resetCategoriesAdminState(): void {
|
||||
categories.value = []
|
||||
types.value = []
|
||||
loading.value = false
|
||||
loadingTypes.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// Auto-enregistrement singleton : purge le state sur 401/clearSession pour
|
||||
// eviter qu'un user suivant (connecte sur le meme onglet) voie l'etat de
|
||||
// l'ancien. Le logout volontaire (page logout.vue) appelle directement
|
||||
// `resetCategoriesAdmin()` ci-dessous.
|
||||
onAuthSessionCleared(resetCategoriesAdminState)
|
||||
|
||||
export function useCategoriesAdmin() {
|
||||
const api = useApi()
|
||||
|
||||
/**
|
||||
* Charge la liste des categories. Le serveur exclut les soft-deleted par
|
||||
* defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination
|
||||
* serveur (volumetrie ≤ 300, pagination front via MalioDataTable).
|
||||
*
|
||||
* `includeDeleted=true` permet a un user avec `catalog.categories.manage`
|
||||
* de voir les soft-deleted (RG-1.09) — au M0 la page n'utilise pas cette
|
||||
* option mais on l'expose pour la suite (corbeille future).
|
||||
*
|
||||
* Swallow volontaire : un 403 (user sans permission view) ne doit pas
|
||||
* toaster — la sidebar masque deja l'entree pour ces users, on tombe sur
|
||||
* la page seulement par URL directe et on affiche un tableau vide propre.
|
||||
*/
|
||||
async function fetchAll(includeDeleted = false): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const query: Record<string, unknown> = { itemsPerPage: HYDRA_NO_PAGINATION }
|
||||
if (includeDeleted) {
|
||||
query.includeDeleted = 'true'
|
||||
}
|
||||
const data = await api.get<HydraCollection<Category>>(
|
||||
'/categories',
|
||||
query,
|
||||
{ toast: false },
|
||||
)
|
||||
categories.value = data.member ?? []
|
||||
} catch (e) {
|
||||
categories.value = []
|
||||
error.value = (e as Error)?.message ?? 'Erreur de chargement'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge le referentiel CategoryType (lecture seule, RG-1.06). Appele a
|
||||
* l'ouverture de la page admin pour que le select du drawer ait deja les
|
||||
* options pretes au moment de la creation/edition.
|
||||
*
|
||||
* Toast desactive : on stocke l'erreur dans `error` plutot que de
|
||||
* spammer un toast — le drawer affichera l'erreur inline s'il y a lieu.
|
||||
*/
|
||||
async function fetchTypes(): Promise<void> {
|
||||
loadingTypes.value = true
|
||||
try {
|
||||
const data = await api.get<HydraCollection<CategoryType>>(
|
||||
'/category_types',
|
||||
{ itemsPerPage: HYDRA_NO_PAGINATION },
|
||||
{ toast: false },
|
||||
)
|
||||
types.value = data.member ?? []
|
||||
} catch (e) {
|
||||
types.value = []
|
||||
error.value = (e as Error)?.message ?? 'Erreur de chargement des types'
|
||||
} finally {
|
||||
loadingTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()` pour
|
||||
* garantir que la prochaine session reparte sur un state propre meme si
|
||||
* `clearSession()` n'a pas ete declenche (cas logout volontaire).
|
||||
*/
|
||||
function resetCategoriesAdmin(): void {
|
||||
resetCategoriesAdminState()
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
types,
|
||||
loading,
|
||||
loadingTypes,
|
||||
error,
|
||||
fetchAll,
|
||||
fetchTypes,
|
||||
resetCategoriesAdmin,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Composable de formulaire categorie (M0 — Gestion des categories).
|
||||
*
|
||||
* Centralise la logique de validation client + appels API (POST / PATCH /
|
||||
* DELETE) du drawer de creation/edition. Contrairement a
|
||||
* `useCategoriesAdmin` qui porte un state singleton partage entre composants,
|
||||
* ce composable est instancie par formulaire (les refs vivent dans la
|
||||
* fonction `useCategoryForm()`) — chaque drawer ouvert a son propre state
|
||||
* isole.
|
||||
*
|
||||
* Validations client en miroir des regles back (RG-1.02 / RG-1.04 / RG-1.05) :
|
||||
* elles servent juste a eviter l'aller-retour reseau evitable. Le serveur
|
||||
* revalide toujours (defense en profondeur).
|
||||
*
|
||||
* Mapping erreurs API :
|
||||
* - 409 (RG-1.07 doublon) → toast + erreur sur le champ `name`
|
||||
* - 422 (violations API Platform) → mapping sur les champs concernes
|
||||
* - autre → erreur globale `_global` + toast generique
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Category } from '~/modules/catalog/types/category'
|
||||
import { extractApiErrorMessage, extractApiViolations } from '~/shared/utils/api'
|
||||
|
||||
/**
|
||||
* Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici
|
||||
* (status et payload data) pour eviter de typer toute la lib.
|
||||
*/
|
||||
interface ApiFetchError {
|
||||
response?: {
|
||||
status?: number
|
||||
_data?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export function useCategoryForm() {
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
// State local du formulaire — pas singleton, chaque appel a useCategoryForm
|
||||
// cree son propre state (cohérent avec le pattern « un drawer = un form »).
|
||||
const name = ref('')
|
||||
const categoryTypeId = ref<number | null>(null)
|
||||
|
||||
// Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
|
||||
// pattern view → edit du drawer (le bouton Enregistrer reste masque tant
|
||||
// que rien n'a change en mode consultation).
|
||||
const initialName = ref('')
|
||||
const initialCategoryTypeId = ref<number | null>(null)
|
||||
|
||||
const errors = ref<{
|
||||
name: string
|
||||
categoryType: string
|
||||
_global: string
|
||||
}>({
|
||||
name: '',
|
||||
categoryType: '',
|
||||
_global: '',
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
const isDirty = computed(
|
||||
() =>
|
||||
name.value !== initialName.value
|
||||
|| categoryTypeId.value !== initialCategoryTypeId.value,
|
||||
)
|
||||
|
||||
/**
|
||||
* Pre-remplit le formulaire a partir d'une categorie existante (mode
|
||||
* consultation/edition) ou vide (mode creation). Reinitialise les
|
||||
* erreurs et le snapshot initial pour repartir d'un etat propre.
|
||||
*/
|
||||
function loadFrom(category: Category | null): void {
|
||||
errors.value = { name: '', categoryType: '', _global: '' }
|
||||
if (category) {
|
||||
name.value = category.name
|
||||
categoryTypeId.value = category.categoryType.id
|
||||
initialName.value = category.name
|
||||
initialCategoryTypeId.value = category.categoryType.id
|
||||
} else {
|
||||
name.value = ''
|
||||
categoryTypeId.value = null
|
||||
initialName.value = ''
|
||||
initialCategoryTypeId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation client miroir des RG back. Renvoie true si tout passe et
|
||||
* peuple `errors` sinon. Le trim est applique cote client (miroir RG-1.03)
|
||||
* mais le serveur retrim de toute facon — pas de risque de divergence.
|
||||
*/
|
||||
function validate(): boolean {
|
||||
errors.value = { name: '', categoryType: '', _global: '' }
|
||||
const trimmedName = name.value.trim()
|
||||
|
||||
// RG-1.02 — name obligatoire (vide / whitespace-only).
|
||||
if (trimmedName === '') {
|
||||
errors.value.name = t('admin.categories.validation.nameRequired')
|
||||
} else if (trimmedName.length < 2 || trimmedName.length > 120) {
|
||||
// RG-1.04 — longueur 2-120 apres trim.
|
||||
errors.value.name = t('admin.categories.validation.nameLength')
|
||||
}
|
||||
|
||||
// RG-1.05 — categoryType obligatoire.
|
||||
if (categoryTypeId.value === null) {
|
||||
errors.value.categoryType = t('admin.categories.validation.typeRequired')
|
||||
}
|
||||
|
||||
return errors.value.name === '' && errors.value.categoryType === ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le payload POST a partir du state. Le `categoryType` est
|
||||
* envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
|
||||
* Platform pour referencer une ressource liee. Retourne un object literal
|
||||
* compatible avec `AnyObject` de `useApi()` (un type nomme strict comme
|
||||
* `CategoryCreateInput` ne serait pas assignable a `Record<string, unknown>`
|
||||
* en TS strict).
|
||||
*/
|
||||
function buildCreatePayload(): Record<string, unknown> {
|
||||
return {
|
||||
name: name.value.trim(),
|
||||
categoryType: `/api/category_types/${categoryTypeId.value}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappe les violations 422 d'API Platform sur les champs du formulaire.
|
||||
* Renvoie true des qu'au moins une violation a ete posee — false sinon
|
||||
* (payload sans violations exploitables, ou tous les `propertyPath` hors
|
||||
* du mapping connu). L'extraction Hydra (`violations` / `hydra:violations`)
|
||||
* est centralisee dans `shared/utils/api.ts` pour rester reutilisable
|
||||
* sur les futurs drawers de formulaire.
|
||||
*/
|
||||
function mapServerViolations(data: unknown): boolean {
|
||||
const violations = extractApiViolations(data)
|
||||
if (violations.length === 0) return false
|
||||
let mapped = false
|
||||
for (const v of violations) {
|
||||
if (v.propertyPath === 'name') {
|
||||
errors.value.name = v.message
|
||||
mapped = true
|
||||
} else if (v.propertyPath === 'categoryType') {
|
||||
errors.value.categoryType = v.message
|
||||
mapped = true
|
||||
}
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
/**
|
||||
* Traite une erreur API : mappe selon le status, declenche les toasts
|
||||
* appropries. Centralise la logique entre create/update.
|
||||
*
|
||||
* - 409 (RG-1.07) : doublon — toast + errors.name avec libelle qui inclut
|
||||
* le nom soumis.
|
||||
* - 422 : tentative de mapping fin via les violations API Platform — si au
|
||||
* moins une violation est mappee, pas de toast (erreur affichee inline
|
||||
* sous le champ concerne).
|
||||
* - autre : message global + toast generique. Le toast natif d'useApi
|
||||
* est desactive (`toast: false`) pour permettre ce mapping fin ; il faut
|
||||
* donc en re-emettre un manuellement ici, sinon une 500 reste silencieuse.
|
||||
*
|
||||
* Retourne true si l'erreur a ete reconnue et traitee (409/422 mappes),
|
||||
* false sinon (fallback generique).
|
||||
*/
|
||||
function handleApiError(e: unknown, attemptedName: string): boolean {
|
||||
const status = (e as ApiFetchError)?.response?.status
|
||||
const data = (e as ApiFetchError)?.response?._data
|
||||
|
||||
if (status === 409) {
|
||||
const duplicateMessage = t('admin.categories.toast.duplicate', {
|
||||
name: attemptedName,
|
||||
})
|
||||
errors.value.name = duplicateMessage
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: duplicateMessage,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (status === 422 && mapServerViolations(data)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const extracted = extractApiErrorMessage(data)
|
||||
errors.value._global = extracted || 'Une erreur est survenue.'
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: errors.value._global,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/categories. Renvoie la categorie creee, ou `null` si la
|
||||
* validation client a echoue ou si le serveur a renvoye une erreur. Le
|
||||
* caller (drawer) decide quoi faire en fonction (fermer ou rester ouvert).
|
||||
*/
|
||||
async function submitCreate(): Promise<Category | null> {
|
||||
if (!validate()) return null
|
||||
submitting.value = true
|
||||
errors.value._global = ''
|
||||
const payload = buildCreatePayload()
|
||||
try {
|
||||
const created = await api.post<Category>('/categories', payload, {
|
||||
toast: false,
|
||||
})
|
||||
toast.success({
|
||||
title: 'Succès',
|
||||
message: t('admin.categories.toast.created'),
|
||||
})
|
||||
return created
|
||||
} catch (e) {
|
||||
handleApiError(e, String(payload.name))
|
||||
return null
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/categories/{id}. Envoie uniquement les champs modifies pour
|
||||
* coller a la semantique merge-patch (Content-Type pose par useApi).
|
||||
* Renvoie la categorie mise a jour, ou `null` en cas d'echec.
|
||||
*/
|
||||
async function submitUpdate(id: number): Promise<Category | null> {
|
||||
if (!validate()) return null
|
||||
submitting.value = true
|
||||
errors.value._global = ''
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (name.value !== initialName.value) {
|
||||
payload.name = name.value.trim()
|
||||
}
|
||||
if (categoryTypeId.value !== initialCategoryTypeId.value) {
|
||||
payload.categoryType = `/api/category_types/${categoryTypeId.value}`
|
||||
}
|
||||
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
|
||||
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
|
||||
// on protege le composable contre un appel direct mal utilise.
|
||||
if (Object.keys(payload).length === 0) {
|
||||
submitting.value = false
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const updated = await api.patch<Category>(`/categories/${id}`, payload, {
|
||||
toast: false,
|
||||
})
|
||||
toast.success({
|
||||
title: 'Succès',
|
||||
message: t('admin.categories.toast.updated'),
|
||||
})
|
||||
return updated
|
||||
} catch (e) {
|
||||
const attemptedName = typeof payload.name === 'string'
|
||||
? payload.name
|
||||
: name.value.trim()
|
||||
handleApiError(e, attemptedName)
|
||||
return null
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/categories/{id} → soft delete (RG-1.12). Le serveur pose
|
||||
* `deleted_at = now()` et retourne 204. Renvoie true en cas de succes,
|
||||
* false sinon (avec toast erreur deja affiche).
|
||||
*/
|
||||
async function submitDelete(id: number): Promise<boolean> {
|
||||
submitting.value = true
|
||||
errors.value._global = ''
|
||||
try {
|
||||
await api.delete(`/categories/${id}`, {}, { toast: false })
|
||||
toast.success({
|
||||
title: 'Succès',
|
||||
message: t('admin.categories.toast.deleted'),
|
||||
})
|
||||
return true
|
||||
} catch (e) {
|
||||
handleApiError(e, name.value)
|
||||
return false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset complet du formulaire — utilise par le drawer apres save ou
|
||||
* fermeture pour ne pas garder de donnees stale entre deux ouvertures.
|
||||
*/
|
||||
function reset(): void {
|
||||
name.value = ''
|
||||
categoryTypeId.value = null
|
||||
initialName.value = ''
|
||||
initialCategoryTypeId.value = null
|
||||
errors.value = { name: '', categoryType: '', _global: '' }
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
name,
|
||||
categoryTypeId,
|
||||
errors,
|
||||
submitting,
|
||||
isDirty,
|
||||
// Methods
|
||||
loadFrom,
|
||||
validate,
|
||||
submitCreate,
|
||||
submitUpdate,
|
||||
submitDelete,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('admin.categories.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
:label="t('admin.categories.newCategory')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Table des categories. Affichage exhaustif (volumetrie cible
|
||||
<= 300, cf. spec § 4.1) — tri 100% serveur via CategoryProvider
|
||||
(name ASC, RG-1.10). La barre de pagination du MalioDataTable
|
||||
reste cosmetique tant qu'aucun slice client n'est cable : a
|
||||
traiter cote @malio/layer-ui le jour ou la volumetrie monte. -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="categoryItems"
|
||||
:total-items="categories.length"
|
||||
:row-clickable="true"
|
||||
:empty-message="t('admin.categories.noCategories')"
|
||||
@row-click="onRowClick"
|
||||
/>
|
||||
|
||||
<!-- Drawer creation / consultation / edition. -->
|
||||
<CategoryDrawer
|
||||
v-model="drawerOpen"
|
||||
:category="selectedCategory"
|
||||
@saved="onCategorySaved"
|
||||
@delete="onDeleteRequest"
|
||||
/>
|
||||
|
||||
<!-- Modale de confirmation suppression (soft delete cote serveur). -->
|
||||
<CategoryDeleteModal
|
||||
v-model="deleteModalOpen"
|
||||
:category-name="categoryToDelete?.name ?? ''"
|
||||
:loading="deleting"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Category } from '~/modules/catalog/types/category'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { can } = usePermissions()
|
||||
const { categories, fetchAll, fetchTypes } = useCategoriesAdmin()
|
||||
const { submitDelete } = useCategoryForm()
|
||||
|
||||
useHead({ title: t('admin.categories.title') })
|
||||
|
||||
const canManage = computed(() => can('catalog.categories.manage'))
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const selectedCategory = ref<Category | null>(null)
|
||||
const deleteModalOpen = ref(false)
|
||||
const categoryToDelete = ref<Category | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) —
|
||||
// on aplatit en label lisible pour l'affichage.
|
||||
const columns = [
|
||||
{ key: 'name', label: t('admin.categories.table.name') },
|
||||
{ key: 'typeLabel', label: t('admin.categories.table.type') },
|
||||
]
|
||||
|
||||
const categoryItems = computed(() =>
|
||||
categories.value.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
typeLabel: cat.categoryType?.label ?? '',
|
||||
})),
|
||||
)
|
||||
|
||||
function getCategoryById(id: number): Category | undefined {
|
||||
return categories.value.find(c => c.id === id)
|
||||
}
|
||||
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
const category = getCategoryById(item.id as number)
|
||||
if (category) openEditDrawer(category)
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
selectedCategory.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEditDrawer(category: Category) {
|
||||
selectedCategory.value = category
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function onDeleteRequest() {
|
||||
if (!selectedCategory.value) return
|
||||
categoryToDelete.value = selectedCategory.value
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete via le composable de form (qui gere toast + erreur). Refresh
|
||||
* de la liste a la fin pour retirer la ligne. L'index unique partiel
|
||||
* autorise une recreation ulterieure avec le meme couple (name, type) —
|
||||
* RG-1.07.
|
||||
*/
|
||||
async function handleDelete(): Promise<void> {
|
||||
if (!categoryToDelete.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
const ok = await submitDelete(categoryToDelete.value.id)
|
||||
if (ok) {
|
||||
deleteModalOpen.value = false
|
||||
categoryToDelete.value = null
|
||||
drawerOpen.value = false
|
||||
await fetchAll()
|
||||
}
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCategorySaved() {
|
||||
fetchAll()
|
||||
}
|
||||
|
||||
// Chargement initial des deux ressources (liste + referentiel des types).
|
||||
// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le
|
||||
// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ».
|
||||
onMounted(() => {
|
||||
fetchAll()
|
||||
fetchTypes()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Types front du module Catalog (M0 — Gestion des categories).
|
||||
*
|
||||
* Contrats API consommes :
|
||||
* - GET /api/categories → HydraCollection<Category>
|
||||
* - GET /api/categories/{id} → Category
|
||||
* - POST /api/categories → body { name, categoryType: IRI }
|
||||
* - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI }
|
||||
* - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor)
|
||||
* - GET /api/category_types → HydraCollection<CategoryType>
|
||||
*
|
||||
* Notes :
|
||||
* - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3").
|
||||
* - `categoryType` est embarque (groupe Serializer `category:read` sur les
|
||||
* proprietes de CategoryType, cf. spec-back § 3.4).
|
||||
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
|
||||
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reference legere d'un user, telle qu'embarquee dans Category.createdBy /
|
||||
* updatedBy. Volontairement minimaliste : on n'a besoin que de l'identifiant
|
||||
* et de l'username pour l'affichage courant.
|
||||
*/
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference du referentiel CategoryType (lecture seule au M0).
|
||||
*/
|
||||
export interface CategoryType {
|
||||
id: number
|
||||
code: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorie metier — telle qu'elle est lue depuis l'API. L'entite porte le
|
||||
* pattern Timestampable+Blamable (cf. spec-back § 2.8).
|
||||
*/
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
categoryType: CategoryType
|
||||
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
|
||||
deletedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
createdBy: User | null
|
||||
updatedBy: User | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload accepte en POST /api/categories. `categoryType` est envoye en
|
||||
* IRI Hydra (ex. `/api/category_types/3`).
|
||||
*/
|
||||
export interface CategoryCreateInput {
|
||||
name: string
|
||||
categoryType: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload accepte en PATCH /api/categories/{id}. Tous les champs sont
|
||||
* optionnels (modification partielle).
|
||||
*/
|
||||
export interface CategoryUpdateInput {
|
||||
name?: string
|
||||
categoryType?: string
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('commercial.title') }}</h1>
|
||||
<p class="mt-4 text-neutral-500">{{ $t('commercial.welcome') }}</p>
|
||||
<PageHeader>{{ $t('commercial.title') }}</PageHeader>
|
||||
<p class="text-neutral-500">{{ $t('commercial.welcome') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<!-- Accordeon de permissions groupees par module : un panneau par module,
|
||||
avec compteur (selectionnees/total) dans le titre, case "Tout selectionner"
|
||||
et liste des permissions individuelles. Source unique de cette UX, utilisee
|
||||
par RoleDrawer (permissions du role) et UserRbacDrawer (permissions directes). -->
|
||||
<MalioAccordion v-model="openModules">
|
||||
<MalioAccordionItem
|
||||
v-for="group in groupsByModule"
|
||||
:key="group.module"
|
||||
:value="group.module"
|
||||
:title="`${group.module} (${selectedCountFor(group)}/${group.permissions.length})`"
|
||||
header-class="capitalize"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Tout selectionner pour ce module -->
|
||||
<MalioCheckbox
|
||||
:id="`${idPrefix}-group-${group.module}`"
|
||||
:label="t('admin.roles.permissions.selectAll')"
|
||||
:model-value="allSelectedFor(group)"
|
||||
label-class="font-semibold text-sm text-neutral-700"
|
||||
@update:model-value="(val: boolean) => emit('toggle-all', group.module, val)"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<MalioCheckbox
|
||||
v-for="perm in group.permissions"
|
||||
:id="`${idPrefix}-perm-${perm.id}`"
|
||||
:key="perm.id"
|
||||
:label="perm.label"
|
||||
:model-value="selectedIds.has(perm.id)"
|
||||
label-class="text-sm text-neutral-600"
|
||||
@update:model-value="(val: boolean) => emit('toggle', perm.id, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PermissionModule } from '~/shared/types/rbac'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
/** Groupes de permissions a afficher, un par module. */
|
||||
groupsByModule: PermissionModule[]
|
||||
/** Ids des permissions actuellement selectionnees. */
|
||||
selectedIds: Set<number>
|
||||
/** Prefixe pour les ids HTML : evite les collisions si plusieurs accordeons coexistent (ex: "role" vs "direct"). */
|
||||
idPrefix: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [permissionId: number, selected: boolean]
|
||||
'toggle-all': [module: string, selected: boolean]
|
||||
}>()
|
||||
|
||||
// Modules ouverts dans l'accordeon (mode multiple). Etat local : chaque instance
|
||||
// du composant garde sa propre liste, pas de partage entre drawers.
|
||||
const openModules = ref<string[]>([])
|
||||
|
||||
// Nombre de permissions selectionnees pour un module donne.
|
||||
function selectedCountFor(group: PermissionModule): number {
|
||||
return group.permissions.filter(p => props.selectedIds.has(p.id)).length
|
||||
}
|
||||
|
||||
// Vrai si toutes les permissions du module sont selectionnees.
|
||||
function allSelectedFor(group: PermissionModule): boolean {
|
||||
return group.permissions.length > 0 && selectedCountFor(group) === group.permissions.length
|
||||
}
|
||||
</script>
|
||||
@@ -1,66 +0,0 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-neutral-200 overflow-hidden">
|
||||
<!-- En-tete du groupe avec checkbox "tout selectionner" -->
|
||||
<div class="flex items-center gap-3 bg-neutral-50 px-4 py-3 border-b border-neutral-200">
|
||||
<MalioCheckbox
|
||||
:id="`group-${module}`"
|
||||
:label="moduleLabel"
|
||||
:model-value="allSelected"
|
||||
label-class="font-semibold text-sm text-neutral-700 capitalize"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
<span class="ml-auto text-xs text-neutral-400">
|
||||
{{ selectedCount }}/{{ permissions.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Liste des permissions individuelles -->
|
||||
<div class="grid grid-cols-1 gap-1 p-3 sm:grid-cols-2">
|
||||
<MalioCheckbox
|
||||
v-for="perm in permissions"
|
||||
:key="perm.id"
|
||||
:id="`perm-${perm.id}`"
|
||||
:label="perm.label"
|
||||
:model-value="selectedIds.has(perm.id)"
|
||||
label-class="text-sm text-neutral-600"
|
||||
@update:model-value="(val: boolean) => togglePermission(perm.id, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission } from '~/shared/types/rbac'
|
||||
|
||||
const props = defineProps<{
|
||||
module: string
|
||||
moduleLabel: string
|
||||
permissions: Permission[]
|
||||
selectedIds: Set<number>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [permissionId: number, selected: boolean]
|
||||
toggleAll: [module: string, selected: boolean]
|
||||
}>()
|
||||
|
||||
// Nombre de permissions selectionnees dans ce groupe
|
||||
const selectedCount = computed(() =>
|
||||
props.permissions.filter(p => props.selectedIds.has(p.id)).length
|
||||
)
|
||||
|
||||
// Vrai si toutes les permissions du groupe sont selectionnees
|
||||
const allSelected = computed(() =>
|
||||
props.permissions.length > 0 && selectedCount.value === props.permissions.length
|
||||
)
|
||||
|
||||
// Emet l'evenement de bascule pour une permission individuelle
|
||||
function togglePermission(id: number, selected: boolean) {
|
||||
emit('toggle', id, selected)
|
||||
}
|
||||
|
||||
// Emet l'evenement de bascule pour toutes les permissions du groupe
|
||||
function toggleAll(selected: boolean) {
|
||||
emit('toggleAll', props.module, selected)
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +1,17 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
:title="isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole')"
|
||||
drawer-class="w-full max-w-lg"
|
||||
header-class="border-b border-black"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">
|
||||
{{ isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole') }}
|
||||
</h2>
|
||||
</template>
|
||||
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
|
||||
<!-- Champs du role -->
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
@@ -44,55 +50,51 @@
|
||||
<div v-else-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||
{{ t('admin.roles.permissions.noPermissions') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<PermissionGroup
|
||||
v-for="group in permissionsByModule"
|
||||
:key="group.module"
|
||||
:module="group.module"
|
||||
:module-label="group.module"
|
||||
:permissions="group.permissions"
|
||||
:selected-ids="selectedPermissionIds"
|
||||
@toggle="handleTogglePermission"
|
||||
@toggle-all="handleToggleAll"
|
||||
/>
|
||||
</div>
|
||||
<PermissionAccordion
|
||||
v-else
|
||||
:groups-by-module="permissionsByModule"
|
||||
:selected-ids="selectedPermissionIds"
|
||||
id-prefix="role"
|
||||
@toggle="handleTogglePermission"
|
||||
@toggle-all="handleToggleAll"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
||||
<MalioButton
|
||||
v-if="isEditMode"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
:disabled="role?.isSystem"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
:disabled="saving || permissionsLoadFailed"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
|
||||
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
v-if="isEditMode"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
button-class="w-[150px]"
|
||||
:disabled="role?.isSystem"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-[150px]"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-[150px]"
|
||||
:disabled="saving || permissionsLoadFailed"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission, Role } from '~/shared/types/rbac'
|
||||
|
||||
interface PermissionModule {
|
||||
module: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
import type { Permission, PermissionModule, Role } from '~/shared/types/rbac'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
:title="t('admin.users.drawer.title', { username: user?.username ?? '' })"
|
||||
drawer-class="w-full max-w-lg"
|
||||
drawer-class="w-full max-w-[450px]"
|
||||
header-class="border-b border-black"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="flex flex-col gap-6 p-4">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">
|
||||
{{ t('admin.users.drawer.title', { username: user?.username ?? '' }) }}
|
||||
</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4 py-4">
|
||||
<!-- Etat d'erreur de chargement des referentiels : bloque la
|
||||
sauvegarde pour empecher un ecrasement silencieux des droits. -->
|
||||
<div
|
||||
@@ -60,18 +66,14 @@
|
||||
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||
{{ t('admin.roles.permissions.noPermissions') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<PermissionGroup
|
||||
v-for="group in permissionsByModule"
|
||||
:key="group.module"
|
||||
:module="group.module"
|
||||
:module-label="group.module"
|
||||
:permissions="group.permissions"
|
||||
:selected-ids="selectedDirectPermissionIds"
|
||||
@toggle="handleTogglePermission"
|
||||
@toggle-all="handleToggleAll"
|
||||
/>
|
||||
</div>
|
||||
<PermissionAccordion
|
||||
v-else
|
||||
:groups-by-module="permissionsByModule"
|
||||
:selected-ids="selectedDirectPermissionIds"
|
||||
id-prefix="direct"
|
||||
@toggle="handleTogglePermission"
|
||||
@toggle-all="handleToggleAll"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Section Sites autorises (ticket 2 module Sites) -->
|
||||
@@ -103,33 +105,32 @@
|
||||
<EffectivePermissions :permissions="effectivePermissions" />
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
:disabled="saving || loadFailed"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
|
||||
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-[150px]"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-[150px]"
|
||||
:disabled="saving || loadFailed"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
|
||||
import type { Permission, PermissionModule, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
|
||||
interface PermissionModule {
|
||||
module: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const auth = useAuthStore()
|
||||
|
||||
@@ -1,95 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.auditLog.title') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
|
||||
<!-- Labels uniformes au-dessus : les composants Malio sont utilises sans
|
||||
leur `label` flottant interne pour ne pas mixer deux patterns de label.
|
||||
A revoir une fois le composant calendar Malio développé -->
|
||||
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-5">
|
||||
<!-- TODO(malio-ui): remplacer par un composant Malio quand la lib
|
||||
exposera un datetime picker. Cf. exception documentee dans
|
||||
CLAUDE.md (section "Composants formulaires"). -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.date_from') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="filters.performedAtAfter"
|
||||
type="datetime-local"
|
||||
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
||||
>
|
||||
</div>
|
||||
<!-- TODO(malio-ui): idem ci-dessus. -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.date_to') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="filters.performedAtBefore"
|
||||
type="datetime-local"
|
||||
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.entity_type') }}
|
||||
</label>
|
||||
<div class="[&>div>div]:!mt-0">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedEntityTypes"
|
||||
:options="entityTypeOptions"
|
||||
:display-select-all="true"
|
||||
:display-tag="true"
|
||||
min-width="w-full"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.user') }}
|
||||
</label>
|
||||
<MalioInputText
|
||||
v-model="performedByInput"
|
||||
icon-name="mdi:account-search"
|
||||
input-class="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.action') }}
|
||||
</label>
|
||||
<div class="[&>div>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="actionValue"
|
||||
:options="actionOptions"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<PageHeader>
|
||||
{{ t('admin.auditLog.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('audit.filters.reset')"
|
||||
button-class="text-xs"
|
||||
@click="resetFilters"
|
||||
:label="t('audit.filters.title')"
|
||||
icon-name="mdi:tune"
|
||||
icon-position="left"
|
||||
icon-size="24"
|
||||
button-class="w-[184px] justify-start gap-4 text-black"
|
||||
@click="openFilters"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Tableau -->
|
||||
<MalioDataTable
|
||||
class="mt-4"
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
@@ -123,12 +50,99 @@
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<!-- Drawer de filtres : etat brouillon, applique uniquement au clic sur
|
||||
"Voir les resultats". `body-class="p-0"` pour que l'accordeon aille
|
||||
bord a bord (les items portent leur propre px-7). -->
|
||||
<MalioDrawer
|
||||
v-model="filterDrawerOpen"
|
||||
drawer-class="max-w-[450px]"
|
||||
body-class="p-0"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold uppercase">{{ t('audit.filters.title') }}</h2>
|
||||
</template>
|
||||
|
||||
<MalioAccordion>
|
||||
<!-- Dates : deux champs date+heure Du / Au (champs datetime a l'origine) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.date_range')" value="dates">
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
|
||||
<span>{{ t('audit.filters.date_from') }}</span>
|
||||
<!-- Borne le picker "Du" par la valeur "Au" pour interdire une plage
|
||||
inversee a la saisie (le backend renverrait silencieusement 0 ligne). -->
|
||||
<MalioDateTime
|
||||
v-model="draftDateFrom"
|
||||
:max="draftDateTo ?? undefined"
|
||||
/>
|
||||
<span>{{ t('audit.filters.date_to') }}</span>
|
||||
<MalioDateTime
|
||||
v-model="draftDateTo"
|
||||
:min="draftDateFrom ?? undefined"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Type d'entite : cases a cocher (multi-selection) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.entity_type')" value="entity">
|
||||
<div class="flex flex-col gap-4">
|
||||
<MalioCheckbox
|
||||
v-for="opt in entityTypeOptions"
|
||||
:id="`filter-entity-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftEntityTypes.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleEntity(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Action : boutons radio (selection unique, '' = toutes) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.action')" value="action">
|
||||
<MalioRadioButton
|
||||
v-for="opt in actionOptions"
|
||||
:key="opt.value"
|
||||
v-model="draftAction"
|
||||
name="audit-action"
|
||||
:value="opt.value"
|
||||
:label="opt.label"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Utilisateur : recherche texte (ILIKE partiel cote backend) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.user')" value="user">
|
||||
<MalioInputText
|
||||
v-model="draftPerformedBy"
|
||||
icon-name="mdi:account-search"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('audit.filters.reset')"
|
||||
button-class="w-[150px]"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('audit.filters.apply')"
|
||||
button-class="w-[170px]"
|
||||
@click="applyFilters"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
|
||||
<!-- Drawer detail : diff courant + timeline complete de l'entite -->
|
||||
<MalioDrawer
|
||||
v-model="drawerOpen"
|
||||
:title="drawerTitle"
|
||||
drawer-class="max-w-2xl"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">
|
||||
{{ drawerTitle }}
|
||||
</h2>
|
||||
</template>
|
||||
<div v-if="selectedEntry">
|
||||
<AuditLogDetail :entry="selectedEntry" />
|
||||
<div class="mt-4 border-t border-gray-200 pt-3">
|
||||
@@ -149,12 +163,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
||||
|
||||
const { t, te } = useI18n()
|
||||
const { can } = usePermissions()
|
||||
const { fetchLogsCached, fetchEntityTypes } = useAuditLog()
|
||||
const toast = useToast()
|
||||
|
||||
// Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en
|
||||
// libelle lisible via la cle i18n `audit.entity.<module>_<entity>`. Si aucune
|
||||
@@ -173,8 +188,11 @@ if (!can('core.audit_log.view')) {
|
||||
|
||||
useHead({ title: t('admin.auditLog.title') })
|
||||
|
||||
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle
|
||||
// CLAUDE.md "Tableau : pas de persistance URL").
|
||||
// Etat des filtres APPLIQUES : pilote `loadEntries`. Local uniquement, JAMAIS
|
||||
// persiste dans l'URL (cf. regle CLAUDE.md "Tableau : pas de persistance URL").
|
||||
// `performedAtAfter`/`performedAtBefore` stockent une date+heure ISO naive
|
||||
// (`YYYY-MM-DDTHH:MM:00`, fournie par MalioDateTime), convertie en ISO UTC
|
||||
// au moment du fetch.
|
||||
const filters = reactive<AuditLogFilters>({
|
||||
performedAtAfter: undefined,
|
||||
performedAtBefore: undefined,
|
||||
@@ -185,26 +203,23 @@ const filters = reactive<AuditLogFilters>({
|
||||
itemsPerPage: 10,
|
||||
})
|
||||
|
||||
// Multi-selection entity_type : bind dedie au MalioSelectCheckbox.
|
||||
// Attention : les composants Malio attendent `{ label, value }` (pas `{ text }`).
|
||||
const selectedEntityTypes = ref<(string | number)[]>([])
|
||||
// Etat BROUILLON du drawer de filtres : edite librement, recopie dans `filters`
|
||||
// uniquement au clic sur "Voir les resultats". Permet d'annuler une saisie en
|
||||
// fermant le drawer sans relancer de requete.
|
||||
const filterDrawerOpen = ref(false)
|
||||
const draftDateFrom = ref<string | null>(null)
|
||||
const draftDateTo = ref<string | null>(null)
|
||||
const draftEntityTypes = ref<string[]>([])
|
||||
const draftAction = ref<string>('')
|
||||
const draftPerformedBy = ref<string>('')
|
||||
|
||||
// Liste des entity types (distincts) pour alimenter les cases a cocher.
|
||||
const entityTypes = ref<string[]>([])
|
||||
// On garde l'identifiant technique comme `value` pour l'envoi API, mais on
|
||||
// affiche le libelle traduit quand il existe (fallback: identifiant brut).
|
||||
const entityTypeOptions = computed(() =>
|
||||
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
|
||||
)
|
||||
|
||||
// Bind champ performedBy : MalioInputText attend `string | null`, on ne peut
|
||||
// pas binder directement un `string | undefined` reactive.
|
||||
const performedByInput = ref<string>('')
|
||||
|
||||
// Action : '' = "toutes les actions". On declare l'option dans `actionOptions`
|
||||
// plutot que via `emptyOptionLabel` (qui n'inclut pas l'option vide dans
|
||||
// `props.options`, donc `selectedLabel` reste vide). On evite aussi `value: null`
|
||||
// car MalioSelect grise visuellement les options dont la valeur est `null`
|
||||
// (Select.vue:137) — on utilise donc une chaine vide comme sentinelle.
|
||||
const actionValue = ref<string>('')
|
||||
// Actions : '' = "toutes". Sert d'options aux boutons radio.
|
||||
const actionOptions = [
|
||||
{ value: '', label: t('audit.filters.all_actions') },
|
||||
{ value: 'create', label: t('audit.action.create') },
|
||||
@@ -259,29 +274,55 @@ const isFiltered = computed(() =>
|
||||
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
|
||||
let requestToken = 0
|
||||
|
||||
// Pendant un reset, on suspend temporairement les watchers pour ne pas
|
||||
// declencher 4 fetchs paralleles (un par champ mute). Les watchers Vue 3
|
||||
// sont asynchrones (microtask) : il faut attendre un `nextTick` avant de
|
||||
// les relacher, sinon le flag est deja `false` au moment ou ils s'executent
|
||||
// et les fetchs partent quand meme. Un seul loadEntries() est appele
|
||||
// explicitement apres la liberation.
|
||||
let watchersSuspended = false
|
||||
// Ouvre le drawer en recopiant l'etat applique vers le brouillon, pour que la
|
||||
// reouverture reflete les filtres actifs.
|
||||
function openFilters(): void {
|
||||
draftDateFrom.value = filters.performedAtAfter ?? null
|
||||
draftDateTo.value = filters.performedAtBefore ?? null
|
||||
draftEntityTypes.value = Array.isArray(filters.entityType)
|
||||
? [...filters.entityType]
|
||||
: (filters.entityType ? [filters.entityType] : [])
|
||||
draftAction.value = filters.action ?? ''
|
||||
draftPerformedBy.value = filters.performedBy ?? ''
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
// Bascule un type d'entite dans le brouillon (multi-selection). Les valeurs
|
||||
// sont uniques par construction (v-for sur entityTypeOptions), pas besoin de Set.
|
||||
function toggleEntity(value: string, selected: boolean): void {
|
||||
draftEntityTypes.value = selected
|
||||
? [...draftEntityTypes.value, value]
|
||||
: draftEntityTypes.value.filter(v => v !== value)
|
||||
}
|
||||
|
||||
// "Reinitialiser" : vide le brouillon ET les filtres actifs, puis recharge.
|
||||
// La remise a zero s'applique immediatement (la table revient a la liste
|
||||
// complete) ; le drawer reste ouvert pour montrer le formulaire vide.
|
||||
function resetFilters(): void {
|
||||
draftDateFrom.value = null
|
||||
draftDateTo.value = null
|
||||
draftEntityTypes.value = []
|
||||
draftAction.value = ''
|
||||
draftPerformedBy.value = ''
|
||||
|
||||
async function resetFilters(): Promise<void> {
|
||||
watchersSuspended = true
|
||||
filters.performedAtAfter = undefined
|
||||
filters.performedAtBefore = undefined
|
||||
filters.entityType = undefined
|
||||
filters.performedBy = undefined
|
||||
filters.action = undefined
|
||||
filters.performedBy = undefined
|
||||
filters.page = 1
|
||||
selectedEntityTypes.value = []
|
||||
performedByInput.value = ''
|
||||
actionValue.value = ''
|
||||
// Les watchers mute de Vue 3 se planifient en microtask : on attend
|
||||
// leur execution avec le flag `true`, puis on libere.
|
||||
await nextTick()
|
||||
watchersSuspended = false
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
// "Voir les resultats" : applique le brouillon, recharge et ferme le drawer.
|
||||
function applyFilters(): void {
|
||||
filters.performedAtAfter = draftDateFrom.value ?? undefined
|
||||
filters.performedAtBefore = draftDateTo.value ?? undefined
|
||||
filters.entityType = draftEntityTypes.value.length > 0 ? [...draftEntityTypes.value] : undefined
|
||||
filters.action = draftAction.value === '' ? undefined : draftAction.value
|
||||
filters.performedBy = draftPerformedBy.value.trim() === '' ? undefined : draftPerformedBy.value.trim()
|
||||
filters.page = 1
|
||||
filterDrawerOpen.value = false
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
@@ -291,7 +332,8 @@ async function loadEntries(): Promise<void> {
|
||||
try {
|
||||
const data = await fetchLogsCached({
|
||||
...filters,
|
||||
// Convertit datetime-local (YYYY-MM-DDTHH:MM) en ISO pour l'API.
|
||||
// MalioDateTime fournit une date+heure sans fuseau (heure locale) ;
|
||||
// on la convertit en ISO UTC pour l'API (bornes exactes, intervalle inclusif).
|
||||
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
|
||||
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
|
||||
})
|
||||
@@ -300,13 +342,19 @@ async function loadEntries(): Promise<void> {
|
||||
if (token !== requestToken) return
|
||||
entries.value = data.member ?? []
|
||||
totalItems.value = data.totalItems ?? 0
|
||||
} catch {
|
||||
// En cas d'echec (reseau, 403, 500...), on reset l'etat pour ne pas
|
||||
// laisser l'utilisateur croire que les donnees affichees sont a jour.
|
||||
// Le toast d'erreur est deja emis par `useApi()` via useAuditLog.
|
||||
} catch (err) {
|
||||
// useAuditLog appelle useApi avec { toast: false } pour ne pas multiplier
|
||||
// les toasts, donc c'est ici qu'on fait remonter l'erreur. Sans ce log+toast,
|
||||
// une RangeError de `toIso` (date invalide) ou une 500 API laissait l'utilisateur
|
||||
// devant une table vide indistinguable d'un filtre a zero resultat.
|
||||
if (token === requestToken) {
|
||||
entries.value = []
|
||||
totalItems.value = 0
|
||||
console.error('[audit-log] loadEntries failed', err)
|
||||
toast.error({
|
||||
title: t('audit.error.title'),
|
||||
message: t('audit.error.message'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (token === requestToken) {
|
||||
@@ -315,14 +363,9 @@ async function loadEntries(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce auto-importe depuis `frontend/shared/utils/debounce.ts` : evite
|
||||
// un refetch a chaque frappe sur le champ texte performedBy (reseau + SQL)
|
||||
// et laisse l'utilisateur finir sa saisie avant de lancer la requete.
|
||||
const debouncedReload = debounce(() => loadEntries(), 300)
|
||||
|
||||
function toIso(localDateTime: string): string {
|
||||
// datetime-local n'a pas de timezone : on assume heure locale et on
|
||||
// laisse le navigateur generer l'ISO via Date().
|
||||
// MalioDateTime emet une date+heure sans fuseau (heure murale locale) ;
|
||||
// on laisse Date() generer l'ISO UTC correspondant pour l'API.
|
||||
return new Date(localDateTime).toISOString()
|
||||
}
|
||||
|
||||
@@ -368,53 +411,16 @@ function onPerPageChange(value: number): void {
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
// Sync MalioSelectCheckbox -> filters.entityType + reset page 1 + reload.
|
||||
watch(selectedEntityTypes, values => {
|
||||
if (watchersSuspended) return
|
||||
filters.entityType = values.length > 0 ? values.map(v => String(v)) : undefined
|
||||
filters.page = 1
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
// Sync MalioSelect action -> filters.action.
|
||||
watch(actionValue, value => {
|
||||
if (watchersSuspended) return
|
||||
filters.action = value === '' ? undefined : value
|
||||
filters.page = 1
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
// Sync performedBy : frappe utilisateur -> debounce 300ms pour eviter un
|
||||
// refetch par caractere. Le reset passe par debouncedReload egalement pour
|
||||
// coalescer si plusieurs watchers tirent en meme temps.
|
||||
watch(performedByInput, value => {
|
||||
if (watchersSuspended) return
|
||||
filters.performedBy = value === '' ? undefined : value
|
||||
filters.page = 1
|
||||
debouncedReload()
|
||||
})
|
||||
|
||||
// Synchronisation reactive : tout changement de dates declenche un fetch +
|
||||
// reset de la pagination a la page 1.
|
||||
watch(
|
||||
() => [filters.performedAtAfter, filters.performedAtBefore],
|
||||
() => {
|
||||
if (watchersSuspended) return
|
||||
filters.page = 1
|
||||
loadEntries()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
// Charge les entity types en parallele de la liste principale : un
|
||||
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher
|
||||
// le tableau d'audit de s'afficher. En cas d'erreur, on laisse le
|
||||
// filtre vide — l'utilisateur pourra quand meme consulter le journal.
|
||||
try {
|
||||
entityTypes.value = await fetchEntityTypes()
|
||||
} catch {
|
||||
entityTypes.value = []
|
||||
}
|
||||
await loadEntries()
|
||||
// Charge les entity types ET la liste principale en parallele (TTFD divise
|
||||
// par 2 sur un backend lent). Le `.catch` du premier garantit qu'un echec
|
||||
// de /audit-log-entity-types ne bloque pas l'affichage du tableau —
|
||||
// l'utilisateur perd juste le filtre, pas la page entiere.
|
||||
await Promise.all([
|
||||
fetchEntityTypes()
|
||||
.then(types => { entityTypes.value = types })
|
||||
.catch(() => { entityTypes.value = [] }),
|
||||
loadEntries(),
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.roles.title') }}
|
||||
</h1>
|
||||
<MalioButton
|
||||
v-if="can('core.roles.manage')"
|
||||
:label="t('admin.roles.newRole')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</div>
|
||||
<PageHeader>
|
||||
{{ t('admin.roles.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
v-if="can('core.roles.manage')"
|
||||
:label="t('admin.roles.newRole')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Table des roles -->
|
||||
<MalioDataTable
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="roleItems"
|
||||
:total-items="roles.length"
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.users.title') }}
|
||||
</h1>
|
||||
</div>
|
||||
<PageHeader>{{ t('admin.users.title') }}</PageHeader>
|
||||
|
||||
<!-- Table des utilisateurs -->
|
||||
<MalioDataTable
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="userItems"
|
||||
:total-items="users.length"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('dashboard.title') }}</h1>
|
||||
<p class="mt-4 text-neutral-500">{{ $t('dashboard.welcome') }}</p>
|
||||
<PageHeader>{{ $t('dashboard.title') }}</PageHeader>
|
||||
<p class="text-neutral-500">{{ $t('dashboard.welcome') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const { resetSidebar } = useSidebar()
|
||||
const { resetModules } = useModules()
|
||||
const { resetCurrentSite } = useCurrentSite()
|
||||
const { resetAuditLog } = useAuditLog()
|
||||
const { resetCategoriesAdmin } = useCategoriesAdmin()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -27,6 +28,7 @@ onMounted(async () => {
|
||||
resetModules()
|
||||
resetCurrentSite()
|
||||
resetAuditLog()
|
||||
resetCategoriesAdmin()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
:title="isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite')"
|
||||
drawer-class="w-full max-w-lg"
|
||||
header-class="border-b border-black"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">
|
||||
{{ isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite') }}
|
||||
</h2>
|
||||
</template>
|
||||
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
:label="t('admin.sites.form.name')"
|
||||
@@ -70,30 +76,35 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
||||
<MalioButton
|
||||
v-if="isEditMode"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
:disabled="saving || !isValidHex"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
|
||||
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
v-if="isEditMode"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
button-class="w-[150px]"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-[150px]"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-[150px]"
|
||||
:disabled="saving || !isValidHex"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.sites.title') }}
|
||||
</h1>
|
||||
<MalioButton
|
||||
v-if="can('sites.manage')"
|
||||
:label="t('admin.sites.newSite')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</div>
|
||||
<PageHeader>
|
||||
{{ t('admin.sites.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
v-if="can('sites.manage')"
|
||||
:label="t('admin.sites.newSite')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Table des sites -->
|
||||
<MalioDataTable
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="siteItems"
|
||||
:total-items="sites.length"
|
||||
|
||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
||||
"name": "starseed-frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.5.0",
|
||||
"@malio/layer-ui": "^1.7.1",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -1866,9 +1866,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.5.0/layer-ui-1.5.0.tgz",
|
||||
"integrity": "sha512-uVuG8kRakWgpWYQCMUf1LFD+gjx0iRFfNJn/jlqjxiZmZyGZMckcMW2qA9hGZBiheBsTJWw1pRR4ufuyAYPY0A==",
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.1/layer-ui-1.7.1.tgz",
|
||||
"integrity": "sha512-RYMMappWt/fgjD+BM7//h2O6kxD6WH9Fui8hoC29xtKySRQsqD61XKTdR7BRRkpktbxKmV39q/hblyAFBqV5yw==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.5.0",
|
||||
"@malio/layer-ui": "^1.7.1",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<!-- Entete de page standard : source unique du style des titres.
|
||||
Slot par defaut = texte du titre, slot #actions = boutons a droite. -->
|
||||
<div class="mb-[44px] flex items-center justify-between gap-4">
|
||||
<h1 class="text-[32px] font-semibold text-primary-500">
|
||||
<slot/>
|
||||
</h1>
|
||||
<div v-if="$slots.actions" class="shrink-0">
|
||||
<slot name="actions"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FetchOptions , FetchError } from 'ofetch'
|
||||
import { $fetch } from 'ofetch'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
|
||||
export type AnyObject = Record<string, unknown>
|
||||
|
||||
@@ -41,24 +42,8 @@ export function useApi(): ApiClient {
|
||||
|
||||
function extractErrorMessage(error: unknown, responseData?: unknown): string {
|
||||
const data = responseData ?? (error as FetchError)?.data
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return data
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
const record = data as Record<string, unknown>
|
||||
return (
|
||||
(record['hydra:description'] as string) ||
|
||||
(record.detail as string) ||
|
||||
(record.message as string) ||
|
||||
(record.error as string) ||
|
||||
(record.title as string) ||
|
||||
(record['hydra:title'] as string) ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
const msg = extractApiErrorMessage(data)
|
||||
if (msg) return msg
|
||||
return (error as FetchError)?.message ?? 'Erreur inconnue.'
|
||||
}
|
||||
|
||||
|
||||
@@ -43,3 +43,12 @@ export interface EffectivePermission {
|
||||
module: string
|
||||
sources: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Groupement de permissions par module pour l'affichage en accordeon.
|
||||
* Construit cote consommateur a partir de la liste plate /api/permissions.
|
||||
*/
|
||||
export interface PermissionModule {
|
||||
module: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
@@ -31,3 +31,62 @@ export interface HydraCollection<T> {
|
||||
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
||||
return collection.member ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
|
||||
* pointe le champ concerne, `message` est le libelle a afficher.
|
||||
*/
|
||||
export interface ApiViolation {
|
||||
propertyPath: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les violations d'un payload d'erreur 422 d'API Platform 4. Supporte
|
||||
* les deux formats de negociation (`violations` ou `hydra:violations`) et
|
||||
* renvoie un tableau vide si le payload n'en contient pas d'exploitables.
|
||||
*
|
||||
* Utilise par useCategoryForm et tout futur composable de formulaire qui
|
||||
* doit mapper les violations serveur sur ses champs.
|
||||
*/
|
||||
export function extractApiViolations(data: unknown): ApiViolation[] {
|
||||
if (!data || typeof data !== 'object') return []
|
||||
const record = data as Record<string, unknown>
|
||||
const raw = record.violations ?? record['hydra:violations']
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: ApiViolation[] = []
|
||||
for (const v of raw) {
|
||||
if (!v || typeof v !== 'object') continue
|
||||
const obj = v as Record<string, unknown>
|
||||
out.push({
|
||||
propertyPath: String(obj.propertyPath ?? ''),
|
||||
message: String(obj.message ?? ''),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
|
||||
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
|
||||
* `hydra:description` → `detail` → `description` → `message` → `error` →
|
||||
* `title` → `hydra:title`. Renvoie '' si rien d'exploitable.
|
||||
*
|
||||
* Si `data` est une string, la renvoie telle quelle (cas des erreurs
|
||||
* Symfony en text/plain ou des messages bruts).
|
||||
*/
|
||||
export function extractApiErrorMessage(data: unknown): string {
|
||||
if (typeof data === 'string') return data
|
||||
if (!data || typeof data !== 'object') return ''
|
||||
const record = data as Record<string, unknown>
|
||||
return (
|
||||
(record['hydra:description'] as string)
|
||||
?? (record.detail as string)
|
||||
?? (record.description as string)
|
||||
?? (record.message as string)
|
||||
?? (record.error as string)
|
||||
?? (record.title as string)
|
||||
?? (record['hydra:title'] as string)
|
||||
?? ''
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'>
|
||||
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories'>
|
||||
}
|
||||
|
||||
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', 'audit-log'],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||
},
|
||||
'user-full': {
|
||||
key: 'user-full',
|
||||
@@ -63,8 +63,10 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
'sites.view',
|
||||
'sites.manage',
|
||||
'sites.bypass_scope',
|
||||
'catalog.categories.view',
|
||||
'catalog.categories.manage',
|
||||
],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||
},
|
||||
'user-readonly': {
|
||||
key: 'user-readonly',
|
||||
@@ -109,4 +111,4 @@ export function getPersona(key: PersonaKey): Persona {
|
||||
return personas[key]
|
||||
}
|
||||
|
||||
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'audit-log'] as const
|
||||
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'audit-log'] as const
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'audit-log'
|
||||
export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'categories' | 'audit-log'
|
||||
|
||||
/**
|
||||
* Page Object de la sidebar (MalioSidebar), scope sur les items "admin".
|
||||
|
||||
@@ -200,12 +200,28 @@ migration-migrate:
|
||||
# en DB, le purger crash.
|
||||
# 3. fixtures -> sync-permissions : fixtures:load purge la table permission,
|
||||
# donc sync doit passer apres.
|
||||
# 4. recreation des index partiels uniques : schema:update drop les index
|
||||
# orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas
|
||||
# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc
|
||||
# ils disparaissent apres schema:update. On les recree par dbal:run-sql :
|
||||
# - `uq_category_name_type_active` (M0 Catalog) : tests RG-1.07.
|
||||
# - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe
|
||||
# parmi actifs non archives/non supprimes (RG-1.16), tests ERP-55.
|
||||
# Sans ces restores, les POST doublons remontent 201 au lieu de 409.
|
||||
# 5. app:apply-column-comments : meme cause, schema:update drop les COMMENT
|
||||
# ON COLUMN/TABLE des tables managees par l'ORM (le mapping PHP ne porte
|
||||
# pas d'attribut options['comment']). On rejoue le catalogue partage
|
||||
# `ColumnCommentsCatalog` pour conserver la documentation SQL exigee par
|
||||
# le test architecture ColumnsHaveSqlCommentTest (ERP-67).
|
||||
test-db-setup:
|
||||
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
|
||||
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
|
||||
$(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:apply-column-comments
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
|
||||
fixtures:
|
||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-67 — Retrofit `COMMENT ON COLUMN` / `COMMENT ON TABLE` sur toutes les
|
||||
* tables metier existantes.
|
||||
*
|
||||
* Postgres stocke la description dans `pg_description`. Les outils d'admin
|
||||
* (DBeaver, DataGrip, pgAdmin) l'affichent automatiquement, ce qui evite de
|
||||
* remonter au code Doctrine pour comprendre la semantique d'une colonne.
|
||||
*
|
||||
* Source unique : `ColumnCommentsCatalog::comments()`. Le meme catalogue est
|
||||
* rejoue par `app:apply-column-comments` apres `doctrine:schema:update --force`
|
||||
* en environnement de test (Doctrine ORM ne conservant pas les commentaires
|
||||
* absents du mapping PHP).
|
||||
*
|
||||
* Convention :
|
||||
* - Description en francais, ≤ 200 caracteres.
|
||||
* - Semantique du champ + contraintes / lien RG si pertinent.
|
||||
*
|
||||
* Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE
|
||||
* Starseed n°11) car elle touche plusieurs modules. Les futures migrations
|
||||
* applicatives devront poser leur propre `COMMENT ON COLUMN` au moment de
|
||||
* creer leurs colonnes (cf. regle ABSOLUE n°12 + .claude/rules/backend.md).
|
||||
*/
|
||||
final class Version20260528120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-67 : retrofit COMMENT ON COLUMN/TABLE sur toutes les tables metier existantes.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Ne commente que les tables deja presentes a ce stade de la chaine de
|
||||
// migrations. Les modules crees plus tard (ex: M1 Commercial, 06-01)
|
||||
// figurent desormais dans le catalogue partage mais leurs tables
|
||||
// n'existent pas encore ici : elles posent leurs propres COMMENT dans
|
||||
// leur migration dediee (regle ABSOLUE n°12). Garde-fou indispensable,
|
||||
// sinon l'ajout d'un module au catalogue casse ce retrofit avec un
|
||||
// "relation X does not exist".
|
||||
$existingTables = array_values(array_filter(
|
||||
array_keys(ColumnCommentsCatalog::comments()),
|
||||
static fn (string $table): bool => $schema->hasTable($table),
|
||||
));
|
||||
|
||||
foreach (ColumnCommentsCatalog::toSqlStatements($existingTables) as $sql) {
|
||||
$this->addSql($sql);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
foreach (ColumnCommentsCatalog::comments() as $table => $entries) {
|
||||
// Symetrie avec up() : on n'efface que les commentaires des tables
|
||||
// presentes (les tables des modules ulterieurs sont gerees par leur
|
||||
// propre migration).
|
||||
if (!$schema->hasTable($table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
foreach ($entries as $column => $_) {
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS NULL', $quotedTable));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS NULL',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* M1 — Repertoire clients (ERP-53) : creation de toute la structure BDD du
|
||||
* module Commercial (clients + sous-collections + referentiels comptables).
|
||||
*
|
||||
* Tables creees :
|
||||
* - Referentiels comptables (statiques, seedes ici) : tva_mode, payment_delay,
|
||||
* payment_type, bank.
|
||||
* - Table principale : client (formulaire + Information + Comptabilite +
|
||||
* archive + soft-delete + Timestampable/Blamable).
|
||||
* - Sous-collections : client_category (M2M), client_contact (1:n),
|
||||
* client_address (1:n), client_rib (1:n).
|
||||
* - Jointures de client_address : client_address_site, client_address_contact,
|
||||
* client_address_category.
|
||||
*
|
||||
* Seed `category_type` (extension M0) : DISTRIBUTEUR / COURTIER / SECTEUR /
|
||||
* AUTRE, en `ON CONFLICT (code) DO NOTHING` (idempotent — la table peut deja
|
||||
* porter des donnees en prod). En dev/test, les fixtures purgent et re-seedent
|
||||
* ces 4 types (cf. CategoryTypeFixtures) ; ce seed migration couvre la prod ou
|
||||
* les fixtures ne tournent pas.
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et NON
|
||||
* `App\Module\Commercial\...` : avec plusieurs migrations_paths, Doctrine
|
||||
* Migrations 3.x trie par FQCN alphabetique (AlphabeticalComparator → strcmp).
|
||||
* Un namespace `App\Module\Commercial\...` trierait AVANT `DoctrineMigrations\...`
|
||||
* et la migration s'executerait avant la creation de user/category/site sur
|
||||
* base vide → echec des FK. Le namespace racine garantit l'ordre par timestamp.
|
||||
*
|
||||
* Style DDL aligne sur la migration M0 (Version20260527164000) plutot que sur
|
||||
* le pseudo-SQL de la spec § 3.2 : `INT GENERATED BY DEFAULT AS IDENTITY` (et
|
||||
* non SERIAL), `TIMESTAMP(0) WITHOUT TIME ZONE` (et non TIMESTAMPTZ, car le
|
||||
* `TimestampableBlamableTrait` mappe `datetime_immutable`). Garantit que
|
||||
* `schema:update` restera un no-op quand les entites arriveront (ticket ERP-54).
|
||||
*
|
||||
* Decision Q4 (29/05/2026) : unicite metier sur le NOM DE SOCIETE uniquement.
|
||||
* Pas d'index unique sur siren ni email (RG-1.15 / RG-1.17 supprimees).
|
||||
*
|
||||
* Chaque colonne porte un `COMMENT ON COLUMN` (regle ABSOLUE n°12, garde-fou
|
||||
* ColumnsHaveSqlCommentTest). Les tables n'etant pas encore mappees par l'ORM,
|
||||
* ces commentaires survivent au `schema:update --force` du setup de test.
|
||||
*/
|
||||
final class Version20260601000000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-53 (M1) : tables client + sous-collections + referentiels comptables + seed category_type.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->createAccountingReferentials();
|
||||
$this->createClientTable();
|
||||
$this->createClientCategory();
|
||||
$this->createClientContact();
|
||||
$this->createClientAddress();
|
||||
$this->createClientAddressJoinTables();
|
||||
$this->createClientRib();
|
||||
$this->seedCategoryTypes();
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Ordre inverse des dependances FK : on supprime d'abord les jointures
|
||||
// et sous-collections, puis client, puis les referentiels.
|
||||
$this->addSql('DROP TABLE client_address_category');
|
||||
$this->addSql('DROP TABLE client_address_contact');
|
||||
$this->addSql('DROP TABLE client_address_site');
|
||||
$this->addSql('DROP TABLE client_rib');
|
||||
$this->addSql('DROP TABLE client_address');
|
||||
$this->addSql('DROP TABLE client_contact');
|
||||
$this->addSql('DROP TABLE client_category');
|
||||
$this->addSql('DROP TABLE client');
|
||||
$this->addSql('DROP TABLE bank');
|
||||
$this->addSql('DROP TABLE payment_type');
|
||||
$this->addSql('DROP TABLE payment_delay');
|
||||
$this->addSql('DROP TABLE tva_mode');
|
||||
|
||||
// Retire uniquement les 4 types seedes par cette migration. Les autres
|
||||
// types eventuels (CRUD futur) sont preserves.
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM category_type WHERE code IN ('DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE')
|
||||
SQL);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Referentiels comptables (4 tables statiques, memes colonnes)
|
||||
// =================================================================
|
||||
|
||||
private function createAccountingReferentials(): void
|
||||
{
|
||||
$referentials = [
|
||||
'tva_mode' => 'Referentiel des modes de TVA appliques a un client (France, Export, Intracom).',
|
||||
'payment_delay' => 'Referentiel des delais de reglement (15 jours, 30 jours, a reception).',
|
||||
'payment_type' => 'Referentiel des types de reglement (virement, LCR, cheque, non soumise).',
|
||||
'bank' => 'Referentiel des banques selectionnables pour le reglement par virement.',
|
||||
];
|
||||
|
||||
foreach ($referentials as $table => $tableComment) {
|
||||
$this->addSql(sprintf(<<<'SQL'
|
||||
CREATE TABLE %s (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
code VARCHAR(30) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL, $table));
|
||||
$this->addSql(sprintf('CREATE UNIQUE INDEX uq_%s_code ON %s (code)', $table, $table));
|
||||
|
||||
$this->comment($table, '_table', $tableComment);
|
||||
$this->comment($table, 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment($table, 'code', 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.');
|
||||
$this->comment($table, 'label', 'Libelle affichable (FR, ≤ 120 caracteres).');
|
||||
$this->comment($table, 'position', 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).');
|
||||
}
|
||||
|
||||
// Seed initial (cf. spec § 3.2). Tables fraichement creees donc vides :
|
||||
// INSERT direct sans ON CONFLICT.
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO tva_mode (code, label, position) VALUES
|
||||
('FRANCE_VENTES', 'France (ventes)', 10),
|
||||
('EXPORT_VENTES', 'Export (ventes)', 20),
|
||||
('INTRACOM_VENTES', 'Intracom (ventes)', 30)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO payment_delay (code, label, position) VALUES
|
||||
('J15', '15 jours', 10),
|
||||
('J30', '30 jours', 20),
|
||||
('A_RECEPTION', 'À réception', 30)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO payment_type (code, label, position) VALUES
|
||||
('VIREMENT', 'Virement', 10),
|
||||
('LCR', 'LCR', 20),
|
||||
('NON_SOUMISE', 'Non soumise', 30),
|
||||
('CHEQUE', 'Chèque', 40)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO bank (code, label, position) VALUES
|
||||
('SG', 'Société Générale', 10),
|
||||
('CIC', 'CIC', 20),
|
||||
('CA', 'Crédit Agricole', 30)
|
||||
SQL);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Table principale `client`
|
||||
// =================================================================
|
||||
|
||||
private function createClientTable(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
company_name VARCHAR(180) NOT NULL,
|
||||
first_name VARCHAR(120) DEFAULT NULL,
|
||||
last_name VARCHAR(120) DEFAULT NULL,
|
||||
phone_primary VARCHAR(20) NOT NULL,
|
||||
phone_secondary VARCHAR(20) DEFAULT NULL,
|
||||
email VARCHAR(180) NOT NULL,
|
||||
distributor_id INT DEFAULT NULL,
|
||||
broker_id INT DEFAULT NULL,
|
||||
triage_service BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
description TEXT DEFAULT NULL,
|
||||
competitors VARCHAR(255) DEFAULT NULL,
|
||||
founded_at DATE DEFAULT NULL,
|
||||
employees_count INT DEFAULT NULL,
|
||||
revenue_amount NUMERIC(15, 2) DEFAULT NULL,
|
||||
director_name VARCHAR(120) DEFAULT NULL,
|
||||
profit_amount NUMERIC(15, 2) DEFAULT NULL,
|
||||
siren VARCHAR(20) DEFAULT NULL,
|
||||
account_number VARCHAR(40) DEFAULT NULL,
|
||||
tva_mode_id INT DEFAULT NULL,
|
||||
n_tva VARCHAR(40) DEFAULT NULL,
|
||||
payment_delay_id INT DEFAULT NULL,
|
||||
payment_type_id INT DEFAULT NULL,
|
||||
bank_id INT DEFAULT NULL,
|
||||
is_archived BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT 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_client_distrib_or_broker
|
||||
CHECK (NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)),
|
||||
CONSTRAINT fk_client_distributor
|
||||
FOREIGN KEY (distributor_id) REFERENCES client (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_broker
|
||||
FOREIGN KEY (broker_id) REFERENCES client (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_tva_mode
|
||||
FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_client_payment_delay
|
||||
FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_client_payment_type
|
||||
FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_client_bank
|
||||
FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_client_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_client_is_archived ON client (is_archived)');
|
||||
$this->addSql('CREATE INDEX idx_client_deleted_at ON client (deleted_at)');
|
||||
$this->addSql('CREATE INDEX idx_client_distributor_id ON client (distributor_id)');
|
||||
$this->addSql('CREATE INDEX idx_client_broker_id ON client (broker_id)');
|
||||
$this->addSql('CREATE INDEX idx_client_created_by ON client (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_client_updated_by ON client (updated_by)');
|
||||
|
||||
// Unicite metier partielle (Q4) : nom de societe insensible a la casse,
|
||||
// parmi les non-archives ET non soft-deletes uniquement. Pas d'index
|
||||
// unique sur siren ni email.
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE UNIQUE INDEX uq_client_company_name_active
|
||||
ON client (LOWER(company_name))
|
||||
WHERE is_archived = FALSE AND deleted_at IS NULL
|
||||
SQL);
|
||||
|
||||
$this->comment('client', '_table', 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).');
|
||||
$this->comment('client', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('client', 'company_name', 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).');
|
||||
$this->comment('client', 'first_name', 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).');
|
||||
$this->comment('client', 'last_name', 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).');
|
||||
$this->comment('client', 'phone_primary', 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.');
|
||||
$this->comment('client', 'phone_secondary', 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).');
|
||||
$this->comment('client', 'email', 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).');
|
||||
$this->comment('client', 'distributor_id', 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.');
|
||||
$this->comment('client', 'broker_id', 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.');
|
||||
$this->comment('client', 'triage_service', 'Drapeau service triage active pour le client. Faux par defaut.');
|
||||
$this->comment('client', 'description', 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.');
|
||||
$this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).');
|
||||
$this->comment('client', 'account_number', 'Onglet Comptabilite : numero de compte comptable du client.');
|
||||
$this->comment('client', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.');
|
||||
$this->comment('client', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.');
|
||||
$this->comment('client', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id, ON DELETE RESTRICT.');
|
||||
$this->comment('client', 'payment_type_id', 'Onglet Comptabilite : type de reglement — FK -> payment_type.id, ON DELETE RESTRICT. Code LCR impose >= 1 RIB (RG-1.13), VIREMENT impose une banque (RG-1.12).');
|
||||
$this->comment('client', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id, ON DELETE RESTRICT. Obligatoire si payment_type = VIREMENT (RG-1.12).');
|
||||
$this->comment('client', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.clients.archive (RG-1.22/23).');
|
||||
$this->comment('client', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration (RG-1.22/23).');
|
||||
$this->comment('client', 'deleted_at', 'Horodatage du soft-delete technique (HP M2) — non expose par l API au M1. Null = ligne active.');
|
||||
$this->addTimestampableBlamableComments('client');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// M2M client <-> category
|
||||
// =================================================================
|
||||
|
||||
private function createClientCategory(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_category (
|
||||
client_id INT NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
PRIMARY KEY (client_id, category_id),
|
||||
CONSTRAINT fk_client_category_client
|
||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_category_category
|
||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_client_category_category ON client_category (category_id)');
|
||||
|
||||
$this->comment('client_category', '_table', 'Jointure M2M client <-> category (Catalog) — categories metier du client (au moins une obligatoire).');
|
||||
$this->comment('client_category', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client porteur de la categorie.');
|
||||
$this->comment('client_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie rattachee au client.');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : contacts (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createClientContact(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_contact (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
client_id INT NOT NULL,
|
||||
first_name VARCHAR(120) DEFAULT NULL,
|
||||
last_name VARCHAR(120) DEFAULT NULL,
|
||||
job_title VARCHAR(120) DEFAULT NULL,
|
||||
phone_primary VARCHAR(20) DEFAULT NULL,
|
||||
phone_secondary VARCHAR(20) DEFAULT NULL,
|
||||
email VARCHAR(180) DEFAULT NULL,
|
||||
position INT DEFAULT 0 NOT 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_client_contact_name
|
||||
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL),
|
||||
CONSTRAINT fk_client_contact_client
|
||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_contact_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_contact_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_client_contact_client ON client_contact (client_id)');
|
||||
|
||||
$this->comment('client_contact', '_table', 'Contacts d un client (1:n) — au moins firstName OU lastName par contact (RG-1.05).');
|
||||
$this->comment('client_contact', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('client_contact', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du contact.');
|
||||
$this->comment('client_contact', 'first_name', 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).');
|
||||
$this->comment('client_contact', 'last_name', 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).');
|
||||
$this->comment('client_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
|
||||
$this->comment('client_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (RG-1.20).');
|
||||
$this->comment('client_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (RG-1.20).');
|
||||
$this->comment('client_contact', 'email', 'Email du contact (lowercase serveur, RG-1.21).');
|
||||
$this->comment('client_contact', 'position', 'Ordre d affichage du contact dans la liste du client (croissant).');
|
||||
$this->addTimestampableBlamableComments('client_contact');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : adresses (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createClientAddress(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_address (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
client_id INT NOT NULL,
|
||||
is_prospect BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
is_delivery BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
is_billing BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
country VARCHAR(80) DEFAULT 'France' NOT NULL,
|
||||
postal_code VARCHAR(20) NOT NULL,
|
||||
city VARCHAR(120) NOT NULL,
|
||||
street VARCHAR(255) NOT NULL,
|
||||
street_complement VARCHAR(255) DEFAULT NULL,
|
||||
billing_email VARCHAR(180) DEFAULT NULL,
|
||||
position INT DEFAULT 0 NOT 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_client_address_prospect_exclusive
|
||||
CHECK (NOT (is_prospect = TRUE AND (is_delivery = TRUE OR is_billing = TRUE))),
|
||||
CONSTRAINT chk_client_address_billing_email
|
||||
CHECK ((is_billing = FALSE AND billing_email IS NULL)
|
||||
OR (is_billing = TRUE AND billing_email IS NOT NULL)),
|
||||
CONSTRAINT fk_client_address_client
|
||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_address_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_address_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_client_address_client ON client_address (client_id)');
|
||||
|
||||
$this->comment('client_address', '_table', 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).');
|
||||
$this->comment('client_address', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('client_address', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.');
|
||||
$this->comment('client_address', 'is_prospect', 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.');
|
||||
$this->comment('client_address', 'is_delivery', 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.');
|
||||
$this->comment('client_address', 'is_billing', 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.');
|
||||
$this->comment('client_address', 'country', 'Pays de l adresse — defaut France.');
|
||||
$this->comment('client_address', 'postal_code', 'Code postal (4-5 chiffres attendus, RG-1.09).');
|
||||
$this->comment('client_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).');
|
||||
$this->comment('client_address', 'street', 'Numero et voie de l adresse.');
|
||||
$this->comment('client_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.');
|
||||
$this->comment('client_address', 'billing_email', 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).');
|
||||
$this->comment('client_address', 'position', 'Ordre d affichage de l adresse dans la liste du client (croissant).');
|
||||
$this->addTimestampableBlamableComments('client_address');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Jointures de client_address (M2M)
|
||||
// =================================================================
|
||||
|
||||
private function createClientAddressJoinTables(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_address_site (
|
||||
client_address_id INT NOT NULL,
|
||||
site_id INT NOT NULL,
|
||||
PRIMARY KEY (client_address_id, site_id),
|
||||
CONSTRAINT fk_client_address_site_address
|
||||
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_address_site_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->comment('client_address_site', '_table', 'Jointure M2M client_address <-> site (Sites) — sites desservis par l adresse (>= 1 obligatoire, RG-1.10).');
|
||||
$this->comment('client_address_site', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('client_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_address_contact (
|
||||
client_address_id INT NOT NULL,
|
||||
client_contact_id INT NOT NULL,
|
||||
PRIMARY KEY (client_address_id, client_contact_id),
|
||||
CONSTRAINT fk_client_address_contact_address
|
||||
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_address_contact_contact
|
||||
FOREIGN KEY (client_contact_id) REFERENCES client_contact (id) ON DELETE CASCADE
|
||||
)
|
||||
SQL);
|
||||
$this->comment('client_address_contact', '_table', 'Jointure M2M client_address <-> client_contact — contacts associes a une adresse.');
|
||||
$this->comment('client_address_contact', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('client_address_contact', 'client_contact_id', 'FK -> client_contact.id, ON DELETE CASCADE — contact associe a l adresse.');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_address_category (
|
||||
client_address_id INT NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
PRIMARY KEY (client_address_id, category_id),
|
||||
CONSTRAINT fk_client_address_category_address
|
||||
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_address_category_category
|
||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->comment('client_address_category', '_table', 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).');
|
||||
$this->comment('client_address_category', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('client_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : RIB (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createClientRib(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_rib (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
client_id INT NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
bic VARCHAR(20) NOT NULL,
|
||||
iban VARCHAR(34) NOT NULL,
|
||||
position INT DEFAULT 0 NOT 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 fk_client_rib_client
|
||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_rib_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_rib_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_client_rib_client ON client_rib (client_id)');
|
||||
|
||||
$this->comment('client_rib', '_table', 'Coordonnees bancaires d un client (1:n) — >= 1 RIB obligatoire si payment_type = LCR (RG-1.13). Tous les champs audites (pas d AuditIgnore).');
|
||||
$this->comment('client_rib', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('client_rib', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du RIB.');
|
||||
$this->comment('client_rib', 'label', 'Libelle du RIB (ex: compte principal).');
|
||||
$this->comment('client_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).');
|
||||
$this->comment('client_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).');
|
||||
$this->comment('client_rib', 'position', 'Ordre d affichage du RIB dans la liste du client (croissant).');
|
||||
$this->addTimestampableBlamableComments('client_rib');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Seed extension category_type (M0)
|
||||
// =================================================================
|
||||
|
||||
private function seedCategoryTypes(): void
|
||||
{
|
||||
// Idempotent : la table category_type peut deja porter des donnees en
|
||||
// prod. ON CONFLICT (code) s appuie sur l index unique uq_category_type_code.
|
||||
// NB : la table M0 n a pas de colonne `position` (id/code/label seulement),
|
||||
// contrairement au pseudo-SQL de la spec § 3.3.
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO category_type (code, label) VALUES
|
||||
('DISTRIBUTEUR', 'Distributeur'),
|
||||
('COURTIER', 'Courtier'),
|
||||
('SECTEUR', 'Secteur'),
|
||||
('AUTRE', 'Autre')
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
SQL);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Helpers
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
|
||||
* en reutilisant le catalogue partage (source unique, cf. 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,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog;
|
||||
|
||||
final class CatalogModule
|
||||
{
|
||||
public const string ID = 'catalog';
|
||||
public const string LABEL = 'Catalogue';
|
||||
// REQUIRED = true : Category sera FK NOT NULL cote futurs modules Tiers
|
||||
// (M-Clients, M-Fournisseurs, M-Prestataires). Desactiver Catalog casserait
|
||||
// tout le metier au boot Doctrine. Cf. review Tristan MR #12 + spec M0 § 2.1.
|
||||
public const bool REQUIRED = true;
|
||||
|
||||
/**
|
||||
* Liste declarative des permissions RBAC exposees par le module Catalog.
|
||||
*
|
||||
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
|
||||
* qui se charge d'upserter ces entrees dans la table `permission`, de
|
||||
* reactiver les codes precedemment marques orphelins et de marquer comme
|
||||
* orphelins ceux qui ont disparu du code source.
|
||||
*
|
||||
* La cle `module` est auto-injectee par le sync command a partir de
|
||||
* `self::ID`, il est donc inutile de la repeter dans chaque entree.
|
||||
*
|
||||
* Convention de nommage des codes : `module.resource[.sub].action` en
|
||||
* snake_case, le prefixe module devant correspondre exactement a
|
||||
* `self::ID` (verifie par la commande de synchronisation).
|
||||
*
|
||||
* Granularite alignee sur Core (view + manage), pas view/create/edit/delete
|
||||
* (cf. spec M0 § 2.7).
|
||||
*
|
||||
* @return array<int, array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'catalog.categories.view', 'label' => 'Voir les categories'],
|
||||
['code' => 'catalog.categories.manage', 'label' => 'Gerer les categories (creer, editer, supprimer)'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\CategoryProvide
|
||||
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
@@ -82,7 +83,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Category implements TimestampableInterface, BlamableInterface
|
||||
class Category implements TimestampableInterface, BlamableInterface, CategoryInterface
|
||||
{
|
||||
// === Timestampable + Blamable ===
|
||||
// Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs
|
||||
@@ -97,9 +98,14 @@ class Category implements TimestampableInterface, BlamableInterface
|
||||
#[Groups(['category:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
// RG-1.02 + RG-1.03 : un name compose uniquement d'espaces doit declencher
|
||||
// NotBlank. Le normalizer 'trim' fait le menage avant validation, alignant
|
||||
// le comportement sur le trim cote Processor (qui s'applique apres) : ainsi
|
||||
// POST {name: " "} -> 422 et POST {name: " Vis "} -> 201 avec "Vis"
|
||||
// persiste, sans contradiction entre l'ordre Validate / Process.
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
|
||||
#[Assert\Length(min: 2, max: 120)]
|
||||
#[Assert\NotBlank(message: 'Le nom est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(min: 2, max: 120, normalizer: 'trim')]
|
||||
#[Groups(['category:read', 'category:write'])]
|
||||
private ?string $name = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Catalog\Domain\Repository\CategoryTypeRepositoryInterface;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
/**
|
||||
* Fixtures du module Catalog : seed des types de categorie metier (M1).
|
||||
*
|
||||
* La table `category_type` est creee vide au M0 ; le M1 la peuple avec les 4
|
||||
* types DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE (cf. spec M1 § 3.3).
|
||||
*
|
||||
* Pourquoi une fixture EN PLUS du seed de la migration (Version20260601000000) :
|
||||
* `category_type` est une entite managee par l ORM, donc le purger Doctrine la
|
||||
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les 4 types
|
||||
* seedes par la migration disparaitraient apres `make db-reset` / setup de test.
|
||||
* Le seed migration couvre la prod (ou les fixtures ne tournent pas) ; cette
|
||||
* fixture re-aligne dev et test. Les deux chemins produisent un etat identique.
|
||||
*
|
||||
* Idempotence : lookup par `code` parmi les types existants avant insertion,
|
||||
* sur le modele d AppFixtures::ensureSystemRole. Rejouable sans doublon meme
|
||||
* si le purger est desactive.
|
||||
*/
|
||||
class CategoryTypeFixtures extends Fixture
|
||||
{
|
||||
/**
|
||||
* Source unique des 4 types metier : code technique => libelle FR.
|
||||
* Doit rester aligne sur le seed de la migration Version20260601000000.
|
||||
*/
|
||||
private const TYPES = [
|
||||
'DISTRIBUTEUR' => 'Distributeur',
|
||||
'COURTIER' => 'Courtier',
|
||||
'SECTEUR' => 'Secteur',
|
||||
'AUTRE' => 'Autre',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly CategoryTypeRepositoryInterface $categoryTypeRepository,
|
||||
) {}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Index des types deja presents par code, pour ne pas creer de doublon.
|
||||
$existingByCode = [];
|
||||
foreach ($this->categoryTypeRepository->findAllOrderedByLabel() as $type) {
|
||||
$existingByCode[$type->getCode()] = $type;
|
||||
}
|
||||
|
||||
foreach (self::TYPES as $code => $label) {
|
||||
$type = $existingByCode[$code] ?? new CategoryType();
|
||||
$type->setCode($code);
|
||||
$type->setLabel($label);
|
||||
$manager->persist($type);
|
||||
}
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineBankRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Banque selectionnable pour le reglement par virement (Societe Generale,
|
||||
* CIC, Credit Agricole) : referentiel statique seede par la migration M1 et
|
||||
* re-seede en dev/test par CommercialReferentialFixtures.
|
||||
*
|
||||
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
|
||||
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
|
||||
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctrineBankRepository::class)]
|
||||
#[ORM\Table(name: 'bank')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_bank_code', columns: ['code'])]
|
||||
class Bank
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['bank:read', 'client:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['bank:read', 'client:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['bank:read', 'client:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
#[Groups(['bank:read'])]
|
||||
private int $position = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Client (M1 Commercial) — entite racine du repertoire clients. Porte le
|
||||
* formulaire principal, l'onglet Information, l'onglet Comptabilite, le
|
||||
* mecanisme d'archivage (is_archived / archived_at) et le soft-delete technique
|
||||
* prepare mais non expose au M1 (deleted_at, HP-M2-1).
|
||||
*
|
||||
* Decisions structurantes :
|
||||
* - Audit complet (#[Auditable]) sur tous les champs (M2M categories audite
|
||||
* automatiquement). Timestampable/Blamable via le trait Shared.
|
||||
* - PAS de #[ORM\UniqueConstraint] : l'unicite du nom de societe (RG-1.16) est
|
||||
* portee par l'index partiel fonctionnel uq_client_company_name_active
|
||||
* (LOWER(company_name) WHERE is_archived = FALSE AND deleted_at IS NULL),
|
||||
* inexprimable en attribut ORM, donc possede par la seule migration. Le SIREN
|
||||
* et l'email NE SONT PAS uniques (RG-1.15/1.17 supprimees, decision Q4).
|
||||
* - distributor / broker : 2 FK auto-referentes mutuellement exclusives
|
||||
* (RG-1.03, CHECK chk_client_distrib_or_broker en base).
|
||||
* - categories : M2M vers Category (module Catalog) via le contrat
|
||||
* CategoryInterface + resolve_target_entities (regle n°1, pas d'import direct).
|
||||
*
|
||||
* Aucun ApiResource au M1.1 (ERP-54) : les operations API (Provider + Processor,
|
||||
* normalisation, archivage, accounting conditionnel) sont branchees en ERP-55.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctrineClientRepository::class)]
|
||||
#[ORM\Table(name: 'client')]
|
||||
// Index nommes pour matcher la migration (Version20260601000000). L'index
|
||||
// unique partiel uq_client_company_name_active reste possede par la migration :
|
||||
// Doctrine ORM ne sait pas exprimer un index fonctionnel (LOWER) + partiel
|
||||
// (WHERE) via attribut. Pas de #[ORM\UniqueConstraint] (decision Q4).
|
||||
#[ORM\Index(name: 'idx_client_is_archived', columns: ['is_archived'])]
|
||||
#[ORM\Index(name: 'idx_client_deleted_at', columns: ['deleted_at'])]
|
||||
#[ORM\Index(name: 'idx_client_distributor_id', columns: ['distributor_id'])]
|
||||
#[ORM\Index(name: 'idx_client_broker_id', columns: ['broker_id'])]
|
||||
#[ORM\Index(name: 'idx_client_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_client_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Client implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['client:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
// === Formulaire principal ===
|
||||
#[ORM\Column(length: 180)]
|
||||
#[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(min: 2, max: 180, normalizer: 'trim')]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $companyName = null;
|
||||
|
||||
// RG-1.01 : firstName OU lastName obligatoire (validation au futur Processor).
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, normalizer: 'trim')]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, normalizer: 'trim')]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $phonePrimary = null;
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $phoneSecondary = null;
|
||||
|
||||
#[ORM\Column(length: 180)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Email]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $email = null;
|
||||
|
||||
// RG-1.03 : distributor / broker auto-references mutuellement exclusives
|
||||
// (CHECK chk_client_distrib_or_broker en base).
|
||||
#[ORM\ManyToOne(targetEntity: self::class)]
|
||||
#[ORM\JoinColumn(name: 'distributor_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?Client $distributor = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: self::class)]
|
||||
#[ORM\JoinColumn(name: 'broker_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?Client $broker = null;
|
||||
|
||||
#[ORM\Column(name: 'triage_service', options: ['default' => false])]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private bool $triageService = false;
|
||||
|
||||
// RG : au moins une categorie (Count min 1). M2M vers Category via le contrat
|
||||
// CategoryInterface (resolve_target_entities -> Category).
|
||||
/** @var Collection<int, CategoryInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||
#[ORM\JoinTable(name: 'client_category')]
|
||||
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private Collection $categories;
|
||||
|
||||
// === Onglet Information ===
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $competitors = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?DateTimeImmutable $foundedAt = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
#[Assert\PositiveOrZero]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?int $employeesCount = null;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $revenueAmount = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $directorName = null;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $profitAmount = null;
|
||||
|
||||
// === Onglet Comptabilite ===
|
||||
// Lecture conditionnee via le groupe `client:read:accounting` (ajoute par le
|
||||
// futur Provider si l'user a la permission accounting.view). Ecriture via
|
||||
// `client:write:accounting` (le futur Processor exige accounting.manage).
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?string $siren = null;
|
||||
|
||||
#[ORM\Column(length: 40, nullable: true)]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?string $accountNumber = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TvaMode::class)]
|
||||
#[ORM\JoinColumn(name: 'tva_mode_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?TvaMode $tvaMode = null;
|
||||
|
||||
#[ORM\Column(length: 40, nullable: true)]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?string $nTva = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PaymentDelay::class)]
|
||||
#[ORM\JoinColumn(name: 'payment_delay_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?PaymentDelay $paymentDelay = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PaymentType::class)]
|
||||
#[ORM\JoinColumn(name: 'payment_type_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?PaymentType $paymentType = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Bank::class)]
|
||||
#[ORM\JoinColumn(name: 'bank_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?Bank $bank = null;
|
||||
|
||||
// === Sous-collections (exposees via sous-ressources API dediees, ulterieur) ===
|
||||
/** @var Collection<int, ClientContact> */
|
||||
#[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $contacts;
|
||||
|
||||
/** @var Collection<int, ClientAddress> */
|
||||
#[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $addresses;
|
||||
|
||||
/** @var Collection<int, ClientRib> */
|
||||
#[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $ribs;
|
||||
|
||||
// === Archive / Soft delete ===
|
||||
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
|
||||
#[Groups(['client:read', 'client:write:archive'])]
|
||||
private bool $isArchived = false;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['client:read'])]
|
||||
private ?DateTimeImmutable $archivedAt = null;
|
||||
|
||||
// Soft delete technique (HP-M2-1) : non expose en lecture/ecriture au M1.
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
private ?DateTimeImmutable $deletedAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->categories = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
$this->addresses = new ArrayCollection();
|
||||
$this->ribs = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCompanyName(): ?string
|
||||
{
|
||||
return $this->companyName;
|
||||
}
|
||||
|
||||
public function setCompanyName(string $companyName): static
|
||||
{
|
||||
$this->companyName = $companyName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFirstName(): ?string
|
||||
{
|
||||
return $this->firstName;
|
||||
}
|
||||
|
||||
public function setFirstName(?string $firstName): static
|
||||
{
|
||||
$this->firstName = $firstName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastName(): ?string
|
||||
{
|
||||
return $this->lastName;
|
||||
}
|
||||
|
||||
public function setLastName(?string $lastName): static
|
||||
{
|
||||
$this->lastName = $lastName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhonePrimary(): ?string
|
||||
{
|
||||
return $this->phonePrimary;
|
||||
}
|
||||
|
||||
public function setPhonePrimary(string $phonePrimary): static
|
||||
{
|
||||
$this->phonePrimary = $phonePrimary;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhoneSecondary(): ?string
|
||||
{
|
||||
return $this->phoneSecondary;
|
||||
}
|
||||
|
||||
public function setPhoneSecondary(?string $phoneSecondary): static
|
||||
{
|
||||
$this->phoneSecondary = $phoneSecondary;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(string $email): static
|
||||
{
|
||||
$this->email = $email;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDistributor(): ?Client
|
||||
{
|
||||
return $this->distributor;
|
||||
}
|
||||
|
||||
public function setDistributor(?Client $distributor): static
|
||||
{
|
||||
$this->distributor = $distributor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBroker(): ?Client
|
||||
{
|
||||
return $this->broker;
|
||||
}
|
||||
|
||||
public function setBroker(?Client $broker): static
|
||||
{
|
||||
$this->broker = $broker;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isTriageService(): bool
|
||||
{
|
||||
return $this->triageService;
|
||||
}
|
||||
|
||||
public function setTriageService(bool $triageService): static
|
||||
{
|
||||
$this->triageService = $triageService;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CategoryInterface> */
|
||||
public function getCategories(): Collection
|
||||
{
|
||||
return $this->categories;
|
||||
}
|
||||
|
||||
public function addCategory(CategoryInterface $category): static
|
||||
{
|
||||
if (!$this->categories->contains($category)) {
|
||||
$this->categories->add($category);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCategory(CategoryInterface $category): static
|
||||
{
|
||||
$this->categories->removeElement($category);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): static
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCompetitors(): ?string
|
||||
{
|
||||
return $this->competitors;
|
||||
}
|
||||
|
||||
public function setCompetitors(?string $competitors): static
|
||||
{
|
||||
$this->competitors = $competitors;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFoundedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->foundedAt;
|
||||
}
|
||||
|
||||
public function setFoundedAt(?DateTimeImmutable $foundedAt): static
|
||||
{
|
||||
$this->foundedAt = $foundedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmployeesCount(): ?int
|
||||
{
|
||||
return $this->employeesCount;
|
||||
}
|
||||
|
||||
public function setEmployeesCount(?int $employeesCount): static
|
||||
{
|
||||
$this->employeesCount = $employeesCount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRevenueAmount(): ?string
|
||||
{
|
||||
return $this->revenueAmount;
|
||||
}
|
||||
|
||||
public function setRevenueAmount(?string $revenueAmount): static
|
||||
{
|
||||
$this->revenueAmount = $revenueAmount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDirectorName(): ?string
|
||||
{
|
||||
return $this->directorName;
|
||||
}
|
||||
|
||||
public function setDirectorName(?string $directorName): static
|
||||
{
|
||||
$this->directorName = $directorName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProfitAmount(): ?string
|
||||
{
|
||||
return $this->profitAmount;
|
||||
}
|
||||
|
||||
public function setProfitAmount(?string $profitAmount): static
|
||||
{
|
||||
$this->profitAmount = $profitAmount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSiren(): ?string
|
||||
{
|
||||
return $this->siren;
|
||||
}
|
||||
|
||||
public function setSiren(?string $siren): static
|
||||
{
|
||||
$this->siren = $siren;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAccountNumber(): ?string
|
||||
{
|
||||
return $this->accountNumber;
|
||||
}
|
||||
|
||||
public function setAccountNumber(?string $accountNumber): static
|
||||
{
|
||||
$this->accountNumber = $accountNumber;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTvaMode(): ?TvaMode
|
||||
{
|
||||
return $this->tvaMode;
|
||||
}
|
||||
|
||||
public function setTvaMode(?TvaMode $tvaMode): static
|
||||
{
|
||||
$this->tvaMode = $tvaMode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNTva(): ?string
|
||||
{
|
||||
return $this->nTva;
|
||||
}
|
||||
|
||||
public function setNTva(?string $nTva): static
|
||||
{
|
||||
$this->nTva = $nTva;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPaymentDelay(): ?PaymentDelay
|
||||
{
|
||||
return $this->paymentDelay;
|
||||
}
|
||||
|
||||
public function setPaymentDelay(?PaymentDelay $paymentDelay): static
|
||||
{
|
||||
$this->paymentDelay = $paymentDelay;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPaymentType(): ?PaymentType
|
||||
{
|
||||
return $this->paymentType;
|
||||
}
|
||||
|
||||
public function setPaymentType(?PaymentType $paymentType): static
|
||||
{
|
||||
$this->paymentType = $paymentType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBank(): ?Bank
|
||||
{
|
||||
return $this->bank;
|
||||
}
|
||||
|
||||
public function setBank(?Bank $bank): static
|
||||
{
|
||||
$this->bank = $bank;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, ClientContact> */
|
||||
public function getContacts(): Collection
|
||||
{
|
||||
return $this->contacts;
|
||||
}
|
||||
|
||||
public function addContact(ClientContact $contact): static
|
||||
{
|
||||
if (!$this->contacts->contains($contact)) {
|
||||
$this->contacts->add($contact);
|
||||
$contact->setClient($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeContact(ClientContact $contact): static
|
||||
{
|
||||
if ($this->contacts->removeElement($contact) && $contact->getClient() === $this) {
|
||||
$contact->setClient(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, ClientAddress> */
|
||||
public function getAddresses(): Collection
|
||||
{
|
||||
return $this->addresses;
|
||||
}
|
||||
|
||||
public function addAddress(ClientAddress $address): static
|
||||
{
|
||||
if (!$this->addresses->contains($address)) {
|
||||
$this->addresses->add($address);
|
||||
$address->setClient($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeAddress(ClientAddress $address): static
|
||||
{
|
||||
if ($this->addresses->removeElement($address) && $address->getClient() === $this) {
|
||||
$address->setClient(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, ClientRib> */
|
||||
public function getRibs(): Collection
|
||||
{
|
||||
return $this->ribs;
|
||||
}
|
||||
|
||||
public function addRib(ClientRib $rib): static
|
||||
{
|
||||
if (!$this->ribs->contains($rib)) {
|
||||
$this->ribs->add($rib);
|
||||
$rib->setClient($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeRib(ClientRib $rib): static
|
||||
{
|
||||
if ($this->ribs->removeElement($rib) && $rib->getClient() === $this) {
|
||||
$rib->setClient(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->isArchived;
|
||||
}
|
||||
|
||||
public function setIsArchived(bool $isArchived): static
|
||||
{
|
||||
$this->isArchived = $isArchived;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getArchivedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->archivedAt;
|
||||
}
|
||||
|
||||
public function setArchivedAt(?DateTimeImmutable $archivedAt): static
|
||||
{
|
||||
$this->archivedAt = $archivedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Adresse d'un client (1:n) — onglet Adresse. Une adresse de prospection
|
||||
* (isProspect) est exclusive d'une adresse de livraison/facturation
|
||||
* (RG-1.06/07/08, CHECK BDD). Un email de facturation est obligatoire ssi
|
||||
* isBilling (RG-1.11, CHECK BDD). Au moins un site doit etre rattache
|
||||
* (RG-1.10, Assert\Count).
|
||||
*
|
||||
* Relations M2M :
|
||||
* - sites : SiteInterface (module Sites) via resolve_target_entities
|
||||
* - contacts : ClientContact (meme module)
|
||||
* - categories : CategoryInterface (module Catalog) via resolve_target_entities
|
||||
* — limitees aux types SECTEUR/AUTRE cote validation (RG-1.29, futur Processor)
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable/Blamable. Aucun ApiResource au M1.1
|
||||
* (sous-ressources branchees a un ticket dedie).
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctrineClientAddressRepository::class)]
|
||||
#[ORM\Table(name: 'client_address')]
|
||||
#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])]
|
||||
#[Auditable]
|
||||
class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['client_address:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'addresses')]
|
||||
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Client $client = null;
|
||||
|
||||
#[ORM\Column(name: 'is_prospect', options: ['default' => false])]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private bool $isProspect = false;
|
||||
|
||||
#[ORM\Column(name: 'is_delivery', options: ['default' => false])]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private bool $isDelivery = false;
|
||||
|
||||
#[ORM\Column(name: 'is_billing', options: ['default' => false])]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private bool $isBilling = false;
|
||||
|
||||
#[ORM\Column(length: 80, options: ['default' => 'France'])]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private string $country = 'France';
|
||||
|
||||
// RG-1.09 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $postalCode = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
// RG-1.11 : obligatoire ssi isBilling (CHECK BDD + futur Processor).
|
||||
#[ORM\Column(length: 180, nullable: true)]
|
||||
#[Assert\Email]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $billingEmail = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private int $position = 0;
|
||||
|
||||
// RG-1.10 : au moins un site rattache a chaque adresse.
|
||||
/** @var Collection<int, SiteInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
|
||||
#[ORM\JoinTable(name: 'client_address_site')]
|
||||
#[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private Collection $sites;
|
||||
|
||||
/** @var Collection<int, ClientContact> */
|
||||
#[ORM\ManyToMany(targetEntity: ClientContact::class)]
|
||||
#[ORM\JoinTable(name: 'client_address_contact')]
|
||||
#[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'client_contact_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private Collection $contacts;
|
||||
|
||||
// RG-1.29 : categories de type SECTEUR/AUTRE uniquement (filtre au Processor).
|
||||
/** @var Collection<int, CategoryInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||
#[ORM\JoinTable(name: 'client_address_category')]
|
||||
#[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private Collection $categories;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->sites = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
$this->categories = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getClient(): ?Client
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function setClient(?Client $client): static
|
||||
{
|
||||
$this->client = $client;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isProspect(): bool
|
||||
{
|
||||
return $this->isProspect;
|
||||
}
|
||||
|
||||
public function setIsProspect(bool $isProspect): static
|
||||
{
|
||||
$this->isProspect = $isProspect;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isDelivery(): bool
|
||||
{
|
||||
return $this->isDelivery;
|
||||
}
|
||||
|
||||
public function setIsDelivery(bool $isDelivery): static
|
||||
{
|
||||
$this->isDelivery = $isDelivery;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isBilling(): bool
|
||||
{
|
||||
return $this->isBilling;
|
||||
}
|
||||
|
||||
public function setIsBilling(bool $isBilling): static
|
||||
{
|
||||
$this->isBilling = $isBilling;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCountry(): string
|
||||
{
|
||||
return $this->country;
|
||||
}
|
||||
|
||||
public function setCountry(string $country): static
|
||||
{
|
||||
$this->country = $country;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPostalCode(): ?string
|
||||
{
|
||||
return $this->postalCode;
|
||||
}
|
||||
|
||||
public function setPostalCode(?string $postalCode): static
|
||||
{
|
||||
$this->postalCode = $postalCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCity(): ?string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function setCity(?string $city): static
|
||||
{
|
||||
$this->city = $city;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreet(): ?string
|
||||
{
|
||||
return $this->street;
|
||||
}
|
||||
|
||||
public function setStreet(?string $street): static
|
||||
{
|
||||
$this->street = $street;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreetComplement(): ?string
|
||||
{
|
||||
return $this->streetComplement;
|
||||
}
|
||||
|
||||
public function setStreetComplement(?string $streetComplement): static
|
||||
{
|
||||
$this->streetComplement = $streetComplement;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBillingEmail(): ?string
|
||||
{
|
||||
return $this->billingEmail;
|
||||
}
|
||||
|
||||
public function setBillingEmail(?string $billingEmail): static
|
||||
{
|
||||
$this->billingEmail = $billingEmail;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, SiteInterface> */
|
||||
public function getSites(): Collection
|
||||
{
|
||||
return $this->sites;
|
||||
}
|
||||
|
||||
public function addSite(SiteInterface $site): static
|
||||
{
|
||||
if (!$this->sites->contains($site)) {
|
||||
$this->sites->add($site);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSite(SiteInterface $site): static
|
||||
{
|
||||
$this->sites->removeElement($site);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, ClientContact> */
|
||||
public function getContacts(): Collection
|
||||
{
|
||||
return $this->contacts;
|
||||
}
|
||||
|
||||
public function addContact(ClientContact $contact): static
|
||||
{
|
||||
if (!$this->contacts->contains($contact)) {
|
||||
$this->contacts->add($contact);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeContact(ClientContact $contact): static
|
||||
{
|
||||
$this->contacts->removeElement($contact);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CategoryInterface> */
|
||||
public function getCategories(): Collection
|
||||
{
|
||||
return $this->categories;
|
||||
}
|
||||
|
||||
public function addCategory(CategoryInterface $category): static
|
||||
{
|
||||
if (!$this->categories->contains($category)) {
|
||||
$this->categories->add($category);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCategory(CategoryInterface $category): static
|
||||
{
|
||||
$this->categories->removeElement($category);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientContactRepository;
|
||||
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\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Contact d'un client (1:n) — onglet Contact. Au moins firstName OU lastName
|
||||
* doit etre renseigne (RG-1.05) : la contrainte est portee par un CHECK BDD
|
||||
* (chk_client_contact_name) et validee dans le futur ClientContactProcessor ;
|
||||
* l'entite reste permissive (les deux champs sont nullable).
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable/Blamable (pattern Shared standard).
|
||||
* Les operations CRUD (sous-ressources POST/PATCH/DELETE) sont branchees au
|
||||
* ticket dedie des sous-ressources — aucun ApiResource au M1.1 (ERP-54).
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctrineClientContactRepository::class)]
|
||||
#[ORM\Table(name: 'client_contact')]
|
||||
#[ORM\Index(name: 'idx_client_contact_client', columns: ['client_id'])]
|
||||
#[Auditable]
|
||||
class ClientContact implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['client_contact:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'contacts')]
|
||||
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Client $client = null;
|
||||
|
||||
// RG-1.05 : firstName OU lastName obligatoire (CHECK BDD + Processor). Les
|
||||
// deux restent nullable au niveau ORM.
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, normalizer: 'trim')]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, normalizer: 'trim')]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, normalizer: 'trim')]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $jobTitle = null;
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $phonePrimary = null;
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $phoneSecondary = null;
|
||||
|
||||
#[ORM\Column(length: 180, nullable: true)]
|
||||
#[Assert\Email]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getClient(): ?Client
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function setClient(?Client $client): static
|
||||
{
|
||||
$this->client = $client;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFirstName(): ?string
|
||||
{
|
||||
return $this->firstName;
|
||||
}
|
||||
|
||||
public function setFirstName(?string $firstName): static
|
||||
{
|
||||
$this->firstName = $firstName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastName(): ?string
|
||||
{
|
||||
return $this->lastName;
|
||||
}
|
||||
|
||||
public function setLastName(?string $lastName): static
|
||||
{
|
||||
$this->lastName = $lastName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getJobTitle(): ?string
|
||||
{
|
||||
return $this->jobTitle;
|
||||
}
|
||||
|
||||
public function setJobTitle(?string $jobTitle): static
|
||||
{
|
||||
$this->jobTitle = $jobTitle;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhonePrimary(): ?string
|
||||
{
|
||||
return $this->phonePrimary;
|
||||
}
|
||||
|
||||
public function setPhonePrimary(?string $phonePrimary): static
|
||||
{
|
||||
$this->phonePrimary = $phonePrimary;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhoneSecondary(): ?string
|
||||
{
|
||||
return $this->phoneSecondary;
|
||||
}
|
||||
|
||||
public function setPhoneSecondary(?string $phoneSecondary): static
|
||||
{
|
||||
$this->phoneSecondary = $phoneSecondary;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(?string $email): static
|
||||
{
|
||||
$this->email = $email;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRibRepository;
|
||||
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\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Coordonnees bancaires d'un client (1:n) — onglet Comptabilite. Au moins un
|
||||
* RIB est obligatoire si le type de reglement du client est LCR (RG-1.13,
|
||||
* verifie au futur Processor).
|
||||
*
|
||||
* Audit (#[Auditable]) : TOUS les champs sont audites, y compris `iban` et
|
||||
* `bic` — AUCUN #[AuditIgnore] (decision Matthieu en revue MR 29/05/2026 :
|
||||
* l'audit etant admin-only, la tracabilite RIB est necessaire pour le suivi
|
||||
* comptable et la conformite, cf. spec § 2.5 / § 6.1).
|
||||
*
|
||||
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1
|
||||
* (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable
|
||||
* standard. Aucun ApiResource au M1.1 (sous-ressource branchee ulterieurement).
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctrineClientRibRepository::class)]
|
||||
#[ORM\Table(name: 'client_rib')]
|
||||
#[ORM\Index(name: 'idx_client_rib_client', columns: ['client_id'])]
|
||||
#[Auditable]
|
||||
class ClientRib implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['client_rib:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'ribs')]
|
||||
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Client $client = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(max: 120, normalizer: 'trim')]
|
||||
#[Groups(['client_rib:read', 'client_rib:write'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Bic]
|
||||
#[Groups(['client_rib:read', 'client_rib:write'])]
|
||||
private ?string $bic = null;
|
||||
|
||||
#[ORM\Column(length: 34)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Iban]
|
||||
#[Groups(['client_rib:read', 'client_rib:write'])]
|
||||
private ?string $iban = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
#[Groups(['client_rib:read', 'client_rib:write'])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getClient(): ?Client
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function setClient(?Client $client): static
|
||||
{
|
||||
$this->client = $client;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBic(): ?string
|
||||
{
|
||||
return $this->bic;
|
||||
}
|
||||
|
||||
public function setBic(string $bic): static
|
||||
{
|
||||
$this->bic = $bic;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIban(): ?string
|
||||
{
|
||||
return $this->iban;
|
||||
}
|
||||
|
||||
public function setIban(string $iban): static
|
||||
{
|
||||
$this->iban = $iban;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrinePaymentDelayRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Delai de reglement applique a un client (15 jours, 30 jours, a reception) :
|
||||
* referentiel statique seede par la migration M1 et re-seede en dev/test par
|
||||
* CommercialReferentialFixtures.
|
||||
*
|
||||
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
|
||||
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
|
||||
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctrinePaymentDelayRepository::class)]
|
||||
#[ORM\Table(name: 'payment_delay')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_payment_delay_code', columns: ['code'])]
|
||||
class PaymentDelay
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
#[Groups(['payment_delay:read'])]
|
||||
private int $position = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrinePaymentTypeRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Type de reglement applique a un client (virement, LCR, cheque, non soumise) :
|
||||
* referentiel statique seede par la migration M1 et re-seede en dev/test par
|
||||
* CommercialReferentialFixtures.
|
||||
*
|
||||
* Le `code` porte une semantique metier : VIREMENT impose une banque (RG-1.12),
|
||||
* LCR impose au moins un RIB (RG-1.13).
|
||||
*
|
||||
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
|
||||
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
|
||||
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctrinePaymentTypeRepository::class)]
|
||||
#[ORM\Table(name: 'payment_type')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_payment_type_code', columns: ['code'])]
|
||||
class PaymentType
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
#[Groups(['payment_type:read'])]
|
||||
private int $position = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineTvaModeRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Mode de TVA applique a un client (France ventes, Export, Intracom) :
|
||||
* referentiel statique seede par la migration M1 (Version20260601000000) et
|
||||
* re-seede en dev/test par CommercialReferentialFixtures.
|
||||
*
|
||||
* Lecture seule au M1 : pas de POST/PATCH/DELETE (HP-M2-2). L'ApiResource
|
||||
* (GetCollection + Get, tri position ASC) est branche au ticket dedie des
|
||||
* referentiels lecture seule.
|
||||
*
|
||||
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
|
||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
|
||||
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
|
||||
* d'un Client (onglet Comptabilite) au lieu d'un IRI.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctrineTvaModeRepository::class)]
|
||||
#[ORM\Table(name: 'tva_mode')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_tva_mode_code', columns: ['code'])]
|
||||
class TvaMode
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
#[Groups(['tva_mode:read'])]
|
||||
private int $position = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Repository;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
|
||||
interface BankRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Bank;
|
||||
|
||||
/**
|
||||
* Retourne toutes les banques triees position ASC puis label ASC.
|
||||
*
|
||||
* @return list<Bank>
|
||||
*/
|
||||
public function findAllOrdered(): array;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Repository;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
|
||||
interface ClientAddressRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?ClientAddress;
|
||||
|
||||
public function save(ClientAddress $address): void;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Repository;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\ClientContact;
|
||||
|
||||
interface ClientContactRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?ClientContact;
|
||||
|
||||
public function save(ClientContact $contact): void;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Repository;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
interface ClientRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Client;
|
||||
|
||||
public function save(Client $client): void;
|
||||
|
||||
/**
|
||||
* Construit un QueryBuilder de liste pour le repertoire clients.
|
||||
* - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24).
|
||||
* - Exclut les archives sauf si $includeArchived = true (RG-1.25).
|
||||
* - Tri par defaut : companyName ASC (RG-1.26).
|
||||
*/
|
||||
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Repository;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\ClientRib;
|
||||
|
||||
interface ClientRibRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?ClientRib;
|
||||
|
||||
public function save(ClientRib $rib): void;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Repository;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||
|
||||
interface PaymentDelayRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?PaymentDelay;
|
||||
|
||||
/**
|
||||
* Retourne tous les delais de reglement tries position ASC puis label ASC.
|
||||
*
|
||||
* @return list<PaymentDelay>
|
||||
*/
|
||||
public function findAllOrdered(): array;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Repository;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
|
||||
interface PaymentTypeRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?PaymentType;
|
||||
|
||||
/**
|
||||
* Retourne tous les types de reglement tries position ASC puis label ASC.
|
||||
*
|
||||
* @return list<PaymentType>
|
||||
*/
|
||||
public function findAllOrdered(): array;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Repository;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||
|
||||
interface TvaModeRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?TvaMode;
|
||||
|
||||
/**
|
||||
* Retourne tous les modes de TVA tries position ASC puis label ASC
|
||||
* (ordre des selecteurs, reutilise par la fixture de re-seed).
|
||||
*
|
||||
* @return list<TvaMode>
|
||||
*/
|
||||
public function findAllOrdered(): array;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
/**
|
||||
* Fixtures du module Commercial : re-seed des 4 referentiels comptables
|
||||
* (tva_mode, payment_delay, payment_type, bank) seedes par la migration M1
|
||||
* (Version20260601000000).
|
||||
*
|
||||
* Pourquoi cette fixture EN PLUS du seed de la migration : depuis ERP-54 ces
|
||||
* 4 tables sont des entites managees par l'ORM, donc le purger Doctrine les
|
||||
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les
|
||||
* referentiels seedes par la migration disparaitraient apres `make db-reset`
|
||||
* (0 ligne en dev/test) — cassant les FK Client -> referentiels et les tests
|
||||
* RG-1.12/1.13. Le seed migration couvre la prod (ou les fixtures ne tournent
|
||||
* pas) ; cette fixture re-aligne dev et test. Memes valeurs des deux cotes.
|
||||
*
|
||||
* Idempotence : lookup par `code` avant insertion (sur le modele de
|
||||
* CategoryTypeFixtures). Rejouable sans doublon meme si le purger est desactive.
|
||||
*/
|
||||
class CommercialReferentialFixtures extends Fixture
|
||||
{
|
||||
/**
|
||||
* Source unique des referentiels : classe d'entite => [code => [label, position]].
|
||||
* Doit rester aligne sur le seed de la migration Version20260601000000.
|
||||
*
|
||||
* @var array<class-string, array<string, array{string, int}>>
|
||||
*/
|
||||
private const REFERENTIALS = [
|
||||
TvaMode::class => [
|
||||
'FRANCE_VENTES' => ['France (ventes)', 10],
|
||||
'EXPORT_VENTES' => ['Export (ventes)', 20],
|
||||
'INTRACOM_VENTES' => ['Intracom (ventes)', 30],
|
||||
],
|
||||
PaymentDelay::class => [
|
||||
'J15' => ['15 jours', 10],
|
||||
'J30' => ['30 jours', 20],
|
||||
'A_RECEPTION' => ['À réception', 30],
|
||||
],
|
||||
PaymentType::class => [
|
||||
'VIREMENT' => ['Virement', 10],
|
||||
'LCR' => ['LCR', 20],
|
||||
'NON_SOUMISE' => ['Non soumise', 30],
|
||||
'CHEQUE' => ['Chèque', 40],
|
||||
],
|
||||
Bank::class => [
|
||||
'SG' => ['Société Générale', 10],
|
||||
'CIC' => ['CIC', 20],
|
||||
'CA' => ['Crédit Agricole', 30],
|
||||
],
|
||||
];
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
foreach (self::REFERENTIALS as $entityClass => $rows) {
|
||||
$this->seedReferential($manager, $entityClass, $rows);
|
||||
}
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert idempotent d'un referentiel : indexe l'existant par code puis
|
||||
* cree/met a jour chaque entree. Les 4 entites partagent le meme contrat
|
||||
* setCode/setLabel/setPosition.
|
||||
*
|
||||
* @param class-string $entityClass
|
||||
* @param array<string, array{string, int}> $rows
|
||||
*/
|
||||
private function seedReferential(ObjectManager $manager, string $entityClass, array $rows): void
|
||||
{
|
||||
$existingByCode = [];
|
||||
foreach ($manager->getRepository($entityClass)->findAll() as $entity) {
|
||||
$existingByCode[$entity->getCode()] = $entity;
|
||||
}
|
||||
|
||||
foreach ($rows as $code => [$label, $position]) {
|
||||
$entity = $existingByCode[$code] ?? new $entityClass();
|
||||
$entity->setCode($code);
|
||||
$entity->setLabel($label);
|
||||
$entity->setPosition($position);
|
||||
$manager->persist($entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Repository\BankRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Bank>
|
||||
*/
|
||||
class DoctrineBankRepository extends ServiceEntityRepository implements BankRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Bank::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Bank
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function findAllOrdered(): array
|
||||
{
|
||||
return $this->createQueryBuilder('b')
|
||||
->orderBy('b.position', 'ASC')
|
||||
->addOrderBy('b.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
use App\Module\Commercial\Domain\Repository\ClientAddressRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ClientAddress>
|
||||
*/
|
||||
class DoctrineClientAddressRepository extends ServiceEntityRepository implements ClientAddressRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ClientAddress::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?ClientAddress
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(ClientAddress $address): void
|
||||
{
|
||||
$this->getEntityManager()->persist($address);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\ClientContact;
|
||||
use App\Module\Commercial\Domain\Repository\ClientContactRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ClientContact>
|
||||
*/
|
||||
class DoctrineClientContactRepository extends ServiceEntityRepository implements ClientContactRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ClientContact::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?ClientContact
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(ClientContact $contact): void
|
||||
{
|
||||
$this->getEntityManager()->persist($contact);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Client>
|
||||
*/
|
||||
class DoctrineClientRepository extends ServiceEntityRepository implements ClientRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Client::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Client
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(Client $client): void
|
||||
{
|
||||
$this->getEntityManager()->persist($client);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder
|
||||
{
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
->andWhere('c.deletedAt IS NULL')
|
||||
->orderBy('c.companyName', 'ASC')
|
||||
;
|
||||
|
||||
if (!$includeArchived) {
|
||||
$qb->andWhere('c.isArchived = false');
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\ClientRib;
|
||||
use App\Module\Commercial\Domain\Repository\ClientRibRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ClientRib>
|
||||
*/
|
||||
class DoctrineClientRibRepository extends ServiceEntityRepository implements ClientRibRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ClientRib::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?ClientRib
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(ClientRib $rib): void
|
||||
{
|
||||
$this->getEntityManager()->persist($rib);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||
use App\Module\Commercial\Domain\Repository\PaymentDelayRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<PaymentDelay>
|
||||
*/
|
||||
class DoctrinePaymentDelayRepository extends ServiceEntityRepository implements PaymentDelayRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PaymentDelay::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?PaymentDelay
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function findAllOrdered(): array
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->orderBy('p.position', 'ASC')
|
||||
->addOrderBy('p.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
use App\Module\Commercial\Domain\Repository\PaymentTypeRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<PaymentType>
|
||||
*/
|
||||
class DoctrinePaymentTypeRepository extends ServiceEntityRepository implements PaymentTypeRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PaymentType::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?PaymentType
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function findAllOrdered(): array
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->orderBy('p.position', 'ASC')
|
||||
->addOrderBy('p.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||
use App\Module\Commercial\Domain\Repository\TvaModeRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<TvaMode>
|
||||
*/
|
||||
class DoctrineTvaModeRepository extends ServiceEntityRepository implements TvaModeRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TvaMode::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?TvaMode
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function findAllOrdered(): array
|
||||
{
|
||||
return $this->createQueryBuilder('t')
|
||||
->orderBy('t.position', 'ASC')
|
||||
->addOrderBy('t.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Console;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:apply-column-comments',
|
||||
description: 'Reapplique les COMMENT ON TABLE/COLUMN du catalogue (workaround schema:update).',
|
||||
)]
|
||||
final class ApplyColumnCommentsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$statements = ColumnCommentsCatalog::toSqlStatements();
|
||||
|
||||
foreach ($statements as $sql) {
|
||||
$this->connection->executeStatement($sql);
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d COMMENT ON statements appliques.', count($statements)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,8 @@ final class SeedE2ECommand extends Command
|
||||
'sites.view',
|
||||
'sites.manage',
|
||||
'sites.bypass_scope',
|
||||
'catalog.categories.view',
|
||||
'catalog.categories.manage',
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Interface minimale exposant ce qu'un module tiers (Commercial...) doit
|
||||
* connaitre d'une Category, sans creer de couplage direct vers le module
|
||||
* Catalog (regle ABSOLUE n°1 : pas d'import inter-modules).
|
||||
*
|
||||
* Implementee par App\Module\Catalog\Domain\Entity\Category.
|
||||
* Utilisee comme cible des ManyToMany Client.categories et
|
||||
* ClientAddress.categories via resolve_target_entities (cf. doctrine.yaml),
|
||||
* sur le meme modele que SiteInterface / UserInterface.
|
||||
*/
|
||||
interface CategoryInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
public function getName(): ?string;
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Database;
|
||||
|
||||
/**
|
||||
* Catalogue centralise des descriptions SQL (`COMMENT ON TABLE` /
|
||||
* `COMMENT ON COLUMN`) appliquees aux tables metier de Starseed.
|
||||
*
|
||||
* Source unique de verite, utilisee par :
|
||||
* - `migrations/Version20260528120000.php` : retrofit initial des tables
|
||||
* pre-existantes (ERP-67).
|
||||
* - `App\Module\Core\Infrastructure\Console\ApplyColumnCommentsCommand` :
|
||||
* reapplique les commentaires apres `doctrine:schema:update --force` en
|
||||
* environnement de test (cf. commentaire de `test-db-setup` dans le
|
||||
* `makefile`). Doctrine ORM ne conservant pas les commentaires absents
|
||||
* du mapping PHP, on les rejoue depuis ce catalogue.
|
||||
*
|
||||
* Pour ajouter ou modifier un commentaire :
|
||||
* - Mettre a jour `comments()` ci-dessous.
|
||||
* - La migration retrofit pose la valeur initiale, la commande la rejoue
|
||||
* en boucle. Toute future colonne doit etre documentee dans sa propre
|
||||
* migration (cf. CLAUDE.md regle ABSOLUE n°12) — ce catalogue ne sert
|
||||
* qu'au retrofit + au workaround schema:update.
|
||||
*
|
||||
* Convention : description en francais, ≤ 200 caracteres, semantique du
|
||||
* champ + contraintes / lien RG si pertinent. La cle speciale `_table` est
|
||||
* appliquee a la table elle-meme (`COMMENT ON TABLE`).
|
||||
*/
|
||||
final class ColumnCommentsCatalog
|
||||
{
|
||||
/**
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
public static function comments(): array
|
||||
{
|
||||
return [
|
||||
'audit_log' => [
|
||||
'_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.",
|
||||
'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).",
|
||||
'entity_type' => "Type d'entite auditee au format module.Entity (ex: core.User, commercial.Client) — evite les collisions inter-modules.",
|
||||
'entity_id' => "Identifiant de l'entite auditee (supporte INT et UUID — stocke en varchar pour rester generique).",
|
||||
'action' => "Type d'operation auditee : 'create', 'update' ou 'delete'.",
|
||||
'changes' => 'Snapshot complet pour create/delete, diff {champ: {old, new}} pour update. Cles sensibles filtrees (password, token, secret).',
|
||||
'performed_by' => "Username de l'auteur de l'action (denormalise, survit a la suppression du user) — vaut 'system' en CLI.",
|
||||
'performed_at' => "Horodatage UTC de l'action auditee.",
|
||||
'ip_address' => "Adresse IP de l'auteur (IPv4/IPv6) — null hors contexte HTTP.",
|
||||
'request_id' => "UUID v4 de la requete HTTP — regroupe les changements d'un meme flush, facilite la correlation logs.",
|
||||
],
|
||||
|
||||
'category' => [
|
||||
'_table' => 'Categories M0 — referentiel type par category_type, soft-delete via deleted_at, unicite (LOWER(name), category_type_id) parmi les actifs.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique par type parmi les actifs (RG-1.06).',
|
||||
'category_type_id' => 'Reference au type de la categorie — FK -> category_type.id, ON DELETE RESTRICT (un type ne peut etre supprime tant qu il a des categories).',
|
||||
'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'category_type' => [
|
||||
'_table' => 'Referentiel statique des types de categories — code technique stable + libelle FR.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code technique stable du type (snake_case, ≤ 40 caracteres) — unique, utilise dans le code et les configurations.',
|
||||
'label' => 'Libelle affichable du type (FR, ≤ 120 caracteres).',
|
||||
],
|
||||
|
||||
'permission' => [
|
||||
'_table' => 'Referentiel des permissions RBAC — codes au format module.resource[.subresource].action, synchronise par app:sync-permissions.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code RBAC au format module.resource[.subresource].action — unique, synchronise par app:sync-permissions.',
|
||||
'label' => 'Libelle affichable de la permission (FR).',
|
||||
'module' => 'Identifiant du module proprietaire de la permission (snake_case, ex: core, commercial).',
|
||||
'orphan' => "Drapeau permission orpheline — vrai quand son module declarant a ete supprime, masquee de l'interface RBAC.",
|
||||
],
|
||||
|
||||
'role' => [
|
||||
'_table' => 'Referentiel des roles RBAC — agregent un ensemble de permissions, attribues aux utilisateurs.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code technique stable du role (snake_case) — utilise dans le code (ex: admin, user). Unique.',
|
||||
'label' => 'Libelle affichable du role (FR).',
|
||||
'description' => 'Description longue du role (optionnelle).',
|
||||
'is_system' => "Drapeau role systeme — bloque la suppression et la modification du code via l'interface.",
|
||||
],
|
||||
|
||||
'role_permission' => [
|
||||
'_table' => 'Table de jointure roles <-> permissions (ManyToMany).',
|
||||
'role_id' => 'FK -> role.id, ON DELETE CASCADE — role qui porte la permission.',
|
||||
'permission_id' => 'FK -> permission.id, ON DELETE CASCADE — permission attribuee au role.',
|
||||
],
|
||||
|
||||
'site' => [
|
||||
'_table' => 'Sites geographiques — perimetre de scoping multi-site, attribues aux utilisateurs via user_site.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'name' => 'Nom du site (≤ 100 caracteres).',
|
||||
'city' => 'Ville du site (≤ 100 caracteres).',
|
||||
'postal_code' => 'Code postal (chaine ≤ 20 caracteres) — VARCHAR pour gerer les zeros initiaux et les formats internationaux.',
|
||||
'color' => "Code couleur hexadecimal (#RRGGBB) — differenciation visuelle dans l'UI.",
|
||||
'street' => "Numero et voie de l'adresse (≤ 200 caracteres).",
|
||||
'complement' => "Complement d'adresse (etage, batiment...) — optionnel.",
|
||||
'created_at' => 'Horodatage UTC de creation de la ligne — rempli par TimestampableBlamableSubscriber au prePersist.',
|
||||
'updated_at' => 'Horodatage UTC de derniere modification — rempli par TimestampableBlamableSubscriber au preUpdate.',
|
||||
],
|
||||
|
||||
'user' => [
|
||||
'_table' => 'Comptes utilisateurs Starseed — authentification JWT, RBAC via roles et permissions directes.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'username' => 'Identifiant de connexion (≤ 100 caracteres) — unique.',
|
||||
'password' => 'Hash du mot de passe (algorithme courant Symfony) — exclu de l audit via #[AuditIgnore].',
|
||||
'created_at' => 'Horodatage UTC de creation du compte — rempli manuellement dans le constructeur (pas via TimestampableBlamableSubscriber).',
|
||||
'is_admin' => 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.',
|
||||
'current_site_id' => "Site actuellement selectionne par l'utilisateur (contexte de session) — FK -> site.id, ON DELETE SET NULL.",
|
||||
],
|
||||
|
||||
'user_permission' => [
|
||||
'_table' => 'Table de jointure utilisateurs <-> permissions directes (hors role).',
|
||||
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur destinataire de la permission directe.',
|
||||
'permission_id' => 'FK -> permission.id, ON DELETE CASCADE — permission accordee individuellement.',
|
||||
],
|
||||
|
||||
'user_role' => [
|
||||
'_table' => 'Table de jointure utilisateurs <-> roles (ManyToMany).',
|
||||
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur portant le role.',
|
||||
'role_id' => 'FK -> role.id, ON DELETE CASCADE — role attribue a l utilisateur.',
|
||||
],
|
||||
|
||||
'user_site' => [
|
||||
'_table' => 'Table de jointure utilisateurs <-> sites accessibles — gere le scoping multi-site (un user ne voit que les donnees de ses sites).',
|
||||
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur ayant acces au site.',
|
||||
'site_id' => 'FK -> site.id, ON DELETE CASCADE — site accessible par l utilisateur.',
|
||||
],
|
||||
|
||||
// === M1 Commercial (ERP-53/54) — miroir des COMMENT de la migration
|
||||
// Version20260601000000 pour le chemin schema:update (dev/test). ===
|
||||
|
||||
'tva_mode' => [
|
||||
'_table' => 'Referentiel des modes de TVA appliques a un client (France, Export, Intracom).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.',
|
||||
'label' => 'Libelle affichable (FR, ≤ 120 caracteres).',
|
||||
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
|
||||
],
|
||||
|
||||
'payment_delay' => [
|
||||
'_table' => 'Referentiel des delais de reglement (15 jours, 30 jours, a reception).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.',
|
||||
'label' => 'Libelle affichable (FR, ≤ 120 caracteres).',
|
||||
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
|
||||
],
|
||||
|
||||
'payment_type' => [
|
||||
'_table' => 'Referentiel des types de reglement (virement, LCR, cheque, non soumise).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.',
|
||||
'label' => 'Libelle affichable (FR, ≤ 120 caracteres).',
|
||||
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
|
||||
],
|
||||
|
||||
'bank' => [
|
||||
'_table' => 'Referentiel des banques selectionnables pour le reglement par virement.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.',
|
||||
'label' => 'Libelle affichable (FR, ≤ 120 caracteres).',
|
||||
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
|
||||
],
|
||||
|
||||
'client' => [
|
||||
'_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).',
|
||||
'first_name' => 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).',
|
||||
'last_name' => 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).',
|
||||
'phone_primary' => 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.',
|
||||
'phone_secondary' => 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).',
|
||||
'email' => 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).',
|
||||
'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.',
|
||||
'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.',
|
||||
'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.',
|
||||
'description' => 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.',
|
||||
'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).',
|
||||
'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).',
|
||||
'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).',
|
||||
'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).',
|
||||
'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).',
|
||||
'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).',
|
||||
'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).',
|
||||
'account_number' => 'Onglet Comptabilite : numero de compte comptable du client.',
|
||||
'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.',
|
||||
'n_tva' => 'Onglet Comptabilite : numero de TVA intracommunautaire.',
|
||||
'payment_delay_id' => 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id, ON DELETE RESTRICT.',
|
||||
'payment_type_id' => 'Onglet Comptabilite : type de reglement — FK -> payment_type.id, ON DELETE RESTRICT. Code LCR impose >= 1 RIB (RG-1.13), VIREMENT impose une banque (RG-1.12).',
|
||||
'bank_id' => 'Onglet Comptabilite : banque — FK -> bank.id, ON DELETE RESTRICT. Obligatoire si payment_type = VIREMENT (RG-1.12).',
|
||||
'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.clients.archive (RG-1.22/23).',
|
||||
'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration (RG-1.22/23).',
|
||||
'deleted_at' => 'Horodatage du soft-delete technique (HP M2) — non expose par l API au M1. Null = ligne active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'client_category' => [
|
||||
'_table' => 'Jointure M2M client <-> category (Catalog) — categories metier du client (au moins une obligatoire).',
|
||||
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client porteur de la categorie.',
|
||||
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie rattachee au client.',
|
||||
],
|
||||
|
||||
'client_contact' => [
|
||||
'_table' => 'Contacts d un client (1:n) — au moins firstName OU lastName par contact (RG-1.05).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire du contact.',
|
||||
'first_name' => 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).',
|
||||
'last_name' => 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).',
|
||||
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
|
||||
'phone_primary' => 'Telephone principal du contact — chiffres uniquement (RG-1.20).',
|
||||
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (RG-1.20).',
|
||||
'email' => 'Email du contact (lowercase serveur, RG-1.21).',
|
||||
'position' => 'Ordre d affichage du contact dans la liste du client (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'client_address' => [
|
||||
'_table' => 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.',
|
||||
'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.',
|
||||
'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.',
|
||||
'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.',
|
||||
'country' => 'Pays de l adresse — defaut France.',
|
||||
'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).',
|
||||
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).',
|
||||
'street' => 'Numero et voie de l adresse.',
|
||||
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
|
||||
'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).',
|
||||
'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'client_address_site' => [
|
||||
'_table' => 'Jointure M2M client_address <-> site (Sites) — sites desservis par l adresse (>= 1 obligatoire, RG-1.10).',
|
||||
'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.',
|
||||
'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.',
|
||||
],
|
||||
|
||||
'client_address_contact' => [
|
||||
'_table' => 'Jointure M2M client_address <-> client_contact — contacts associes a une adresse.',
|
||||
'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.',
|
||||
'client_contact_id' => 'FK -> client_contact.id, ON DELETE CASCADE — contact associe a l adresse.',
|
||||
],
|
||||
|
||||
'client_address_category' => [
|
||||
'_table' => 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).',
|
||||
'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).',
|
||||
],
|
||||
|
||||
'client_rib' => [
|
||||
'_table' => 'Coordonnees bancaires d un client (1:n) — >= 1 RIB obligatoire si payment_type = LCR (RG-1.13). Tous les champs audites (pas d AuditIgnore).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire du RIB.',
|
||||
'label' => 'Libelle du RIB (ex: compte principal).',
|
||||
'bic' => 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).',
|
||||
'iban' => 'IBAN du compte (≤ 34 caracteres).',
|
||||
'position' => 'Ordre d affichage du RIB dans la liste du client (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptions standardisees pour les 4 colonnes du pattern
|
||||
* Timestampable/Blamable (`TimestampableBlamableTrait`).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function timestampableBlamableComments(): array
|
||||
{
|
||||
return [
|
||||
'created_at' => 'Horodatage UTC de creation de la ligne — rempli par TimestampableBlamableSubscriber au prePersist.',
|
||||
'updated_at' => 'Horodatage UTC de derniere modification — rempli par TimestampableBlamableSubscriber au preUpdate.',
|
||||
'created_by' => "ID de l'utilisateur ayant cree la ligne — null hors HTTP (CLI, migration, fixture). FK -> \"user\".id, ON DELETE SET NULL.",
|
||||
'updated_by' => "ID de l'utilisateur ayant modifie la ligne en dernier — null hors HTTP. FK -> \"user\".id, ON DELETE SET NULL.",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la liste des requetes SQL `COMMENT ON TABLE/COLUMN` (en
|
||||
* dollar-quoting Postgres `$_$`) a partir du catalogue.
|
||||
*
|
||||
* @param null|list<string> $onlyTables Restreint la generation a ces tables
|
||||
* (utile pour la migration retrofit qui
|
||||
* ne doit commenter que les tables deja
|
||||
* presentes a son instant T — les tables
|
||||
* des modules crees plus tard posent
|
||||
* leurs propres COMMENT). null = tout.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function toSqlStatements(?array $onlyTables = null): array
|
||||
{
|
||||
$allowed = null === $onlyTables ? null : array_fill_keys($onlyTables, true);
|
||||
|
||||
$statements = [];
|
||||
foreach (self::comments() as $table => $entries) {
|
||||
if (null !== $allowed && !isset($allowed[$table])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$quotedTable = self::quoteIdent($table);
|
||||
foreach ($entries as $column => $description) {
|
||||
if ('_table' === $column) {
|
||||
$statements[] = sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$statements[] = sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
self::quoteIdent($column),
|
||||
$description,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $statements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote un identifiant SQL avec des guillemets doubles. Necessaire pour
|
||||
* la table `user` (mot reserve PG) ; applique a tous par coherence.
|
||||
*/
|
||||
private static function quoteIdent(string $name): string
|
||||
{
|
||||
return '"'.str_replace('"', '""', $name).'"';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* Garde-fou architecture : toute colonne d'une table metier doit porter une
|
||||
* description SQL (`COMMENT ON COLUMN`).
|
||||
*
|
||||
* Postgres stocke la description dans `pg_description`, recuperable via
|
||||
* `col_description(table_oid, column_position)`. Une colonne sans description
|
||||
* remonte `NULL`. Le test parcourt `information_schema.columns` filtre sur le
|
||||
* schema `public` et echoue si une seule colonne metier n'a pas de description.
|
||||
*
|
||||
* Tables ignorees :
|
||||
* - `doctrine_migration_versions` : table system Doctrine, schema fige par la
|
||||
* librairie.
|
||||
* - Whitelist `EXCLUDED_TABLES` : doit rester vide ou justifiee — toute entree
|
||||
* doit avoir un ticket Lesstime ouvert pour le retrofit.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ColumnsHaveSqlCommentTest extends KernelTestCase
|
||||
{
|
||||
/**
|
||||
* Tables system, gerees par Doctrine — leur schema n'est pas notre.
|
||||
*/
|
||||
private const EXCLUDED_BUILTINS = [
|
||||
'doctrine_migration_versions',
|
||||
];
|
||||
|
||||
/**
|
||||
* Entites mappees uniquement en `when@test` (fixtures techniques pour les
|
||||
* tests d'integration, jamais en prod). Pas de migration, donc pas de
|
||||
* lieu naturel pour poser un COMMENT ON COLUMN.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const EXCLUDED_TEST_FIXTURES = [
|
||||
// tests/Fixtures/SiteAware/FakeSiteAwareEntity.php — fixture du module
|
||||
// Sites pour couvrir le SiteScopedQueryExtension. Cree via schema:update
|
||||
// sur la DB de test uniquement.
|
||||
'fake_site_aware_entity',
|
||||
];
|
||||
|
||||
/**
|
||||
* Whitelist metier — DOIT rester vide ou justifiee.
|
||||
*
|
||||
* Chaque entree doit comporter (1) un commentaire expliquant pourquoi la
|
||||
* table n'est pas encore documentee et (2) la reference d'un ticket
|
||||
* Lesstime ouvert pour le retrofit.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const EXCLUDED_TABLES = [];
|
||||
|
||||
public function testAllPublicColumnsHaveASqlComment(): void
|
||||
{
|
||||
/** @var Connection $conn */
|
||||
$conn = self::getContainer()->get('doctrine.dbal.default_connection');
|
||||
|
||||
$excluded = [...self::EXCLUDED_BUILTINS, ...self::EXCLUDED_TEST_FIXTURES, ...self::EXCLUDED_TABLES];
|
||||
|
||||
$rows = $conn->fetchAllAssociative(
|
||||
<<<'SQL'
|
||||
SELECT c.table_name, c.column_name
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema = 'public'
|
||||
AND c.table_name NOT IN (:excluded)
|
||||
AND col_description(
|
||||
(c.table_schema || '.' || c.table_name)::regclass,
|
||||
c.ordinal_position
|
||||
) IS NULL
|
||||
ORDER BY c.table_name, c.ordinal_position
|
||||
SQL,
|
||||
['excluded' => $excluded],
|
||||
['excluded' => ArrayParameterType::STRING],
|
||||
);
|
||||
|
||||
if ([] !== $rows) {
|
||||
$missing = array_map(
|
||||
static fn (array $row): string => sprintf('%s.%s', $row['table_name'], $row['column_name']),
|
||||
$rows,
|
||||
);
|
||||
|
||||
self::fail(sprintf(
|
||||
"%d colonne(s) sans COMMENT ON COLUMN — ajouter une description SQL dans la migration qui les cree (cf. .claude/rules/backend.md § Migrations Doctrine) :\n - %s",
|
||||
count($missing),
|
||||
implode("\n - ", $missing),
|
||||
));
|
||||
}
|
||||
|
||||
// Garde : si la requete ne renvoie rien et qu'aucune table publique
|
||||
// n'existe (sauf doctrine_migration_versions), le test deviendrait un
|
||||
// faux positif vert. On verifie qu'il y a bien des tables a auditer.
|
||||
$tableCount = (int) $conn->fetchOne(
|
||||
<<<'SQL'
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name NOT IN (:excluded)
|
||||
SQL,
|
||||
['excluded' => $excluded],
|
||||
['excluded' => ArrayParameterType::STRING],
|
||||
);
|
||||
|
||||
self::assertGreaterThan(0, $tableCount, 'Aucune table publique a auditer : schema vide ou whitelist trop large.');
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
@@ -49,6 +53,11 @@ 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.
|
||||
* - 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
|
||||
* tracabilite user-driven, meme justification que CategoryType. Cf.
|
||||
* spec-back M1 § 2.6 + § 3.5.
|
||||
*
|
||||
* Les futurs referentiels statiques s'ajoutent ici avec une justification.
|
||||
*/
|
||||
@@ -58,6 +67,10 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||
Permission::class,
|
||||
Site::class,
|
||||
CategoryType::class,
|
||||
TvaMode::class,
|
||||
PaymentDelay::class,
|
||||
PaymentType::class,
|
||||
Bank::class,
|
||||
];
|
||||
|
||||
public function testAllBusinessEntitiesImplementBothInterfaces(): void
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
<?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\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Classe de base pour les tests fonctionnels du module Catalog.
|
||||
*
|
||||
* Etend la base Core :
|
||||
* - factories `createCategoryType()` et `createCategory()` pour seeder vite
|
||||
* les referentiels et les entites metier dans les tests ;
|
||||
* - helpers d'authentification specifiques au M0 : `createAdminClient()`,
|
||||
* `createManageClient()`, `createViewClient()` et un helper persona
|
||||
* `createPersonaClient($label)` simulant les 4 roles MALIO sans permission
|
||||
* catalog (Bureau / Compta / Commerciale / Usine).
|
||||
*
|
||||
* Cleanup : les noms de Category sont prefixes `test_cat_` et les codes de
|
||||
* CategoryType sont prefixes `TEST_`. Le tearDown purge ces lignes, ainsi
|
||||
* que les users / roles `test_*` crees par `createUserWithPermission` et
|
||||
* `createPersonaClient`. Pas de DAMA en local, donc purge manuelle obligatoire.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class AbstractCatalogApiTestCase extends AbstractApiTestCase
|
||||
{
|
||||
protected const string TEST_CATEGORY_PREFIX = 'test_cat_';
|
||||
protected const string TEST_CATEGORY_TYPE_PREFIX = 'TEST_';
|
||||
protected const string TEST_USER_PREFIX = 'test_';
|
||||
protected const string TEST_ROLE_PREFIX = 'test_';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->cleanupCatalogTestData();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un CategoryType de test. Le code est prefixe `TEST_` pour le
|
||||
* cleanup, suffixe par un nonce aleatoire pour eviter les collisions
|
||||
* inter-tests.
|
||||
*/
|
||||
protected function createCategoryType(?string $code = null, ?string $label = null): CategoryType
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
$type = new CategoryType();
|
||||
$type->setCode($code ?? self::TEST_CATEGORY_TYPE_PREFIX.strtoupper($suffix));
|
||||
$type->setLabel($label ?? 'Test Type '.$suffix);
|
||||
|
||||
$em->persist($type);
|
||||
$em->flush();
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree une Category de test. Le nom est prefixe `test_cat_` pour le
|
||||
* cleanup. Si aucun type n'est fourni, un nouveau CategoryType est cree.
|
||||
* Le flag $deletedAt permet de seeder directement une categorie
|
||||
* soft-deleted (pour les tests RG-1.08 / RG-1.11).
|
||||
*/
|
||||
protected function createCategory(
|
||||
?string $name = null,
|
||||
?CategoryType $type = null,
|
||||
?DateTimeImmutable $deletedAt = null,
|
||||
): Category {
|
||||
$em = $this->getEm();
|
||||
|
||||
$type ??= $this->createCategoryType();
|
||||
|
||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
$category = new Category();
|
||||
$category->setName($name ?? self::TEST_CATEGORY_PREFIX.$suffix);
|
||||
$category->setCategoryType($type);
|
||||
if (null !== $deletedAt) {
|
||||
$category->setDeletedAt($deletedAt);
|
||||
}
|
||||
|
||||
$em->persist($category);
|
||||
$em->flush();
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client authentifie en tant qu'admin fixture (bypass via isAdmin).
|
||||
*/
|
||||
protected function createAdminClient(): Client
|
||||
{
|
||||
return $this->authenticatedClient('admin', 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Client non-admin portant la permission `catalog.categories.manage`.
|
||||
* Utilise pour prouver qu'un non-admin avec la permission obtient 200 /
|
||||
* 201 / 204 sur POST / PATCH / DELETE.
|
||||
*
|
||||
* @return array{client: Client, credentials: array{username: string, password: string}}
|
||||
*/
|
||||
protected function createManageClient(): array
|
||||
{
|
||||
$credentials = $this->createUserWithPermission('catalog.categories.manage');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
|
||||
return ['client' => $client, 'credentials' => $credentials];
|
||||
}
|
||||
|
||||
/**
|
||||
* Client non-admin portant la permission `catalog.categories.view`.
|
||||
*/
|
||||
protected function createViewClient(): Client
|
||||
{
|
||||
$credentials = $this->createUserWithPermission('catalog.categories.view');
|
||||
|
||||
return $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Client authentifie en tant qu'un des 4 personas metier MALIO sans
|
||||
* permission catalog. Les 4 roles (Bureau / Compta / Commerciale / Usine)
|
||||
* sont seules creees a la volee dans le test, sans aucune permission
|
||||
* catalog.categories.* attachee. Le user obtient donc systematiquement
|
||||
* 403 sur tous les endpoints `/api/categories*` et `/api/category_types*`.
|
||||
*
|
||||
* Note : ces roles ne sont pas seedes dans AppFixtures (cf. HP-8 de la
|
||||
* spec M0). Les tests les materialisent juste pour prouver que porter
|
||||
* un role metier sans la permission catalog donne bien 403.
|
||||
*/
|
||||
protected function createPersonaClient(string $personaLabel): Client
|
||||
{
|
||||
if (!self::$kernel) {
|
||||
self::bootKernel();
|
||||
}
|
||||
|
||||
$em = $this->getEm();
|
||||
|
||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
$username = self::TEST_USER_PREFIX.strtolower($personaLabel).'_'.$suffix;
|
||||
$password = 'testpass';
|
||||
|
||||
/** @var UserPasswordHasherInterface $hasher */
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
|
||||
// Role nomme d'apres le persona MALIO, ZERO permission catalog.
|
||||
$role = new Role(
|
||||
self::TEST_ROLE_PREFIX.strtolower($personaLabel).'_'.$suffix,
|
||||
$personaLabel.' (test)',
|
||||
false,
|
||||
);
|
||||
$em->persist($role);
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
$user->setIsAdmin(false);
|
||||
$user->setPassword($hasher->hashPassword($user, $password));
|
||||
$user->addRbacRole($role);
|
||||
|
||||
// Rattachement aux sites pour rester aligne sur createUserWithPermission.
|
||||
foreach ($em->getRepository(Site::class)->findAll() as $site) {
|
||||
$user->addSite($site);
|
||||
}
|
||||
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
|
||||
return $this->authenticatedClient($username, $password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge des donnees Catalog crees par les tests.
|
||||
*
|
||||
* Strategie : purge complete des tables `category` et `category_type`
|
||||
* (aucune fixture ne les remplit au M0 — la migration cree les tables
|
||||
* vides, cf. spec-back § 1 + HP-1). On evite ainsi les pieges de
|
||||
* cleanup par prefixe quand un test valide le mauvais payload (ex:
|
||||
* name="" persiste sans matcher le LIKE) et laisse des orphelins
|
||||
* bloquant le DELETE category_type par FK violation.
|
||||
*
|
||||
* Ordre :
|
||||
* 1. Categories d'abord (FK ON DELETE RESTRICT vers category_type) ;
|
||||
* 2. CategoryTypes ensuite ;
|
||||
* 3. Users / Roles `test_*` enfin (FK created_by/updated_by sur
|
||||
* category est ON DELETE SET NULL, mais on a deja purge category).
|
||||
*/
|
||||
private function cleanupCatalogTestData(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
$em->createQuery('DELETE FROM '.Category::class)->execute();
|
||||
$em->createQuery('DELETE FROM '.CategoryType::class)->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix'
|
||||
)->setParameter('prefix', self::TEST_USER_PREFIX.'%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix'
|
||||
)->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Tests Audit : l'attribut `#[Auditable]` porte sur Category, donc chaque
|
||||
* POST / PATCH / DELETE doit produire une ligne dans `audit_log` via le
|
||||
* AuditListener + AuditLogWriter (cf. spec audit-log.md).
|
||||
*
|
||||
* Verifications :
|
||||
* - une ligne `entity_type='catalog.Category'` apparait apres chaque
|
||||
* operation HTTP authentifiee comme admin ;
|
||||
* - l'action est `create` / `update` (le soft delete est trace comme
|
||||
* `update` puisque c'est un UPDATE Doctrine, cf. spec § 6.1) ;
|
||||
* - `performed_by` est le username du user authentifie ;
|
||||
* - `changes` est non vide (snapshot complet pour insert, diff pour update).
|
||||
*
|
||||
* Lecture via la connexion DBAL `audit` (pattern de AuditLogApiTest).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryAuditTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
private const string ENTITY_TYPE = 'catalog.Category';
|
||||
|
||||
private ?Connection $auditConnection = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
self::bootKernel();
|
||||
|
||||
/** @var Connection $conn */
|
||||
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
|
||||
$this->auditConnection = $conn;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (null !== $this->auditConnection) {
|
||||
$this->auditConnection->close();
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testAuditLogOnCreate(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'audit_create',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
$createdId = (string) $response->toArray()['id'];
|
||||
|
||||
$rows = $this->fetchAuditRows($createdId, 'create');
|
||||
self::assertCount(1, $rows, 'Un audit_log "create" doit etre genere apres POST.');
|
||||
self::assertSame('admin', $rows[0]['performed_by']);
|
||||
|
||||
$changes = $this->decodeChanges($rows[0]['changes']);
|
||||
// Snapshot complet : au moins le name doit etre dedans.
|
||||
self::assertArrayHasKey('name', $changes);
|
||||
self::assertSame(
|
||||
self::TEST_CATEGORY_PREFIX.'audit_create',
|
||||
$changes['name'] ?? null,
|
||||
'Le snapshot create doit porter le name persiste.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testAuditLogOnUpdate(): void
|
||||
{
|
||||
$category = $this->createCategory();
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('PATCH', '/api/categories/'.$category->getId(), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['name' => self::TEST_CATEGORY_PREFIX.'audit_patched'],
|
||||
]);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$rows = $this->fetchAuditRows((string) $category->getId(), 'update');
|
||||
self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log "update" doit etre genere apres PATCH.');
|
||||
// On prend la ligne la plus recente.
|
||||
$latest = $rows[0];
|
||||
self::assertSame('admin', $latest['performed_by']);
|
||||
|
||||
$changes = $this->decodeChanges($latest['changes']);
|
||||
// L'update doit contenir la diff sur `name` : {old: ..., new: 'audit_patched'}.
|
||||
self::assertArrayHasKey('name', $changes);
|
||||
self::assertIsArray($changes['name']);
|
||||
self::assertArrayHasKey('new', $changes['name']);
|
||||
self::assertSame(self::TEST_CATEGORY_PREFIX.'audit_patched', $changes['name']['new']);
|
||||
}
|
||||
|
||||
public function testAuditLogOnSoftDelete(): void
|
||||
{
|
||||
$category = $this->createCategory();
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('DELETE', '/api/categories/'.$category->getId());
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
|
||||
// Le soft delete = UPDATE Doctrine -> action 'update' en audit, avec
|
||||
// la diff sur deletedAt (RG-1.12 + spec § 6.1).
|
||||
$rows = $this->fetchAuditRows((string) $category->getId(), 'update');
|
||||
self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log doit tracer le soft delete (en tant qu\'update).');
|
||||
$latest = $rows[0];
|
||||
$changes = $this->decodeChanges($latest['changes']);
|
||||
|
||||
self::assertArrayHasKey('deletedAt', $changes, 'La diff doit contenir deletedAt.');
|
||||
self::assertIsArray($changes['deletedAt']);
|
||||
self::assertArrayHasKey('new', $changes['deletedAt']);
|
||||
self::assertNotNull(
|
||||
$changes['deletedAt']['new'],
|
||||
'deletedAt.new doit etre rempli (timestamp ISO ou tableau Doctrine).',
|
||||
);
|
||||
}
|
||||
|
||||
public function testAuditLogPerformerCarriesAuthenticatedUsername(): void
|
||||
{
|
||||
// Manage user (non-admin) : prouve que performed_by suit l'auth, pas
|
||||
// un mock hardcode "admin".
|
||||
$type = $this->createCategoryType();
|
||||
$manage = $this->createManageClient();
|
||||
$client = $manage['client'];
|
||||
$managerUsername = $manage['credentials']['username'];
|
||||
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'audit_manager',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
$createdId = (string) $response->toArray()['id'];
|
||||
|
||||
$rows = $this->fetchAuditRows($createdId, 'create');
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame(
|
||||
$managerUsername,
|
||||
$rows[0]['performed_by'],
|
||||
'performed_by doit refleter le user authentifie (pas l\'admin par defaut).',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Category::class lookups via entity_id + action
|
||||
*
|
||||
* @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string, performed_by: string}>
|
||||
*/
|
||||
private function fetchAuditRows(string $entityId, string $action): array
|
||||
{
|
||||
/** @var list<array<string, string>> $rows */
|
||||
return $this->auditConnection->fetchAllAssociative(
|
||||
'SELECT id, entity_type, entity_id, action, changes, performed_by '
|
||||
.'FROM audit_log '
|
||||
.'WHERE entity_type = :type AND entity_id = :id AND action = :action '
|
||||
.'ORDER BY performed_at DESC',
|
||||
[
|
||||
'type' => self::ENTITY_TYPE,
|
||||
'id' => $entityId,
|
||||
'action' => $action,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeChanges(string $raw): array
|
||||
{
|
||||
/** @var array<string, mixed> $decoded */
|
||||
return json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Tests RG-1.12 / RG-1.13 : suppression et soft-delete de Category.
|
||||
*
|
||||
* - RG-1.12 : DELETE pose `deletedAt` au lieu d'un hard delete (la ligne
|
||||
* reste en BDD avec `deleted_at IS NOT NULL`) et renvoie 204.
|
||||
* - RG-1.13 : PATCH ne peut pas ecrire `deletedAt` (groupe `category:write`
|
||||
* l'exclut), donc une tentative d'override est silencieusement ignoree.
|
||||
* - Provider sur PATCH/DELETE : 404 si la categorie cible est deja
|
||||
* soft-deleted (cf. CategoryProvider, ticket 0.3).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryDeleteTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
public function testDeleteReturns204AndPersistsSoftDelete(): void
|
||||
{
|
||||
$category = $this->createCategory();
|
||||
$categoryId = $category->getId();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('DELETE', '/api/categories/'.$categoryId);
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
|
||||
// RG-1.12 : la ligne doit toujours exister en BDD avec deletedAt non null.
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
|
||||
/** @var null|Category $reloaded */
|
||||
$reloaded = $em->getRepository(Category::class)->find($categoryId);
|
||||
self::assertNotNull($reloaded, 'La ligne ne doit PAS etre supprimee physiquement (soft delete).');
|
||||
self::assertNotNull($reloaded->getDeletedAt(), 'deletedAt doit etre rempli apres DELETE.');
|
||||
}
|
||||
|
||||
public function testPatchCannotSetDeletedAt(): void
|
||||
{
|
||||
// RG-1.13 : le groupe `category:write` ne contient pas `deletedAt`,
|
||||
// donc une tentative d'override doit etre silencieusement ignoree.
|
||||
$category = $this->createCategory();
|
||||
$categoryId = $category->getId();
|
||||
self::assertNull($category->getDeletedAt());
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('PATCH', '/api/categories/'.$categoryId, [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => [
|
||||
'deletedAt' => new DateTimeImmutable()->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
]);
|
||||
|
||||
// Le code precis depend d'API Platform : 200 (champ ignore) ou 400.
|
||||
// Quoi qu'il arrive, deletedAt en BDD doit rester null.
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
|
||||
/** @var Category $reloaded */
|
||||
$reloaded = $em->getRepository(Category::class)->find($categoryId);
|
||||
self::assertNull(
|
||||
$reloaded->getDeletedAt(),
|
||||
'PATCH ne doit JAMAIS pouvoir ecrire deletedAt (RG-1.13).',
|
||||
);
|
||||
}
|
||||
|
||||
public function testPatchOnSoftDeletedReturns404(): void
|
||||
{
|
||||
// Le Provider est cable sur PATCH (cf. Category::class § Patch). Une
|
||||
// categorie deja soft-deletee n'est pas visible en lecture, donc le
|
||||
// PATCH doit recevoir 404 (route resolved by API Platform retournee
|
||||
// par le provider) — comme un Get unitaire (RG-1.11 etendue).
|
||||
$category = $this->createCategory(
|
||||
null,
|
||||
null,
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('PATCH', '/api/categories/'.$category->getId(), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['name' => self::TEST_CATEGORY_PREFIX.'try_patch'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testDeleteOnSoftDeletedReturns404(): void
|
||||
{
|
||||
// Idem PATCH : un DELETE sur une categorie deja soft-deletee est un
|
||||
// 404 (le Provider la masque), pas une operation idempotente silencieuse.
|
||||
$category = $this->createCategory(
|
||||
null,
|
||||
null,
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('DELETE', '/api/categories/'.$category->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Tests RG-1.11 : GET /api/categories/{id}.
|
||||
*
|
||||
* - Category soft-deleted sans flag → 404 ;
|
||||
* - Category soft-deleted avec `?includeDeleted=true` → 200 ;
|
||||
* - Category inexistante → 404.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryGetTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
public function testGetActiveCategoryReturns200(): void
|
||||
{
|
||||
$category = $this->createCategory();
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories/'.$category->getId());
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame($category->getId(), $response->toArray()['id']);
|
||||
}
|
||||
|
||||
public function testGetSoftDeletedReturns404(): void
|
||||
{
|
||||
$category = $this->createCategory(
|
||||
null,
|
||||
null,
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/categories/'.$category->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testGetSoftDeletedWithFlagReturns200(): void
|
||||
{
|
||||
$category = $this->createCategory(
|
||||
null,
|
||||
null,
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories/'.$category->getId().'?includeDeleted=true');
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
$data = $response->toArray();
|
||||
self::assertSame($category->getId(), $data['id']);
|
||||
self::assertNotNull($data['deletedAt'], 'Le champ deletedAt doit etre expose dans la reponse.');
|
||||
}
|
||||
|
||||
public function testGetNonExistentReturns404(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/categories/9999999');
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Tests RG-1.08 / RG-1.09 / RG-1.10 : comportement de GET /api/categories.
|
||||
*
|
||||
* - RG-1.08 : par defaut, les categories soft-deleted sont exclues ;
|
||||
* - RG-1.09 : `?includeDeleted=true` inclut les soft-deleted ;
|
||||
* - RG-1.10 : tri par defaut `name ASC` cote serveur.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryListTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
public function testListExcludesSoftDeletedByDefault(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'alpha', $type);
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'beta', $type);
|
||||
$this->createCategory(
|
||||
self::TEST_CATEGORY_PREFIX.'gone',
|
||||
$type,
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$data = $response->toArray();
|
||||
$members = $data['member'];
|
||||
|
||||
// On filtre sur le prefix test_cat_ pour ne pas etre pollue par
|
||||
// d'autres entrees presentes en base (fixtures, autres tests).
|
||||
$names = array_values(array_filter(
|
||||
array_map(fn (array $m): string => $m['name'], $members),
|
||||
fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
|
||||
));
|
||||
|
||||
self::assertContains(self::TEST_CATEGORY_PREFIX.'alpha', $names);
|
||||
self::assertContains(self::TEST_CATEGORY_PREFIX.'beta', $names);
|
||||
self::assertNotContains(
|
||||
self::TEST_CATEGORY_PREFIX.'gone',
|
||||
$names,
|
||||
'Les categories soft-deleted doivent etre exclues par defaut (RG-1.08).',
|
||||
);
|
||||
}
|
||||
|
||||
public function testIncludeDeletedFlagSurfacesSoftDeleted(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'alpha2', $type);
|
||||
$this->createCategory(
|
||||
self::TEST_CATEGORY_PREFIX.'gone2',
|
||||
$type,
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories?includeDeleted=true');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$names = array_values(array_filter(
|
||||
array_map(fn (array $m): string => $m['name'], $response->toArray()['member']),
|
||||
fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
|
||||
));
|
||||
|
||||
self::assertContains(self::TEST_CATEGORY_PREFIX.'alpha2', $names);
|
||||
self::assertContains(
|
||||
self::TEST_CATEGORY_PREFIX.'gone2',
|
||||
$names,
|
||||
'?includeDeleted=true doit faire apparaitre les soft-deleted (RG-1.09).',
|
||||
);
|
||||
}
|
||||
|
||||
public function testDefaultSortIsNameAsc(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
// Insertion volontairement dans le desordre pour prouver le tri.
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'zorro', $type);
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'alpha_sort', $type);
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'mid', $type);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$names = array_values(array_filter(
|
||||
array_map(fn (array $m): string => $m['name'], $response->toArray()['member']),
|
||||
fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
|
||||
));
|
||||
|
||||
// Verifie que la sous-liste de nos 3 entrees est triee croissante.
|
||||
$expectedSubset = [
|
||||
self::TEST_CATEGORY_PREFIX.'alpha_sort',
|
||||
self::TEST_CATEGORY_PREFIX.'mid',
|
||||
self::TEST_CATEGORY_PREFIX.'zorro',
|
||||
];
|
||||
|
||||
$filtered = array_values(array_intersect($names, $expectedSubset));
|
||||
self::assertSame(
|
||||
$expectedSubset,
|
||||
$filtered,
|
||||
'Les categories doivent etre retournees triees par name ASC (RG-1.10).',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
|
||||
/**
|
||||
* Tests RG-1.01 : permissions RBAC catalog.categories.view / manage.
|
||||
*
|
||||
* Verifie que :
|
||||
* - les 4 personas metier MALIO (Bureau / Compta / Commerciale / Usine) sans
|
||||
* permission catalog.categories.* obtiennent 403 sur tous les verbes des
|
||||
* endpoints `/api/categories*` et `/api/category_types*` ;
|
||||
* - un utilisateur anonyme (sans JWT) obtient 401 ;
|
||||
* - l'admin (bypass via isAdmin) obtient le code attendu (200 / 201 / 204).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
// ============ /api/categories — collection ============
|
||||
|
||||
#[DataProvider('personaProvider')]
|
||||
public function testPersonaWithoutCatalogPermissionGets403OnGetCollection(string $personaLabel): void
|
||||
{
|
||||
$client = $this->createPersonaClient($personaLabel);
|
||||
$client->request('GET', '/api/categories');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testAnonymousGets401OnGetCollection(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', '/api/categories');
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testAdminGets200OnGetCollection(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/categories');
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
public function testUserWithViewPermissionGets200OnGetCollection(): void
|
||||
{
|
||||
$client = $this->createViewClient();
|
||||
$client->request('GET', '/api/categories');
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
// ============ /api/categories — POST ============
|
||||
|
||||
#[DataProvider('personaProvider')]
|
||||
public function testPersonaWithoutManagePermissionGets403OnPost(string $personaLabel): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createPersonaClient($personaLabel);
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'forbidden',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testAnonymousGets401OnPost(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = self::createClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'anon',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testAdminGets201OnPost(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'admin_create',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testUserWithOnlyViewPermissionGets403OnPost(): void
|
||||
{
|
||||
// Prouve qu'avoir `view` ne suffit pas a POSTer (manage requis).
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createViewClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'view_only',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// ============ /api/categories/{id} — PATCH ============
|
||||
|
||||
#[DataProvider('personaProvider')]
|
||||
public function testPersonaWithoutManagePermissionGets403OnPatch(string $personaLabel): void
|
||||
{
|
||||
$category = $this->createCategory();
|
||||
$client = $this->createPersonaClient($personaLabel);
|
||||
$client->request('PATCH', '/api/categories/'.$category->getId(), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['name' => self::TEST_CATEGORY_PREFIX.'patched'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// ============ /api/categories/{id} — DELETE ============
|
||||
|
||||
#[DataProvider('personaProvider')]
|
||||
public function testPersonaWithoutManagePermissionGets403OnDelete(string $personaLabel): void
|
||||
{
|
||||
$category = $this->createCategory();
|
||||
$client = $this->createPersonaClient($personaLabel);
|
||||
$client->request('DELETE', '/api/categories/'.$category->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testAdminGets204OnDelete(): void
|
||||
{
|
||||
$category = $this->createCategory();
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('DELETE', '/api/categories/'.$category->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
// ============ /api/category_types — referentiel ============
|
||||
|
||||
#[DataProvider('personaProvider')]
|
||||
public function testPersonaWithoutCatalogPermissionGets403OnCategoryTypes(string $personaLabel): void
|
||||
{
|
||||
$client = $this->createPersonaClient($personaLabel);
|
||||
$client->request('GET', '/api/category_types');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function personaProvider(): iterable
|
||||
{
|
||||
yield 'Bureau' => ['Bureau'];
|
||||
|
||||
yield 'Compta' => ['Compta'];
|
||||
|
||||
yield 'Commerciale' => ['Commerciale'];
|
||||
|
||||
yield 'Usine' => ['Usine'];
|
||||
}
|
||||
|
||||
public function testAnonymousGets401OnCategoryTypes(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', '/api/category_types');
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testAdminGets200OnCategoryTypes(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/category_types');
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
public function testUserWithViewPermissionGets200OnCategoryTypes(): void
|
||||
{
|
||||
// Le referentiel reutilise la meme permission catalog.categories.view.
|
||||
$client = $this->createViewClient();
|
||||
$client->request('GET', '/api/category_types');
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Tests RG-1.15 / RG-1.16 : le TimestampableBlamableSubscriber doit remplir
|
||||
* automatiquement les 4 colonnes au prePersist (RG-1.15) et au preUpdate
|
||||
* (RG-1.16), sans qu'aucun champ ne soit modifiable par l'API client.
|
||||
*
|
||||
* - POST authentifie : createdAt = updatedAt = now, createdBy = updatedBy = user
|
||||
* - Persist hors HTTP (console context) : dates remplies, blame null
|
||||
* - PATCH par un user different : updatedAt + updatedBy changent, createdAt /
|
||||
* createdBy restent figes
|
||||
* - DELETE : deletedAt rempli ET updatedAt + updatedBy mis a jour (UPDATE
|
||||
* Doctrine declenche le subscriber)
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
public function testCreatedByAdminOnPost(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
|
||||
/** @var User $admin */
|
||||
$admin = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'admin']);
|
||||
self::assertNotNull($admin);
|
||||
$adminId = $admin->getId();
|
||||
|
||||
$before = new DateTimeImmutable();
|
||||
// Petit decalage pour absorber les arrondis a la seconde de Postgres.
|
||||
sleep(1);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'tsb_admin',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
$createdId = $response->toArray()['id'];
|
||||
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
|
||||
/** @var Category $reloaded */
|
||||
$reloaded = $em->getRepository(Category::class)->find($createdId);
|
||||
|
||||
// RG-1.15 — dates remplies, egales au prePersist
|
||||
self::assertNotNull($reloaded->getCreatedAt());
|
||||
self::assertNotNull($reloaded->getUpdatedAt());
|
||||
self::assertGreaterThanOrEqual(
|
||||
$before->getTimestamp(),
|
||||
$reloaded->getCreatedAt()->getTimestamp(),
|
||||
'createdAt doit etre post-test-start.',
|
||||
);
|
||||
self::assertSame(
|
||||
$reloaded->getCreatedAt()->getTimestamp(),
|
||||
$reloaded->getUpdatedAt()->getTimestamp(),
|
||||
'Au POST, createdAt et updatedAt doivent etre identiques.',
|
||||
);
|
||||
|
||||
// RG-1.15 — blame remplis avec le user authentifie (admin)
|
||||
self::assertNotNull($reloaded->getCreatedBy());
|
||||
self::assertNotNull($reloaded->getUpdatedBy());
|
||||
self::assertSame($adminId, $reloaded->getCreatedBy()->getId());
|
||||
self::assertSame($adminId, $reloaded->getUpdatedBy()->getId());
|
||||
}
|
||||
|
||||
public function testCreatedByNullInConsoleContext(): void
|
||||
{
|
||||
// RG-1.15 : persist sans contexte HTTP -> Security::getUser() retourne
|
||||
// null -> blame reste null, mais les dates restent remplies.
|
||||
// On utilise la factory createCategory() qui fait un persist Doctrine
|
||||
// direct (pas via le client HTTP).
|
||||
$category = $this->createCategory(self::TEST_CATEGORY_PREFIX.'console');
|
||||
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
|
||||
/** @var Category $reloaded */
|
||||
$reloaded = $em->getRepository(Category::class)->find($category->getId());
|
||||
|
||||
// Dates remplies par le subscriber.
|
||||
self::assertNotNull($reloaded->getCreatedAt());
|
||||
self::assertNotNull($reloaded->getUpdatedAt());
|
||||
|
||||
// Blame null (pas de Security::getUser() dispo hors HTTP).
|
||||
self::assertNull(
|
||||
$reloaded->getCreatedBy(),
|
||||
'createdBy doit etre null hors contexte HTTP (RG-1.15 fallback).',
|
||||
);
|
||||
self::assertNull($reloaded->getUpdatedBy());
|
||||
}
|
||||
|
||||
public function testPatchUpdatesUpdatedFieldsOnly(): void
|
||||
{
|
||||
// Etape 1 : creation par admin pour figer createdBy=admin.
|
||||
$type = $this->createCategoryType();
|
||||
$adminClient = $this->createAdminClient();
|
||||
|
||||
$response = $adminClient->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'tsb_patch',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
$createdId = $response->toArray()['id'];
|
||||
|
||||
// Snapshot des valeurs initiales pour comparaison apres PATCH.
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
|
||||
/** @var Category $initial */
|
||||
$initial = $em->getRepository(Category::class)->find($createdId);
|
||||
$initialCreatedAt = $initial->getCreatedAt();
|
||||
$initialUpdatedAt = $initial->getUpdatedAt();
|
||||
$initialCreatedById = $initial->getCreatedBy()->getId();
|
||||
|
||||
// Decalage temporel suffisant pour que la precision PG (seconde)
|
||||
// capte un updatedAt different.
|
||||
sleep(1);
|
||||
|
||||
// Etape 2 : PATCH par un autre user (manager non-admin) — simule "bob".
|
||||
$manage = $this->createManageClient();
|
||||
$bobClient = $manage['client'];
|
||||
|
||||
/** @var User $bob */
|
||||
$bob = $this->getEm()->getRepository(User::class)->findOneBy(['username' => $manage['credentials']['username']]);
|
||||
$bobId = $bob->getId();
|
||||
self::assertNotSame($initialCreatedById, $bobId, 'Le test exige deux users distincts.');
|
||||
|
||||
$bobClient->request('PATCH', '/api/categories/'.$createdId, [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['name' => self::TEST_CATEGORY_PREFIX.'tsb_patched_by_bob'],
|
||||
]);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
// Etape 3 : verifications RG-1.16
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
|
||||
/** @var Category $patched */
|
||||
$patched = $em->getRepository(Category::class)->find($createdId);
|
||||
|
||||
// createdAt / createdBy figes
|
||||
self::assertSame(
|
||||
$initialCreatedAt->getTimestamp(),
|
||||
$patched->getCreatedAt()->getTimestamp(),
|
||||
'createdAt doit etre fige au PATCH (RG-1.16).',
|
||||
);
|
||||
self::assertSame(
|
||||
$initialCreatedById,
|
||||
$patched->getCreatedBy()->getId(),
|
||||
'createdBy doit etre fige au PATCH (RG-1.16).',
|
||||
);
|
||||
|
||||
// updatedAt / updatedBy mis a jour
|
||||
self::assertGreaterThan(
|
||||
$initialUpdatedAt->getTimestamp(),
|
||||
$patched->getUpdatedAt()->getTimestamp(),
|
||||
'updatedAt doit avancer apres PATCH (RG-1.16).',
|
||||
);
|
||||
self::assertSame(
|
||||
$bobId,
|
||||
$patched->getUpdatedBy()->getId(),
|
||||
'updatedBy doit refleter le user PATCH (RG-1.16).',
|
||||
);
|
||||
}
|
||||
|
||||
public function testSoftDeleteAlsoUpdatesUpdatedFields(): void
|
||||
{
|
||||
// RG-1.16 : le soft delete est un UPDATE Doctrine, donc le subscriber
|
||||
// doit aussi avancer updatedAt et updatedBy en plus de poser deletedAt.
|
||||
$type = $this->createCategoryType();
|
||||
$adminClient = $this->createAdminClient();
|
||||
|
||||
$response = $adminClient->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'tsb_delete',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
$createdId = $response->toArray()['id'];
|
||||
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
|
||||
/** @var Category $initial */
|
||||
$initial = $em->getRepository(Category::class)->find($createdId);
|
||||
$initialUpdatedAt = $initial->getUpdatedAt();
|
||||
|
||||
sleep(1);
|
||||
|
||||
// Soft delete par un manager non-admin.
|
||||
$manage = $this->createManageClient();
|
||||
$bobClient = $manage['client'];
|
||||
|
||||
/** @var User $bob */
|
||||
$bob = $this->getEm()->getRepository(User::class)->findOneBy(['username' => $manage['credentials']['username']]);
|
||||
$bobId = $bob->getId();
|
||||
|
||||
$bobClient->request('DELETE', '/api/categories/'.$createdId);
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
|
||||
/** @var Category $deleted */
|
||||
$deleted = $em->getRepository(Category::class)->find($createdId);
|
||||
|
||||
// deletedAt rempli
|
||||
self::assertNotNull($deleted->getDeletedAt(), 'deletedAt doit etre rempli apres DELETE.');
|
||||
|
||||
// updatedAt avance, updatedBy = bob
|
||||
self::assertGreaterThan(
|
||||
$initialUpdatedAt->getTimestamp(),
|
||||
$deleted->getUpdatedAt()->getTimestamp(),
|
||||
'updatedAt doit avancer au soft delete (RG-1.16).',
|
||||
);
|
||||
self::assertSame(
|
||||
$bobId,
|
||||
$deleted->getUpdatedBy()->getId(),
|
||||
'updatedBy doit refleter l\'auteur du soft delete (RG-1.16).',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
/**
|
||||
* Tests RG-1.07 : unicite case-insensitive de (LOWER(name), category_type_id)
|
||||
* parmi les categories non soft-deleted. L'index Postgres partiel
|
||||
* `uq_category_name_type_active` est traduit en 409 Conflict par le
|
||||
* CategoryProcessor.
|
||||
*
|
||||
* Cas couverts :
|
||||
* - doublon strict (meme name + meme type) → 409 ;
|
||||
* - doublon case-insensitive (Vis / vis sur meme type) → 409 ;
|
||||
* - meme name sur 2 types differents → les deux passent (pas de doublon) ;
|
||||
* - recreation apres soft delete → 201 (l'index partiel libere le couple).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
public function testDuplicateNameSameTypeReturns409(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
// 1er POST : doit reussir.
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'unique',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// 2eme POST : meme name + meme type → doublon strict.
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'unique',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertSame(409, $response->getStatusCode());
|
||||
|
||||
// Message attendu par la spec RG-1.07.
|
||||
$payload = $response->toArray(false);
|
||||
$description = $payload['description'] ?? $payload['detail'] ?? $payload['hydra:description'] ?? '';
|
||||
self::assertStringContainsString(
|
||||
'existe déjà pour ce type',
|
||||
$description,
|
||||
'Le message d\'erreur 409 doit citer la spec ("existe deja pour ce type").',
|
||||
);
|
||||
}
|
||||
|
||||
public function testDuplicateNameCaseInsensitiveReturns409(): void
|
||||
{
|
||||
// RG-1.07 : la collision est case-insensitive (index sur LOWER(name)).
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'Vis',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
// Meme prefix mais variation de casse → meme LOWER → collision.
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'VIS',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertSame(409, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testSameNameDifferentTypeAllowed(): void
|
||||
{
|
||||
// RG-1.07 : la contrainte est SUR (name, type), pas sur name seul.
|
||||
// Le meme nom doit etre acceptable sur deux types differents.
|
||||
$type1 = $this->createCategoryType();
|
||||
$type2 = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'shared',
|
||||
'categoryType' => '/api/category_types/'.$type1->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'shared',
|
||||
'categoryType' => '/api/category_types/'.$type2->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testRecreateAfterSoftDeleteAllowed(): void
|
||||
{
|
||||
// RG-1.07 : l'index Postgres est partiel (WHERE deleted_at IS NULL).
|
||||
// Apres un soft delete, le couple (name, type) est libere et un
|
||||
// nouveau POST identique doit reussir.
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
// 1) creation
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
$created = $response->toArray();
|
||||
|
||||
// 2) soft delete
|
||||
$client->request('DELETE', '/api/categories/'.$created['id']);
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
|
||||
// 3) recreation : meme name + meme type → autorise (couple libere).
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
|
||||
/**
|
||||
* Tests des regles de validation POST/PATCH sur Category :
|
||||
* - RG-1.02 : `name` obligatoire (NotBlank) ;
|
||||
* - RG-1.03 : `name` trim cote serveur via CategoryProcessor ;
|
||||
* - RG-1.04 : `name` longueur 2..120 (Length) ;
|
||||
* - RG-1.05 : `categoryType` obligatoire ;
|
||||
* - RG-1.06 : `categoryType` doit pointer un type existant.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
// ============ RG-1.02 — name NotBlank ============
|
||||
|
||||
public function testNameRequiredReturns422(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
// name absent
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testNameEmptyStringReturns422(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => '',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testNameWhitespaceOnlyReturns422(): void
|
||||
{
|
||||
// Le Processor trim avant la validation : " " devient "" -> NotBlank
|
||||
// doit declencher 422 (RG-1.02 combinee a RG-1.03).
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => ' ',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
// ============ RG-1.03 — name trim cote serveur ============
|
||||
|
||||
public function testNameIsTrimmedOnCreate(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
$payloadName = ' '.self::TEST_CATEGORY_PREFIX.'trim ';
|
||||
$expected = trim($payloadName);
|
||||
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => $payloadName,
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// Verification cote base : la valeur stockee est trimee.
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
$stored = $em->getRepository(Category::class)->findOneBy(['name' => $expected]);
|
||||
self::assertNotNull($stored, 'La categorie trimee doit etre persistee sous "'.$expected.'"');
|
||||
self::assertSame($expected, $stored->getName());
|
||||
}
|
||||
|
||||
// ============ RG-1.04 — longueur 2..120 ============
|
||||
|
||||
public function testNameTooShortReturns422(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => 'A',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testNameTooLongReturns422(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => str_repeat('a', 121),
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testNameAtMaxLengthIs201(): void
|
||||
{
|
||||
// Borne haute : 120 caracteres doit passer (l'index est sur LOWER, name
|
||||
// est unique en collision avec d'autres tests donc on prefixe la marque
|
||||
// test_cat_ pour le cleanup et completons jusqu'a 120 caracteres).
|
||||
$prefix = self::TEST_CATEGORY_PREFIX;
|
||||
$name = $prefix.str_repeat('z', 120 - strlen($prefix));
|
||||
self::assertSame(120, strlen($name));
|
||||
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => $name,
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
// ============ RG-1.05 — categoryType obligatoire ============
|
||||
|
||||
public function testCategoryTypeRequiredReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'no_type',
|
||||
// categoryType absent
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testCategoryTypeNullIsRejected(): void
|
||||
{
|
||||
// `categoryType: null` echoue a la deserialization IRI (API Platform
|
||||
// renvoie 400) bien avant la validation Assert\NotNull. La spec § 4.3
|
||||
// accepte les deux : on assert le contrat fort "ne passe pas en BDD".
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'null_type',
|
||||
'categoryType' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertContains(
|
||||
$response->getStatusCode(),
|
||||
[400, 422],
|
||||
'categoryType=null doit etre rejete (400 deserialization ou 422 validation).',
|
||||
);
|
||||
}
|
||||
|
||||
// ============ RG-1.06 — categoryType doit exister ============
|
||||
|
||||
public function testCategoryTypeMustExistReturns4xx(): void
|
||||
{
|
||||
// IRI vers un id qui n'existe pas. API Platform peut renvoyer 400
|
||||
// (resolution IRI echouee) ou 422 (validation NotNull declenchee).
|
||||
// La spec § 4.3 accepte les deux : on assert le contrat "ne passe pas".
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'ghost_type',
|
||||
'categoryType' => '/api/category_types/9999999',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertContains(
|
||||
$response->getStatusCode(),
|
||||
[400, 404, 422],
|
||||
'IRI categoryType inexistante doit etre rejetee (400/404/422 selon API Platform).',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user