Compare commits

..

2 Commits

Author SHA1 Message Date
malio c1708cd8f5 Merge branch 'develop' into refactor/refonte-contact-suppression-inline-back
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m54s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m12s
2026-06-03 13:47:23 +00:00
Matthieu 74f0f981d8 refactor(commercial) : suppression du contact principal inline du Client (M1)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m57s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m13s
Le contact principal (firstName, lastName, phonePrimary, phoneSecondary,
email) n'est plus porte par l'entite Client : les contacts vivent uniquement
dans ClientContact (onglet Contact). RG-1.01 et RG-1.02 supprimees du Client
(equivalent RG-1.05 / RG-1.14 sur ClientContact).

- Migration (namespace racine DoctrineMigrations, ordre par timestamp) :
  backfill des clients sans contact vers client_contact (position 0) puis
  DROP des 5 colonnes inline. down() best-effort documente.
- Entite Client : retrait des 5 props + getters/setters + groupes.
- ClientProcessor : MAIN_FIELDS / changedBusinessFields / normalize alleges,
  validateMainContact (RG-1.01) supprimee.
- Recherche repertoire : companyName seul (D1).
- Export XLSX : colonnes de contact retirees (D2).
- Fixtures + catalogue de commentaires SQL alignes.
- Tests fonctionnels et unitaires mis a jour.
2026-06-03 15:31:01 +02:00
67 changed files with 1025 additions and 4578 deletions
+131 -19
View File
@@ -6,13 +6,6 @@
- PHP CS Fixer : regles Symfony + PSR-12 + strict types (commande : `make php-cs-fixer-allow-risky`) - PHP CS Fixer : regles Symfony + PSR-12 + strict types (commande : `make php-cs-fixer-allow-risky`)
- Commentaires (docblock, inline, bloc) **en francais** ; code (classes, methodes, variables) en anglais - Commentaires (docblock, inline, bloc) **en francais** ; code (classes, methodes, variables) en anglais
## Messages de validation (obligatoire)
Toute contrainte `#[Assert\*]` d'une entite metier : **message FR explicite**, et `Assert\Length.max` = `length` de la colonne ORM (coherence 3 niveaux nullable DB <-> NotBlank back <-> required front, ERP-101). RG inter-champs via `#[Assert\Callback]->atPath('<champ>')` (mapping inline front, pas toast). Exceptions miroir Length (Bic/Iban/Regex borne) : whitelist `EntityConstraintsHaveFrenchMessageTest::EXCLUDED_LENGTH_MIRROR`.
Garde-fou : `tests/Architecture/EntityConstraintsHaveFrenchMessageTest` (casse `make test`).
→ patterns code + exemples + justification complete : skill `backend-entity-conventions`.
## API Platform (pas de controllers) ## API Platform (pas de controllers)
- Toujours utiliser `#[ApiResource]` + Providers + Processors — pas de controllers Symfony classiques - Toujours utiliser `#[ApiResource]` + Providers + Processors — pas de controllers Symfony classiques
@@ -22,10 +15,61 @@ Garde-fou : `tests/Architecture/EntityConstraintsHaveFrenchMessageTest` (casse `
## Pagination (obligatoire) ## Pagination (obligatoire)
Toute collection API est paginee (defaut 10, max 50 ; `?pagination=false` = echappatoire selects, `?itemsPerPage=25` borne par le max). Standard global dans `config/packages/api_platform.yaml`. Jamais `paginationEnabled: false` hors whitelist `CollectionsArePaginatedTest::EXCLUDED`. Provider custom : ne jamais retourner un `array` brut sur une `CollectionOperationInterface` (court-circuite Hydra) — wrapper un Paginator (ORM : `ApiPlatform\Doctrine\Orm\Paginator` ; DBAL : `DbalPaginator`) et gerer `?pagination=false` via `$this->pagination->isEnabled(...)`. **Regle** : toute collection API DOIT etre paginee. Aucun retour de collection complete cote serveur.
Garde-fou : `tests/Architecture/CollectionsArePaginatedTest` (casse `make test`). ### Standard global
→ tableau des cles `pagination_*` + selects + providers ORM/DBAL detailles : skill `backend-entity-conventions`.
Pose dans `config/packages/api_platform.yaml` (section `defaults:`) et heritee par toutes les ressources :
| Cle | Valeur | Effet |
|---|---|---|
| `pagination_enabled` | `true` | Pagination Hydra active par defaut. |
| `pagination_items_per_page` | `10` | Taille de page par defaut, aligne sur l'UI `MalioDataTable`. |
| `pagination_maximum_items_per_page` | `50` | Borne dure : `?itemsPerPage=999` → ramene a 50. Anti deep-fetch. |
| `pagination_client_items_per_page` | `true` | Le client peut envoyer `?itemsPerPage=25` (bornee par le max). |
| `pagination_client_enabled` | `true` | Le client peut envoyer `?pagination=false` pour TOUT recuperer (echappatoire selects). |
### Override par ressource (rare)
Si une ressource a besoin d'un autre defaut (ex: payload lourd), utiliser les attributs sur l'operation. **JAMAIS `paginationEnabled: false`** sans whitelist explicite dans `tests/Architecture/CollectionsArePaginatedTest::EXCLUDED`.
```php
new GetCollection(
paginationItemsPerPage: 5, // override taille par defaut
paginationMaximumItemsPerPage: 20, // override borne max
)
```
### Selects et autocompletions
Pour alimenter un `<select>` ou un drawer RBAC (Role, Permission, Site, CategoryType), le front passe :
```ts
useApi().get('/api/roles?pagination=false')
```
Le serveur retourne toute la collection, sans `view`. C'est l'echappatoire prevue par `pagination_client_enabled: true`. Sur les ressources a forte volumetrie, preferer une saisie assistee (recherche serveur via `?q=`) — a planifier dans un ticket dedie.
Les tests fonctionnels qui exercent ce comportement doivent egalement passer `?pagination=false` (cf. `CategoryListTest`, `PermissionApiTest`).
### Providers customs et pagination
Un provider custom qui retourne un `array` brut sur une `CollectionOperationInterface` **court-circuite la pagination Hydra** (pas de `totalItems`, pas de `view`). Patterns supportes :
- **ORM** : injecter `ApiPlatform\State\Pagination\Pagination`, wrap un `Doctrine\ORM\Tools\Pagination\Paginator` dans `ApiPlatform\Doctrine\Orm\Paginator`. Exemple : `CategoryProvider`.
- **DBAL** : implementer un paginator local conforme a `PaginatorInterface`. Exemple : `DbalPaginator` (Core) + `AuditLogProvider`.
Gerer l'echappatoire `?pagination=false` :
```php
if (!$this->pagination->isEnabled($operation, $context)) {
return $qb->getQuery()->getResult(); // tout retourner
}
```
### Garde-fou architecture
`tests/Architecture/CollectionsArePaginatedTest` scanne reflexivement toutes les classes `#[ApiResource]` sous `src/` et echoue si une `GetCollection` pose `paginationEnabled: false` hors whitelist `EXCLUDED`. Ajouter une entree a la whitelist requiert une justification courte + un ticket Lesstime ouvert.
## Repositories ## Repositories
@@ -56,17 +100,42 @@ Format obligatoire : `module.resource[.subresource].action` en snake_case.
### Libelle i18n du type d'entite (obligatoire avec `#[Auditable]`) ### Libelle i18n du type d'entite (obligatoire avec `#[Auditable]`)
Toute entite `#[Auditable]` doit avoir sa cle `audit.entity.<module>_<entity>` dans `frontend/i18n/locales/fr.json` (cle = `strtolower(module)` + `_` + `strtolower(Entity)`, decision ERP-99). Sans elle, le filtre « Type d'entite » de l'audit-log retombe silencieusement sur le type technique brut (ex: `commercial.Client`). Fait partie de la definition de fini d'une entite auditee. **Toute entite `#[Auditable]` doit avoir son libelle FR dans le bloc `audit.entity` de `frontend/i18n/locales/fr.json`.** C'est la contrepartie i18n de l'attribut : sans elle, le filtre « Type d'entite » de l'audit-log affiche le type technique brut (ex: `commercial.Client`) au lieu d'un libelle lisible.
Garde-fou : `tests/Architecture/AuditableEntitiesHaveI18nLabelTest` (casse `make test`). Pourquoi : le filtre est dynamique (`GET /audit-log-entity-types` renvoie les `entity_type` distincts presents en base) ; des qu'un module audite une entite, son type y apparait. Le front (`formatEntityType`, `audit-log.vue`) construit la cle `audit.entity.<module>_<entity>` et, faute de traduction, **retombe silencieusement** sur le type brut.
→ derivation detaillee + exemples : skill `backend-entity-conventions`.
Derivation de la cle (emplacement centralise + schema flat — decision ERP-99) :
| FQCN entite | `entity_type` (back) | Cle i18n (flat) |
|---|---|---|
| `App\Module\Commercial\Domain\Entity\Client` | `commercial.Client` | `commercial_client` |
| `App\Module\Commercial\Domain\Entity\ClientAddress` | `commercial.ClientAddress` | `commercial_clientaddress` |
| `App\Module\Catalog\Domain\Entity\Category` | `catalog.Category` | `catalog_category` |
Regle : `strtolower(module)` + `_` + `strtolower(Entity)`. Ajouter sa cle de libelle audit fait partie de la **definition de fini** d'une entite metier auditee.
**Garde-fou** : `tests/Architecture/AuditableEntitiesHaveI18nLabelTest` scanne les entites `#[Auditable]` et echoue si une seule n'a pas sa cle `audit.entity.*`. Conclusion : creer une entite `#[Auditable]` sans son libelle i18n casse `make test`.
## Timestampable + Blamable (obligatoire pour entites metier) ## Timestampable + Blamable (obligatoire pour entites metier)
Toute **nouvelle** entite metier sous `src/Module/*/Domain/Entity/` : `implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait` (porte les 4 colonnes `created_at` / `updated_at` / `created_by` / `updated_by`, remplies par `TimestampableBlamableSubscriber` au prePersist/preUpdate). La migration cree les 4 colonnes (`created_at`/`updated_at` NOT NULL, `created_by`/`updated_by` nullable `ON DELETE SET NULL`). Referentiel statique justifie : whitelist `EntitiesAreTimestampableBlamableTest::EXCLUDED`. Toute **nouvelle** entite metier sous `src/Module/*/Domain/Entity/` doit porter les 4 colonnes `created_at` / `updated_at` / `created_by` / `updated_by`, remplies automatiquement. Trois lignes a ajouter a l'entite :
Garde-fou : `tests/Architecture/EntitiesAreTimestampableBlamableTest` (casse `make test`). ```php
→ snippet complet : skill `backend-entity-conventions` ; spec : @docs/specs/M0-categories/spec-back.md § 2.8 + § 2.8.bis. use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
class MyEntity implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait; // porte les 4 props + getters/setters
// ... reste metier
}
```
- Le `TimestampableBlamableSubscriber` (`Shared/Infrastructure/Doctrine/`) remplit les colonnes au `prePersist` / `preUpdate`. Hors contexte HTTP (CLI, cron, migration), le blame reste `null` (libelle « Systeme » cote front).
- La migration de l'entite doit creer les 4 colonnes (`created_at` / `updated_at` NOT NULL, `created_by` / `updated_by` nullable `ON DELETE SET NULL`).
- **Garde-fou CI** : `tests/Architecture/EntitiesAreTimestampableBlamableTest` echoue si une entite oublie le pattern. Un referentiel statique justifie (ex: `CategoryType`) doit etre explicitement whiteliste dans la constante `EXCLUDED` avec un commentaire.
- Spec complete : @docs/specs/M0-categories/spec-back.md § 2.8 + § 2.8.bis
## Serialization ## Serialization
@@ -84,7 +153,50 @@ Exemple : pour qu'`User.profile` soit embarque au lieu d'un lien IRI sous le gro
## Migrations Doctrine ## Migrations Doctrine
Toute migration creant/modifiant une colonne d'une table metier pose un `COMMENT ON COLUMN` (FR, ≤ 200 caracteres, semantique + contrainte/RG, cible pour les FK). Les 4 colonnes Timestampable/Blamable recoivent leur description via le helper centralise `addStandardTimestampableBlamableComments($schema, 'table')`. Bonus : `COMMENT ON TABLE` pour decrire la table. ### Documentation SQL obligatoire (`COMMENT ON COLUMN`)
Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` parcourt `information_schema.columns` (schema `public`) ; une seule colonne sans `col_description` casse `make test` (hors `EXCLUDED_TABLES`). **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.
→ exemples SQL + textes du helper : skill `backend-entity-conventions`.
**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.
-50
View File
@@ -44,44 +44,6 @@ Tout champ de formulaire / filtre doit utiliser les composants `Malio*` plutot q
Toute autre exception requiert validation avant merge. Toute autre exception requiert validation avant merge.
## Standard ecran formulaire — reference : ecran Client
**Tout nouvel ecran de formulaire doit ressembler au premier ecran Client** (`frontend/modules/commercial/pages/clients/new.vue` + `[id]/edit.vue`) : meme structure (bloc principal puis onglets), memes marges/espacements, memes blocs de collection (ajout/suppression inline), meme validation inline 422 par champ. C'est la reference visuelle et fonctionnelle des formulaires du projet — s'en inspirer avant d'en creer un nouveau.
## Validation des formulaires — useFormErrors obligatoire (erreur par champ)
**Tout formulaire qui soumet a une API DOIT afficher les erreurs de validation 422 sous le champ concerne, via `useFormErrors`** (`frontend/shared/composables/useFormErrors.ts`). C'est le pendant front de « le back renvoie TOUTES les violations d'une 422 d'un coup » : un seul aller-retour, chaque erreur affichee inline sous son champ (prop `:error` des `Malio*`), pas un toast fourre-tout.
Principe cle : **le nom du champ cote front = le `propertyPath` renvoye par le back**. Aucun mapping manuel champ par champ.
Pattern de reference (champs scalaires) :
```ts
const { errors, setError, clearErrors, handleApiError } = useFormErrors()
async function submit() {
clearErrors()
try {
await useApi().post('/clients', payload, { toast: false }) // toast: false obligatoire
} catch (e) {
// 422 → mapping inline par champ (pas de toast) ; autre → toast de fallback.
handleApiError(e, { fallbackMessage: t('foo.error') })
}
}
```
```vue
<MalioInputText v-model="form.companyName" :error="errors.companyName" />
<MalioSelect v-model="form.siren" :error="errors.siren" />
```
Regles :
- **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin).
- **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07).
- **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref<Record<string, string>[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record<string, string>`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`.
**Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`.
## Tableaux de donnees — MalioDataTable obligatoire ## Tableaux de donnees — MalioDataTable obligatoire
Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer par `MalioDataTable` : Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer par `MalioDataTable` :
@@ -146,18 +108,6 @@ A NE PAS faire :
- Seuls les deep links "de navigation metier" (ex: ouvrir un detail precis `/users/42`) sont dans l'URL - Seuls les deep links "de navigation metier" (ex: ouvrir un detail precis `/users/42`) sont dans l'URL
- Exceptions autorisees **sur demande explicite** de l'utilisateur - Exceptions autorisees **sur demande explicite** de l'utilisateur
## Validation des formulaires (standard ERP-101)
Regle transverse a TOUS les formulaires front (et a rappeler a l'ecriture de chaque ticket back/front portant un formulaire). Decidee en ERP-101 (declencheur : ecran « Ajouter un client » ERP-63).
- **Champs obligatoires** : prop `required` du composant `Malio*` + etoile (asterisque) rouge dans le label. Ne JAMAIS griser le bouton « Valider » sans feedback : bouton toujours actif + erreurs affichees sous les champs.
- **Couche de validation autoritaire = le back** : les RG sont re-validees serveur (mode strict). Au `422`, mapper `violations[].propertyPath` vers la prop `error` du champ via `extractApiViolations` (deja utilise par `useCategoryForm`). Zero duplication de RG, zero drift.
- **Feedback instantane au blur** : uniquement requis / min / max / format (pas de re-implementation des RG metier cote front).
- **Regles front-only** : celles sans equivalent back (ex. FK nullable cote back mais obligatoire selon un choix UI) sont validees et affichees cote front.
- **Email — PAS de masque** : un email n'a pas de structure fixe. Normalisation via la prop `lowercase` de `MalioInputEmail` (trim + suppression des espaces + lowercase, coherent avec la normalisation serveur RG-1.21). Le format est valide par la prop `error` (violations serveur ou check au blur), jamais par un masque. Retirer tout shaping email ad hoc des ecrans.
- **Contrat back attendu** : tout `422` issu d'un Processor/Validator doit porter `violations[].propertyPath` aligne sur les noms de champs du formulaire, pour etre consommable par `extractApiViolations`.
- **Dependance** : le branchement des props `required` suppose `@malio/layer-ui` a jour (props `required` + etoile — MUI-41 / ERP-101).
## Interdits ## Interdits
- `modules-loader.ts`, `.module.ts` — le scan des layers est automatique - `modules-loader.ts`, `.module.ts` — le scan des layers est automatique
@@ -1,251 +0,0 @@
---
name: backend-entity-conventions
description: Conventions détaillées des entités métier Starseed (back PHP/Symfony/API Platform) — messages de validation FR sur les contraintes, pagination API Platform et providers ORM/DBAL, libellé i18n du type d'entité auditée, Timestampable/Blamable, COMMENT ON COLUMN des migrations. Charger dès qu'on crée ou modifie une entité Domain, un ApiResource, un Provider/Processor, une contrainte de validation, ou une migration Doctrine. Le résumé court de chaque règle (+ nom du test garde-fou) reste dans .claude/rules/backend.md ; ce skill porte les patterns, tableaux et exemples complets.
---
# Conventions entités métier — détail
Ce skill contient le détail (patterns code, tableaux, dérivations) des 5 règles back qui ont chacune
un test Architecture déterministe. L'énoncé court de chaque règle vit dans `.claude/rules/backend.md`
(chargé à chaque session) ; ici on trouve le « comment » complet.
> Règle d'or : le **test Architecture reste le juge** (il casse `make test`). Ce skill aide à écrire
> le code juste du premier coup, il ne remplace pas le garde-fou.
---
## 1. Messages de validation (Garde-fou : `EntityConstraintsHaveFrenchMessageTest`)
**Toute contrainte `#[Assert\*]` portée par une entité métier doit avoir un message FR explicite**, et
**`Assert\Length.max` doit refléter le `length` de la colonne ORM**. C'est le pendant back du mapping
d'erreur par champ côté front (ERP-101 : `useFormErrors` / `mapViolationsToRecord` affiche sous chaque
champ le `message` renvoyé par le back).
Pourquoi :
- Sans `message:` explicite, Symfony renvoie le défaut **anglais** (« This value is not a valid email
address. »). La locale FR globale (`default_locale: fr` dans `framework.yaml`) sert de FILET via
`validators.fr.xlf`, mais les contraintes métier portent en plus leur message FR pour un contrôle total.
- Une colonne string bornée **sans `Assert\Length`** échoue au niveau Postgres (500 générique, non
rattachée au champ) au lieu d'une 422 propre. Le `max` doit égaler le `length` ORM (anti-dérive).
Pattern par champ scalaire :
```php
// Email métier
#[Assert\Email(message: 'L\'adresse email n\'est pas valide.')]
// Longueur calée sur la colonne (VARCHAR(120))
#[ORM\Column(length: 120)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
// Obligatoire (aligner nullable DB / NotBlank back / required front)
#[Assert\NotBlank(message: 'Le téléphone est obligatoire.', normalizer: 'trim')]
```
Cohérence à 3 niveaux pour un champ obligatoire : colonne `nullable` (DB) <-> `Assert\NotBlank` (back)
<-> `:required` + astérisque (front ERP-101). Les trois doivent s'accorder.
Exceptions au miroir `Length` : un format déjà borné par `Assert\Bic` / `Assert\Iban` (longueur
garantie) ou par un `Assert\Regex` borné (ex. code postal `{4,5}`, couleur hex `#RRGGBB`) — whitelister
alors la propriété dans `EntityConstraintsHaveFrenchMessageTest::EXCLUDED_LENGTH_MIRROR` avec justification.
Les règles inter-champs (RG métier : exclusivité distributor/broker RG-1.03, billingEmail RG-1.11, etc.)
passent par un `#[Assert\Callback]` qui construit la violation avec `->atPath('<champ>')` — indispensable
pour que le front la mappe en inline plutôt qu'en toast.
### Garde-fou architecture
`tests/Architecture/EntityConstraintsHaveFrenchMessageTest` scanne réflexivement les entités sous
`src/Module/*/Domain/Entity/` et échoue si :
1. une contrainte connue n'a pas de message FR explicite (comparé au défaut Symfony) ;
2. une colonne string bornée writable n'a pas de `Assert\Length(max == ORM length)` (hors whitelist).
Une contrainte non gérée par le mapping du test le fait échouer : il faut l'ajouter explicitement
(anti faux positif vert).
---
## 2. Pagination (Garde-fou : `CollectionsArePaginatedTest`)
**Règle** : toute collection API DOIT être paginée. Aucun retour de collection complète côté serveur.
### Standard global
Posé dans `config/packages/api_platform.yaml` (section `defaults:`) et hérité par toutes les ressources :
| Clé | Valeur | Effet |
|---|---|---|
| `pagination_enabled` | `true` | Pagination Hydra active par défaut. |
| `pagination_items_per_page` | `10` | Taille de page par défaut, alignée sur l'UI `MalioDataTable`. |
| `pagination_maximum_items_per_page` | `50` | Borne dure : `?itemsPerPage=999` → ramené à 50. Anti deep-fetch. |
| `pagination_client_items_per_page` | `true` | Le client peut envoyer `?itemsPerPage=25` (bornée par le max). |
| `pagination_client_enabled` | `true` | Le client peut envoyer `?pagination=false` pour TOUT récupérer (échappatoire selects). |
### Override par ressource (rare)
Si une ressource a besoin d'un autre défaut (ex: payload lourd), utiliser les attributs sur l'opération.
**JAMAIS `paginationEnabled: false`** sans whitelist explicite dans
`tests/Architecture/CollectionsArePaginatedTest::EXCLUDED`.
```php
new GetCollection(
paginationItemsPerPage: 5, // override taille par défaut
paginationMaximumItemsPerPage: 20, // override borne max
)
```
### Selects et autocomplétions
Pour alimenter un `<select>` ou un drawer RBAC (Role, Permission, Site, CategoryType), le front passe :
```ts
useApi().get('/api/roles?pagination=false')
```
Le serveur retourne toute la collection, sans `view`. C'est l'échappatoire prévue par
`pagination_client_enabled: true`. Sur les ressources à forte volumétrie, préférer une saisie assistée
(recherche serveur via `?q=`) — à planifier dans un ticket dédié.
Les tests fonctionnels qui exercent ce comportement doivent également passer `?pagination=false`
(cf. `CategoryListTest`, `PermissionApiTest`).
### Providers customs et pagination
Un provider custom qui retourne un `array` brut sur une `CollectionOperationInterface`
**court-circuite la pagination Hydra** (pas de `totalItems`, pas de `view`). Patterns supportés :
- **ORM** : injecter `ApiPlatform\State\Pagination\Pagination`, wrap un
`Doctrine\ORM\Tools\Pagination\Paginator` dans `ApiPlatform\Doctrine\Orm\Paginator`. Exemple : `CategoryProvider`.
- **DBAL** : implémenter un paginator local conforme à `PaginatorInterface`. Exemple : `DbalPaginator`
(Core) + `AuditLogProvider`.
Gérer l'échappatoire `?pagination=false` :
```php
if (!$this->pagination->isEnabled($operation, $context)) {
return $qb->getQuery()->getResult(); // tout retourner
}
```
### Garde-fou architecture
`tests/Architecture/CollectionsArePaginatedTest` scanne réflexivement toutes les classes
`#[ApiResource]` sous `src/` et échoue si une `GetCollection` pose `paginationEnabled: false` hors
whitelist `EXCLUDED`. Ajouter une entrée à la whitelist requiert une justification courte + un ticket
Lesstime ouvert.
---
## 3. Libellé i18n du type d'entité auditée (Garde-fou : `AuditableEntitiesHaveI18nLabelTest`)
**Toute entité `#[Auditable]` doit avoir son libellé FR dans le bloc `audit.entity` de
`frontend/i18n/locales/fr.json`.** C'est la contrepartie i18n de l'attribut : sans elle, le filtre
« Type d'entité » de l'audit-log affiche le type technique brut (ex: `commercial.Client`) au lieu d'un
libellé lisible.
Pourquoi : le filtre est dynamique (`GET /audit-log-entity-types` renvoie les `entity_type` distincts
présents en base) ; dès qu'un module audite une entité, son type y apparaît. Le front
(`formatEntityType`, `audit-log.vue`) construit la clé `audit.entity.<module>_<entity>` et, faute de
traduction, **retombe silencieusement** sur le type brut.
Dérivation de la clé (emplacement centralisé + schéma flat — décision ERP-99) :
| FQCN entité | `entity_type` (back) | Clé i18n (flat) |
|---|---|---|
| `App\Module\Commercial\Domain\Entity\Client` | `commercial.Client` | `commercial_client` |
| `App\Module\Commercial\Domain\Entity\ClientAddress` | `commercial.ClientAddress` | `commercial_clientaddress` |
| `App\Module\Catalog\Domain\Entity\Category` | `catalog.Category` | `catalog_category` |
Règle : `strtolower(module)` + `_` + `strtolower(Entity)`. Ajouter sa clé de libellé audit fait partie
de la **définition de fini** d'une entité métier auditée.
**Garde-fou** : `tests/Architecture/AuditableEntitiesHaveI18nLabelTest` scanne les entités `#[Auditable]`
et échoue si une seule n'a pas sa clé `audit.entity.*`. Conclusion : créer une entité `#[Auditable]`
sans son libellé i18n casse `make test`.
---
## 4. Timestampable + Blamable (Garde-fou : `EntitiesAreTimestampableBlamableTest`)
Toute **nouvelle** entité métier sous `src/Module/*/Domain/Entity/` doit porter les 4 colonnes
`created_at` / `updated_at` / `created_by` / `updated_by`, remplies automatiquement. Trois lignes à
ajouter à l'entité :
```php
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
class MyEntity implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait; // porte les 4 props + getters/setters
// ... reste métier
}
```
- Le `TimestampableBlamableSubscriber` (`Shared/Infrastructure/Doctrine/`) remplit les colonnes au
`prePersist` / `preUpdate`. Hors contexte HTTP (CLI, cron, migration), le blame reste `null`
(libellé « Système » côté front).
- La migration de l'entité doit créer les 4 colonnes (`created_at` / `updated_at` NOT NULL,
`created_by` / `updated_by` nullable `ON DELETE SET NULL`).
- **Garde-fou CI** : `tests/Architecture/EntitiesAreTimestampableBlamableTest` échoue si une entité
oublie le pattern. Un référentiel statique justifié (ex: `CategoryType`) doit être explicitement
whitelisté dans la constante `EXCLUDED` avec un commentaire.
- Spec complète : @docs/specs/M0-categories/spec-back.md § 2.8 + § 2.8.bis
---
## 5. Migrations Doctrine — COMMENT ON COLUMN (Garde-fou : `ColumnsHaveSqlCommentTest`)
**Toute migration qui crée ou modifie une colonne d'une table métier doit poser un `COMMENT ON COLUMN`
décrivant le champ.** La description est stockée dans `pg_description` et visible dans tous les outils
d'admin BDD (DBeaver, DataGrip, pgAdmin), sans avoir à lire les annotations PHP.
**Format de la description** :
- En français
- ≤ 200 caractères
- Sémantique du champ — contraintes / lien RG si pertinent
- Pour les colonnes d'identifiant ou FK, mentionner la cible
Exemples :
```php
// Migration : création d'une colonne avec son commentaire dans la même 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 : préciser 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 booléen : préciser le sens et la valeur par défaut
$this->addSql("COMMENT ON COLUMN user.is_admin IS 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.'");
// Bonus : décrire la table elle-même
$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` ajoutées par
`TimestampableBlamableTrait` reçoivent une description **standardisée** via le helper centralisé pour
éviter la duplication. Helper à créer ou appeler :
```php
// Dans la migration, après avoir ajouté les 4 colonnes :
$this->addStandardTimestampableBlamableComments($schema, 'client');
```
L'implémentation 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` filtré sur le
schéma `public` et échoue si **une seule colonne** n'a pas de `col_description`. Seules les tables
système (`doctrine_migration_versions`) et la whitelist `EXCLUDED_TABLES` explicite (commentaire de
justification + ticket Lesstime ouvert pour le retrofit) sont tolérées.
Conclusion : si tu crées une colonne sans poser son `COMMENT ON COLUMN`, `make test` casse en CI.
+147 -309
View File
@@ -1,362 +1,209 @@
# Starseed # Starseed
CRM/ERP en architecture **modular monolith DDD** — Symfony 8 (API Platform 4) + Nuxt 4. CRM/ERP — Symfony 8 (API Platform 4) + Nuxt 4
Le backend est la **source de vérité unique** : il décide des modules actifs et de
l'organisation de la sidebar. Le frontend scanne `frontend/modules/*/` comme layers
Nuxt et consomme l'API pour la navigation.
---
## Sommaire
- [Stack](#stack)
- [Prérequis](#prérequis)
- [Démarrage rapide](#démarrage-rapide)
- [Dev local : avec ou sans données de seed](#dev-local--avec-ou-sans-données-de-seed)
- [Comptes (dev)](#comptes-dev)
- [Bases de données : dev et test](#bases-de-données--dev-et-test)
- [Tests](#tests)
- [Déploiement : seed RBAC en recette / prod](#déploiement--seed-rbac-en-recette--prod)
- [Commandes make](#commandes-make)
- [Architecture](#architecture)
- [Structure du dépôt](#structure-du-dépôt)
- [CI/CD](#cicd)
- [Conventions](#conventions)
---
## Stack ## Stack
- **Backend** : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16 - **Backend** : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16
- **Frontend** : Nuxt 4 (SPA, SSR off), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, @nuxtjs/i18n - **Frontend** : Nuxt 4 (SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui
- **Auth** : JWT HTTP-only cookie (Lexik), login sur `/login_check` - **Auth** : JWT HTTP-only cookie (Lexik)
- **Infra** : Docker Compose (dev + prod multi-stage) - **Infra** : Docker Compose (dev + prod multi-stage)
- **CI/CD** : Gitea Actions (auto-tag + build Docker) - **CI/CD** : Gitea Actions (auto-tag + build Docker)
| Service | Port | ## Quick Start
|---------------|------|
| API (Nginx) | 8083 |
| Frontend dev | 3004 |
| PostgreSQL | 5437 |
---
## Prérequis
- Docker + Docker Compose
- `make`
- `nvm` (la version de Node est fixée par `.nvmrc`, voir `make node-use`)
Toutes les commandes `make` s'exécutent dans le container PHP (`php-starseed-fpm`) ;
rien n'est requis sur l'hôte hormis Docker — **sauf les tests E2E**, qui tournent sur
l'hôte (navigateur réel, voir [Tests](#tests)).
---
## Démarrage rapide
```bash ```bash
make start # Démarre les containers Docker make start # Demarrer les containers Docker
make install # Composer + clés JWT + migrations + permissions + BDD de test make install # Composer, migrations, fixtures, build Nuxt
make dev-nuxt # Serveur Nuxt avec hot reload (http://localhost:3004)
``` ```
`make install` prépare une base de dev **vierge** (schéma + RBAC structurel, sans Dev frontend (hot reload) :
données de démo) et la base de **test**. Pour obtenir des comptes et des données de
démo prêtes à l'emploi, lis la section suivante.
> Override local possible : `make` lit `infra/dev/.env.docker`, surchargé par
> `infra/dev/.env.docker.local` s'il existe (créé automatiquement par `make env-init`).
---
## Dev local : avec ou sans données de seed
Le projet distingue deux états de base de données de dev. Les **fixtures Doctrine sont
en `require-dev`** : elles n'existent qu'en dev, jamais dans le build de prod.
### Sans données de seed (base vierge)
C'est ce que produit `make install`. La base contient :
- le **schéma** complet (toutes les migrations jouées) ;
- les **rôles système** `admin` / `user` (seedés en SQL par la migration RBAC) ;
- le **catalogue de permissions** synchronisé (`app:sync-permissions`).
Mais **aucun compte utilisateur ni donnée métier**. Pour pouvoir te connecter,
crée toi-même un compte :
```bash ```bash
make shell make dev-nuxt # Port 3003
php bin/console app:create-user admin monMotDePasse --admin # compte ROLE_ADMIN
``` ```
Optionnel — provisionner les **rôles métier** (bureau / compta / commerciale / usine ## Ports
+ matrice RBAC § 2.7) sans comptes de démo :
```bash | Service | Port |
php bin/console app:seed-rbac |------------|------|
``` | API (Nginx)| 8083 |
| Frontend | 3004 |
| PostgreSQL | 5437 |
Cet état est utile pour repartir d'une base propre, reproduire un bug sur données ## Commandes
minimales, ou tester un parcours d'onboarding réel.
### Avec données de seed (base de démo) | Commande | Description |
|----------|-------------|
`make db-reset` (ou `make fixtures` après un `make install`) recharge la base de dev | `make start` | Demarrer les containers |
avec un jeu complet de données de démonstration, **idempotent** : | `make stop` | Arreter les containers |
| `make restart` | Redemarrer les containers |
```bash | `make install` | Install complet |
make db-reset # ATTENTION : drop + recrée la base de dev, puis charge tout le seed | `make reset` | Tout supprimer et reinstaller |
``` | `make dev-nuxt` | Serveur dev Nuxt (hot reload) |
| `make shell` | Shell dans le container PHP |
Ce que les fixtures posent : | `make cache-clear` | Vider le cache Symfony |
| `make migration-migrate` | Lancer les migrations |
- **3 utilisateurs système** : `admin` (ROLE_ADMIN), `alice`, `bob` (ROLE_USER), | `make fixtures` | Charger les fixtures |
rattachés à des sites distincts ; | `make db-reset` | Reset BDD + migrations + fixtures |
- **3 sites** : Chatellerault, Saint-Jean, Pommevic ; | `make test` | PHPUnit (tests back) |
- **les comptes de démo RBAC métier** (`bureau`, `compta`, `commerciale`, `usine`, | `make nuxt-test` | Vitest (tests unitaires front) |
mot de passe `demo`) avec la matrice § 2.7 attachée ; | `make test-e2e` | Playwright (tests E2E front) |
- les **référentiels et données métier** des modules (catégories, clients de démo, | `make test-e2e-ui` | Playwright UI interactive (debug) |
référentiels comptables…). | `make seed-e2e` | Seed les 6 personas E2E |
| `make install-e2e-deps` | One-time : Chromium + libs systeme (sudo) |
Toutes les fixtures sont rejouables sans effet de bord (lookup par clé naturelle, | `make php-cs-fixer-allow-risky` | Fix code style PHP |
aucun doublon). | `make logs-dev` | Tail logs Symfony |
> Différence avec `make install` : `install` ne charge **pas** les fixtures sur la base
> de dev (il alimente uniquement la base de test). Utilise `make db-reset` ou
> `make fixtures` quand tu veux des données de démo en dev.
---
## Comptes (dev)
Disponibles uniquement après `make db-reset` / `make fixtures` (état « avec seed ») :
| Username | Password | Rôle | RBAC métier |
|---------------|----------|------------|---------------------------------------------------------------|
| `admin` | `admin` | ROLE_ADMIN | bypass complet (`is_admin`) |
| `alice` | `alice` | ROLE_USER | — |
| `bob` | `bob` | ROLE_USER | — |
| `bureau` | `demo` | ROLE_USER | clients : view + manage |
| `compta` | `demo` | ROLE_USER | clients : view + accounting.view / manage |
| `commerciale` | `demo` | ROLE_USER | clients : view + manage (Information obligatoire — RG-1.04) |
| `usine` | `demo` | ROLE_USER | aucun accès clients |
---
## Bases de données : dev et test
Deux bases distinctes vivent dans le **même container PostgreSQL** (port 5437) :
| Base | Environnement | Construite par | Usage |
|------------|---------------|--------------------------------------|--------------------------------|
| `<db>` | `dev` | `make install` / `make db-reset` | développement manuel, dev-nuxt |
| `<db>_test` | `test` | `make test-db-setup` | PHPUnit (jamais touchée à la main) |
Le suffixe `_test` est appliqué **automatiquement** par Doctrine quand `APP_ENV=test`
(config `when@test` dans `config/packages/doctrine.yaml`). La base de test est donc
totalement **isolée** de la base de dev : lancer `make test` ne touche jamais tes
données de dev.
`make test-db-setup` fait davantage que jouer les migrations, car certaines structures
ne sont pas portées par des migrations « métier » :
1. `doctrine:migrations:migrate` — schéma métier réel ;
2. `doctrine:schema:update --force` — crée les tables mappées en `when@test`
uniquement (entités de test) ;
3. `app:apply-column-comments` — réapplique les `COMMENT ON COLUMN` que
`schema:update` efface sur les tables managées par l'ORM (garde-fou
`ColumnsHaveSqlCommentTest`) ;
4. `fixtures:load``sync-permissions``seed-rbac` — dans cet ordre précis
(le purger des fixtures vide la table `permission`, donc la sync passe après) ;
5. recréation des **index partiels uniques** (`LOWER(...) WHERE ...`) non exprimables
en attributs ORM, indispensables aux tests d'unicité (RG-1.07, RG-1.16, RG-1.03/1.29).
`make install` et `make db-reset` appellent déjà `test-db-setup` : tu n'as à le
relancer à la main que si la base de test diverge (nouvelle migration, nouvelle
permission) sans vouloir reseed la base de dev.
---
## Tests ## Tests
| Suite | Commande | Outil | Où | - **Back** : `make test` (PHPUnit). Fixtures dediees sous `tests/Fixtures/`.
|-------------------|------------------|----------------------|-----------------------------------| - **Front unitaire** : `make nuxt-test` (Vitest, happy-dom). Composables, utils, stores — rapide, <30s.
| Back | `make test` | PHPUnit | container PHP, base `<db>_test` | - **Front E2E** : `make test-e2e` (Playwright). Couvre login + matrice RBAC sidebar. Suite volontairement minimaliste (11 tests) — voir la regle d'or dans `CLAUDE.md`.
| Front unitaire | `make nuxt-test` | Vitest (happy-dom) | container Node, < 30 s |
| Front E2E | `make test-e2e` | Playwright | **hôte** (navigateur réel requis) |
| Tout (back+front) | `make test-all` | PHPUnit + Vitest | — |
### Tests back (PHPUnit)
**Bootstrap E2E (une fois par poste)** :
```bash ```bash
make test # toute la suite make install-e2e-deps # Telecharge Chromium + libs systeme via apt (sudo)
make test FILES=tests/Module/Commercial # un dossier / fichier ciblé
``` ```
PHPUnit force `APP_ENV=test` (`phpunit.dist.xml`) : les tests tournent **toujours** **Workflow E2E** :
sur la base `<db>_test`, jamais sur la base de dev. Prérequis : que la base de test
existe — c'est le cas après `make install`. Si elle a divergé, rejoue
`make test-db-setup` (cf. [Bases de données](#bases-de-données--dev-et-test)).
### Tests front unitaires (Vitest)
```bash ```bash
make nuxt-test # composables, utils, stores — rapide et stable # Terminal 1 : containers + dev server
```
C'est la **place par défaut** pour étendre la couverture (cf. règle d'or ci-dessous).
### Tests E2E (Playwright)
Suite volontairement minimaliste (login + matrice RBAC sidebar). **Règle d'or : un
nouveau test E2E ne s'ajoute que si un bug critique est passé en prod** — sinon,
préférer un test Vitest ou étendre un persona existant.
Bootstrap (une fois par poste) :
```bash
make install-e2e-deps # télécharge Chromium + libs système (apt/dnf, sudo)
```
Workflow :
```bash
# Terminal 1 — containers, seed des personas, serveur dev
make start && make seed-e2e && make dev-nuxt make start && make seed-e2e && make dev-nuxt
# Terminal 2 tests # Terminal 2 : tests
make test-e2e # headless make test-e2e
make test-e2e-ui # UI interactive (debug)
``` ```
> Toute permission testable touche **3 miroirs** à garder alignés : `config/sidebar.php`,
> `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`.
---
## Déploiement : seed RBAC en recette / prod
Les fixtures Doctrine étant en `require-dev`, elles sont **absentes du build de prod**.
Le RBAC métier (rôles `bureau` / `compta` / `commerciale` / `usine` + matrice § 2.7)
est seedé par une **commande applicative idempotente**, jouée dans l'étape de release,
**après** les migrations et la synchronisation des permissions :
```bash
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console app:sync-permissions # pose les permissions (commercial.clients.*, …)
php bin/console app:seed-rbac # PROD : rôles + matrice § 2.7 (sans comptes démo)
```
En **recette / staging**, ajouter le flag pour disposer de logins de test. Le mot de
passe est fourni **explicitement** (jamais en dur, jamais committé) :
```bash
php bin/console app:seed-rbac --with-demo-users --password='<mot-de-passe>'
# ou via la variable d'environnement RBAC_DEMO_PASSWORD
```
La commande est rejouable sans effet de bord (aucun doublon de rôle, de lien ou de
compte). Pour créer un premier administrateur en prod :
```bash
php bin/console app:create-user <username> <password> --admin
```
---
## Commandes make
`make` (sans argument) ou `make help` affiche l'aide colorée. Les principales :
| Commande | Description |
|--------------------------------|----------------------------------------------------------|
| `make start` / `stop` / `restart` | Cycle de vie des containers |
| `make install` | Install complet (base dev vierge + base de test) |
| `make reset` | Tout supprimer et réinstaller (**drop la BDD**) |
| `make dev-nuxt` | Serveur Nuxt hot reload (port 3004) |
| `make shell` / `shell-root` | Shell bash dans le container PHP |
| `make migration-migrate` | Jouer les migrations Doctrine |
| `make fixtures` | Charger les fixtures (données de démo dev) |
| `make sync-permissions` | Synchroniser le catalogue RBAC |
| `make seed-rbac` | Seed RBAC métier (rôles + matrice § 2.7) |
| `make db-reset` | Reset base dev : drop + migrate + fixtures + RBAC |
| `make test-db-setup` | (Re)construire la base de test |
| `make test` | PHPUnit (back) |
| `make nuxt-test` | Vitest (front unitaire) |
| `make test-all` | PHPUnit + Vitest |
| `make test-e2e` / `test-e2e-ui`| Playwright (E2E, sur l'hôte) |
| `make seed-e2e` | Seed des 6 personas E2E |
| `make php-cs-fixer-allow-risky`| Fix du code style PHP |
| `make php-cs-fixer-check` | Dry-run du fixer (CI / avant push) |
| `make logs-dev` | Tail des logs Symfony |
---
## Architecture ## Architecture
**Modular Monolith DDD** : chaque module est un bounded context autonome, **Modular Monolith DDD** : chaque module est un bounded context autonome, activable/desactivable par tenant. Le backend est la seule source de verite pour l'activation et l'organisation de la sidebar.
activable / désactivable par tenant. Le backend est la seule source de vérité pour
l'activation des modules et l'organisation de la sidebar.
- `config/modules.php` — liste des modules actifs - `config/modules.php` — liste des modules actifs
- `config/sidebar.php` — structure de la sidebar (sections + items avec module owner) - `config/sidebar.php` — structure de la sidebar (sections + items avec module owner)
- `GET /api/modules` — IDs des modules actifs (public) - `GET /api/sidebar` — retourne les sections filtrees par les modules actifs + les routes desactivees
- `GET /api/sidebar` — sections filtrées par modules actifs + routes désactivées (public) - Frontend : chaque `frontend/modules/*/` est auto-detecte comme layer Nuxt, la sidebar est fetchee de l'API
**Désactiver un module** : commenter sa ligne dans `config/modules.php`, vider le cache. Pour desactiver un module : commenter sa ligne dans `config/modules.php`, clear cache. Ses items de sidebar disparaissent et ses routes sont bloquees par le middleware front.
Ses items de sidebar disparaissent et ses routes sont bloquées par le middleware front.
Le code reste dans le bundle (layer auto-détecté) → réactivation instantanée.
**Réorganiser la sidebar** : éditer `config/sidebar.php` uniquement le code des Pour reorganiser la sidebar (ex: deplacer un item d'une section a l'autre) : editer `config/sidebar.php` uniquement, le code des modules n'est pas touche.
modules n'est pas touché.
**Communication inter-modules** : jamais d'import direct d'un module à l'autre. Passer ## Structure
par `Shared/Domain/Contract/` (interfaces) ou des domain events.
---
## Structure du dépôt
``` ```
src/ # Backend Symfony src/ # Backend Symfony
Shared/ # Noyau technique partagé (Domain/, Application/Bus/, Infrastructure/ApiPlatform/) Kernel.php
Shared/ # Noyau technique partage
Domain/
ValueObject/ # Email, ...
Event/ # DomainEventInterface
Contract/ # Interfaces inter-modules
Application/
Bus/ # CommandBusInterface, QueryBusInterface
Infrastructure/
ApiPlatform/
Resource/ # AppVersion, ModulesResource, SidebarResource
State/ # AppVersionProvider, ModulesProvider, SidebarProvider
Module/ Module/
Core/ # Module obligatoire (auth, users, RBAC) Core/ # Module obligatoire (auth, users)
CoreModule.php # Déclaration (ID, LABEL, REQUIRED, permissions()) CoreModule.php # Declaration (ID, LABEL, REQUIRED)
Domain/ Application/ Infrastructure/ Domain/
Commercial/ Catalog/ Sites/ # Modules métier Entity/ # User
Repository/ # UserRepositoryInterface
Event/ # UserCreated
Application/
DTO/ # UserOutput
Infrastructure/
Doctrine/ # DoctrineUserRepository, Migrations/
ApiPlatform/State/
Provider/ # MeProvider
Processor/ # UserPasswordHasherProcessor
Console/ # CreateUserCommand
DataFixtures/ # AppFixtures
Commercial/ # Autre module (exemple)
CommercialModule.php
config/ config/
modules.php # Source de vérité : activation modules.php # Source de verite activation
sidebar.php # Source de vérité : navigation sidebar.php # Source de verite navigation
packages/ # Config Symfony (doctrine, api_platform, security…) version.yaml
migrations/ # Migrations d'initialisation (namespace racine : setup, RBAC, seed de base) packages/ # Config Symfony
jwt/ # Cles JWT
migrations/ # Anciennes migrations
frontend/ # App Nuxt 4 (SPA) frontend/ # App Nuxt 4 (SPA)
app/ # Shell : layouts, middlewares (auth.global, modules.global) app/
shared/ # Code inter-modules (composables, stores, utils, types) layouts/ # default.vue, auth.vue
modules/ # Layers Nuxt auto-détectés (core/, commercial/…) middleware/ # auth.global.ts, modules.global.ts
i18n/locales/ # Traductions (sidebar.*, audit.entity.*, …) shared/ # Code partage (hors modules)
composables/ # useApi, useAppVersion, useSidebar
components/ui/ # AppTopNav, ...
stores/ # auth, ui
services/ # auth
types/ # SidebarSection, UserData
utils/ # api (Hydra)
modules/ # Modules auto-detectes comme layers Nuxt
core/
nuxt.config.ts # Marqueur layer
pages/ # index, login, logout
commercial/
nuxt.config.ts
pages/ # commercial.vue
app.vue
nuxt.config.ts # Scanne modules/*/ automatiquement
i18n/locales/ # Traductions (sidebar.*, etc.)
assets/ # CSS, images
public/ # Fichiers statiques
infra/ infra/
dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug, .env.docker) dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug)
prod/ # Docker prod (multi-stage, nginx, php-prod.ini) prod/ # Docker prod (multi-stage, nginx, php-prod.ini)
.gitea/workflows/ # CI Gitea (auto-tag, build Docker) .gitea/workflows/ # CI Gitea (auto-tag, build Docker)
.claude/
skills/create-module/ # Skill Claude Code pour scaffolder un module
``` ```
---
## CI/CD ## CI/CD
- **Auto Tag** : push sur `develop` → bump `config/version.yaml` → tag `vX.Y.Z` - **Auto Tag** : push sur `develop` → bump `config/version.yaml` → tag `vX.Y.Z`
- **Build Docker** : push tag `v*` → build image multi-stage → push Gitea Registry - **Build Docker** : push tag `v*` → build image multi-stage → push Gitea Registry
Secrets requis dans Gitea : Secrets requis dans Gitea :
- `RELEASE_TOKEN` — PAT avec droits `write:repository` - `RELEASE_TOKEN` — PAT avec droits `write:repository`
- `REGISTRY_TOKEN` — token pour le registry Docker - `REGISTRY_TOKEN` — token pour le registry Docker
--- ## Déploiement — seed RBAC (recette / prod)
Le RBAC métier (rôles `bureau` / `compta` / `commerciale` / `usine` + matrice § 2.7)
est seedé par une **commande applicative idempotente** (présente dans le build prod,
contrairement aux fixtures Doctrine en `require-dev`). À jouer dans l'étape de release,
**après** les migrations et la synchronisation des permissions :
```bash
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console app:sync-permissions # pose les permissions commercial.clients.*
php bin/console app:seed-rbac # PROD : rôles + matrice § 2.7 (sans comptes démo)
```
En **recette / staging**, ajouter le flag pour disposer de logins de test (mot de passe
fourni explicitement, jamais en dur) :
```bash
php bin/console app:seed-rbac --with-demo-users --password='<mot-de-passe>'
# ou via la variable d'env RBAC_DEMO_PASSWORD
```
La commande est rejouable sans effet de bord (aucun doublon de rôle, de lien ou de compte).
En dev, `make db-reset` produit le même résultat (rôles + matrice + comptes démo).
## Credentials (dev)
| Username | Password | Role | RBAC métier |
|----------|----------|------|-------------|
| admin | admin | ROLE_ADMIN | bypass (is_admin) |
| alice | alice | ROLE_USER | — |
| bob | bob | ROLE_USER | — |
| bureau | demo | ROLE_USER | clients : view + manage |
| compta | demo | ROLE_USER | clients : view + accounting.view/manage |
| commerciale | demo | ROLE_USER | clients : view + manage (Information obligatoire — RG-1.04) |
| usine | demo | ROLE_USER | aucun accès clients |
## Conventions ## Conventions
@@ -366,13 +213,4 @@ Secrets requis dans Gitea :
<type>(<scope optionnel>) : <message> <type>(<scope optionnel>) : <message>
``` ```
Espaces obligatoires autour du `:`. Types : `build`, `chore`, `ci`, `docs`, `feat`, Types : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`
`fix`, `perf`, `refactor`, `revert`, `style`, `test`.
### Langue
- UI et communication : **français**
- Code (classes, méthodes, variables) : **anglais**
- Commentaires (PHP, TS, Vue) : **français**
> Règles détaillées : `CLAUDE.md` et `.claude/rules/`.
-1
View File
@@ -33,7 +33,6 @@
"symfony/runtime": "8.0.*", "symfony/runtime": "8.0.*",
"symfony/security-bundle": "8.0.*", "symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*", "symfony/serializer": "8.0.*",
"symfony/translation": "8.0.*",
"symfony/twig-bundle": "8.0.*", "symfony/twig-bundle": "8.0.*",
"symfony/uid": "8.0.*", "symfony/uid": "8.0.*",
"symfony/validator": "8.0.*", "symfony/validator": "8.0.*",
Generated
+1 -94
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "2dc5db01e7f5d6aecd5956749b21a092", "content-hash": "aada2e60fd7563f1498b5505b37e3f4b",
"packages": [ "packages": [
{ {
"name": "api-platform/doctrine-common", "name": "api-platform/doctrine-common",
@@ -7657,99 +7657,6 @@
], ],
"time": "2026-03-30T15:14:47+00:00" "time": "2026-03-30T15:14:47+00:00"
}, },
{
"name": "symfony/translation",
"version": "v8.0.10",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation.git",
"reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67",
"reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/polyfill-mbstring": "^1.0",
"symfony/translation-contracts": "^3.6.1"
},
"conflict": {
"nikic/php-parser": "<5.0",
"symfony/http-client-contracts": "<2.5",
"symfony/service-contracts": "<2.5"
},
"provide": {
"symfony/translation-implementation": "2.3|3.0"
},
"require-dev": {
"nikic/php-parser": "^5.0",
"psr/log": "^1|^2|^3",
"symfony/config": "^7.4|^8.0",
"symfony/console": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/finder": "^7.4|^8.0",
"symfony/http-client-contracts": "^2.5|^3.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/intl": "^7.4|^8.0",
"symfony/polyfill-intl-icu": "^1.21",
"symfony/routing": "^7.4|^8.0",
"symfony/service-contracts": "^2.5|^3",
"symfony/yaml": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"files": [
"Resources/functions.php"
],
"psr-4": {
"Symfony\\Component\\Translation\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides tools to internationalize your application",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/translation/tree/v8.0.10"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-06T11:30:54+00:00"
},
{ {
"name": "symfony/translation-contracts", "name": "symfony/translation-contracts",
"version": "v3.6.1", "version": "v3.6.1",
-12
View File
@@ -1,12 +0,0 @@
framework:
# Locale par defaut FR (ERP-107) : les messages natifs des contraintes
# Symfony (Email, NotBlank, Length, Iban, Bic...) sont alors servis en
# francais via validators.fr.xlf. C'est le FILET ; les contraintes metier
# portent en plus un `message:` FR explicite, teste par
# tests/Architecture/EntityConstraintsHaveFrenchMessageTest.
default_locale: fr
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- fr
providers:
+19 -22
View File
@@ -38,28 +38,6 @@ declare(strict_types=1);
*/ */
return [ return [
// Section "Commerciale" : pole metier principal, remontee en tete de sidebar (ERP-71).
// L'ordre interne des onglets et les permissions restent inchanges (simple deplacement
// du bloc, aucun gate touche).
[
'label' => 'sidebar.commercial.section',
'icon' => 'mdi:account-arrow-left-outline',
'items' => [
[
'label' => 'sidebar.commercial.clients',
'to' => '/clients',
'icon' => 'mdi:account-group-outline',
'module' => 'commercial',
'permission' => 'commercial.clients.view',
],
[
'label' => 'sidebar.commercial.suppliers',
'to' => '/suppliers',
'icon' => 'mdi:account-arrow-left-outline',
'module' => 'commercial',
],
],
],
// Section "Administration" : regroupe toutes les pages de configuration // Section "Administration" : regroupe toutes les pages de configuration
// applicative (RBAC, users, sites, audit log). // applicative (RBAC, users, sites, audit log).
// //
@@ -121,6 +99,25 @@ return [
], ],
], ],
], ],
[
'label' => 'sidebar.commercial.section',
'icon' => 'mdi:account-arrow-left-outline',
'items' => [
[
'label' => 'sidebar.commercial.clients',
'to' => '/clients',
'icon' => 'mdi:account-group-outline',
'module' => 'commercial',
'permission' => 'commercial.clients.view',
],
[
'label' => 'sidebar.commercial.suppliers',
'to' => '/suppliers',
'icon' => 'mdi:account-arrow-left-outline',
'module' => 'commercial',
],
],
],
// Section "Mon compte" : espace personnel. Accessible a tout user authentifie // Section "Mon compte" : espace personnel. Accessible a tout user authentifie
// (aucune permission RBAC requise, tous les items restent dans `core` pour // (aucune permission RBAC requise, tous les items restent dans `core` pour
// rester toujours presents meme quand les modules metier sont desactives). // rester toujours presents meme quand les modules metier sont desactives).
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.83' app.version: '0.1.74'
@@ -1,146 +0,0 @@
# Validation « tous les blocs » sur les onglets à blocs dynamiques (Client M1)
> Date : 2026-06-04 · Module : Commercial (M1 Clients) · Tickets liés : ERP-101 / ERP-107
> Écrans : `clients/new.vue`, `clients/[id]/edit.vue` · Onglets concernés : Contacts, Adresses, RIB
## 1. Problème
À la soumission des onglets à **blocs d'ajout dynamiques** (Contacts / Adresses / RIB), la validation
par champ ne s'affiche pas correctement. Deux causes **distinctes et cumulées** :
### Cause A — 500 back qui court-circuite la validation (cause racine)
Les opérations `Post` des sous-ressources sont déclarées ainsi :
```php
new Post(
uriTemplate: '/clients/{clientId}/contacts',
uriVariables: ['clientId' => new Link(fromClass: Client::class, toProperty: 'client')],
processor: ClientContactProcessor::class,
)
```
Au stade « read » du POST, API Platform résout `clientId` via `LinksHandlerTrait` (branche `toProperty`,
`vendor/api-platform/doctrine-orm/State/LinksHandlerTrait.php:134-141`). La requête générée porte sur
l'entité **enfant** :
```sql
SELECT o FROM ClientContact o INNER JOIN o.client c WHERE c.id = :clientId
```
exécutée via `ItemProvider::provide``getOneOrNullResult()`
(`vendor/api-platform/doctrine-orm/State/ItemProvider.php:89`). Donc :
| Nb d'enfants du client | Lignes retournées | Résultat |
|---|---|---|
| 0 | 0 | `null` → OK (cas du test CI actuel) |
| 1 | 1 | OK |
| **≥ 2** | **≥ 2** | **`NonUniqueResultException` → HTTP 500** |
Conséquence : un client à ≥2 contacts (resp. adresses, RIB) ne peut plus en recevoir un nouveau.
La 500 survient **avant** la déserialisation/validation → aucune 422 n'est produite → `mapRowError`
(qui ne mappe que les 422) retombe sur un toast générique.
Les **3** sous-ressources ont strictement la même config → même bug latent (contacts est juste le
premier à sauter car les clients de démo ont 3 contacts).
### Cause B — la boucle front s'arrête au premier bloc en erreur
`submitContacts` / `submitAddresses` / boucle RIB de `submitAccounting` (dans `new.vue` ET `edit.vue`)
font `return` dans le `catch` du premier bloc en échec :
```js
catch (error) {
if (!mapRowError(error, contactErrors, index)) { toast(...) }
return // ← stoppe : les blocs suivants ne sont jamais validés ni affichés
}
```
→ même une fois le 500 corrigé, seules les erreurs du **premier** bloc fautif s'afficheraient.
## 2. Objectif
À la validation d'un onglet collection, **tenter tous les blocs** et **afficher l'erreur inline sous
chaque champ fautif, pour chaque bloc**, en un seul aller-retour de soumission. Pas de toast récapitulatif
(décision : inline seul, cohérent ERP-101). Pas de toast succès tant qu'au moins un bloc reste en erreur.
Hors périmètre : le workflow incrémental (créer le client, puis débloquer les onglets) reste inchangé ;
les onglets scalaires (Principal / Information / Comptabilité-scalaires) fonctionnent déjà et ne sont pas
touchés.
## 3. Conception
### 3.1 Back — supprimer le read cassé du POST (cause racine)
Sur les opérations `Post` de `ClientContact`, `ClientAddress`, `ClientRib` :
- Ajouter **`read: false`**. Le stade « read » est inutile : le `*Processor::linkParent` rattache déjà le
parent manuellement via `$em->getRepository(Client::class)->find($clientId)`. Pattern déjà employé dans
le projet (`Sites/.../CurrentSiteResource.php`).
- Durcir les 3 `linkParent` : si `find($clientId)` renvoie `null`, lever
`Symfony\Component\HttpKernel\Exception\NotFoundHttpException` (préserve le **404** sur parent
inexistant — sans le read, on régresserait sinon en 500 au persist sur `client_id NOT NULL`).
Effet : plus de `getOneOrNullResult` foireux → déserialisation + validation Symfony s'exécutent → **422
propre par champ** avec `violations[].propertyPath` (déjà garanti par ERP-107 : messages FR explicites).
Aucune autre modification (security, normalizationContext, processor restant) n'est nécessaire.
### 3.2 Front — collecter les erreurs de tous les blocs
Dans `submitContacts`, `submitAddresses`, et la boucle RIB de `submitAccounting`, **dans `new.vue` ET
`edit.vue`** :
- Conserver la réinitialisation du tableau d'erreurs en début de submit (`xxxErrors.value = []`).
- Introduire un drapeau local `hasError`. Dans le `catch`, remplacer `return` par
`hasError = true; continue` → la boucle tente/valide **tous** les blocs ; chaque 422 se mappe sur
`xxxErrors[index]` via `mapRowError` (mécanique existante, inchangée).
- Après la boucle : si `hasError`**ne pas** appeler `completeTab(...)`, **pas** de toast succès. Sinon
→ comportement actuel (`completeTab` + toast succès).
- Les blocs déjà créés (id non-null) repassent en `PATCH` au resubmit → idempotent, pas de doublon.
- Awaits **séquentiels** conservés (volume faible, ordre des blocs préservé, pas de course).
Le binding inline est déjà en place côté template (`:errors="contactErrors[index]"` /
`:error="ribErrors[index]?.iban"` …). Aucun changement de composant `Malio*` requis.
### 3.3 Réutilisation / isolation
Le bloc « boucle de soumission d'une collection avec collecte d'erreurs par index » est dupliqué 3× × 2
pages. Pour rester testable et DRY, extraire un helper de soumission de collection (ex.
`submitCollection(rows, { buildBody, post, patch, errors })` retournant `{ hasError }`) consommé par les
6 sites d'appel. À acter dans le plan d'implémentation (option : garder inline si l'extraction dégrade la
lisibilité — décision lors du plan).
## 4. Tests
### Back (TDD — échouent d'abord)
Dans `tests/Module/Commercial/Api/ClientSubResourceApiTest` :
- `testPostContactToClientWithTwoExistingContactsReturns201` : seed un client + 2 contacts, POST un 3ᵉ →
attendu **201** (rouge aujourd'hui : 500).
- `testPostContactInvalidEmailOnClientWithExistingContactsReturns422` : même seed, POST email invalide →
**422** avec `propertyPath=email` et message FR (vérifie que la validation est bien atteinte).
- Variantes germes pour adresses et RIB (au moins une chacune) pour verrouiller les 3 sous-ressources.
Pré-requis : helper de seed de contacts/adresses/RIB dans `AbstractCommercialApiTestCase` (ajouter si
absent).
### Front (Vitest)
- Si helper `submitCollection` extrait : test unitaire « 3 blocs, le 2ᵉ renvoie 422 → les erreurs du 2ᵉ
sont mappées, les blocs 1 et 3 sont tentés, `hasError = true`, tab non complété ».
- Sinon : test de composant sur `ClientContactBlock` + page, vérifiant l'affichage inline multi-blocs.
### Vérifications finales
`make test` + `make php-cs-fixer-allow-risky` (back), `make nuxt-test` (front). Golden path manuel :
client à 3 contacts, ajouter un 4ᵉ avec email invalide → 422 inline sous l'email du bon bloc, pas de 500.
## 5. Impact / risques
- API contract : POST sous-ressource passe de 500→201/422 (correction) ; 404 préservé sur parent
inexistant. Pas de changement de payload ni de réponse de succès.
- Le test fonctionnel CI actuel (POST sur client à 0 contact) reste vert.
- Régression possible si un consommateur dépendait du read implicite du parent au POST : aucun identifié
(les 3 processors gèrent déjà le rattachement manuellement).
@@ -1,633 +0,0 @@
# Validation « tous les blocs » — onglets à blocs dynamiques (Client M1) — Plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Permettre la validation 422 par champ sur TOUS les blocs des onglets Contacts / Adresses / RIB d'un client (création + édition), en supprimant la 500 `NonUniqueResultException` qui les bloque dès ≥2 enfants et en ne stoppant plus la boucle front au premier bloc en erreur.
**Architecture:** Côté back, on retire le stade « read » inutile du POST des 3 sous-ressources (`read: false`) — le parent est déjà rattaché manuellement par le processor — et on durcit ce rattachement (404 si parent absent). Côté front, on factorise la boucle de soumission de collection dans `useClientFormErrors().submitRows(...)` qui tente tous les blocs et collecte les erreurs par index, puis on branche les 6 sites d'appel (`new.vue` + `edit.vue` × contacts/adresses/RIB).
**Tech Stack:** Symfony 8 / API Platform 4 (PHP 8.4, PHPUnit) ; Nuxt 4 / Vue 3 / TypeScript / Vitest.
**Spec de référence :** `docs/superpowers/specs/2026-06-04-client-collection-blocks-validation-design.md`
**Pré-vol :** `make start` (containers up), branche de travail = celle de la MR (`feat/erp-107-validation-messages-fr`) ou une branche dédiée selon décision utilisateur.
---
## Structure des fichiers
**Back — modifiés :**
- `src/Module/Commercial/Domain/Entity/ClientContact.php``read: false` sur `Post`
- `src/Module/Commercial/Domain/Entity/ClientAddress.php``read: false` sur `Post`
- `src/Module/Commercial/Domain/Entity/ClientRib.php``read: false` sur `Post`
- `src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php``linkParent` → 404
- `.../Processor/ClientAddressProcessor.php` — idem
- `.../Processor/ClientRibProcessor.php` — idem
- `tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php` — helper `seedContact()`
- `tests/Module/Commercial/Api/ClientSubResourceApiTest.php` — tests de non-régression
**Front — modifiés :**
- `frontend/modules/commercial/composables/useClientFormErrors.ts` — méthode `submitRows()`
- `frontend/modules/commercial/composables/__tests__/useClientFormErrors.spec.ts` — créé (test unitaire)
- `frontend/modules/commercial/pages/clients/new.vue` — branchements (3 submits)
- `frontend/modules/commercial/pages/clients/[id]/edit.vue` — branchements (3 submits)
---
## Task 1 : Back — test rouge (POST sur client à ≥2 enfants)
**Files:**
- Modify: `tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php`
- Test: `tests/Module/Commercial/Api/ClientSubResourceApiTest.php`
- [ ] **Step 1 : Ajouter un helper de seed de contact à la base de test**
Dans `AbstractCommercialApiTestCase.php`, ajouter (sous `seedClient`, avant `cleanupCommercialTestData`) :
```php
/**
* Seede directement un ClientContact en base (sans passer par l'API), pour
* preparer un client deja dote de N contacts. Au moins le prenom est pose
* (RG-1.05 / CHECK chk_client_contact_name).
*/
protected function seedContact(ClientEntity $client, string $firstName): \App\Module\Commercial\Domain\Entity\ClientContact
{
$em = $this->getEm();
$contact = new \App\Module\Commercial\Domain\Entity\ClientContact();
$contact->setClient($client);
$contact->setFirstName($firstName);
$em->persist($contact);
$em->flush();
return $contact;
}
```
- [ ] **Step 2 : Écrire les tests rouges**
Dans `ClientSubResourceApiTest.php`, ajouter dans la section `// === Contacts ===` :
```php
/**
* Regression ERP (bug subresource Link toProperty) : POST d'un contact sur un
* client qui en a DEJA >= 2 ne doit pas exploser en 500
* (NonUniqueResultException sur la resolution du parent), mais creer (201).
*/
public function testPostContactOnClientWithTwoExistingContactsReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Contact Multi');
$this->seedContact($seed, 'Alpha');
$this->seedContact($seed, 'Beta');
$client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Gamma'],
]);
self::assertResponseStatusCodeSame(201);
}
/**
* Meme contexte (>= 2 contacts existants) : un email invalide doit produire
* une 422 par champ (la validation est bien atteinte), pas une 500.
*/
public function testPostInvalidContactOnPopulatedClientReturns422OnField(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Contact Multi Bad');
$this->seedContact($seed, 'Alpha');
$this->seedContact($seed, 'Beta');
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Gamma', 'email' => 'pas-un-email'],
]);
self::assertResponseStatusCodeSame(422);
$byPath = [];
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
self::assertArrayHasKey('email', $byPath);
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
}
```
- [ ] **Step 3 : Lancer les tests, vérifier qu'ils échouent (500 au lieu de 201/422)**
Run : `make test` (ou ciblé dans le container : `docker exec php-starseed-fpm php bin/phpunit --filter ClientSubResourceApiTest`)
Expected : les 2 nouveaux tests ÉCHOUENT (HTTP 500 `NonUniqueResultException`). `testPostContactOnClient...` reçoit 500, pas 201.
- [ ] **Step 4 : Commit (test rouge)**
```bash
git add tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php tests/Module/Commercial/Api/ClientSubResourceApiTest.php
git commit -m "test(commercial) : reproduit la 500 NonUniqueResult au POST contact sur client peuple (ERP-107)"
```
---
## Task 2 : Back — fix (read:false + linkParent durci) → tests verts
**Files:**
- Modify: `src/Module/Commercial/Domain/Entity/ClientContact.php:48-57`
- Modify: `src/Module/Commercial/Domain/Entity/ClientAddress.php:61-70`
- Modify: `src/Module/Commercial/Domain/Entity/ClientRib.php:52-61`
- Modify: `.../State/Processor/ClientContactProcessor.php:76-94`
- Modify: `.../State/Processor/ClientAddressProcessor.php:63-81`
- Modify: `.../State/Processor/ClientRibProcessor.php:65-83`
- [ ] **Step 1 : `read: false` sur les 3 opérations `Post`**
`ClientContact.php`, opération `Post` — ajouter la ligne `read: false,` :
```php
new Post(
uriTemplate: '/clients/{clientId}/contacts',
uriVariables: [
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
],
// read:false : pas de stade lecture du parent (le Link toProperty
// resoudrait l'enfant et casse en NonUniqueResult des >= 2 enfants).
// Le parent est rattache par ClientContactProcessor::linkParent.
read: false,
security: "is_granted('commercial.clients.manage')",
normalizationContext: ['groups' => ['client_contact:read']],
denormalizationContext: ['groups' => ['client_contact:write']],
processor: ClientContactProcessor::class,
),
```
`ClientAddress.php` — idem dans son `Post` (`security: commercial.clients.manage`, processor `ClientAddressProcessor`), commentaire pointant `ClientAddressProcessor::linkParent`.
`ClientRib.php` — idem dans son `Post` (`security: commercial.clients.accounting.manage`, processor `ClientRibProcessor`), commentaire pointant `ClientRibProcessor::linkParent`.
- [ ] **Step 2 : Durcir les 3 `linkParent` (404 si parent absent)**
Dans chaque processor, ajouter l'import en tête de fichier :
```php
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
```
`ClientContactProcessor::linkParent` — remplacer le bloc final par :
```php
if (null === $clientId) {
return;
}
$client = $clientId instanceof Client
? $clientId
: $this->em->getRepository(Client::class)->find($clientId);
// read:false sur le POST : sans stade lecture, un parent introuvable
// n'est plus intercepte en amont -> 404 explicite (sinon 500 au persist
// sur client_id NOT NULL).
if (!$client instanceof Client) {
throw new NotFoundHttpException('Client introuvable.');
}
$contact->setClient($client);
```
`ClientAddressProcessor::linkParent` — idem avec `$address->setClient($client);`.
`ClientRibProcessor::linkParent` — idem avec `$rib->setClient($client);`.
- [ ] **Step 3 : Lancer les tests, vérifier qu'ils passent**
Run : `make test`
Expected : les 2 tests de Task 1 PASSENT (201 + 422 `propertyPath=email`). Aucun test existant cassé (notamment `testPostContactInvalidEmailReturns422WithFrenchMessageOnField` et les tests d'archi ERP-107 restent verts).
- [ ] **Step 4 : Lint PHP**
Run : `make php-cs-fixer-allow-risky`
Expected : 0 fichier à corriger (ou corrections appliquées et re-vérifiées).
- [ ] **Step 5 : Commit (fix back)**
```bash
git add src/Module/Commercial/Domain/Entity/ClientContact.php src/Module/Commercial/Domain/Entity/ClientAddress.php src/Module/Commercial/Domain/Entity/ClientRib.php src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientRibProcessor.php
git commit -m "fix(commercial) : POST sous-ressource client en read:false + parent 404 (corrige 500 NonUniqueResult, ERP-107)"
```
---
## Task 3 : Back — germes adresses + RIB (verrouille les 3 sous-ressources)
**Files:**
- Modify: `tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php` (helpers `seedAddress`, `seedRib`)
- Test: `tests/Module/Commercial/Api/ClientSubResourceApiTest.php`
- [ ] **Step 1 : Helpers de seed adresse + RIB**
Dans `AbstractCommercialApiTestCase.php`, ajouter :
```php
/** Seede une adresse minimale valide (RG : CP/ville/rue requis). */
protected function seedAddress(ClientEntity $client, string $city): \App\Module\Commercial\Domain\Entity\ClientAddress
{
$em = $this->getEm();
$address = new \App\Module\Commercial\Domain\Entity\ClientAddress();
$address->setClient($client);
$address->setPostalCode('33000');
$address->setCity($city);
$address->setStreet('1 rue du Test');
$em->persist($address);
$em->flush();
return $address;
}
/** Seede un RIB valide (BIC/IBAN conformes). */
protected function seedRib(ClientEntity $client, string $label): \App\Module\Commercial\Domain\Entity\ClientRib
{
$em = $this->getEm();
$rib = new \App\Module\Commercial\Domain\Entity\ClientRib();
$rib->setClient($client);
$rib->setLabel($label);
$rib->setBic('BNPAFRPPXXX');
$rib->setIban('FR1420041010050500013M02606');
$em->persist($rib);
$em->flush();
return $rib;
}
```
> Note : si une propriété est non-nullable et absente ci-dessus (ex. `position`, flags d'adresse), poser les setters correspondants avec une valeur par défaut neutre — vérifier les entités `ClientAddress` / `ClientRib` au moment de l'écriture.
- [ ] **Step 2 : Tests de non-régression adresses + RIB**
Dans `ClientSubResourceApiTest.php`, section adresses puis RIB :
```php
public function testPostAddressOnClientWithTwoExistingAddressesReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Addr Multi');
$this->seedAddress($seed, 'Bordeaux');
$this->seedAddress($seed, 'Lyon');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['postalCode' => '75001', 'city' => 'Paris', 'street' => '2 rue Neuve'],
]);
self::assertResponseStatusCodeSame(201);
}
public function testPostRibOnClientWithTwoExistingRibsReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Rib Multi');
$this->seedRib($seed, 'Compte 1');
$this->seedRib($seed, 'Compte 2');
$client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['label' => 'Compte 3', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
]);
self::assertResponseStatusCodeSame(201);
}
```
> Le POST RIB exige `commercial.clients.accounting.manage` — `admin` (ROLE_ADMIN) l'a. Si une 403 apparaît, vérifier le compte de test.
- [ ] **Step 3 : Lancer, vérifier vert**
Run : `make test`
Expected : PASS (les 2 nouveaux tests verts grâce au fix de Task 2).
- [ ] **Step 4 : Commit**
```bash
git add tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php tests/Module/Commercial/Api/ClientSubResourceApiTest.php
git commit -m "test(commercial) : verrouille POST adresses/RIB sur client peuple (ERP-107)"
```
---
## Task 4 : Front — helper `submitRows` + test unitaire
**Files:**
- Modify: `frontend/modules/commercial/composables/useClientFormErrors.ts`
- Create: `frontend/modules/commercial/composables/__tests__/useClientFormErrors.spec.ts`
- [ ] **Step 1 : Écrire le test rouge**
Créer `useClientFormErrors.spec.ts` :
```ts
import { describe, it, expect, vi } from 'vitest'
import { useClientFormErrors } from '../useClientFormErrors'
// Construit une erreur facon useApi : 422 avec violations Hydra.
function http422(path: string, message: string) {
return { response: { status: 422, _data: { violations: [{ propertyPath: path, message }] } } }
}
describe('useClientFormErrors.submitRows', () => {
it('tente TOUS les blocs et mappe les erreurs par index, sans stopper au premier echec', async () => {
const { contactErrors, submitRows } = useClientFormErrors()
const seen: number[] = []
const onUnmapped = vi.fn()
const saveRow = async (_row: unknown, index: number) => {
seen.push(index)
if (index === 1) throw http422('email', 'Email invalide')
}
const hasError = await submitRows(
[{ a: 0 }, { a: 1 }, { a: 2 }],
contactErrors,
saveRow,
onUnmapped,
)
expect(seen).toEqual([0, 1, 2]) // tous les blocs tentes
expect(hasError).toBe(true)
expect(contactErrors.value[1]).toEqual({ email: 'Email invalide' })
expect(contactErrors.value[0]).toBeUndefined()
expect(onUnmapped).not.toHaveBeenCalled() // 422 mappee, pas de fallback
})
it('saute les lignes filtrees par shouldSkip et renvoie false si tout passe', async () => {
const { contactErrors, submitRows } = useClientFormErrors()
const saved: number[] = []
const hasError = await submitRows(
[{ skip: true }, { skip: false }],
contactErrors,
async (_row, index) => { saved.push(index) },
vi.fn(),
(row: { skip: boolean }) => row.skip,
)
expect(saved).toEqual([1])
expect(hasError).toBe(false)
})
})
```
- [ ] **Step 2 : Lancer, vérifier l'échec**
Run : `make nuxt-test` (ou ciblé : `docker exec <node> npx vitest run useClientFormErrors`)
Expected : FAIL — `submitRows` n'existe pas encore.
- [ ] **Step 3 : Implémenter `submitRows`**
Dans `useClientFormErrors.ts`, ajouter la méthode (dans la fonction, après `mapRowError`) et l'exposer dans le `return` :
```ts
/**
* Soumet TOUS les blocs d'une collection (contacts/adresses/RIB) en collectant
* les erreurs par index : on n'arrete PAS au premier bloc en echec (ERP-101).
* Reinitialise le tableau d'erreurs cible, tente chaque ligne via `saveRow`,
* mappe les 422 inline (mapRowError) ou delegue le fallback a `onUnmappedError`.
* Retourne true si au moins un bloc a echoue (le caller ne valide alors pas l'onglet).
*/
async function submitRows<T>(
rows: T[],
target: Ref<Record<string, string>[]>,
saveRow: (row: T, index: number) => Promise<void>,
onUnmappedError: (error: unknown, index: number) => void,
shouldSkip?: (row: T, index: number) => boolean,
): Promise<boolean> {
target.value = []
let hasError = false
for (let index = 0; index < rows.length; index++) {
if (shouldSkip?.(rows[index], index)) {
continue
}
try {
await saveRow(rows[index], index)
}
catch (error) {
if (!mapRowError(error, target, index)) {
onUnmappedError(error, index)
}
hasError = true
}
}
return hasError
}
```
Ajouter `submitRows` à l'objet retourné par `useClientFormErrors`.
- [ ] **Step 4 : Lancer, vérifier vert**
Run : `make nuxt-test`
Expected : PASS (les 2 cas verts).
- [ ] **Step 5 : Commit**
```bash
git add frontend/modules/commercial/composables/useClientFormErrors.ts frontend/modules/commercial/composables/__tests__/useClientFormErrors.spec.ts
git commit -m "feat(commercial) : submitRows collecte les erreurs de tous les blocs de collection (ERP-101)"
```
---
## Task 5 : Front — brancher `submitRows` dans new.vue + edit.vue
**Files:**
- Modify: `frontend/modules/commercial/pages/clients/new.vue` (`submitContacts`, `submitAddresses`, boucle RIB de `submitAccounting`)
- Modify: `frontend/modules/commercial/pages/clients/[id]/edit.vue` (les 3 équivalents)
- [ ] **Step 1 : Récupérer `submitRows` du composable**
Dans `new.vue` ET `edit.vue`, ajouter `submitRows` à la déstructuration de `useClientFormErrors()` :
```ts
const {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
mapRowError,
submitRows,
} = useClientFormErrors()
```
- [ ] **Step 2 : Réécrire `submitContacts` (new.vue)**
Remplacer le corps de la boucle par un appel à `submitRows` :
```ts
async function submitContacts(): Promise<void> {
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
tabSubmitting.value = true
try {
const hasError = await submitRows(
contacts.value,
contactErrors,
async (contact) => {
const body = {
firstName: contact.firstName || null,
lastName: contact.lastName || null,
jobTitle: contact.jobTitle || null,
phonePrimary: contact.phonePrimary || null,
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
email: contact.email || null,
}
if (contact.id === null) {
const created = await api.post<ContactResponse>(
`/clients/${clientId.value}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
contact.iri = created['@id'] ?? null
}
else {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
}
},
(error) => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
(contact) => !isContactNamed(contact),
)
if (hasError) return
completeTab('contact')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
finally {
tabSubmitting.value = false
}
}
```
- [ ] **Step 3 : Réécrire `submitAddresses` (new.vue)**
```ts
async function submitAddresses(): Promise<void> {
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
tabSubmitting.value = true
try {
const hasError = await submitRows(
addresses.value,
addressErrors,
async (address) => {
const body = {
isProspect: address.isProspect,
isDelivery: address.isDelivery,
isBilling: address.isBilling,
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
street: address.street || null,
streetComplement: address.streetComplement || null,
categories: address.categoryIris,
sites: address.siteIris,
contacts: address.contactIris,
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
}
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/addresses`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.id = created.id
}
else {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
}
},
(error) => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
)
if (hasError) return
completeTab('address')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
finally {
tabSubmitting.value = false
}
}
```
- [ ] **Step 4 : Réécrire la boucle RIB de `submitAccounting` (new.vue)**
Garder le PATCH scalaire inchangé (1) ; remplacer la boucle (2) :
```ts
// 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs).
const ribHasError = await submitRows(
ribs.value,
ribErrors,
async (rib) => {
const body = { label: rib.label, bic: rib.bic, iban: rib.iban }
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}
},
(error) => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
(rib) => !ribIsComplete(rib),
)
if (ribHasError) return
completeTab('accounting')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
```
> Retirer le `ribErrors.value = []` désormais fait par `submitRows`. Le `accountingErrors.clearErrors()` du PATCH scalaire reste.
- [ ] **Step 5 : Mirror dans edit.vue**
Appliquer les mêmes réécritures aux `submitContacts` / `submitAddresses` / boucle RIB de `submitAccounting` d'`edit.vue`. Conserver le **fallback d'erreur propre à edit.vue** (si edit.vue utilise `showError(...)` au lieu de `toast.error(...)`, passer ce fallback comme `onUnmappedError`). Vérifier les noms des refs (`clientId` peut y être l'id de route).
- [ ] **Step 6 : Vérifier le typecheck + tests front**
Run : `make nuxt-test`
Expected : PASS. Aucune régression des specs existantes (`ClientContactBlock.spec.ts`, etc.).
- [ ] **Step 7 : Commit**
```bash
git add frontend/modules/commercial/pages/clients/new.vue "frontend/modules/commercial/pages/clients/[id]/edit.vue"
git commit -m "feat(commercial) : valide tous les blocs contacts/adresses/RIB et affiche les erreurs par bloc (ERP-101)"
```
---
## Task 6 : Vérification finale + golden path manuel
- [ ] **Step 1 : Suite complète back**
Run : `make test` puis `make php-cs-fixer-allow-risky`
Expected : tout vert, 0 fichier à corriger.
- [ ] **Step 2 : Suite complète front**
Run : `make nuxt-test`
Expected : tout vert.
- [ ] **Step 3 : Golden path manuel (`make dev-nuxt`, port 3004)**
Scénario : ouvrir un client à 3 contacts (compte `admin`), onglet Contacts, ajouter un bloc avec email invalide + un autre bloc avec prénom/nom vides → Valider.
Attendu : **pas de 500** ; « L'adresse email n'est pas valide. » sous l'email du bon bloc ET « Le prénom ou le nom du contact est obligatoire. » sous le prénom de l'autre bloc, **affichés simultanément**. L'onglet ne se valide pas tant qu'une erreur subsiste. Idem à vérifier rapidement sur Adresses et RIB.
- [ ] **Step 4 : Si une vérif échoue ou ne peut être lancée, le dire explicitement** (ne pas annoncer « fini »).
---
## Self-review (auteur du plan)
- **Couverture spec §3.1 (back)** : Task 2 (read:false + linkParent 404) ✓ ; §3.2 (front collect-all) : Tasks 4-5 ✓ ; §3.3 (helper réutilisable) : Task 4 `submitRows` ✓ ; §4 tests : Tasks 1, 3 (back), 4 (front) + Task 6 golden path ✓.
- **Périmètre 3 sous-ressources** : contacts (Task 1-2), adresses + RIB (Task 3 + branchements Task 5) ✓.
- **Décision « inline seul »** : aucun toast succès si `hasError` ; pas de toast récap ✓.
- **Pas de placeholder** : le seul point ouvert est la note Task 3 Step 1 (setters non-nullables éventuels d'adresse/RIB à compléter en lisant les entités) — à lever à l'écriture. Cohérence des noms : `submitRows` utilisé identiquement en Task 4 et Task 5.
-1
View File
@@ -883,7 +883,6 @@ Cf. § 2.6. Pattern Shared standard.
### Onglet Comptabilité ### Onglet Comptabilité
- **RG-1.30** _(ajoutée — correctif incohérence spec-front/spec-back)_ : à la **validation complète de l'onglet Comptabilité**, les six champs scalaires `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType` sont **obligatoires** (alignement sur spec-front § Onglet Comptabilité). Colonnes `nullable` en base (l'onglet est rempli dans un second temps, et l'onglet principal ne les envoie pas) + validateur contextuel `ClientAccountingCompletenessValidator` invoqué par le `ClientProcessor` — même parti que RG-1.04 (Information). Déclenchement : uniquement quand **les six champs sont présents dans le payload** (le front les envoie toujours ensemble via « Valider ») ; un PATCH ciblant un sous-ensemble de champs comptables (édition ponctuelle) n'est pas soumis à la complétude. Chaque champ manquant → 422 sur son `propertyPath` (mapping inline front, ERP-101). `bank` reste hors complétude (conditionnel RG-1.12).
- **RG-1.12** : Le champ `bank` est visible et obligatoire **uniquement** si `paymentType.code = 'VIREMENT'`. Validation server-side dans le `ClientProcessor` : si `payment_type.code = VIREMENT` et `bank IS NULL` → 422. - **RG-1.12** : Le champ `bank` est visible et obligatoire **uniquement** si `paymentType.code = 'VIREMENT'`. Validation server-side dans le `ClientProcessor` : si `payment_type.code = VIREMENT` et `bank IS NULL` → 422.
- **RG-1.13** : Les champs RIB (`label`, `bic`, `iban`) sont obligatoires si **au moins un bloc RIB est présent ET** `paymentType.code = 'LCR'`. C'est-à-dire : - **RG-1.13** : Les champs RIB (`label`, `bic`, `iban`) sont obligatoires si **au moins un bloc RIB est présent ET** `paymentType.code = 'LCR'`. C'est-à-dire :
- Si `paymentType.code = LCR` ET `client.ribs.count() = 0` → 422 « Au moins un RIB est obligatoire pour le type LCR ». - Si `paymentType.code = LCR` ET `client.ribs.count() = 0` → 422 « Au moins un RIB est obligatoire pour le type LCR ».
+1 -2
View File
@@ -258,8 +258,7 @@ Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse.
- Composable dédié `useAddressAutocomplete()` (à créer en M1). - Composable dédié `useAddressAutocomplete()` (à créer en M1).
- Appel HTTP **direct depuis le front** (CORS OK), pas de proxy back. - 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}` (sans filtre `type`) → suggestions adresse. - 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.
-**Ne pas forcer `type=housenumber`** sur la recherche d'adresse (corrigé en ERP-66) : la BAN ne renvoie un résultat de ce type qu'une fois un numéro saisi, donc une recherche par nom de rue (« boulevard du port ») renverrait **0 résultat** pendant toute la frappe. Sans filtre `type`, la BAN classe rues + numéros par pertinence — comportement d'autocomplétion attendu.
- 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. - 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) ## Points laissés ouverts par la V0 (résolus côté back)
@@ -1,119 +0,0 @@
# ERP-101 — Mapping des erreurs de validation par champ (convention forms)
> Statut : design validé — implémentation TDD en cours
> Branche : `feat/ERP-101-form-field-validation-mapping`
> Date : 2026-06-03
## Problème
Quand le back renvoie une **422** (violations API Platform), il renvoie **toutes** les
violations d'un coup (un `propertyPath` + `message` par champ fautif). Aujourd'hui, seul
le drawer Catégorie (`useCategoryForm`) exploite ce détail pour afficher l'erreur **sous
le champ concerné** ; il le fait via un `if/else` manuel par champ, non réutilisable.
Le formulaire Client (≈ 20 champs sur 5 submits, dont 3 collections) ne mappe rien : une
422 multi-champs ⇒ un seul **toast global**. On veut un retour par champ, et surtout
**une convention unique réutilisée par tous les modules**.
## Décisions
1. **Primitif générique** plutôt que composable par form : `useFormErrors()` partagé.
2. **Périmètre complet** sur Client : champs scalaires **et** collections (erreur par ligne).
## Architecture — 3 briques
### 1. `mapViolationsToRecord(data)` — `frontend/shared/utils/api.ts`
Util pur, fondation réutilisée partout. Transforme un payload 422 en
`Record<propertyPath, message>`. S'appuie sur `extractApiViolations` (déjà existant,
gère les formats `violations` et `hydra:violations`).
```ts
export function mapViolationsToRecord(data: unknown): Record<string, string> {
const out: Record<string, string> = {}
for (const v of extractApiViolations(data)) {
if (v.propertyPath) out[v.propertyPath] = v.message
}
return out
}
```
### 2. `useFormErrors()` — `frontend/shared/composables/useFormErrors.ts`
API que tous les forms **scalaires** consomment.
```ts
const { errors, hasErrors, setServerErrors, setError, clearError, clearErrors, handleApiError } = useFormErrors()
```
- `errors` : `reactive<Record<string, string>>` indexé par `propertyPath`.
- `setServerErrors(data)` : `mapViolationsToRecord` → remplit `errors`. Retourne `true`
si au moins une violation a été mappée.
- `setError(field, msg)` / `clearError(field)` / `clearErrors()` : manipulation fine.
- `hasErrors` : `computed` booléen.
- `handleApiError(e, opts?)` : dispatch standard depuis une erreur ofetch —
**422**`setServerErrors` (mapping inline, pas de toast) ; **autre** → toast
générique de fallback (message extrait via `extractApiErrorMessage`).
Côté template, le nom du champ **est** le `propertyPath` :
```vue
<MalioInputText v-model="main.companyName" :error="errors.companyName" />
<MalioInputText v-model="accounting.siren" :error="errors.siren" />
```
> L'unicité SIREN (RG-1.15) remonte en **422 `UniqueEntity` avec `propertyPath: "siren"`**
> → mappée automatiquement. Pas de cas 409 spécial (contrairement à Catégorie).
### 3. Collections — erreurs par ligne
Chaque ligne (contact / adresse / RIB) est persistée par **son propre appel API**, donc
le back renvoie un 422 **relatif à la sous-entité** (`propertyPath: "email"`, `"iban"`…).
- Le parent tient, par collection, un tableau d'erreurs **aligné sur l'index de ligne** :
`const contactErrors = ref<Record<string, string>[]>([])`.
- Au submit de la ligne `i` : `catch``contactErrors.value[i] = mapViolationsToRecord(data)`.
- On `clearErrors` la collection au début de chaque passe de submit.
- Les blocs reçoivent une prop `:errors` (`Record<string, string>`) et bindent
`:error="errors?.email"` sur chaque champ Malio.
## Fichiers touchés
| Fichier | Action |
|---|---|
| `shared/utils/api.ts` | + `mapViolationsToRecord` |
| `shared/composables/useFormErrors.ts` | **nouveau** composable |
| `modules/commercial/pages/clients/new.vue` | scalaires (Main/Info/Compta) + erreurs par ligne |
| `modules/commercial/pages/clients/[id]/edit.vue` | idem |
| `modules/commercial/components/ClientContactBlock.vue` | + prop `:errors`, bind `:error` |
| `modules/commercial/components/ClientAddressBlock.vue` | + prop `:errors`, bind `:error` |
| RIB (inline dans new/edit) | bind `:error` par ligne |
## Tests (Vitest — règle « pas d'E2E »)
- `mapViolationsToRecord` : formats `violations` / `hydra:violations`, payload vide,
`propertyPath` manquant.
- `useFormErrors` : `setServerErrors` mappe et retourne `true` / `false` sans violation,
`clearErrors`, fallback toast sur non-422.
## Convention posée pour tous les forms
À reporter dans `.claude/rules/frontend.md` une fois le pattern stabilisé :
> Tout form qui veut un retour d'erreur par champ : appels API en `{ toast: false }` +
> `useFormErrors` pour les champs scalaires (422 inline), `mapViolationsToRecord` par
> ligne pour les collections. `useCategoryForm` migrera sur `useFormErrors`.
## Fait dans la foulée (post-ERP-101 initial)
- **`useCategoryForm` migré sur `useFormErrors`** : `errors` devient le `reactive` du
composable (drawer adapté : `form.errors.name` au lieu de `form.errors.value.name`,
bloc `_global` retiré → erreur transverse en toast). 28 tests verts.
- **Convention reportée dans `.claude/rules/frontend.md`** (section « Validation des
formulaires — useFormErrors obligatoire »).
## Hors scope ERP-101 (suivi : ticket ERP-107)
- Langue / présence des messages de validation côté back : le `message` affiché est celui
renvoyé par le serveur. Audit des contraintes Symfony (présence d'un `message` FR,
contraintes manquantes, violations sans `propertyPath`) tracké dans **ERP-107**.
+14 -18
View File
@@ -10,11 +10,7 @@
"confirm": "Confirmer", "confirm": "Confirmer",
"yes": "Oui", "yes": "Oui",
"no": "Non", "no": "Non",
"actions": "Actions", "actions": "Actions"
"comingSoon": {
"title": "En cours de dev",
"subtitle": "Cette fonctionnalité arrive bientôt."
}
}, },
"sidebar": { "sidebar": {
"administration": { "administration": {
@@ -99,6 +95,8 @@
"back": "Retour au répertoire", "back": "Retour au répertoire",
"loading": "Chargement du client…", "loading": "Chargement du client…",
"notFound": "Client introuvable.", "notFound": "Client introuvable.",
"emptyContacts": "Aucun contact enregistré.",
"emptyAddresses": "Aucune adresse enregistrée.",
"confirmArchive": { "confirmArchive": {
"title": "Archiver le client", "title": "Archiver le client",
"message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?" "message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
@@ -113,6 +111,8 @@
"back": "Retour au répertoire", "back": "Retour au répertoire",
"loading": "Chargement du client…", "loading": "Chargement du client…",
"notFound": "Client introuvable.", "notFound": "Client introuvable.",
"emptyContacts": "Aucun contact enregistré.",
"emptyAddresses": "Aucune adresse enregistrée.",
"save": "Valider" "save": "Valider"
}, },
"validation": { "validation": {
@@ -133,9 +133,14 @@
"duplicateCompany": "Un client portant ce nom de société existe déjà.", "duplicateCompany": "Un client portant ce nom de société existe déjà.",
"main": { "main": {
"companyName": "Nom du client (Entreprise)", "companyName": "Nom du client (Entreprise)",
"firstName": "Prénom du contact principal",
"lastName": "Nom du contact principal",
"email": "Email",
"phonePrimary": "Téléphone",
"phoneSecondary": "Téléphone (2)",
"addPhone": "Ajouter un numéro",
"categories": "Catégorie", "categories": "Catégorie",
"relation": "Distributeur / Courtier", "relation": "Distributeur / Courtier",
"relationNone": "Aucun",
"relationDistributor": "Dépend du distributeur", "relationDistributor": "Dépend du distributeur",
"relationBroker": "Dépend du courtier", "relationBroker": "Dépend du courtier",
"distributorName": "Nom du distributeur", "distributorName": "Nom du distributeur",
@@ -168,18 +173,13 @@
"prospect": "Prospect", "prospect": "Prospect",
"delivery": "Adresse de livraison", "delivery": "Adresse de livraison",
"billing": "Facturation", "billing": "Facturation",
"addressType": "Type d'adresse",
"addressTypeProspect": "Prospect",
"addressTypeDelivery": "Livraison",
"addressTypeBilling": "Facturation",
"addressTypeDeliveryBilling": "Adresse + Facturation",
"categories": "Catégorie", "categories": "Catégorie",
"country": "Pays", "country": "Pays",
"postalCode": "Code postal", "postalCode": "Code postal",
"city": "Ville", "city": "Ville",
"street": "Adresse", "street": "Adresse",
"streetComplement": "Adresse complémentaire", "streetComplement": "Adresse complémentaire",
"sites": "Sites", "sites": "Sites Starseed",
"contacts": "Contact(s) rattaché(s)", "contacts": "Contact(s) rattaché(s)",
"billingEmail": "Email de facturation", "billingEmail": "Email de facturation",
"remove": "Supprimer l'adresse", "remove": "Supprimer l'adresse",
@@ -233,10 +233,7 @@
}, },
"sites": { "sites": {
"notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site." "notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site."
}, }
"title": "Erreur",
"generic": "Une erreur est survenue.",
"unknown": "Erreur inconnue."
}, },
"sites": { "sites": {
"selector": { "selector": {
@@ -293,8 +290,7 @@
"success": { "success": {
"auth": { "auth": {
"logout": "Deconnexion reussie" "logout": "Deconnexion reussie"
}, }
"title": "Succès"
}, },
"admin": { "admin": {
"roles": { "roles": {
@@ -20,7 +20,7 @@
:label="t('admin.categories.form.name')" :label="t('admin.categories.form.name')"
input-class="w-full" input-class="w-full"
:max-length="120" :max-length="120"
:error="form.errors.name" :error="form.errors.value.name"
required required
/> />
@@ -32,9 +32,15 @@
:options="typeOptions" :options="typeOptions"
:label="t('admin.categories.form.type')" :label="t('admin.categories.form.type')"
:empty-option-label="t('admin.categories.form.typePlaceholder')" :empty-option-label="t('admin.categories.form.typePlaceholder')"
:error="form.errors.categoryType" :error="form.errors.value.categoryType"
:disabled="loadingTypes" :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> </form>
<!-- Footer fixe : depuis 1.7.1 le slot #footer est un frere du body <!-- Footer fixe : depuis 1.7.1 le slot #footer est un frere du body
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Category, CategoryType } from '~/modules/catalog/types/category' import type { Category, CategoryType } from '~/modules/catalog/types/category'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { useCategoryForm } from '../useCategoryForm' import { useCategoryForm } from '../useCategoryForm'
// Stubs des auto-imports Nuxt consommes par le composable. // Stubs des auto-imports Nuxt consommes par le composable.
@@ -22,9 +21,6 @@ vi.stubGlobal('useToast', () => ({
success: mockToastSuccess, success: mockToastSuccess,
error: mockToastError, error: mockToastError,
})) }))
// useFormErrors est un auto-import Nuxt : on expose l'implementation reelle
// (elle consomme useToast, deja stubbe ci-dessus) pour tester l'integration.
vi.stubGlobal('useFormErrors', useFormErrors)
// useI18n.t : on renvoie la cle telle quelle (pratique pour asserter dessus). // useI18n.t : on renvoie la cle telle quelle (pratique pour asserter dessus).
// Quand le composable passe des params (ex: doublon), on les serialise pour // Quand le composable passe des params (ex: doublon), on les serialise pour
// pouvoir verifier que l'interpolation a bien recu le bon nom. // pouvoir verifier que l'interpolation a bien recu le bon nom.
@@ -65,7 +61,7 @@ describe('useCategoryForm', () => {
expect(form.name.value).toBe('Vis') expect(form.name.value).toBe('Vis')
expect(form.categoryTypeId.value).toBe(1) expect(form.categoryTypeId.value).toBe(1)
expect(form.errors).toEqual({}) expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
}) })
it('vide le formulaire en mode creation (null)', () => { it('vide le formulaire en mode creation (null)', () => {
@@ -109,7 +105,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.name).toBe('admin.categories.validation.nameRequired') expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
}) })
it('signale erreur si name est whitespace-only (trim → vide)', () => { it('signale erreur si name est whitespace-only (trim → vide)', () => {
@@ -120,7 +116,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.name).toBe('admin.categories.validation.nameRequired') expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
}) })
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => { it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
@@ -131,7 +127,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.name).toBe('admin.categories.validation.nameLength') expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
}) })
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => { it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
@@ -142,7 +138,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.name).toBe('admin.categories.validation.nameLength') expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
}) })
it('signale erreur si categoryTypeId est null (RG-1.05)', () => { it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
@@ -153,7 +149,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.categoryType).toBe('admin.categories.validation.typeRequired') expect(form.errors.value.categoryType).toBe('admin.categories.validation.typeRequired')
}) })
it('passe quand name et categoryType sont valides', () => { it('passe quand name et categoryType sont valides', () => {
@@ -164,22 +160,19 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(true) expect(ok).toBe(true)
expect(form.errors).toEqual({}) expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
}) })
it('reinitialise les erreurs avant chaque validation', () => { it('reinitialise les erreurs avant chaque validation', () => {
const form = useCategoryForm() const form = useCategoryForm()
// Erreur prealable : une validation en echec peuple errors.name. // Erreur prealable.
form.name.value = '' form.errors.value._global = 'erreur ancienne'
form.categoryTypeId.value = 1
form.validate()
expect(form.errors.name).toBeTruthy()
// Seconde validation avec des valeurs valides : errors repart vide.
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.validate() form.validate()
expect(form.errors).toEqual({}) expect(form.errors.value._global).toBe('')
}) })
}) })
@@ -220,7 +213,7 @@ describe('useCategoryForm', () => {
await form.submitCreate() await form.submitCreate()
expect(mockToastSuccess).toHaveBeenCalledWith({ expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'success.title', title: 'Succès',
message: 'admin.categories.toast.created', message: 'admin.categories.toast.created',
}) })
}) })
@@ -238,8 +231,8 @@ describe('useCategoryForm', () => {
expect(result).toBeNull() expect(result).toBeNull()
// La cle est interpolee avec le nom soumis : on retrouve "Vis" dans // La cle est interpolee avec le nom soumis : on retrouve "Vis" dans
// les params i18n (stub serialise les params). // les params i18n (stub serialise les params).
expect(form.errors.name).toContain('admin.categories.toast.duplicate') expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.name).toContain('"name":"Vis"') expect(form.errors.value.name).toContain('"name":"Vis"')
expect(mockToastError).toHaveBeenCalledTimes(1) expect(mockToastError).toHaveBeenCalledTimes(1)
const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string } const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string }
expect(toastArg.message).toContain('Vis') expect(toastArg.message).toContain('Vis')
@@ -263,7 +256,7 @@ describe('useCategoryForm', () => {
const result = await form.submitCreate() const result = await form.submitCreate()
expect(result).toBeNull() expect(result).toBeNull()
expect(form.errors.name).toBe('name should not be blank.') expect(form.errors.value.name).toBe('name should not be blank.')
// Pas de toast quand on a mappe les violations : l erreur est // Pas de toast quand on a mappe les violations : l erreur est
// affichee inline sous le champ concerne. // affichee inline sous le champ concerne.
expect(mockToastError).not.toHaveBeenCalled() expect(mockToastError).not.toHaveBeenCalled()
@@ -286,10 +279,10 @@ describe('useCategoryForm', () => {
await form.submitCreate() await form.submitCreate()
expect(form.errors.categoryType).toBe('Type invalide.') expect(form.errors.value.categoryType).toBe('Type invalide.')
}) })
it('fallback en toast generique si le status n est ni 409 ni 422', async () => { it('fallback en erreur globale + toast si le status n est ni 409 ni 422', async () => {
mockPost.mockRejectedValueOnce({ mockPost.mockRejectedValueOnce({
response: { status: 500, _data: { 'hydra:description': 'Boom server' } }, response: { status: 500, _data: { 'hydra:description': 'Boom server' } },
}) })
@@ -299,10 +292,9 @@ describe('useCategoryForm', () => {
await form.submitCreate() await form.submitCreate()
// Pas d'erreur inline par champ : l'erreur transverse part en toast. expect(form.errors.value._global).toBe('Boom server')
expect(form.errors).toEqual({})
expect(mockToastError).toHaveBeenCalledWith({ expect(mockToastError).toHaveBeenCalledWith({
title: 'errors.title', title: 'Erreur',
message: 'Boom server', message: 'Boom server',
}) })
}) })
@@ -378,7 +370,7 @@ describe('useCategoryForm', () => {
await form.submitUpdate(42) await form.submitUpdate(42)
expect(mockToastSuccess).toHaveBeenCalledWith({ expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'success.title', title: 'Succès',
message: 'admin.categories.toast.updated', message: 'admin.categories.toast.updated',
}) })
}) })
@@ -394,8 +386,8 @@ describe('useCategoryForm', () => {
const result = await form.submitUpdate(42) const result = await form.submitUpdate(42)
expect(result).toBeNull() expect(result).toBeNull()
expect(form.errors.name).toContain('admin.categories.toast.duplicate') expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.name).toContain('"name":"Doublon"') expect(form.errors.value.name).toContain('"name":"Doublon"')
}) })
}) })
@@ -409,7 +401,7 @@ describe('useCategoryForm', () => {
expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false }) expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false })
expect(ok).toBe(true) expect(ok).toBe(true)
expect(mockToastSuccess).toHaveBeenCalledWith({ expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'success.title', title: 'Succès',
message: 'admin.categories.toast.deleted', message: 'admin.categories.toast.deleted',
}) })
}) })
@@ -423,6 +415,7 @@ describe('useCategoryForm', () => {
const ok = await form.submitDelete(42) const ok = await form.submitDelete(42)
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.value._global).toBe('down')
expect(mockToastError).toHaveBeenCalled() expect(mockToastError).toHaveBeenCalled()
}) })
}) })
@@ -431,15 +424,15 @@ describe('useCategoryForm', () => {
it('vide le formulaire et les erreurs', () => { it('vide le formulaire et les erreurs', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.loadFrom(CAT) form.loadFrom(CAT)
form.name.value = '' form.name.value = 'edit'
form.validate() // peuple errors.name form.errors.value._global = 'erreur'
form.submitting.value = true form.submitting.value = true
form.reset() form.reset()
expect(form.name.value).toBe('') expect(form.name.value).toBe('')
expect(form.categoryTypeId.value).toBeNull() expect(form.categoryTypeId.value).toBeNull()
expect(form.errors).toEqual({}) expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
expect(form.submitting.value).toBe(false) expect(form.submitting.value).toBe(false)
}) })
}) })
@@ -12,13 +12,14 @@
* elles servent juste a eviter l'aller-retour reseau evitable. Le serveur * elles servent juste a eviter l'aller-retour reseau evitable. Le serveur
* revalide toujours (defense en profondeur). * revalide toujours (defense en profondeur).
* *
* Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les * Mapping erreurs API :
* violations 422 sont mappees par `propertyPath` (`name`, `categoryType`) ; * - 409 (RG-1.07 doublon) → toast + erreur sur le champ `name`
* l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon * - 422 (violations API Platform) → mapping sur les champs concernes
* RG-1.07) reste un cas metier specifique : erreur inline sur `name` + toast. * - autre erreur globale `_global` + toast generique
*/ */
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { Category } from '~/modules/catalog/types/category' 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 * Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici
@@ -36,9 +37,6 @@ export function useCategoryForm() {
const { t } = useI18n() const { t } = useI18n()
const toast = useToast() const toast = useToast()
// Etat d'erreurs par champ (indexe par propertyPath) + dispatch API 422.
const formErrors = useFormErrors()
// State local du formulaire — pas singleton, chaque appel a useCategoryForm // State local du formulaire — pas singleton, chaque appel a useCategoryForm
// cree son propre state (cohérent avec le pattern « un drawer = un form »). // cree son propre state (cohérent avec le pattern « un drawer = un form »).
const name = ref('') const name = ref('')
@@ -50,6 +48,16 @@ export function useCategoryForm() {
const initialName = ref('') const initialName = ref('')
const initialCategoryTypeId = ref<number | null>(null) const initialCategoryTypeId = ref<number | null>(null)
const errors = ref<{
name: string
categoryType: string
_global: string
}>({
name: '',
categoryType: '',
_global: '',
})
const submitting = ref(false) const submitting = ref(false)
const isDirty = computed( const isDirty = computed(
@@ -64,7 +72,7 @@ export function useCategoryForm() {
* erreurs et le snapshot initial pour repartir d'un etat propre. * erreurs et le snapshot initial pour repartir d'un etat propre.
*/ */
function loadFrom(category: Category | null): void { function loadFrom(category: Category | null): void {
formErrors.clearErrors() errors.value = { name: '', categoryType: '', _global: '' }
if (category) { if (category) {
name.value = category.name name.value = category.name
categoryTypeId.value = category.categoryType.id categoryTypeId.value = category.categoryType.id
@@ -84,29 +92,32 @@ export function useCategoryForm() {
* mais le serveur retrim de toute facon — pas de risque de divergence. * mais le serveur retrim de toute facon — pas de risque de divergence.
*/ */
function validate(): boolean { function validate(): boolean {
formErrors.clearErrors() errors.value = { name: '', categoryType: '', _global: '' }
const trimmedName = name.value.trim() const trimmedName = name.value.trim()
// RG-1.02 — name obligatoire (vide / whitespace-only). // RG-1.02 — name obligatoire (vide / whitespace-only).
if (trimmedName === '') { if (trimmedName === '') {
formErrors.setError('name', t('admin.categories.validation.nameRequired')) errors.value.name = t('admin.categories.validation.nameRequired')
} else if (trimmedName.length < 2 || trimmedName.length > 120) { } else if (trimmedName.length < 2 || trimmedName.length > 120) {
// RG-1.04 — longueur 2-120 apres trim. // RG-1.04 — longueur 2-120 apres trim.
formErrors.setError('name', t('admin.categories.validation.nameLength')) errors.value.name = t('admin.categories.validation.nameLength')
} }
// RG-1.05 — categoryType obligatoire. // RG-1.05 — categoryType obligatoire.
if (categoryTypeId.value === null) { if (categoryTypeId.value === null) {
formErrors.setError('categoryType', t('admin.categories.validation.typeRequired')) errors.value.categoryType = t('admin.categories.validation.typeRequired')
} }
return !formErrors.errors.name && !formErrors.errors.categoryType return errors.value.name === '' && errors.value.categoryType === ''
} }
/** /**
* Construit le payload POST a partir du state. Le `categoryType` est * Construit le payload POST a partir du state. Le `categoryType` est
* envoye en IRI Hydra (`/api/category_types/{id}`) — convention API * envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
* Platform pour referencer une ressource liee. * 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> { function buildCreatePayload(): Record<string, unknown> {
return { return {
@@ -116,24 +127,72 @@ export function useCategoryForm() {
} }
/** /**
* Traite une erreur API : 409 (doublon RG-1.07) → erreur inline sur `name` * Mappe les violations 422 d'API Platform sur les champs du formulaire.
* + toast ; sinon delegue a `useFormErrors.handleApiError` (422 mappe inline * Renvoie true des qu'au moins une violation a ete posee — false sinon
* par champ sans toast, autre → toast de fallback). Retourne true si traitee * (payload sans violations exploitables, ou tous les `propertyPath` hors
* inline (409/422 mappe), false si fallback toast. * 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 { function handleApiError(e: unknown, attemptedName: string): boolean {
const status = (e as ApiFetchError)?.response?.status const status = (e as ApiFetchError)?.response?.status
const data = (e as ApiFetchError)?.response?._data
if (status === 409) { if (status === 409) {
const duplicateMessage = t('admin.categories.toast.duplicate', { const duplicateMessage = t('admin.categories.toast.duplicate', {
name: attemptedName, name: attemptedName,
}) })
formErrors.setError('name', duplicateMessage) errors.value.name = duplicateMessage
toast.error({ title: t('errors.title'), message: duplicateMessage }) toast.error({
title: 'Erreur',
message: duplicateMessage,
})
return true return true
} }
return formErrors.handleApiError(e, { fallbackMessage: t('errors.generic') }) 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
} }
/** /**
@@ -144,13 +203,14 @@ export function useCategoryForm() {
async function submitCreate(): Promise<Category | null> { async function submitCreate(): Promise<Category | null> {
if (!validate()) return null if (!validate()) return null
submitting.value = true submitting.value = true
errors.value._global = ''
const payload = buildCreatePayload() const payload = buildCreatePayload()
try { try {
const created = await api.post<Category>('/categories', payload, { const created = await api.post<Category>('/categories', payload, {
toast: false, toast: false,
}) })
toast.success({ toast.success({
title: t('success.title'), title: 'Succès',
message: t('admin.categories.toast.created'), message: t('admin.categories.toast.created'),
}) })
return created return created
@@ -170,6 +230,7 @@ export function useCategoryForm() {
async function submitUpdate(id: number): Promise<Category | null> { async function submitUpdate(id: number): Promise<Category | null> {
if (!validate()) return null if (!validate()) return null
submitting.value = true submitting.value = true
errors.value._global = ''
const payload: Record<string, unknown> = {} const payload: Record<string, unknown> = {}
if (name.value !== initialName.value) { if (name.value !== initialName.value) {
payload.name = name.value.trim() payload.name = name.value.trim()
@@ -189,7 +250,7 @@ export function useCategoryForm() {
toast: false, toast: false,
}) })
toast.success({ toast.success({
title: t('success.title'), title: 'Succès',
message: t('admin.categories.toast.updated'), message: t('admin.categories.toast.updated'),
}) })
return updated return updated
@@ -211,11 +272,11 @@ export function useCategoryForm() {
*/ */
async function submitDelete(id: number): Promise<boolean> { async function submitDelete(id: number): Promise<boolean> {
submitting.value = true submitting.value = true
formErrors.clearErrors() errors.value._global = ''
try { try {
await api.delete(`/categories/${id}`, {}, { toast: false }) await api.delete(`/categories/${id}`, {}, { toast: false })
toast.success({ toast.success({
title: t('success.title'), title: 'Succès',
message: t('admin.categories.toast.deleted'), message: t('admin.categories.toast.deleted'),
}) })
return true return true
@@ -236,7 +297,7 @@ export function useCategoryForm() {
categoryTypeId.value = null categoryTypeId.value = null
initialName.value = '' initialName.value = ''
initialCategoryTypeId.value = null initialCategoryTypeId.value = null
formErrors.clearErrors() errors.value = { name: '', categoryType: '', _global: '' }
submitting.value = false submitting.value = false
} }
@@ -244,7 +305,7 @@ export function useCategoryForm() {
// State // State
name, name,
categoryTypeId, categoryTypeId,
errors: formErrors.errors, errors,
submitting, submitting,
isDirty, isDirty,
// Methods // Methods
@@ -10,61 +10,41 @@
@click="$emit('remove')" @click="$emit('remove')"
/> />
<!-- Usage de l'adresse : Select unique (plus simple pour l'utilisateur) <!-- Usage de l'adresse : Prospect exclusif de Livraison/Facturation
remplacant les 3 cases. Les options encodent les combinaisons valides (RG-1.06/07/08). L'exclusivite est appliquee au toggle (cocher l'un
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les decoche l'autre) plutot qu'en masquant les options. -->
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). --> <MalioCheckbox
<MalioSelect :model-value="model.isProspect"
:model-value="addressType" :label="t('commercial.clients.form.address.prospect')"
:options="addressTypeOptions" group-class="self-center"
:label="t('commercial.clients.form.address.addressType')"
:readonly="readonly" :readonly="readonly"
:required="true" @update:model-value="(v: boolean) => toggleFlag('isProspect', v)"
@update:model-value="onAddressTypeChange" />
<MalioCheckbox
:model-value="model.isDelivery"
:label="t('commercial.clients.form.address.delivery')"
group-class="self-center"
:readonly="readonly"
@update:model-value="(v: boolean) => toggleFlag('isDelivery', v)"
/>
<MalioCheckbox
:model-value="model.isBilling"
:label="t('commercial.clients.form.address.billing')"
group-class="self-center"
:readonly="readonly"
@update:model-value="(v: boolean) => toggleFlag('isBilling', v)"
/> />
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-1.10). --> <!-- Cellule vide : laisse un trou en position 4 (ligne 1) pour que
<MalioSelectCheckbox Categorie reparte au debut de la ligne suivante. -->
:model-value="model.siteIris" <div aria-hidden="true" />
:options="siteOptions"
:label="t('commercial.clients.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:required="true"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<MalioSelectCheckbox
:model-value="model.contactIris"
:options="contactOptions"
:label="t('commercial.clients.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<!-- Email de facturation : ligne 1 colonne 4, visible/obligatoire
seulement si Facturation (RG-1.11). Sinon un filler comble la
colonne pour que Categorie reparte au debut de la ligne 2. -->
<MalioInputEmail
v-if="isBillingEmailRequired(model)"
:model-value="model.billingEmail"
:label="t('commercial.clients.form.address.billingEmail')"
:required="true"
:readonly="readonly"
:lowercase="true"
:error="errors?.billingEmail"
@update:model-value="(v: string) => update('billingEmail', v)"
/>
<div v-else aria-hidden="true" />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="model.categoryIris" :model-value="model.categoryIris"
:options="categoryOptions" :options="categoryOptions"
:label="t('commercial.clients.form.address.categories')" :label="t('commercial.clients.form.address.categories')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :disabled="readonly"
:required="true"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/> />
@@ -72,8 +52,7 @@
:model-value="model.country" :model-value="model.country"
:options="countryOptions" :options="countryOptions"
:label="t('commercial.clients.form.address.country')" :label="t('commercial.clients.form.address.country')"
:readonly="readonly" :disabled="readonly"
:required="true"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))" @update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/> />
@@ -82,8 +61,6 @@
:label="t('commercial.clients.form.address.postalCode')" :label="t('commercial.clients.form.address.postalCode')"
:mask="POSTAL_CODE_MASK" :mask="POSTAL_CODE_MASK"
:readonly="readonly" :readonly="readonly"
:required="true"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
@@ -94,10 +71,8 @@
:model-value="model.city" :model-value="model.city"
:options="cityOptions" :options="cityOptions"
:label="t('commercial.clients.form.address.city')" :label="t('commercial.clients.form.address.city')"
:readonly="readonly" :disabled="readonly"
empty-option-label="" empty-option-label=""
:required="true"
:error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/> />
<MalioInputText <MalioInputText
@@ -105,8 +80,6 @@
:model-value="model.city" :model-value="model.city"
:label="t('commercial.clients.form.address.city')" :label="t('commercial.clients.form.address.city')"
:readonly="readonly" :readonly="readonly"
:required="true"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)" @update:model-value="(v: string) => update('city', v)"
/> />
@@ -126,8 +99,6 @@
:min-search-length="3" :min-search-length="3"
:label="t('commercial.clients.form.address.street')" :label="t('commercial.clients.form.address.street')"
:readonly="readonly" :readonly="readonly"
:required="true"
:error="errors?.street"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch" @search="onAddressSearch"
@select="onAddressSelect" @select="onAddressSelect"
@@ -137,8 +108,6 @@
:model-value="model.street" :model-value="model.street"
:label="t('commercial.clients.form.address.street')" :label="t('commercial.clients.form.address.street')"
:readonly="readonly" :readonly="readonly"
:required="true"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('street', v)"
/> />
</div> </div>
@@ -148,20 +117,50 @@
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')" :label="t('commercial.clients.form.address.streetComplement')"
:readonly="readonly" :readonly="readonly"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)" @update:model-value="(v: string) => update('streetComplement', v)"
/> />
</div> </div>
<!-- Sites Starseed : cases a cocher inline (>= 1 obligatoire, RG-1.10). -->
<div class="flex justify-between">
<MalioCheckbox
v-for="site in siteOptions"
:key="site.value"
:model-value="model.siteIris.includes(site.value)"
:label="site.label"
group-class="w-auto self-center"
:readonly="readonly"
@update:model-value="(v: boolean) => toggleSite(site.value, v)"
/>
</div>
<MalioSelectCheckbox
:model-value="model.contactIris"
:options="contactOptions"
:label="t('commercial.clients.form.address.contacts')"
:display-tag="true"
:disabled="readonly"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<!-- Email de facturation : visible/obligatoire seulement si Facturation
est coche (RG-1.11). -->
<MalioInputText
v-if="isBillingEmailRequired(model)"
:model-value="model.billingEmail"
:label="t('commercial.clients.form.address.billingEmail')"
:required="true"
:readonly="readonly"
@update:model-value="(v: string) => update('billingEmail', v)"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import {
addressFlagsFromType, applyProspectExclusivity,
addressTypeFromFlags,
isBillingEmailRequired, isBillingEmailRequired,
type AddressType, type AddressFlagsDraft,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/clientFormRules'
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete' import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials' import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
@@ -184,8 +183,6 @@ const props = defineProps<{
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
readonly?: boolean readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -200,29 +197,11 @@ const autocomplete = useAddressAutocomplete()
const model = computed(() => props.modelValue) const model = computed(() => props.modelValue)
// Type d'adresse (Select unique) derive des drapeaux back. null tant qu'aucun
// drapeau n'est pose -> champ vide + bouton « Valider » bloque (cf. parent).
const addressType = computed<AddressType | null>(() => addressTypeFromFlags(model.value))
const addressTypeOptions = computed<RefOption[]>(() => [
{ value: 'prospect', label: t('commercial.clients.form.address.addressTypeProspect') },
{ value: 'delivery', label: t('commercial.clients.form.address.addressTypeDelivery') },
{ value: 'billing', label: t('commercial.clients.form.address.addressTypeBilling') },
{ value: 'delivery_billing', label: t('commercial.clients.form.address.addressTypeDeliveryBilling') },
])
/** Applique le type choisi en repercutant les 3 drapeaux back (immutabilite). */
function onAddressTypeChange(value: string | number | null): void {
if (value === null) return
emit('update:modelValue', { ...props.modelValue, ...addressFlagsFromType(value as AddressType) })
}
// Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre. // Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre.
const degraded = ref(false) const degraded = ref(false)
// Villes proposees par la BAN (alimentees a la saisie du code postal). // Villes proposees par la BAN (alimentees a la saisie du code postal).
const banCityOptions = ref<RefOption[]>([]) const banCityOptions = ref<RefOption[]>([])
// Adresses proposees par la BAN (alimentees a la saisie d'adresse). const addressOptions = ref<RefOption[]>([])
const banAddressOptions = ref<RefOption[]>([])
// Options ville effectives : on garantit que la ville courante figure toujours // Options ville effectives : on garantit que la ville courante figure toujours
// dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options) // dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options)
@@ -235,20 +214,6 @@ const cityOptions = computed<RefOption[]>(() => {
} }
return banCityOptions.value return banCityOptions.value
}) })
// Meme garantie que cityOptions pour le champ Adresse : la rue courante doit
// toujours figurer dans les options, sinon MalioInputAutocomplete (qui resout
// l'affichage depuis ses options) laisse le champ VIDE des que la liste de
// suggestions BAN est vide — typiquement juste apres validation (remontage) ou
// a l'edition d'une adresse existante (1.12), alors que la valeur est bien
// persistee. On reinjecte donc la rue liee si la BAN ne l'a pas (re)proposee.
const addressOptions = computed<RefOption[]>(() => {
const current = props.modelValue.street
if (current && !banAddressOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banAddressOptions.value]
}
return banAddressOptions.value
})
const addressLoading = ref(false) const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = [] let lastAddressSuggestions: AddressSuggestion[] = []
@@ -258,6 +223,25 @@ function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDr
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
} }
/** Coche/decoche un site Starseed rattache a l'adresse (M2M par IRI, RG-1.10). */
function toggleSite(siteIri: string, selected: boolean): void {
const current = props.modelValue.siteIris
const next = selected
? [...current, siteIri]
: current.filter(iri => iri !== siteIri)
update('siteIris', next)
}
/** Applique l'exclusivite Prospect / (Livraison|Facturation) au changement. */
function toggleFlag(field: keyof AddressFlagsDraft, value: boolean): void {
const flags = applyProspectExclusivity(
{ isProspect: model.value.isProspect, isDelivery: model.value.isDelivery, isBilling: model.value.isBilling },
field,
value,
)
emit('update:modelValue', { ...props.modelValue, ...flags })
}
/** Bascule définitivement en mode degrade et previent le parent (toast unique). */ /** Bascule définitivement en mode degrade et previent le parent (toast unique). */
function enterDegraded(): void { function enterDegraded(): void {
if (!degraded.value) { if (!degraded.value) {
@@ -296,7 +280,7 @@ async function onAddressSearch(query: string): Promise<void> {
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
const suggestions = await autocomplete.searchAddress(query, postalCode) const suggestions = await autocomplete.searchAddress(query, postalCode)
lastAddressSuggestions = suggestions lastAddressSuggestions = suggestions
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label })) addressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
} }
catch { catch {
enterDegraded() enterDegraded()
@@ -16,29 +16,24 @@
:model-value="model.lastName" :model-value="model.lastName"
:label="t('commercial.clients.form.contact.lastName')" :label="t('commercial.clients.form.contact.lastName')"
:readonly="readonly" :readonly="readonly"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)" @update:model-value="(v: string) => update('lastName', v)"
/> />
<MalioInputText <MalioInputText
:model-value="model.firstName" :model-value="model.firstName"
:label="t('commercial.clients.form.contact.firstName')" :label="t('commercial.clients.form.contact.firstName')"
:readonly="readonly" :readonly="readonly"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
<MalioInputText <MalioInputText
:model-value="model.jobTitle" :model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')" :label="t('commercial.clients.form.contact.jobTitle')"
:readonly="readonly" :readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)" @update:model-value="(v: string) => update('jobTitle', v)"
/> />
<MalioInputEmail <MalioInputEmail
:model-value="model.email" :model-value="model.email"
:label="t('commercial.clients.form.contact.email')" :label="t('commercial.clients.form.contact.email')"
:readonly="readonly" :readonly="readonly"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)" @update:model-value="(v: string) => update('email', v)"
/> />
<MalioInputPhone <MalioInputPhone
@@ -46,7 +41,6 @@
:label="t('commercial.clients.form.contact.phonePrimary')" :label="t('commercial.clients.form.contact.phonePrimary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly" :addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.clients.form.contact.addPhone')" :add-button-label="t('commercial.clients.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)" @update:model-value="(v: string) => update('phonePrimary', v)"
@@ -58,7 +52,6 @@
:label="t('commercial.clients.form.contact.phoneSecondary')" :label="t('commercial.clients.form.contact.phoneSecondary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)" @update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
</div> </div>
@@ -80,8 +73,6 @@ const props = defineProps<{
removable?: boolean removable?: boolean
/** Bloc en lecture seule (onglet valide). */ /** Bloc en lecture seule (onglet valide). */
readonly?: boolean readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -0,0 +1,14 @@
<template>
<!--
Placeholder des onglets non encore implementes (Transport, Statistiques,
Rapports, Echanges). Frame vide blanche : aucun champ, aucun bouton,
aucun message « En cours » (decision Tristan 28/05). L'orchestrateur passe
automatiquement a l'onglet suivant ce composant n'est qu'une coquille
visuelle reutilisee par 1.11/1.12.
-->
<div class="min-h-[240px] rounded-md bg-white" />
</template>
<script setup lang="ts">
// Composant purement presentationnel : aucune prop, aucun event.
</script>
@@ -1,132 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyAddress } from '~/modules/commercial/types/clientForm'
import ClientAddressBlock from '../ClientAddressBlock.vue'
// Le composable BAN est mocke : aucun appel reseau, aucune suggestion chargee.
// On reproduit ainsi l'etat « adresse persistee, mais liste de suggestions
// vide » (remontage apres validation / edition d'une adresse existante).
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
useAddressAutocomplete: () => ({
searchCity: vi.fn(),
searchAddress: vi.fn(),
}),
}))
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
// Stub de MalioInputAutocomplete : expose les `value` des options recues, pour
// verifier que la rue courante figure bien dans la liste (sinon le composant
// Malio ne peut pas resoudre/afficher la valeur liee -> champ vide).
const MalioInputAutocompleteStub = defineComponent({
name: 'MalioInputAutocomplete',
props: {
modelValue: { type: [String, Number, null], default: undefined },
options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
loading: { type: Boolean, default: false },
minSearchLength: { type: Number, default: 0 },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
},
emits: ['update:modelValue', 'search', 'select'],
setup(props) {
return () => h('div', {
'data-testid': 'addr-autocomplete',
'data-options': JSON.stringify(props.options.map(o => o.value)),
})
},
})
function mountBlock(street: string | null) {
return mount(ClientAddressBlock, {
props: {
modelValue: { ...emptyAddress(), street },
title: 'Adresse',
categoryOptions: [],
siteOptions: [],
contactOptions: [],
countryOptions: [],
},
global: {
stubs: {
MalioButtonIcon: true,
MalioCheckbox: true,
MalioSelect: true,
MalioSelectCheckbox: true,
MalioInputText: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
},
},
})
}
describe('ClientAddressBlock — affichage de l\'adresse persistee', () => {
it('inclut la rue courante dans les options de l\'autocomplete meme sans recherche BAN', () => {
const wrapper = mountBlock('8 Boulevard du Port')
const el = wrapper.find('[data-testid="addr-autocomplete"]')
const values = JSON.parse(el.attributes('data-options') ?? '[]')
expect(values).toContain('8 Boulevard du Port')
})
})
/**
* Stub MalioInputText qui re-expose `label` + `error` recus : permet de cibler
* un champ par son libelle et de verifier l'erreur 422 propagee (ERP-101).
*/
const MalioInputTextProbe = defineComponent({
name: 'MalioInputTextProbe',
props: {
modelValue: { type: [String, Number, null], default: undefined },
error: { type: String, default: '' },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
},
setup(props) {
return () => h('div', {
'data-testid': 'addr-text',
'data-label': props.label,
'data-error': props.error,
})
},
})
describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
function mountWithErrors(errors: Record<string, string>) {
return mount(ClientAddressBlock, {
props: {
modelValue: emptyAddress(),
title: 'Adresse',
categoryOptions: [],
siteOptions: [],
contactOptions: [],
countryOptions: [],
errors,
},
global: {
stubs: {
MalioButtonIcon: true,
MalioCheckbox: true,
MalioSelect: true,
MalioSelectCheckbox: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
MalioInputText: MalioInputTextProbe,
},
},
})
}
it('affiche l\'erreur serveur sur le champ code postal via la prop errors', () => {
const wrapper = mountWithErrors({ postalCode: 'Code postal invalide.' })
const field = wrapper.findAll('[data-testid="addr-text"]').find(
el => el.attributes('data-label') === 'commercial.clients.form.address.postalCode',
)
expect(field?.attributes('data-error')).toBe('Code postal invalide.')
})
})
@@ -1,64 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyContact } from '~/modules/commercial/types/clientForm'
import ClientContactBlock from '../ClientContactBlock.vue'
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
/**
* Stub d'un champ Malio qui re-expose la prop `error` recue dans un attribut
* data-* : permet de verifier que le bloc propage bien `:errors[champ]` sur le
* bon champ (ERP-101 mapping erreur 422 par champ, par ligne de collection).
*/
function errorProbe(testid: string) {
return defineComponent({
name: `Probe-${testid}`,
props: {
modelValue: { type: [String, Number, null], default: undefined },
error: { type: String, default: '' },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
},
setup(props) {
return () => h('div', { 'data-testid': testid, 'data-error': props.error })
},
})
}
function mountBlock(errors?: Record<string, string>) {
return mount(ClientContactBlock, {
props: {
modelValue: emptyContact(),
title: 'Contact 1',
...(errors ? { errors } : {}),
},
global: {
stubs: {
MalioButtonIcon: true,
MalioInputPhone: true,
MalioInputText: errorProbe('contact-text'),
MalioInputEmail: errorProbe('contact-email'),
},
},
})
}
describe('ClientContactBlock — mapping erreur par champ (ERP-101)', () => {
it('affiche l\'erreur serveur sur le champ email via la prop errors', () => {
const wrapper = mountBlock({ email: 'Adresse e-mail invalide.' })
const email = wrapper.find('[data-testid="contact-email"]')
expect(email.attributes('data-error')).toBe('Adresse e-mail invalide.')
})
it('laisse les champs sans erreur quand errors est absent', () => {
const wrapper = mountBlock()
const email = wrapper.find('[data-testid="contact-email"]')
expect(email.attributes('data-error')).toBe('')
})
})
@@ -1,122 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { useClientFormErrors } from '../useClientFormErrors'
// useFormErrors (auto-import) expose l'implementation reelle ; elle consomme
// useToast + useI18n, stubbes ici.
vi.stubGlobal('useToast', () => ({ error: vi.fn(), success: vi.fn() }))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useFormErrors', useFormErrors)
/**
* Tests du composable partage `useClientFormErrors` factorisation du cablage
* d'erreurs des ecrans client (creation/edition), suggestion de revue ERP-101.
* `mapRowError` ne toaste plus : il retourne un booleen et chaque page garde son
* propre fallback (toast.error en creation, showError en edition).
*/
describe('useClientFormErrors', () => {
it('expose les 3 etats scalaires (vides) et les 3 tableaux d\'erreurs par ligne', () => {
const f = useClientFormErrors()
expect(f.mainErrors.errors).toEqual({})
expect(f.informationErrors.errors).toEqual({})
expect(f.accountingErrors.errors).toEqual({})
expect(f.contactErrors.value).toEqual([])
expect(f.addressErrors.value).toEqual([])
expect(f.ribErrors.value).toEqual([])
})
it('mapRowError mappe une 422 sur target[index] et retourne true', () => {
const f = useClientFormErrors()
const error = {
response: {
status: 422,
_data: { violations: [{ propertyPath: 'email', message: 'Adresse invalide.' }] },
},
}
const mapped = f.mapRowError(error, f.contactErrors, 0)
expect(mapped).toBe(true)
expect(f.contactErrors.value[0]).toEqual({ email: 'Adresse invalide.' })
})
it('mapRowError retourne false et ne touche pas la cible pour une erreur non-422', () => {
const f = useClientFormErrors()
const error = { response: { status: 500, _data: {} } }
expect(f.mapRowError(error, f.ribErrors, 0)).toBe(false)
expect(f.ribErrors.value[0]).toBeUndefined()
})
it('mapRowError retourne false pour une 422 sans violation exploitable', () => {
const f = useClientFormErrors()
const error = { response: { status: 422, _data: { 'hydra:description': 'Donnees invalides.' } } }
expect(f.mapRowError(error, f.addressErrors, 0)).toBe(false)
expect(f.addressErrors.value[0]).toBeUndefined()
})
})
// Construit une erreur facon useApi : 422 avec violations Hydra.
function http422(path: string, message: string) {
return { response: { status: 422, _data: { violations: [{ propertyPath: path, message }] } } }
}
/**
* `submitRows` factorise la soumission d'une collection de blocs (contacts /
* adresses / RIB) : on tente TOUS les blocs et on collecte les erreurs par index
* sans stopper au premier echec (ERP-110 / ERP-101).
*/
describe('useClientFormErrors.submitRows', () => {
it('tente TOUS les blocs et mappe les erreurs par index, sans stopper au premier echec', async () => {
const { contactErrors, submitRows } = useClientFormErrors()
const seen: number[] = []
const onUnmapped = vi.fn()
const saveRow = async (_row: unknown, index: number) => {
seen.push(index)
if (index === 1) throw http422('email', 'Email invalide')
}
const hasError = await submitRows(
[{ a: 0 }, { a: 1 }, { a: 2 }],
contactErrors,
saveRow,
onUnmapped,
)
expect(seen).toEqual([0, 1, 2]) // tous les blocs tentes
expect(hasError).toBe(true)
expect(contactErrors.value[1]).toEqual({ email: 'Email invalide' })
expect(contactErrors.value[0]).toBeUndefined()
expect(onUnmapped).not.toHaveBeenCalled() // 422 mappee, pas de fallback
})
it('delegue le fallback onUnmappedError pour une erreur non mappable et marque hasError', async () => {
const { ribErrors, submitRows } = useClientFormErrors()
const onUnmapped = vi.fn()
const hasError = await submitRows(
[{ a: 0 }],
ribErrors,
async () => { throw { response: { status: 500, _data: {} } } },
onUnmapped,
)
expect(hasError).toBe(true)
expect(onUnmapped).toHaveBeenCalledTimes(1)
expect(ribErrors.value[0]).toBeUndefined()
})
it('saute les lignes filtrees par shouldSkip et renvoie false si tout passe', async () => {
const { contactErrors, submitRows } = useClientFormErrors()
const saved: number[] = []
const hasError = await submitRows(
[{ skip: true }, { skip: false }],
contactErrors,
async (_row, index) => { saved.push(index) },
vi.fn(),
(row: { skip: boolean }) => row.skip,
)
expect(saved).toEqual([1])
expect(hasError).toBe(false)
})
})
@@ -28,7 +28,7 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
return Promise.reject(new Error('403 Forbidden')) return Promise.reject(new Error('403 Forbidden'))
} }
if (url === '/sites') { if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] }) return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
} }
return Promise.resolve({ return Promise.resolve({
member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }], member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }],
@@ -40,8 +40,7 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
await refs.loadCommon() await refs.loadCommon()
// Resilience : les referentiels OK sont peuples malgre l'echec de /categories. // Resilience : les referentiels OK sont peuples malgre l'echec de /categories.
// Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal). expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }])
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }]) expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }]) expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
@@ -57,7 +56,7 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
}) })
} }
if (url === '/sites') { if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] }) return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
} }
return Promise.resolve({ member: [] }) return Promise.resolve({ member: [] })
}) })
@@ -68,7 +67,6 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
expect(refs.categories.value).toEqual([ expect(refs.categories.value).toEqual([
{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' }, { value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' },
]) ])
// Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal). expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }])
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
}) })
}) })
@@ -1,94 +0,0 @@
/**
* Composable d'erreurs partage des ecrans client (creation + edition, M1
* Commercial). Factorise le cablage identique entre `clients/new.vue` et
* `clients/[id]/edit.vue` (suggestion de revue ERP-101) :
* - un `useFormErrors` par groupe scalaire (Principal / Information /
* Comptabilite) : violations 422 affichees inline sous chaque champ ;
* - un tableau d'erreurs PAR LIGNE pour chaque collection (contacts /
* adresses / RIB), aligne sur l'index du `v-for`.
*
* `mapRowError` ne toaste PAS lui-meme : il retourne un booleen (true = mappe
* inline). Chaque page conserve ainsi son propre fallback dans le `catch`
* (toast generique en creation, `showError` en edition) sans imposer un
* comportement commun.
*/
import { ref, type Ref } from 'vue'
import { mapViolationsToRecord } from '~/shared/utils/api'
export function useClientFormErrors() {
const mainErrors = useFormErrors()
const informationErrors = useFormErrors()
const accountingErrors = useFormErrors()
const contactErrors = ref<Record<string, string>[]>([])
const addressErrors = ref<Record<string, string>[]>([])
const ribErrors = ref<Record<string, string>[]>([])
/**
* Mappe l'erreur d'une ligne de collection sur le tableau cible (par index).
* 422 avec violations exploitables erreurs inline sous les champs de la
* ligne + retourne true. Sinon ne touche pas la cible et retourne false
* (le caller decide du fallback toast).
*/
function mapRowError(
error: unknown,
target: Ref<Record<string, string>[]>,
index: number,
): boolean {
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
if (Object.keys(mapped).length > 0) {
target.value[index] = mapped
return true
}
return false
}
/**
* Soumet TOUS les blocs d'une collection (contacts / adresses / RIB) en
* collectant les erreurs par index : on n'arrete PAS au premier bloc en echec
* (decision ERP-110 / ERP-101). Reinitialise le tableau d'erreurs cible, tente
* chaque ligne via `saveRow`, mappe les 422 inline (mapRowError) ou delegue le
* fallback a `onUnmappedError`. `shouldSkip` permet d'ignorer les blocs vides
* (non remplis). Retourne true si au moins un bloc a echoue (le caller ne valide
* alors pas l'onglet et n'affiche pas de toast succes).
*/
async function submitRows<T>(
rows: T[],
target: Ref<Record<string, string>[]>,
saveRow: (row: T, index: number) => Promise<void>,
onUnmappedError: (error: unknown, index: number) => void,
shouldSkip?: (row: T, index: number) => boolean,
): Promise<boolean> {
target.value = []
let hasError = false
for (let index = 0; index < rows.length; index++) {
// L'index reste borne par rows.length : la ligne existe forcement.
const row = rows[index] as T
if (shouldSkip?.(row, index)) {
continue
}
try {
await saveRow(row, index)
}
catch (error) {
if (!mapRowError(error, target, index)) {
onUnmappedError(error, index)
}
hasError = true
}
}
return hasError
}
return {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
mapRowError,
submitRows,
}
}
@@ -45,7 +45,6 @@ interface CategoryMember extends HydraMember {
interface SiteMember extends HydraMember { interface SiteMember extends HydraMember {
name: string name: string
postalCode: string
} }
interface ReferentialMember extends HydraMember { interface ReferentialMember extends HydraMember {
@@ -102,10 +101,7 @@ export function useClientReferentials() {
fetchAll<CategoryMember>('/categories') fetchAll<CategoryMember>('/categories')
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites') fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name })) }),
// postal du site), ex: 86100 -> « 86 ». Le code postal est deja
// expose par /sites (groupe site:read) — aucune colonne a ajouter.
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
fetchAll<ReferentialMember>('/tva_modes') fetchAll<ReferentialMember>('/tva_modes')
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }), .then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
fetchAll<ReferentialMember>('/payment_delays') fetchAll<ReferentialMember>('/payment_delays')
@@ -28,24 +28,54 @@
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
:required="true" :required="true"
:readonly="businessReadonly" :readonly="businessReadonly"
:error="mainErrors.errors.companyName" />
<MalioInputText
v-model="main.lastName"
:label="t('commercial.clients.form.main.lastName')"
:readonly="businessReadonly"
/>
<MalioInputText
v-model="main.firstName"
:label="t('commercial.clients.form.main.firstName')"
:readonly="businessReadonly"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="main.categoryIris" :model-value="main.categoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
:readonly="businessReadonly" :disabled="businessReadonly"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/> />
<MalioInputPhone
v-model="main.phonePrimary"
:label="t('commercial.clients.form.main.phonePrimary')"
:mask="PHONE_MASK"
:required="true"
:readonly="businessReadonly"
add-icon-name="mdi:plus"
:addable="!main.hasSecondaryPhone && !businessReadonly"
:add-button-label="t('commercial.clients.form.main.addPhone')"
@add="main.hasSecondaryPhone = true"
/>
<MalioInputPhone
v-if="main.hasSecondaryPhone"
v-model="main.phoneSecondary"
:label="t('commercial.clients.form.main.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="businessReadonly"
/>
<MalioInputEmail
v-model="main.email"
:label="t('commercial.clients.form.main.email')"
:required="true"
:readonly="businessReadonly"
/>
<MalioSelect <MalioSelect
:model-value="main.relationType" :model-value="main.relationType"
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')" :disabled="businessReadonly"
:readonly="businessReadonly"
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
@@ -53,9 +83,7 @@
:model-value="main.brokerIri" :model-value="main.brokerIri"
:options="brokerOptions" :options="brokerOptions"
:label="t('commercial.clients.form.main.brokerName')" :label="t('commercial.clients.form.main.brokerName')"
:readonly="businessReadonly" :disabled="businessReadonly"
:required="true"
:error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/> />
<MalioSelect <MalioSelect
@@ -63,9 +91,7 @@
:model-value="main.distributorIri" :model-value="main.distributorIri"
:options="distributorOptions" :options="distributorOptions"
:label="t('commercial.clients.form.main.distributorName')" :label="t('commercial.clients.form.main.distributorName')"
:readonly="businessReadonly" :disabled="businessReadonly"
:required="true"
:error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/> />
<MalioCheckbox <MalioCheckbox
@@ -86,7 +112,7 @@
</div> </div>
<!-- Onglets : navigation LIBRE, edition independante par onglet --> <!-- Onglets : navigation LIBRE, edition independante par onglet -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
@@ -96,45 +122,38 @@
resize="none" resize="none"
group-class="row-span-2 pt-1" group-class="row-span-2 pt-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:label="t('commercial.clients.form.information.competitors')" :label="t('commercial.clients.form.information.competitors')"
:readonly="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.competitors"
/> />
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
:readonly="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.foundedAt"
/> />
<MalioInputText <MalioInputText
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')" :label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:readonly="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.employeesCount"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.revenueAmount" v-model="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')" :label="t('commercial.clients.form.information.revenueAmount')"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.revenueAmount"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:label="t('commercial.clients.form.information.directorName')" :label="t('commercial.clients.form.information.directorName')"
:readonly="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.directorName"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')" :label="t('commercial.clients.form.information.profitAmount')"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.profitAmount"
/> />
</div> </div>
<div v-if="!businessReadonly" class="mt-12 flex justify-center"> <div v-if="!businessReadonly" class="mt-12 flex justify-center">
@@ -157,10 +176,12 @@
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="contacts.length > 1" :removable="contacts.length > 1"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
/> />
<p v-if="contacts.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.edit.emptyContacts') }}
</p>
<div v-if="!businessReadonly" class="flex justify-center gap-6"> <div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton <MalioButton
variant="secondary" variant="secondary"
@@ -194,11 +215,13 @@
:country-options="countryOptions" :country-options="countryOptions"
:removable="addresses.length > 1" :removable="addresses.length > 1"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@degraded="onAddressDegraded" @degraded="onAddressDegraded"
/> />
<p v-if="addresses.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.edit.emptyAddresses') }}
</p>
<div v-if="!businessReadonly" class="flex justify-center gap-6"> <div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton <MalioButton
variant="secondary" variant="secondary"
@@ -222,57 +245,45 @@
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')" :label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="tvaModeOptions" :options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/> />
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')" :label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions" :options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions" :options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange" @update:model-value="onPaymentTypeChange"
/> />
<MalioSelect <MalioSelect
@@ -280,10 +291,8 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="bankOptions" :options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/> />
</div> </div>
@@ -303,27 +312,21 @@
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }" v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)" @click="askRemoveRib(index)"
/> />
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')" :label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')" :label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
/> />
</div> </div>
</div> </div>
@@ -347,10 +350,10 @@
</template> </template>
<!-- Onglets non encore implementes : frame vide (navigation libre). --> <!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template> <template #transport><TabPlaceholderBlank /></template>
<template #statistics><ComingSoonPlaceholder /></template> <template #statistics><TabPlaceholderBlank /></template>
<template #reports><ComingSoonPlaceholder /></template> <template #reports><TabPlaceholderBlank /></template>
<template #exchanges><ComingSoonPlaceholder /></template> <template #exchanges><TabPlaceholderBlank /></template>
</MalioTabList> </MalioTabList>
</template> </template>
@@ -382,7 +385,6 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient' import { useClient } from '~/modules/commercial/composables/useClient'
import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials' import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import { import {
canEditClient, canEditClient,
categoryOptionsOf, categoryOptionsOf,
@@ -410,15 +412,11 @@ import {
type MainFormDraft, type MainFormDraft,
} from '~/modules/commercial/utils/clientEdit' } from '~/modules/commercial/utils/clientEdit'
import { import {
addressTypeFromFlags,
buildClientFormTabKeys, buildClientFormTabKeys,
hasAllRequiredAccountingFields,
hasAtLeastOneValidContact, hasAtLeastOneValidContact,
isBankRequiredForPaymentType, isBankRequiredForPaymentType,
isBillingEmailRequired, isBillingEmailRequired,
isContactBlank,
isContactNamed, isContactNamed,
isRibBlank,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/clientFormRules'
import { import {
@@ -432,6 +430,7 @@ import {
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
const EMPLOYEES_MASK = '#######' const EMPLOYEES_MASK = '#######'
@@ -496,11 +495,6 @@ function hydrate(detail: ClientDetail): void {
contacts.value = (detail.contacts ?? []).map(mapContactToDraft) contacts.value = (detail.contacts ?? []).map(mapContactToDraft)
addresses.value = (detail.addresses ?? []).map(mapAddressToDraft) addresses.value = (detail.addresses ?? []).map(mapAddressToDraft)
ribs.value = (detail.ribs ?? []).map(mapRibToDraft) ribs.value = (detail.ribs ?? []).map(mapRibToDraft)
// Chaque bloc reste visible meme vide : si une collection est vide, on amorce
// un bloc vierge (non persiste tant qu'incomplet cf. submit*/canValidate*).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
if (ribs.value.length === 0) ribs.value.push(emptyRib())
// Charge les listes distributeur / courtier si une relation est deja posee. // Charge les listes distributeur / courtier si une relation est deja posee.
if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {}) if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {})
if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {}) if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {})
@@ -619,22 +613,6 @@ function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void
}) })
} }
// Erreurs de validation par champ (ERP-101)
// Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) :
// un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour
// chaque collection (aligne sur l'index visible). `mapRowError` mappe une 422
// inline et retourne true ; il ne toaste pas, le fallback `showError` reste
// local a l'edition (cf. catch des submits de collection).
const {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
submitRows,
} = useClientFormErrors()
// Bloc principal // Bloc principal
const isMainValid = computed(() => { const isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== '' const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
@@ -643,6 +621,9 @@ const isMainValid = computed(() => {
|| (main.relationType === 'distributeur' && filled(main.distributorIri)) || (main.relationType === 'distributeur' && filled(main.distributorIri))
|| (main.relationType === 'courtier' && filled(main.brokerIri)) || (main.relationType === 'courtier' && filled(main.brokerIri))
return filled(main.companyName) return filled(main.companyName)
&& filled(main.email)
&& filled(main.phonePrimary)
&& (filled(main.firstName) || filled(main.lastName))
&& main.categoryIris.length >= 1 && main.categoryIris.length >= 1
&& relationValid && relationValid
}) })
@@ -662,7 +643,6 @@ async function onRelationChange(value: string | number | null): Promise<void> {
async function submitMain(): Promise<void> { async function submitMain(): Promise<void> {
if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return
mainSubmitting.value = true mainSubmitting.value = true
mainErrors.clearErrors()
try { try {
const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main), { const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main), {
headers: { Accept: 'application/ld+json' }, headers: { Accept: 'application/ld+json' },
@@ -673,17 +653,7 @@ async function submitMain(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { catch (e) {
// 409 = doublon nom de societe erreur inline + toast ; 422 mapping showError(e, { duplicateCompany: true })
// inline par champ ; autre toast de fallback. Cf. ERP-101.
const status = (e as { response?: { status?: number } })?.response?.status
if (status === 409) {
const message = t('commercial.clients.form.duplicateCompany')
mainErrors.setError('companyName', message)
toast.error({ title: t('commercial.clients.toast.error'), message })
}
else {
mainErrors.handleApiError(e, { fallbackMessage: t('commercial.clients.toast.error') })
}
} }
finally { finally {
mainSubmitting.value = false mainSubmitting.value = false
@@ -695,13 +665,12 @@ async function submitMain(): Promise<void> {
async function submitInformation(): Promise<void> { async function submitInformation(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
informationErrors.clearErrors()
try { try {
await api.patch(`/clients/${clientId}`, buildInformationPayload(information), { toast: false }) await api.patch(`/clients/${clientId}`, buildInformationPayload(information), { toast: false })
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { catch (e) {
informationErrors.handleApiError(e, { fallbackMessage: t('commercial.clients.toast.error') }) showError(e)
} }
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
@@ -725,9 +694,6 @@ function askRemoveContact(index: number): void {
const removed = contacts.value[index] const removed = contacts.value[index]
if (removed?.id != null) removedContactIds.value.push(removed.id) if (removed?.id != null) removedContactIds.value.push(removed.id)
contacts.value.splice(index, 1) contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
}) })
} }
@@ -739,42 +705,28 @@ function askRemoveContact(index: number): void {
async function submitContacts(): Promise<void> { async function submitContacts(): Promise<void> {
if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
contactErrors.value = []
try { try {
for (const id of removedContactIds.value) { for (const id of removedContactIds.value) {
await api.delete(`/client_contacts/${id}`, {}, { toast: false }) await api.delete(`/client_contacts/${id}`, {}, { toast: false })
} }
removedContactIds.value = [] removedContactIds.value = []
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls for (const contact of contacts.value) {
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli if (!isContactNamed(contact)) continue
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc. const body = buildContactPayload(contact)
const hasError = await submitRows( if (contact.id === null) {
contacts.value, const created = await api.post<{ '@id'?: string, id: number }>(
contactErrors, `/clients/${clientId}/contacts`,
async (contact) => { body,
const body = buildContactPayload(contact) { headers: { Accept: 'application/ld+json' }, toast: false },
if (contact.id === null) { )
const created = await api.post<{ '@id'?: string, id: number }>( contact.id = created.id
`/clients/${clientId}/contacts`, contact.iri = created['@id'] ?? null
body, }
{ headers: { Accept: 'application/ld+json' }, toast: false }, else {
) await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
contact.id = created.id }
contact.iri = created['@id'] ?? null }
}
else {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
}
},
error => showError(error),
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
// bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif
// serait perdue en silence avec un faux toast de succes).
contact => contact.id === null && isContactBlank(contact),
)
// Tant qu'un bloc reste en erreur : pas de toast succes.
if (hasError) return
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { catch (e) {
@@ -790,10 +742,7 @@ const canValidateAddresses = computed(() =>
addresses.value.length > 0 addresses.value.length > 0
&& addresses.value.every((a) => { && addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== '' const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return addressTypeFromFlags(a) !== null return a.siteIris.length >= 1 && (!isBillingEmailRequired(a) || filledBillingEmail)
&& a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}), }),
) )
@@ -806,9 +755,6 @@ function askRemoveAddress(index: number): void {
const removed = addresses.value[index] const removed = addresses.value[index]
if (removed?.id != null) removedAddressIds.value.push(removed.id) if (removed?.id != null) removedAddressIds.value.push(removed.id)
addresses.value.splice(index, 1) addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
}) })
} }
@@ -825,34 +771,26 @@ function onAddressDegraded(): void {
async function submitAddresses(): Promise<void> { async function submitAddresses(): Promise<void> {
if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
addressErrors.value = []
try { try {
for (const id of removedAddressIds.value) { for (const id of removedAddressIds.value) {
await api.delete(`/client_addresses/${id}`, {}, { toast: false }) await api.delete(`/client_addresses/${id}`, {}, { toast: false })
} }
removedAddressIds.value = [] removedAddressIds.value = []
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110). for (const address of addresses.value) {
const hasError = await submitRows( const body = buildAddressPayload(address, isBillingEmailRequired(address))
addresses.value, if (address.id === null) {
addressErrors, const created = await api.post<{ id: number }>(
async (address) => { `/clients/${clientId}/addresses`,
const body = buildAddressPayload(address, isBillingEmailRequired(address)) body,
if (address.id === null) { { headers: { Accept: 'application/ld+json' }, toast: false },
const created = await api.post<{ id: number }>( )
`/clients/${clientId}/addresses`, address.id = created.id
body, }
{ headers: { Accept: 'application/ld+json' }, toast: false }, else {
) await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
address.id = created.id }
} }
else {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
}
},
error => showError(error),
)
if (hasError) return
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { catch (e) {
@@ -881,7 +819,6 @@ function ribIsComplete(rib: { label: string | null, bic: string | null, iban: st
} }
const canValidateAccounting = computed(() => { const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && accounting.bankIri === null) return false if (isBankRequired.value && accounting.bankIri === null) return false
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
return true return true
@@ -896,9 +833,6 @@ function askRemoveRib(index: number): void {
const removed = ribs.value[index] const removed = ribs.value[index]
if (removed?.id != null) removedRibIds.value.push(removed.id) if (removed?.id != null) removedRibIds.value.push(removed.id)
ribs.value.splice(index, 1) ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}) })
} }
@@ -911,53 +845,29 @@ function askRemoveRib(index: number): void {
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors()
// Reset des erreurs RIB des le debut : l'etape 1 (PATCH scalaires) peut
// echouer et `return` avant submitRows (qui porte sinon le reset), laissant
// des erreurs de RIB obsoletes affichees sous les blocs.
ribErrors.value = []
try { try {
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs). await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
try {
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
for (const id of removedRibIds.value) { for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false }) await api.delete(`/client_ribs/${id}`, {}, { toast: false })
} }
removedRibIds.value = [] removedRibIds.value = []
// 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes). for (const rib of ribs.value) {
// Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex. if (!ribIsComplete(rib)) continue
// IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline. const body = buildRibPayload(rib)
const ribHasError = await submitRows( if (rib.id === null) {
ribs.value, const created = await api.post<{ id: number }>(
ribErrors, `/clients/${clientId}/ribs`,
async (rib) => { body,
const body = buildRibPayload(rib) { headers: { Accept: 'application/ld+json' }, toast: false },
if (rib.id === null) { )
const created = await api.post<{ id: number }>( rib.id = created.id
`/clients/${clientId}/ribs`, }
body, else {
{ headers: { Accept: 'application/ld+json' }, toast: false }, await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
) }
rib.id = created.id }
}
else {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}
},
error => showError(error),
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
// RIB existant vide est soumis -> 422 NotBlank inline (sinon la modif
// serait perdue en silence avec un faux toast de succes).
rib => rib.id === null && isRibBlank(rib),
)
if (ribHasError) return
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { catch (e) {
@@ -52,23 +52,43 @@
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
readonly readonly
/> />
<MalioInputText
:model-value="client.lastName"
:label="t('commercial.clients.form.main.lastName')"
readonly
/>
<MalioInputText
:model-value="client.firstName"
:label="t('commercial.clients.form.main.firstName')"
readonly
/>
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="categoryIris" :model-value="categoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
disabled
/>
<MalioInputPhone
v-for="(phone, index) in mainPhones"
:key="index"
:model-value="phone"
:label="index === 0 ? t('commercial.clients.form.main.phonePrimary') : t('commercial.clients.form.main.phoneSecondary')"
:mask="PHONE_MASK"
readonly
/>
<MalioInputEmail
:model-value="client.email"
:label="t('commercial.clients.form.main.email')"
readonly readonly
/> />
<!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
<MalioSelect <MalioSelect
v-if="relation.type"
:model-value="relation.type" :model-value="relation.type"
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')" disabled
readonly
/> />
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
aucune valeur sans relation meme comportement qu'en edition). -->
<MalioInputText <MalioInputText
v-if="relation.type" v-if="relation.type"
:model-value="relation.name" :model-value="relation.name"
@@ -84,7 +104,7 @@
</div> </div>
<!-- Onglets (navigation libre, tout en lecture seule) --> <!-- Onglets (navigation libre, tout en lecture seule) -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
@@ -94,7 +114,7 @@
resize="none" resize="none"
group-class="row-span-2 pt-1" group-class="row-span-2 pt-1"
text-input="h-full text-lg" text-input="h-full text-lg"
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="information.competitors" :model-value="information.competitors"
@@ -114,7 +134,7 @@
<MalioInputAmount <MalioInputAmount
:model-value="information.revenueAmount" :model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')" :label="t('commercial.clients.form.information.revenueAmount')"
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="information.directorName" :model-value="information.directorName"
@@ -124,7 +144,7 @@
<MalioInputAmount <MalioInputAmount
:model-value="information.profitAmount" :model-value="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')" :label="t('commercial.clients.form.information.profitAmount')"
readonly disabled
/> />
</div> </div>
</template> </template>
@@ -139,6 +159,9 @@
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
readonly readonly
/> />
<p v-if="contacts.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.consultation.emptyContacts') }}
</p>
</div> </div>
</template> </template>
@@ -151,11 +174,14 @@
:model-value="view.draft" :model-value="view.draft"
:title="t('commercial.clients.form.address.title', { n: index + 1 })" :title="t('commercial.clients.form.address.title', { n: index + 1 })"
:category-options="view.categoryOptions" :category-options="view.categoryOptions"
:site-options="allSiteOptions" :site-options="view.siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
readonly readonly
/> />
<p v-if="addressViews.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.consultation.emptyAddresses') }}
</p>
</div> </div>
</template> </template>
@@ -163,7 +189,7 @@
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText <MalioInputText
:model-value="accounting.siren" :model-value="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
@@ -180,7 +206,7 @@
:options="tvaModeOptions" :options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
empty-option-label="" empty-option-label=""
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="accounting.nTva" :model-value="accounting.nTva"
@@ -192,14 +218,14 @@
:options="paymentDelayOptions" :options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
empty-option-label="" empty-option-label=""
readonly disabled
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions" :options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
empty-option-label="" empty-option-label=""
readonly disabled
/> />
<MalioSelect <MalioSelect
v-if="accounting.bankIri" v-if="accounting.bankIri"
@@ -207,7 +233,7 @@
:options="bankOptions" :options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
empty-option-label="" empty-option-label=""
readonly disabled
/> />
</div> </div>
</div> </div>
@@ -218,7 +244,7 @@
:key="rib.id ?? index" :key="rib.id ?? index"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText <MalioInputText
:model-value="rib.label" :model-value="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
@@ -240,10 +266,10 @@
</template> </template>
<!-- Onglets non encore implementes : frame vide (navigation libre). --> <!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template> <template #transport><TabPlaceholderBlank /></template>
<template #statistics><ComingSoonPlaceholder /></template> <template #statistics><TabPlaceholderBlank /></template>
<template #reports><ComingSoonPlaceholder /></template> <template #reports><TabPlaceholderBlank /></template>
<template #exchanges><ComingSoonPlaceholder /></template> <template #exchanges><TabPlaceholderBlank /></template>
</MalioTabList> </MalioTabList>
</template> </template>
@@ -293,9 +319,10 @@ import {
type ClientDetail, type ClientDetail,
type SelectOption, type SelectOption,
} from '~/modules/commercial/utils/clientConsultation' } from '~/modules/commercial/utils/clientConsultation'
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/clientForm' import { formatPhoneFR } from '~/shared/utils/phone'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur). // Masques d'affichage (purement visuels, la donnee reste celle du serveur).
const PHONE_MASK = '## ## ## ## ##'
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
const { t } = useI18n() const { t } = useI18n()
@@ -303,7 +330,6 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
const { can, canAny } = usePermissions() const { can, canAny } = usePermissions()
const authStore = useAuthStore()
// Gating de la route : la consultation exige `view`. Usine (sans view) est // Gating de la route : la consultation exige `view`. Usine (sans view) est
// redirige vers le repertoire (lui-meme protege). Cf. matrice § 2.7. // redirige vers le repertoire (lui-meme protege). Cf. matrice § 2.7.
@@ -328,6 +354,13 @@ const headerTitle = computed(() => client.value?.companyName ?? t('commercial.cl
const relation = computed(() => (client.value ? relationOf(client.value) : { type: null, name: null })) const relation = computed(() => (client.value ? relationOf(client.value) : { type: null, name: null }))
const categoryIris = computed(() => (client.value?.categories ?? []).map(c => c['@id'])) const categoryIris = computed(() => (client.value?.categories ?? []).map(c => c['@id']))
// Telephones du formulaire principal, formates XX XX XX XX XX (RG d'affichage).
const mainPhones = computed(() =>
[client.value?.phonePrimary, client.value?.phoneSecondary]
.filter((p): p is string => Boolean(p))
.map(formatPhoneFR),
)
const information = computed(() => ({ const information = computed(() => ({
description: client.value?.description ?? null, description: client.value?.description ?? null,
competitors: client.value?.competitors ?? null, competitors: client.value?.competitors ?? null,
@@ -339,21 +372,10 @@ const information = computed(() => ({
directorName: client.value?.directorName ?? null, directorName: client.value?.directorName ?? null,
})) }))
// Chaque bloc reste visible meme vide en consultation : si la collection est const contacts = computed(() => (client.value?.contacts ?? []).map(mapContactToDraft))
// vide, on affiche un bloc vierge en lecture seule (pas de message « Aucun »).
const contacts = computed(() => {
const list = (client.value?.contacts ?? []).map(mapContactToDraft)
return list.length ? list : [emptyContact()]
})
// Vue par adresse : brouillon + options (sites/categories) propres a l'adresse. // Vue par adresse : brouillon + options (sites/categories) propres a l'adresse.
const addressViews = computed(() => { const addressViews = computed(() => (client.value?.addresses ?? []).map(mapAddressView))
const views = (client.value?.addresses ?? []).map(mapAddressView) const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft))
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
})
const ribs = computed(() => {
const list = (client.value?.ribs ?? []).map(mapRibToDraft)
return list.length ? list : [emptyRib()]
})
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view). // Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail))) const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
@@ -363,18 +385,6 @@ const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as Clie
const mainCategoryOptions = computed(() => categoryOptionsOf(client.value?.categories)) const mainCategoryOptions = computed(() => categoryOptionsOf(client.value?.categories))
const contactOptions = computed(() => contactOptionsOf(client.value?.contacts)) const contactOptions = computed(() => contactOptionsOf(client.value?.contacts))
// Liste COMPLETE des sites disponibles, issue de /api/me (groupe me:read donc
// pas de 403 pour les roles metier, contrairement a GET /sites). Libelle = numero
// de departement (2 premiers chiffres du code postal). Permet d'afficher TOUJOURS
// toutes les cases « Sites » (86 / 17 / 82) dans le bloc adresse, meme celles non
// rattachees a l'adresse consultee (les rattachees restent cochees via siteIris).
const allSiteOptions = computed<SelectOption[]>(() =>
(authStore.user?.sites ?? []).map(s => ({
value: `/api/sites/${s.id}`,
label: (s.postalCode ?? '').slice(0, 2),
})),
)
const relationOptions = computed<SelectOption[]>(() => [ const relationOptions = computed<SelectOption[]>(() => [
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') }, { value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') }, { value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
+195 -229
View File
@@ -22,24 +22,50 @@
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
:required="true" :required="true"
:readonly="mainLocked" :readonly="mainLocked"
:error="mainErrors.errors.companyName" />
<MalioInputText
v-model="main.lastName"
:label="t('commercial.clients.form.main.lastName')"
:readonly="mainLocked"
/>
<MalioInputText
v-model="main.firstName"
:label="t('commercial.clients.form.main.firstName')"
:readonly="mainLocked"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="main.categoryIris" :model-value="main.categoryIris"
:options="referentials.categories.value" :options="referentials.categories.value"
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
:readonly="mainLocked" :disabled="mainLocked"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/> />
<!-- Telephones : 1 par defaut, le bouton « + » revele le 2e (max 2, RG-1.02). -->
<MalioInputPhone
v-for="(_, index) in mainPhones"
:key="index"
v-model="mainPhones[index]"
:label="t('commercial.clients.form.main.phonePrimary')"
:mask="PHONE_MASK"
:required="index === 0"
:readonly="mainLocked"
add-icon-name="mdi:plus"
:addable="mainPhones.length === 1 && !mainLocked"
:add-button-label="t('commercial.clients.form.main.addPhone')"
@add="addMainPhone"
/>
<MalioInputEmail
v-model="main.email"
:label="t('commercial.clients.form.main.email')"
:required="true"
:readonly="mainLocked"
/>
<MalioSelect <MalioSelect
:model-value="main.relationType" :model-value="main.relationType"
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')" :disabled="mainLocked"
:readonly="mainLocked"
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
@@ -47,9 +73,7 @@
:model-value="main.brokerIri" :model-value="main.brokerIri"
:options="referentials.brokers.value" :options="referentials.brokers.value"
:label="t('commercial.clients.form.main.brokerName')" :label="t('commercial.clients.form.main.brokerName')"
:readonly="mainLocked" :disabled="mainLocked"
:required="true"
:error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/> />
<MalioSelect <MalioSelect
@@ -57,9 +81,7 @@
:model-value="main.distributorIri" :model-value="main.distributorIri"
:options="referentials.distributors.value" :options="referentials.distributors.value"
:label="t('commercial.clients.form.main.distributorName')" :label="t('commercial.clients.form.main.distributorName')"
:readonly="mainLocked" :disabled="mainLocked"
:required="true"
:error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/> />
<MalioCheckbox <MalioCheckbox
@@ -92,45 +114,38 @@
resize="none" resize="none"
group-class="row-span-2 pt-1" group-class="row-span-2 pt-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:label="t('commercial.clients.form.information.competitors')" :label="t('commercial.clients.form.information.competitors')"
:readonly="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.competitors"
/> />
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
:readonly="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.foundedAt"
/> />
<MalioInputText <MalioInputText
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')" :label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:readonly="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.employeesCount"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.revenueAmount" v-model="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')" :label="t('commercial.clients.form.information.revenueAmount')"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.revenueAmount"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:label="t('commercial.clients.form.information.directorName')" :label="t('commercial.clients.form.information.directorName')"
:readonly="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.directorName"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')" :label="t('commercial.clients.form.information.profitAmount')"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.profitAmount"
/> />
</div> </div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center"> <div v-if="!isValidated('information')" class="mt-12 flex justify-center">
@@ -156,7 +171,6 @@
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="index > 0" :removable="index > 0"
:readonly="isValidated('contact')" :readonly="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
/> />
@@ -193,7 +207,6 @@
:country-options="countryOptions" :country-options="countryOptions"
:removable="index > 0" :removable="index > 0"
:readonly="isValidated('address')" :readonly="isValidated('address')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@degraded="onAddressDegraded" @degraded="onAddressDegraded"
@@ -220,57 +233,45 @@
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')" :label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value" :options="referentials.tvaModes.value"
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/> />
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')" :label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value" :options="referentials.paymentDelays.value"
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value" :options="referentials.paymentTypes.value"
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange" @update:model-value="onPaymentTypeChange"
/> />
<MalioSelect <MalioSelect
@@ -278,10 +279,8 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="referentials.banks.value" :options="referentials.banks.value"
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/> />
</div> </div>
@@ -302,27 +301,21 @@
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }" v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)" @click="askRemoveRib(index)"
/> />
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')" :label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')" :label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
/> />
</div> </div>
</div> </div>
@@ -348,7 +341,7 @@
<!-- Onglet non encore implemente : frame vide, passage automatique. <!-- Onglet non encore implemente : frame vide, passage automatique.
Statistiques / Rapports / Echanges sont edit-only (absents a la Statistiques / Rapports / Echanges sont edit-only (absents a la
creation) cf. buildClientFormTabKeys. --> creation) cf. buildClientFormTabKeys. -->
<template #transport><ComingSoonPlaceholder /></template> <template #transport><TabPlaceholderBlank /></template>
</MalioTabList> </MalioTabList>
<!-- Modal de confirmation generique (suppression contact/adresse/RIB). --> <!-- Modal de confirmation generique (suppression contact/adresse/RIB). -->
@@ -378,18 +371,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials' import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import { import {
addressTypeFromFlags,
buildClientFormTabKeys, buildClientFormTabKeys,
CLIENT_FORM_PLACEHOLDER_TABS, CLIENT_FORM_PLACEHOLDER_TABS,
hasAllRequiredAccountingFields,
hasAtLeastOneValidContact, hasAtLeastOneValidContact,
isBankRequiredForPaymentType, isBankRequiredForPaymentType,
isBillingEmailRequired, isBillingEmailRequired,
isContactBlank,
isContactNamed, isContactNamed,
isRibBlank,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/clientFormRules'
import { import {
@@ -400,9 +388,11 @@ import {
type ContactFormDraft, type ContactFormDraft,
type RibFormDraft, type RibFormDraft,
} from '~/modules/commercial/types/clientForm' } from '~/modules/commercial/types/clientForm'
import { formatPhoneFR } from '~/shared/utils/phone'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
// Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7). // Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7).
const EMPLOYEES_MASK = '#######' const EMPLOYEES_MASK = '#######'
@@ -432,22 +422,6 @@ function apiErrorMessage(error: unknown): string {
return extractApiErrorMessage(data) || t('commercial.clients.toast.error') return extractApiErrorMessage(data) || t('commercial.clients.toast.error')
} }
// Erreurs de validation par champ (ERP-101)
// Etat d'erreurs factorise entre creation et edition (cf. useClientFormErrors) :
// un `useFormErrors` par groupe scalaire (Principal / Information / Comptabilite)
// + un tableau d'erreurs par ligne pour chaque collection (contacts/adresses/RIB).
// `mapRowError` mappe une 422 inline et retourne true ; il ne toaste pas, le
// fallback reste local a la creation (cf. catch des submits de collection).
const {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
submitRows,
} = useClientFormErrors()
useHead({ title: t('commercial.clients.form.title') }) useHead({ title: t('commercial.clients.form.title') })
// Gating de la route : la creation est reservee a `manage`. Compta (accounting // Gating de la route : la creation est reservee a `manage`. Compta (accounting
@@ -470,6 +444,9 @@ const tabSubmitting = ref(false)
// Formulaire principal // Formulaire principal
const main = reactive({ const main = reactive({
companyName: null as string | null, companyName: null as string | null,
firstName: null as string | null,
lastName: null as string | null,
email: null as string | null,
categoryIris: [] as string[], categoryIris: [] as string[],
relationType: null as 'distributeur' | 'courtier' | null, relationType: null as 'distributeur' | 'courtier' | null,
distributorIri: null as string | null, distributorIri: null as string | null,
@@ -477,6 +454,17 @@ const main = reactive({
triageService: false, triageService: false,
}) })
// Telephones du formulaire principal : 1 par defaut, 2 au maximum (RG-1.02).
// L'index 0 alimente phonePrimary, l'index 1 phoneSecondary au POST.
const mainPhones = ref<string[]>([''])
/** Revele le 2e numero (le bouton « + » disparait une fois a 2, RG-1.02). */
function addMainPhone(): void {
if (mainPhones.value.length === 1) {
mainPhones.value.push('')
}
}
// Pas d'option « Aucun » : le select est vide par defaut (relationType = null). // Pas d'option « Aucun » : le select est vide par defaut (relationType = null).
const relationOptions = computed<RefOption[]>(() => [ const relationOptions = computed<RefOption[]>(() => [
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') }, { value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
@@ -484,11 +472,10 @@ const relationOptions = computed<RefOption[]>(() => [
]) ])
// Validation du formulaire principal (gate le bouton « Valider ») : // Validation du formulaire principal (gate le bouton « Valider ») :
// - companyName / >= 1 categorie obligatoires ; // - companyName / email / telephone principal / >= 1 categorie obligatoires ;
// - relation Distributeur/Courtier optionnelle, mais le nom correspondant // - RG-1.01 : nom OU prenom du contact principal ;
// devient requis si l'un des deux est choisi (spec fonctionnelle). // - relation Distributeur/Courtier obligatoire (un des deux), ET le nom
// Les coordonnees de contact ne sont plus saisies ici : elles vivent dans // correspondant obligatoire selon le choix (spec fonctionnelle).
// l'onglet Contacts (RG-1.05/1.14 garantissent >= 1 contact valide).
const isMainValid = computed(() => { const isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== '' const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
// Relation Distributeur/Courtier OPTIONNELLE ; mais si « Depend du // Relation Distributeur/Courtier OPTIONNELLE ; mais si « Depend du
@@ -498,6 +485,9 @@ const isMainValid = computed(() => {
|| (main.relationType === 'distributeur' && filled(main.distributorIri)) || (main.relationType === 'distributeur' && filled(main.distributorIri))
|| (main.relationType === 'courtier' && filled(main.brokerIri)) || (main.relationType === 'courtier' && filled(main.brokerIri))
return filled(main.companyName) return filled(main.companyName)
&& filled(main.email)
&& filled(mainPhones.value[0])
&& (filled(main.firstName) || filled(main.lastName))
&& main.categoryIris.length >= 1 && main.categoryIris.length >= 1
&& relationValid && relationValid
}) })
@@ -519,10 +509,14 @@ async function onRelationChange(value: string | number | null): Promise<void> {
async function submitMain(): Promise<void> { async function submitMain(): Promise<void> {
if (!isMainValid.value || mainSubmitting.value) return if (!isMainValid.value || mainSubmitting.value) return
mainSubmitting.value = true mainSubmitting.value = true
mainErrors.clearErrors()
try { try {
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
companyName: main.companyName, companyName: main.companyName,
firstName: main.firstName || null,
lastName: main.lastName || null,
email: main.email,
phonePrimary: mainPhones.value[0] || null,
phoneSecondary: mainPhones.value[1] || null,
categories: main.categoryIris, categories: main.categoryIris,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null, distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null, broker: main.relationType === 'courtier' ? main.brokerIri : null,
@@ -534,8 +528,18 @@ async function submitMain(): Promise<void> {
}) })
clientId.value = created.id clientId.value = created.id
// Reaffiche la valeur normalisee renvoyee par le serveur. // Reaffiche les valeurs normalisees renvoyees par le serveur.
main.companyName = created.companyName ?? main.companyName main.companyName = created.companyName ?? main.companyName
main.firstName = created.firstName ?? null
main.lastName = created.lastName ?? null
main.email = created.email ?? main.email
// Reaffiche les telephones normalises (reformates via formatPhoneFR).
const normalizedPhones = [formatPhoneFR(created.phonePrimary), formatPhoneFR(created.phoneSecondary)]
.filter(p => p !== '')
mainPhones.value = normalizedPhones.length > 0 ? normalizedPhones : ['']
// Pre-remplit le 1er contact a partir du formulaire principal (editable).
prefillFirstContact()
mainLocked.value = true mainLocked.value = true
unlockedIndex.value = 0 unlockedIndex.value = 0
@@ -543,18 +547,15 @@ async function submitMain(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.createSuccess') }) toast.success({ title: t('commercial.clients.toast.createSuccess') })
} }
catch (error) { catch (error) {
// 409 = doublon nom de societe (RG d'unicite) erreur inline sur le // 409 = doublon nom de societe (RG d'unicite) message explicite ;
// champ + toast explicite ; 422 mapping inline par champ (pas de // sinon on remonte le message de validation du serveur (ex: 422).
// toast) ; autre toast de fallback. Cf. ERP-101.
const status = (error as { response?: { status?: number } })?.response?.status const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) { toast.error({
const message = t('commercial.clients.form.duplicateCompany') title: t('commercial.clients.toast.error'),
mainErrors.setError('companyName', message) message: status === 409
toast.error({ title: t('commercial.clients.toast.error'), message }) ? t('commercial.clients.form.duplicateCompany')
} : apiErrorMessage(error),
else { })
mainErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
}
} }
finally { finally {
mainSubmitting.value = false mainSubmitting.value = false
@@ -629,7 +630,6 @@ const information = reactive({
async function submitInformation(): Promise<void> { async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
informationErrors.clearErrors()
try { try {
await api.patch(`/clients/${clientId.value}`, { await api.patch(`/clients/${clientId.value}`, {
description: information.description || null, description: information.description || null,
@@ -644,7 +644,7 @@ async function submitInformation(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (error) { catch (error) {
informationErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') }) toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
} }
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
@@ -652,10 +652,18 @@ async function submitInformation(): Promise<void> {
} }
// Onglet Contact // Onglet Contact
// Au moins un bloc Contact vide au depart : c'est desormais le seul point de
// saisie des coordonnees (le bloc principal ne porte plus de contact inline).
const contacts = ref<ContactFormDraft[]>([emptyContact()]) const contacts = ref<ContactFormDraft[]>([emptyContact()])
/** Pre-remplit le 1er contact depuis le formulaire principal (apres creation). */
function prefillFirstContact(): void {
const first = contacts.value[0]
if (!first) return
first.lastName = main.lastName
first.firstName = main.firstName
first.email = main.email
first.phonePrimary = mainPhones.value[0] ?? null
}
// « + Nouveau contact » desactive tant que le dernier bloc n'a ni nom ni prenom. // « + Nouveau contact » desactive tant que le dernier bloc n'a ni nom ni prenom.
const canAddContact = computed(() => { const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1] const last = contacts.value[contacts.value.length - 1]
@@ -672,7 +680,6 @@ function addContact(): void {
function askRemoveContact(index: number): void { function askRemoveContact(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => { askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
contacts.value.splice(index, 1) contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
}) })
} }
@@ -681,45 +688,38 @@ async function submitContacts(): Promise<void> {
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
try { try {
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls for (const contact of contacts.value) {
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli // On ignore les blocs totalement vides (ni nom ni prenom).
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc. if (!isContactNamed(contact)) continue
const hasError = await submitRows(
contacts.value, const body = {
contactErrors, firstName: contact.firstName || null,
async (contact) => { lastName: contact.lastName || null,
const body = { jobTitle: contact.jobTitle || null,
firstName: contact.firstName || null, phonePrimary: contact.phonePrimary || null,
lastName: contact.lastName || null, phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
jobTitle: contact.jobTitle || null, email: contact.email || null,
phonePrimary: contact.phonePrimary || null, }
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
email: contact.email || null, if (contact.id === null) {
} const created = await api.post<ContactResponse>(
if (contact.id === null) { `/clients/${clientId.value}/contacts`,
const created = await api.post<ContactResponse>( body,
`/clients/${clientId.value}/contacts`, { headers: { Accept: 'application/ld+json' }, toast: false },
body, )
{ headers: { Accept: 'application/ld+json' }, toast: false }, contact.id = created.id
) contact.iri = created['@id'] ?? null
contact.id = created.id }
contact.iri = created['@id'] ?? null else {
} await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
else { }
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false }) }
}
},
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
// bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif
// serait perdue en silence avec un faux toast de succes).
contact => contact.id === null && isContactBlank(contact),
)
// Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes.
if (hasError) return
completeTab('contact') completeTab('contact')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
} }
@@ -750,16 +750,12 @@ const countryOptions: RefOption[] = [
{ value: 'Espagne', label: 'Espagne' }, { value: 'Espagne', label: 'Espagne' },
] ]
// Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email // RG-1.10 (>= 1 site) + RG-1.11 (email facturation si Facturation) sur chaque adresse.
// facturation si Facturation) sur chaque adresse.
const canValidateAddresses = computed(() => const canValidateAddresses = computed(() =>
addresses.value.length > 0 addresses.value.length > 0
&& addresses.value.every((a) => { && addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== '' const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return addressTypeFromFlags(a) !== null return a.siteIris.length >= 1 && (!isBillingEmailRequired(a) || filledBillingEmail)
&& a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}), }),
) )
@@ -770,7 +766,6 @@ function addAddress(): void {
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => { askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
addresses.value.splice(index, 1) addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
}) })
} }
@@ -789,43 +784,40 @@ async function submitAddresses(): Promise<void> {
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
try { try {
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110). for (const address of addresses.value) {
const hasError = await submitRows( const body = {
addresses.value, isProspect: address.isProspect,
addressErrors, isDelivery: address.isDelivery,
async (address) => { isBilling: address.isBilling,
const body = { country: address.country,
isProspect: address.isProspect, postalCode: address.postalCode || null,
isDelivery: address.isDelivery, city: address.city || null,
isBilling: address.isBilling, street: address.street || null,
country: address.country, streetComplement: address.streetComplement || null,
postalCode: address.postalCode || null, categories: address.categoryIris,
city: address.city || null, sites: address.siteIris,
street: address.street || null, contacts: address.contactIris,
streetComplement: address.streetComplement || null, billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
categories: address.categoryIris, }
sites: address.siteIris,
contacts: address.contactIris, if (address.id === null) {
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null, const created = await api.post<{ id: number }>(
} `/clients/${clientId.value}/addresses`,
if (address.id === null) { body,
const created = await api.post<{ id: number }>( { headers: { Accept: 'application/ld+json' }, toast: false },
`/clients/${clientId.value}/addresses`, )
body, address.id = created.id
{ headers: { Accept: 'application/ld+json' }, toast: false }, }
) else {
address.id = created.id await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
} }
else { }
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
}
},
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
)
if (hasError) return
completeTab('address') completeTab('address')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
} }
@@ -864,11 +856,8 @@ function ribIsComplete(rib: RibFormDraft): boolean {
return filled(rib.label) && filled(rib.bic) && filled(rib.iban) return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
} }
// RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact /
// Adresse, le bouton reste desactive tant que l'onglet n'est pas complet).
// RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR. // RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR.
const canValidateAccounting = computed(() => { const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && (accounting.bankIri === null)) return false if (isBankRequired.value && (accounting.bankIri === null)) return false
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
return true return true
@@ -881,9 +870,6 @@ function addRib(): void {
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => { askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
ribs.value.splice(index, 1) ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce au montage).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}) })
} }
@@ -895,60 +881,38 @@ function askRemoveRib(index: number): void {
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors()
// Reset des erreurs RIB des le debut : l'etape 1 (PATCH scalaires) peut
// echouer et `return` avant submitRows (qui porte sinon le reset), laissant
// des erreurs de RIB obsoletes affichees sous les blocs.
ribErrors.value = []
try { try {
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs). await api.patch(`/clients/${clientId.value}`, {
try { siren: accounting.siren || null,
await api.patch(`/clients/${clientId.value}`, { accountNumber: accounting.accountNumber || null,
siren: accounting.siren || null, tvaMode: accounting.tvaModeIri,
accountNumber: accounting.accountNumber || null, nTva: accounting.nTva || null,
tvaMode: accounting.tvaModeIri, paymentDelay: accounting.paymentDelayIri,
nTva: accounting.nTva || null, paymentType: accounting.paymentTypeIri,
paymentDelay: accounting.paymentDelayIri, bank: isBankRequired.value ? accounting.bankIri : null,
paymentType: accounting.paymentTypeIri, }, { toast: false })
bank: isBankRequired.value ? accounting.bankIri : null,
}, { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
// 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes). for (const rib of ribs.value) {
// Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex. if (!ribIsComplete(rib)) continue
// IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline. if (rib.id === null) {
const ribHasError = await submitRows( const created = await api.post<{ id: number }>(
ribs.value, `/clients/${clientId.value}/ribs`,
ribErrors, { label: rib.label, bic: rib.bic, iban: rib.iban },
async (rib) => { { headers: { Accept: 'application/ld+json' }, toast: false },
const body = { label: rib.label, bic: rib.bic, iban: rib.iban } )
if (rib.id === null) { rib.id = created.id
const created = await api.post<{ id: number }>( }
`/clients/${clientId.value}/ribs`, else {
body, await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false })
{ headers: { Accept: 'application/ld+json' }, toast: false }, }
) }
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}
},
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
// RIB existant vide est soumis -> 422 NotBlank inline (sinon la modif
// serait perdue en silence avec un faux toast de succes).
rib => rib.id === null && isRibBlank(rib),
)
if (ribHasError) return
completeTab('accounting') completeTab('accounting')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
} }
@@ -977,6 +941,11 @@ function runConfirm(): void {
interface ClientResponse { interface ClientResponse {
id: number id: number
companyName: string | null companyName: string | null
firstName: string | null
lastName: string | null
email: string | null
phonePrimary: string | null
phoneSecondary: string | null
} }
interface ContactResponse { interface ContactResponse {
@@ -987,8 +956,5 @@ interface ContactResponse {
onMounted(() => { onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides. // Echec du chargement des referentiels non bloquant : les selects restent vides.
referentials.loadCommon().catch(() => {}) referentials.loadCommon().catch(() => {})
// Au moins un bloc RIB toujours visible en creation : on amorce un bloc vide
// (non persiste tant qu'incomplet RG-1.13).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}) })
</script> </script>
@@ -22,6 +22,12 @@ import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules
function mainDraft(overrides: Partial<MainFormDraft> = {}): MainFormDraft { function mainDraft(overrides: Partial<MainFormDraft> = {}): MainFormDraft {
return { return {
companyName: 'ACME', companyName: 'ACME',
firstName: 'Jean',
lastName: 'Dupont',
email: 'jean@acme.fr',
phonePrimary: '05 49 11 22 33',
phoneSecondary: null,
hasSecondaryPhone: false,
categoryIris: ['/api/categories/1'], categoryIris: ['/api/categories/1'],
relationType: null, relationType: null,
distributorIri: null, distributorIri: null,
@@ -58,10 +64,9 @@ function accountingDraft(overrides: Partial<AccountingFormDraft> = {}): Accounti
} }
// Champs de chaque groupe de serialisation (miroir back ClientProcessor). // Champs de chaque groupe de serialisation (miroir back ClientProcessor).
// Le contact inline (nom/prenom/telephones/email) ne fait plus partie du groupe
// main : les coordonnees vivent desormais sur la sous-ressource ClientContact.
const MAIN_KEYS = [ const MAIN_KEYS = [
'companyName', 'categories', 'distributor', 'broker', 'triageService', 'companyName', 'firstName', 'lastName', 'email', 'phonePrimary',
'phoneSecondary', 'categories', 'distributor', 'broker', 'triageService',
] ]
const INFORMATION_KEYS = [ const INFORMATION_KEYS = [
'description', 'competitors', 'foundedAt', 'employeesCount', 'description', 'competitors', 'foundedAt', 'employeesCount',
@@ -99,6 +104,11 @@ describe('buildMainPayload — scoping strict groupe client:write:main', () => {
expect(payload.distributor).toBeNull() expect(payload.distributor).toBeNull()
expect(payload.broker).toBeNull() expect(payload.broker).toBeNull()
}) })
it('telephone secondaire non revele : envoie null meme si une valeur traine', () => {
const payload = buildMainPayload(mainDraft({ hasSecondaryPhone: false, phoneSecondary: '06 00 00 00 00' }))
expect(payload.phoneSecondary).toBeNull()
})
}) })
describe('buildInformationPayload — scoping strict groupe client:write:information', () => { describe('buildInformationPayload — scoping strict groupe client:write:information', () => {
@@ -158,16 +168,19 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
}) })
describe('mapMainDraft — pre-remplissage bloc principal', () => { describe('mapMainDraft — pre-remplissage bloc principal', () => {
it('resout la relation et extrait les IRI (sans contact inline)', () => { it('formate les telephones, resout la relation et extrait les IRI', () => {
const client = { const client = {
'@id': '/api/clients/1', id: 1, '@id': '/api/clients/1', id: 1,
companyName: 'ACME', triageService: true, companyName: 'ACME', firstName: 'Jean', lastName: 'Dupont', email: 'jean@acme.fr',
phonePrimary: '0549112233', phoneSecondary: '0600000000', triageService: true,
categories: [{ '@id': '/api/categories/1', code: 'SECTEUR' }], categories: [{ '@id': '/api/categories/1', code: 'SECTEUR' }],
distributor: { '@id': '/api/clients/9', companyName: 'DISTRIB' }, distributor: { '@id': '/api/clients/9', companyName: 'DISTRIB' },
} as ClientDetail } as ClientDetail
const draft = mapMainDraft(client) const draft = mapMainDraft(client)
expect(draft.companyName).toBe('ACME') expect(draft.phonePrimary).toBe('05 49 11 22 33')
expect(draft.phoneSecondary).toBe('06 00 00 00 00')
expect(draft.hasSecondaryPhone).toBe(true)
expect(draft.categoryIris).toEqual(['/api/categories/1']) expect(draft.categoryIris).toEqual(['/api/categories/1'])
expect(draft.relationType).toBe('distributeur') expect(draft.relationType).toBe('distributeur')
expect(draft.distributorIri).toBe('/api/clients/9') expect(draft.distributorIri).toBe('/api/clients/9')
@@ -178,6 +191,7 @@ describe('mapMainDraft — pre-remplissage bloc principal', () => {
it('gere les cles omises (skip_null_values) sans planter', () => { it('gere les cles omises (skip_null_values) sans planter', () => {
const draft = mapMainDraft({ '@id': '/api/clients/2', id: 2 } as ClientDetail) const draft = mapMainDraft({ '@id': '/api/clients/2', id: 2 } as ClientDetail)
expect(draft.companyName).toBeNull() expect(draft.companyName).toBeNull()
expect(draft.hasSecondaryPhone).toBe(false)
expect(draft.categoryIris).toEqual([]) expect(draft.categoryIris).toEqual([])
expect(draft.relationType).toBeNull() expect(draft.relationType).toBeNull()
expect(draft.triageService).toBe(false) expect(draft.triageService).toBe(false)
@@ -1,36 +1,17 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { import {
addressFlagsFromType,
addressTypeFromFlags,
applyProspectExclusivity, applyProspectExclusivity,
buildClientFormTabKeys, buildClientFormTabKeys,
canSelectDeliveryOrBilling, canSelectDeliveryOrBilling,
canSelectProspect, canSelectProspect,
hasAllRequiredAccountingFields,
hasAtLeastOneValidContact, hasAtLeastOneValidContact,
isBankRequiredForPaymentType, isBankRequiredForPaymentType,
isBillingEmailRequired, isBillingEmailRequired,
isBlankRow,
isContactBlank,
isContactNamed, isContactNamed,
isRibBlank,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
type ContactDraft, type ContactDraft,
type ContactFillableDraft,
} from '../clientFormRules' } from '../clientFormRules'
/** Bloc contact totalement vide (amorce par defaut). */
function blankContact(): ContactFillableDraft {
return {
firstName: null,
lastName: null,
jobTitle: null,
phonePrimary: null,
phoneSecondary: null,
email: null,
}
}
describe('buildClientFormTabKeys (gating onglet Comptabilite + onglets edit-only)', () => { describe('buildClientFormTabKeys (gating onglet Comptabilite + onglets edit-only)', () => {
it('inclut l onglet accounting si l utilisateur a accounting.view', () => { it('inclut l onglet accounting si l utilisateur a accounting.view', () => {
expect(buildClientFormTabKeys(true)).toContain('accounting') expect(buildClientFormTabKeys(true)).toContain('accounting')
@@ -78,49 +59,6 @@ describe('isContactNamed (RG-1.05)', () => {
}) })
}) })
describe('isBlankRow (primitive : toutes les valeurs vides)', () => {
it('vrai si toutes les valeurs sont nulles / vides / espaces', () => {
expect(isBlankRow([null, undefined, '', ' '])).toBe(true)
expect(isBlankRow([])).toBe(true)
})
it('faux des qu une valeur porte un caractere non-espace', () => {
expect(isBlankRow([null, 'x', ''])).toBe(false)
})
})
describe('isRibBlank (bloc RIB totalement vide vs partiellement rempli)', () => {
it('vrai si label / bic / iban sont tous vides', () => {
expect(isRibBlank({ label: null, bic: null, iban: null })).toBe(true)
expect(isRibBlank({ label: ' ', bic: '', iban: null })).toBe(true)
})
it('faux si un IBAN seul est saisi (bloc a soumettre -> 422 NotBlank inline)', () => {
expect(isRibBlank({ label: null, bic: null, iban: 'FR1420041010050500013M02606' })).toBe(false)
})
it('faux si seul le libelle est saisi', () => {
expect(isRibBlank({ label: 'Compte courant', bic: null, iban: null })).toBe(false)
})
})
describe('isContactBlank (bloc totalement vide vs partiellement rempli)', () => {
it('vrai si aucun champ saisissable n est rempli', () => {
expect(isContactBlank(blankContact())).toBe(true)
expect(isContactBlank({ ...blankContact(), firstName: ' ', email: '' })).toBe(true)
})
it('faux si un email seul est saisi (bloc a soumettre -> 422 RG-1.05 inline)', () => {
expect(isContactBlank({ ...blankContact(), email: 'jean@acme.fr' })).toBe(false)
})
it('faux si seul un telephone, une fonction ou un nom est saisi', () => {
expect(isContactBlank({ ...blankContact(), phonePrimary: '0612345678' })).toBe(false)
expect(isContactBlank({ ...blankContact(), jobTitle: 'Directeur' })).toBe(false)
expect(isContactBlank({ ...blankContact(), firstName: 'Alice' })).toBe(false)
})
})
describe('hasAtLeastOneValidContact (RG-1.14)', () => { describe('hasAtLeastOneValidContact (RG-1.14)', () => {
it('faux sur une liste vide', () => { it('faux sur une liste vide', () => {
expect(hasAtLeastOneValidContact([])).toBe(false) expect(hasAtLeastOneValidContact([])).toBe(false)
@@ -199,32 +137,6 @@ describe('isBillingEmailRequired (RG-1.11)', () => {
}) })
}) })
describe('type d\'adresse (Select front) <-> drapeaux back', () => {
it('addressFlagsFromType mappe chaque type vers les bons drapeaux', () => {
expect(addressFlagsFromType('prospect')).toEqual({ isProspect: true, isDelivery: false, isBilling: false })
expect(addressFlagsFromType('delivery')).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
expect(addressFlagsFromType('billing')).toEqual({ isProspect: false, isDelivery: false, isBilling: true })
expect(addressFlagsFromType('delivery_billing')).toEqual({ isProspect: false, isDelivery: true, isBilling: true })
})
it('addressTypeFromFlags reconstruit le type (Prospect prioritaire, livraison+facturation groupes)', () => {
expect(addressTypeFromFlags({ isProspect: true, isDelivery: false, isBilling: false })).toBe('prospect')
expect(addressTypeFromFlags({ isProspect: false, isDelivery: true, isBilling: false })).toBe('delivery')
expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: true })).toBe('billing')
expect(addressTypeFromFlags({ isProspect: false, isDelivery: true, isBilling: true })).toBe('delivery_billing')
})
it('addressTypeFromFlags retourne null quand aucun drapeau (amorce vierge -> bouton bloque)', () => {
expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: false })).toBeNull()
})
it('aller-retour type -> drapeaux -> type stable pour les 4 types', () => {
for (const type of ['prospect', 'delivery', 'billing', 'delivery_billing'] as const) {
expect(addressTypeFromFlags(addressFlagsFromType(type))).toBe(type)
}
})
})
describe('regles type de reglement (RG-1.12 / RG-1.13)', () => { describe('regles type de reglement (RG-1.12 / RG-1.13)', () => {
it('banque obligatoire si VIREMENT', () => { it('banque obligatoire si VIREMENT', () => {
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true) expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
@@ -238,36 +150,3 @@ describe('regles type de reglement (RG-1.12 / RG-1.13)', () => {
expect(isRibRequiredForPaymentType(null)).toBe(false) expect(isRibRequiredForPaymentType(null)).toBe(false)
}) })
}) })
describe('hasAllRequiredAccountingFields (RG-1.30)', () => {
const complete = {
siren: '123456789',
accountNumber: '00012345678',
nTva: 'FR12345678901',
tvaModeIri: '/api/tva_modes/1',
paymentDelayIri: '/api/payment_delays/1',
paymentTypeIri: '/api/payment_types/1',
}
it('vrai quand les six champs obligatoires sont remplis', () => {
expect(hasAllRequiredAccountingFields(complete)).toBe(true)
})
it('faux si un champ est manquant (null ou vide apres trim)', () => {
expect(hasAllRequiredAccountingFields({ ...complete, siren: null })).toBe(false)
expect(hasAllRequiredAccountingFields({ ...complete, accountNumber: ' ' })).toBe(false)
expect(hasAllRequiredAccountingFields({ ...complete, tvaModeIri: null })).toBe(false)
expect(hasAllRequiredAccountingFields({ ...complete, paymentTypeIri: null })).toBe(false)
})
it('faux quand tout est vide (onglet non rempli)', () => {
expect(hasAllRequiredAccountingFields({
siren: null,
accountNumber: null,
nTva: null,
tvaModeIri: null,
paymentDelayIri: null,
paymentTypeIri: null,
})).toBe(false)
})
})
@@ -93,6 +93,11 @@ export interface RelatedClientRead extends HydraRef {
export interface ClientDetail extends HydraRef { export interface ClientDetail extends HydraRef {
id: number id: number
companyName?: string | null companyName?: string | null
firstName?: string | null
lastName?: string | null
phonePrimary?: string | null
phoneSecondary?: string | null
email?: string | null
triageService?: boolean triageService?: boolean
isArchived?: boolean isArchived?: boolean
categories?: CategoryRead[] categories?: CategoryRead[]
@@ -24,16 +24,23 @@ import {
type ClientDetail, type ClientDetail,
} from '~/modules/commercial/utils/clientConsultation' } from '~/modules/commercial/utils/clientConsultation'
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm' import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
import { formatPhoneFR } from '~/shared/utils/phone'
/** /**
* Etat « plat » du bloc principal (groupe client:write:main). Distinct des * Etat « plat » du bloc principal (groupe client:write:main). Distinct des
* brouillons Contact : ces champs vivent sur le Client lui-meme (companyName, * brouillons Contact : ces champs vivent sur le Client lui-meme (companyName,
* categories, relation, triage), pas sur une sous-ressource ClientContact. Les * contact principal, telephones, email, categories, relation, triage), pas sur
* coordonnees de contact (nom, prenom, telephones, email) ne sont plus portees * une sous-ressource ClientContact.
* par le Client : elles vivent exclusivement dans l'onglet Contacts.
*/ */
export interface MainFormDraft { export interface MainFormDraft {
companyName: string | null companyName: string | null
firstName: string | null
lastName: string | null
email: string | null
phonePrimary: string | null
phoneSecondary: string | null
/** UI : le 2e numero a ete revele (ou existait deja au chargement). */
hasSecondaryPhone: boolean
/** IRI des categories rattachees (M2M). */ /** IRI des categories rattachees (M2M). */
categoryIris: string[] categoryIris: string[]
relationType: 'distributeur' | 'courtier' | null relationType: 'distributeur' | 'courtier' | null
@@ -89,15 +96,22 @@ export interface TabEditability {
// ── Pre-remplissage (GET detail -> brouillons) ────────────────────────────── // ── Pre-remplissage (GET detail -> brouillons) ──────────────────────────────
/** /**
* Mappe le detail client vers le brouillon du bloc principal. La relation * Mappe le detail client vers le brouillon du bloc principal. Les telephones
* Distributeur/Courtier est resolue par exclusivite (RG-1.03) et son IRI extrait * sont reformates XX XX XX XX XX (RG d'affichage). La relation Distributeur/
* de l'embed. * Courtier est resolue par exclusivite (RG-1.03) et son IRI extrait de l'embed.
*/ */
export function mapMainDraft(client: ClientDetail): MainFormDraft { export function mapMainDraft(client: ClientDetail): MainFormDraft {
const relation = relationOf(client) const relation = relationOf(client)
const phoneSecondary = client.phoneSecondary ?? null
return { return {
companyName: client.companyName ?? null, companyName: client.companyName ?? null,
firstName: client.firstName ?? null,
lastName: client.lastName ?? null,
email: client.email ?? null,
phonePrimary: client.phonePrimary ? formatPhoneFR(client.phonePrimary) : null,
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
categoryIris: (client.categories ?? []).map(c => c['@id']), categoryIris: (client.categories ?? []).map(c => c['@id']),
relationType: relation.type, relationType: relation.type,
distributorIri: iriOf(client.distributor), distributorIri: iriOf(client.distributor),
@@ -143,6 +157,11 @@ export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraf
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> { export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
return { return {
companyName: main.companyName, companyName: main.companyName,
firstName: main.firstName || null,
lastName: main.lastName || null,
email: main.email,
phonePrimary: main.phonePrimary || null,
phoneSecondary: main.hasSecondaryPhone ? (main.phoneSecondary || null) : null,
categories: main.categoryIris, categories: main.categoryIris,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null, distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null, broker: main.relationType === 'courtier' ? main.brokerIri : null,
@@ -86,58 +86,6 @@ export function hasAtLeastOneValidContact(contacts: ContactDraft[]): boolean {
return contacts.some(isContactNamed) return contacts.some(isContactNamed)
} }
/**
* Primitive reutilisable : vrai si TOUTES les valeurs fournies sont vides (null /
* undefined / espaces uniquement). Sert a detecter un bloc de collection
* totalement vide (amorce non remplie). Un bloc qui porte la moindre donnee
* n'est PAS « blank » : il doit etre soumis pour declencher sa 422 inline plutot
* que d'etre saute silencieusement.
*/
export function isBlankRow(values: (string | null | undefined)[]): boolean {
return values.every(value => !isFilled(value))
}
/** Champs saisissables d'un bloc contact (pour detecter un bloc totalement vide). */
export interface ContactFillableDraft extends ContactDraft {
jobTitle: string | null
phonePrimary: string | null
phoneSecondary: string | null
email: string | null
}
/**
* Vrai si AUCUN champ saisissable du bloc contact n'est rempli. Distingue un bloc
* d'amorce vide (a ignorer au submit) d'un bloc partiellement rempli sans nom
* (email / telephone / fonction seul) : ce dernier doit etre soumis pour
* declencher la 422 RG-1.05 (« prenom ou nom obligatoire ») affichee inline.
*/
export function isContactBlank(contact: ContactFillableDraft): boolean {
return isBlankRow([
contact.firstName,
contact.lastName,
contact.jobTitle,
contact.phonePrimary,
contact.phoneSecondary,
contact.email,
])
}
/** Champs saisissables d'un bloc RIB (pour detecter un bloc totalement vide). */
export interface RibFillableDraft {
label: string | null
bic: string | null
iban: string | null
}
/**
* Vrai si AUCUN champ du bloc RIB n'est rempli. Un RIB partiellement rempli (ex.
* IBAN seul) n'est PAS « blank » : il doit etre soumis pour declencher les 422
* NotBlank (label / bic / iban) inline plutot que d'etre saute silencieusement.
*/
export function isRibBlank(rib: RibFillableDraft): boolean {
return isBlankRow([rib.label, rib.bic, rib.iban])
}
/** /**
* RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de * RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de
* livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni * livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni
@@ -187,45 +135,6 @@ export function isBillingEmailRequired(flags: AddressFlagsDraft): boolean {
return flags.isBilling return flags.isBilling
} }
/**
* Type d'adresse expose a l'utilisateur (Select unique remplacant les trois
* cases a cocher). Sucre purement front : le back continue de recevoir les
* drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). Les seules
* combinaisons proposees respectent l'exclusivite Prospect (RG-1.06/07/08).
*/
export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing'
/**
* Mappe le type d'adresse choisi vers les trois drapeaux back.
* « Adresse + Facturation » = livraison ET facturation sur la meme adresse.
*/
export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
switch (type) {
case 'prospect':
return { isProspect: true, isDelivery: false, isBilling: false }
case 'delivery':
return { isProspect: false, isDelivery: true, isBilling: false }
case 'billing':
return { isProspect: false, isDelivery: false, isBilling: true }
case 'delivery_billing':
return { isProspect: false, isDelivery: true, isBilling: true }
}
}
/**
* Reconstruit le type d'adresse a partir des drapeaux (consultation / edition
* d'une adresse persistee, ou amorce vierge). Retourne null si aucun drapeau
* n'est positionne le Select reste alors a saisir (et bloque la validation).
*/
export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | null {
if (flags.isProspect) return 'prospect'
if (flags.isDelivery && flags.isBilling) return 'delivery_billing'
if (flags.isDelivery) return 'delivery'
if (flags.isBilling) return 'billing'
return null
}
/** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */ /** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */
const PAYMENT_TYPE_TRANSFER = 'VIREMENT' const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
@@ -247,32 +156,3 @@ export function isBankRequiredForPaymentType(code: string | null | undefined): b
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean { export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
return code === PAYMENT_TYPE_LCR return code === PAYMENT_TYPE_LCR
} }
/** Sous-ensemble du brouillon comptable portant les six champs obligatoires. */
export interface AccountingRequiredDraft {
siren: string | null
accountNumber: string | null
nTva: string | null
tvaModeIri: string | null
paymentDelayIri: string | null
paymentTypeIri: string | null
}
/**
* RG-1.30 : les six champs scalaires de l'onglet Comptabilite sont obligatoires
* pour valider l'onglet (SIREN, N de compte, Mode de TVA, N de TVA, Delai de
* reglement, Type de reglement). bank / RIB restent conditionnels (RG-1.12 /
* RG-1.13) et sont evalues a part. Miroir front du
* ClientAccountingCompletenessValidator : meme gate que les onglets Contact /
* Adresse (bouton « Valider » desactive tant que l'onglet n'est pas complet).
*/
export function hasAllRequiredAccountingFields(accounting: AccountingRequiredDraft): boolean {
const filled = (v: string | null): boolean => v !== null && v.trim() !== ''
return filled(accounting.siren)
&& filled(accounting.accountNumber)
&& filled(accounting.nTva)
&& filled(accounting.tvaModeIri)
&& filled(accounting.paymentDelayIri)
&& filled(accounting.paymentTypeIri)
}
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend", "name": "starseed-frontend",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.7.4", "@malio/layer-ui": "^1.7.3",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -1866,9 +1866,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@malio/layer-ui": { "node_modules/@malio/layer-ui": {
"version": "1.7.4", "version": "1.7.3",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.4/layer-ui-1.7.4.tgz", "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.3/layer-ui-1.7.3.tgz",
"integrity": "sha512-JNXwBelj5UQ35Qv5VmnassXKt8niX9jDXjM1vUSukJQiyeUXRxAiZr16QumVgBN9P9YGDyjXVKrwCHltTXvPtQ==", "integrity": "sha512-jw3ka0Az6Jf0F9ifsooknkwXph8TNgoe6H3CjF8tbBxl8oND8HLHjlZ04ooUCoOUEIlsQ1Mm2hFFlQRCB04qdA==",
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.7.4", "@malio/layer-ui": "^1.7.3",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

@@ -1,51 +0,0 @@
<template>
<!--
Placeholder generique « En cours de dev » pour les ecrans / onglets non
encore implementes. Composant PARTAGE (shared/components) : auto-importe
sans prefixe (`<ComingSoonPlaceholder>`) et reutilisable depuis n'importe
quel module. Affiche un gif (asset local par defaut) + un message i18n.
-->
<div class="flex min-h-[240px] flex-col items-center justify-center gap-4 rounded-md bg-white py-10">
<img
v-if="!imageFailed"
:src="src"
:alt="resolvedTitle"
class="max-h-[220px] w-auto rounded-md"
@error="imageFailed = true"
>
<!-- Repli si le gif ne charge pas (offline, CSP, asset absent) :
illustration emoji, le message reste affiche. -->
<div v-else class="text-5xl" aria-hidden="true">🚧 👨💻 🚧</div>
<div class="text-center">
<p class="text-xl font-bold text-black">{{ resolvedTitle }}</p>
<p class="mt-1 text-black/60">{{ resolvedSubtitle }}</p>
</div>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
/** Source de l'image/gif affichee. Defaut : asset local `/coming-soon.gif`. */
src?: string
/** Titre. Defaut : i18n `common.comingSoon.title`. */
title?: string
/** Sous-titre. Defaut : i18n `common.comingSoon.subtitle`. */
subtitle?: string
}>(),
{
src: '/coming-soon.gif',
title: '',
subtitle: '',
},
)
const { t } = useI18n()
const imageFailed = ref(false)
// Les props priment sur les libelles i18n par defaut (permet a un module
// d'override le texte sans toucher au composant).
const resolvedTitle = computed(() => props.title || t('common.comingSoon.title'))
const resolvedSubtitle = computed(() => props.subtitle || t('common.comingSoon.subtitle'))
</script>
@@ -1,132 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
useAddressAutocomplete,
AddressAutocompleteUnavailableError,
} from '../useAddressAutocomplete'
// On mocke le helper d'appel externe : aucun vrai appel reseau a la BAN.
// vi.mock est hoiste par Vitest au-dessus des imports.
const mockHttp = vi.hoisted(() => vi.fn())
vi.mock('~/shared/utils/httpExternal', () => ({ httpExternal: mockHttp }))
const BAN_URL = 'https://api-adresse.data.gouv.fr/search/'
describe('useAddressAutocomplete', () => {
beforeEach(() => {
mockHttp.mockReset()
})
describe('searchCity', () => {
it('interroge la BAN en type=municipality et mappe { city, postalCode }', async () => {
mockHttp.mockResolvedValueOnce({
type: 'FeatureCollection',
features: [
{ properties: { city: 'Amiens', postcode: '80000', name: 'Amiens', type: 'municipality' } },
{ properties: { city: 'Amiens', postcode: '80080', name: 'Amiens', type: 'municipality' } },
],
})
const { searchCity } = useAddressAutocomplete()
const res = await searchCity('80000')
expect(mockHttp).toHaveBeenCalledWith(
BAN_URL,
expect.objectContaining({ query: { q: '80000', type: 'municipality' } }),
)
expect(res).toEqual([
{ city: 'Amiens', postalCode: '80000' },
{ city: 'Amiens', postalCode: '80080' },
])
})
it('throw une AddressAutocompleteUnavailableError sur erreur reseau / 5xx', async () => {
mockHttp.mockRejectedValueOnce(new Error('500 Server Error'))
const { searchCity } = useAddressAutocomplete()
await expect(searchCity('80000')).rejects.toBeInstanceOf(AddressAutocompleteUnavailableError)
})
it('throw une AddressAutocompleteUnavailableError sur timeout', async () => {
mockHttp.mockRejectedValueOnce(new Error('The operation was aborted due to timeout'))
const { searchCity } = useAddressAutocomplete()
await expect(searchCity('80000')).rejects.toBeInstanceOf(AddressAutocompleteUnavailableError)
})
})
describe('searchAddress', () => {
it('interroge la BAN avec postcode et mappe la suggestion', async () => {
mockHttp.mockResolvedValueOnce({
type: 'FeatureCollection',
features: [
{
properties: {
label: '8 Boulevard du Port 80000 Amiens',
name: '8 Boulevard du Port',
street: 'Boulevard du Port',
postcode: '80000',
city: 'Amiens',
type: 'housenumber',
},
},
],
})
const { searchAddress } = useAddressAutocomplete()
const res = await searchAddress('8 boulevard du port', '80000')
expect(mockHttp).toHaveBeenCalledWith(
BAN_URL,
expect.objectContaining({
query: { q: '8 boulevard du port', postcode: '80000' },
}),
)
expect(res).toEqual([
{
label: '8 Boulevard du Port 80000 Amiens',
street: '8 Boulevard du Port',
postalCode: '80000',
city: 'Amiens',
},
])
})
it('omet le parametre postcode quand aucun code postal n\'est fourni', async () => {
mockHttp.mockResolvedValueOnce({ type: 'FeatureCollection', features: [] })
const { searchAddress } = useAddressAutocomplete()
await searchAddress('8 boulevard du port')
expect(mockHttp).toHaveBeenCalledWith(
BAN_URL,
expect.objectContaining({
query: { q: '8 boulevard du port' },
}),
)
})
it('ne restreint PAS la recherche a type=housenumber (sinon la BAN ne renvoie rien tant qu\'aucun numero n\'est saisi)', async () => {
// Regression : avec `type=housenumber`, une saisie de nom de rue sans
// numero (ex: « boulevard du port ») renvoie 0 resultat cote BAN.
mockHttp.mockResolvedValueOnce({ type: 'FeatureCollection', features: [] })
const { searchAddress } = useAddressAutocomplete()
await searchAddress('boulevard du port', '80000')
const sentQuery = mockHttp.mock.calls[0]?.[1]?.query as Record<string, string>
expect(sentQuery.type).toBeUndefined()
})
it('throw une AddressAutocompleteUnavailableError sur erreur reseau', async () => {
mockHttp.mockRejectedValueOnce(new Error('network down'))
const { searchAddress } = useAddressAutocomplete()
await expect(searchAddress('8 boulevard du port', '80000')).rejects.toBeInstanceOf(
AddressAutocompleteUnavailableError,
)
})
})
})
@@ -1,91 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useFormErrors } from '../useFormErrors'
const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
// useI18n stub : renvoie la cle telle quelle (pour asserter dessus).
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
/**
* Tests du composable `useFormErrors` pendant front de la regle « le back
* renvoie toutes les violations 422 d'un coup » (ERP-101). Centralise l'etat
* d'erreurs par champ (`Record<propertyPath, message>`) et la dispatch d'une
* erreur API : 422 mappee inline, sinon toast de fallback.
*/
describe('useFormErrors', () => {
beforeEach(() => {
mockToastError.mockReset()
})
/** Fabrique une erreur ofetch avec status + payload. */
function fetchError(status: number, data: unknown) {
return { response: { status, _data: data } }
}
it('demarre sans erreur', () => {
const { errors, hasErrors } = useFormErrors()
expect(errors).toEqual({})
expect(hasErrors.value).toBe(false)
})
it('setServerErrors mappe les violations par champ et retourne true', () => {
const { errors, hasErrors, setServerErrors } = useFormErrors()
const mapped = setServerErrors({
violations: [
{ propertyPath: 'companyName', message: 'Obligatoire.' },
{ propertyPath: 'siren', message: 'Deja utilise.' },
],
})
expect(mapped).toBe(true)
expect(errors).toEqual({ companyName: 'Obligatoire.', siren: 'Deja utilise.' })
expect(hasErrors.value).toBe(true)
})
it('setServerErrors retourne false et ne touche rien sans violation', () => {
const { errors, setServerErrors } = useFormErrors()
expect(setServerErrors({})).toBe(false)
expect(errors).toEqual({})
})
it('setError / clearError / clearErrors manipulent l\'etat finement', () => {
const { errors, setError, clearError, clearErrors } = useFormErrors()
setError('iban', 'IBAN invalide.')
expect(errors.iban).toBe('IBAN invalide.')
clearError('iban')
expect(errors.iban).toBeUndefined()
setError('a', 'x')
setError('b', 'y')
clearErrors()
expect(errors).toEqual({})
})
it('handleApiError : 422 avec violations → mappe inline, pas de toast, retourne true', () => {
const { errors, handleApiError } = useFormErrors()
const handled = handleApiError(
fetchError(422, { violations: [{ propertyPath: 'email', message: 'Invalide.' }] }),
)
expect(handled).toBe(true)
expect(errors.email).toBe('Invalide.')
expect(mockToastError).not.toHaveBeenCalled()
})
it('handleApiError : erreur non-422 → toast de fallback, retourne false', () => {
const { errors, handleApiError } = useFormErrors()
const handled = handleApiError(
fetchError(500, { 'hydra:description': 'Erreur serveur.' }),
{ fallbackMessage: 'Oups.' },
)
expect(handled).toBe(false)
expect(errors).toEqual({})
expect(mockToastError).toHaveBeenCalledTimes(1)
// Titre via i18n (cle renvoyee telle quelle par le stub).
expect(mockToastError.mock.calls[0][0]).toMatchObject({ title: 'errors.title', message: 'Erreur serveur.' })
})
it('handleApiError : 422 sans violation mappable → toast de fallback, retourne false', () => {
const { handleApiError } = useFormErrors()
const handled = handleApiError(fetchError(422, { 'hydra:description': 'Donnees invalides.' }))
expect(handled).toBe(false)
expect(mockToastError).toHaveBeenCalledTimes(1)
})
})
@@ -1,29 +1,27 @@
import { httpExternal } from '~/shared/utils/httpExternal' // STUB ERP-63 — remplacé par l'implémentation BAN d'ERP-66.
// Autocompletion d'adresse branchee sur la Base Adresse Nationale (BAN),
// `api-adresse.data.gouv.fr` — service public francais, gratuit, CORS ouvert.
// //
// Appel HTTP DIRECT depuis le front (pas de proxy back), conformement a la spec // Ce fichier appartient fonctionnellement à ERP-66 (#66). ERP-63 n'en livre
// M1 (§ API adresse postale). On passe par `httpExternal` et NON `useApi()` : // qu'un STUB pour ne pas se bloquer : la vraie implémentation (appels
// la BAN est un domaine externe, sans cookie de session ni enveloppe Hydra. // api-adresse.data.gouv.fr) viendra remplacer le CORPS des deux méthodes SANS
// changer leur signature ni l'usage côté composant.
// //
// Contrat (fige) : // Contrat figé par ERP-66 (c'est lui qui fait foi) :
// searchCity(postalCode) -> liste { city, postalCode } // searchCity(postalCode) -> liste { city, postalCode }
// searchAddress(query, cp?) -> liste { label, street, postalCode, city } // searchAddress(query, cp?) -> liste { label, street, postalCode, city }
// En cas d'erreur/timeout, la methode THROW une AddressAutocompleteUnavailableError. // En cas d'erreur/timeout, la méthode THROW. Le composant catch l'erreur,
// Le composant consommateur catch, affiche un toast d'avertissement et bascule // affiche un toast d'avertissement et bascule en saisie libre (MalioInputText).
// en saisie libre (MalioInputText). //
// Comportement du stub : les deux méthodes throw systématiquement → l'onglet
// Adresse part directement en mode dégradé (Ville + Adresse en saisie libre,
// Code postal saisi manuellement). Aucun appel réseau n'est émis ici.
/** URL de l'endpoint de recherche BAN. */ /** Une suggestion de ville renvoyée à partir d'un code postal. */
const BAN_SEARCH_URL = 'https://api-adresse.data.gouv.fr/search/'
/** Une suggestion de ville renvoyee a partir d'un code postal. */
export interface CitySuggestion { export interface CitySuggestion {
city: string city: string
postalCode: string postalCode: string
} }
/** Une suggestion d'adresse complete (saisie assistee du champ « Adresse »). */ /** Une suggestion d'adresse complète (saisie assistée du champ « Adresse »). */
export interface AddressSuggestion { export interface AddressSuggestion {
label: string label: string
street: string street: string
@@ -36,82 +34,27 @@ export interface AddressAutocomplete {
searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]> searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]>
} }
/** Erreur signalant que le service d'autocompletion BAN n'est pas disponible. */ /** Erreur signalant que le service d'autocomplétion BAN n'est pas disponible. */
export class AddressAutocompleteUnavailableError extends Error { export class AddressAutocompleteUnavailableError extends Error {
constructor() { constructor() {
// Message technique (non affiche tel quel) : le composant remonte son // Message technique (non affiché tel quel) : le composant remonte son
// propre libelle i18n. Sert au debug / aux logs uniquement. // propre libellé i18n. Sert au debug / aux logs uniquement.
super('Address autocomplete (BAN) is not available.') super('Address autocomplete (BAN) is not available yet — ERP-66 stub.')
this.name = 'AddressAutocompleteUnavailableError' this.name = 'AddressAutocompleteUnavailableError'
} }
} }
/** Proprietes d'une « feature » GeoJSON renvoyee par la BAN (champs utilises). */ /**
interface BanFeatureProperties { * STUB : renvoie un composable conforme au contrat ERP-66 dont les méthodes
label?: string * échouent toujours, forçant le mode dégradé côté onglet Adresse.
name?: string */
street?: string
postcode?: string
city?: string
}
/** Reponse GeoJSON FeatureCollection de la BAN. */
interface BanResponse {
features?: { properties?: BanFeatureProperties }[]
}
export function useAddressAutocomplete(): AddressAutocomplete { export function useAddressAutocomplete(): AddressAutocomplete {
return { return {
async searchCity(postalCode: string): Promise<CitySuggestion[]> { async searchCity(_postalCode: string): Promise<CitySuggestion[]> {
let res: BanResponse throw new AddressAutocompleteUnavailableError()
try {
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, {
query: { q: postalCode, type: 'municipality' },
})
} catch {
// Reseau coupe, 5xx, timeout... -> mode degrade cote composant.
throw new AddressAutocompleteUnavailableError()
}
return (res.features ?? []).map((feature) => {
const props = feature.properties ?? {}
return {
city: props.city ?? props.name ?? '',
postalCode: props.postcode ?? '',
}
})
}, },
async searchAddress(_query: string, _postalCode?: string): Promise<AddressSuggestion[]> {
async searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]> { throw new AddressAutocompleteUnavailableError()
// IMPORTANT : pas de `type=housenumber` ici. La BAN ne renvoie un
// resultat de ce type qu'une fois un numero saisi → une recherche par
// nom de rue (« boulevard du port ») renverrait 0 resultat pendant
// toute la frappe. Sans filtre `type`, la BAN classe rues + numeros
// par pertinence (comportement d'autocompletion attendu).
// On n'ajoute `postcode` que s'il est fourni (sinon recherche large).
const banQuery: Record<string, string> = { q: query }
if (postalCode) {
banQuery.postcode = postalCode
}
let res: BanResponse
try {
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, { query: banQuery })
} catch {
throw new AddressAutocompleteUnavailableError()
}
return (res.features ?? []).map((feature) => {
const props = feature.properties ?? {}
return {
label: props.label ?? '',
// `name` porte la ligne d'adresse complete (numero + voie) ;
// `street` ne contient que la voie. On privilegie `name`.
street: props.name ?? props.street ?? '',
postalCode: props.postcode ?? '',
city: props.city ?? '',
}
})
}, },
} }
} }
+5 -5
View File
@@ -44,7 +44,7 @@ export function useApi(): ApiClient {
const data = responseData ?? (error as FetchError)?.data const data = responseData ?? (error as FetchError)?.data
const msg = extractApiErrorMessage(data) const msg = extractApiErrorMessage(data)
if (msg) return msg if (msg) return msg
return (error as FetchError)?.message ?? t('errors.unknown') return (error as FetchError)?.message ?? 'Erreur inconnue.'
} }
const methodErrorKeys: Record<string, string> = { const methodErrorKeys: Record<string, string> = {
@@ -76,7 +76,7 @@ export function useApi(): ApiClient {
if (successMessage) { if (successMessage) {
toast.success({ toast.success({
title: t('success.title'), title: 'Succes',
message: successMessage message: successMessage
}) })
} }
@@ -98,10 +98,10 @@ export function useApi(): ApiClient {
apiOptions?.toastErrorMessage || apiOptions?.toastErrorMessage ||
errorMessage || errorMessage ||
extractedMessage || extractedMessage ||
t('errors.generic') 'Une erreur est survenue.'
toast.error({ toast.error({
title: apiOptions?.toastTitle ?? t('errors.title'), title: apiOptions?.toastTitle ?? 'Erreur',
message message
}) })
} }
@@ -139,7 +139,7 @@ export function useApi(): ApiClient {
'Une erreur est survenue.' 'Une erreur est survenue.'
toast.error({ toast.error({
title: apiOptions?.toastTitle ?? t('errors.title'), title: apiOptions?.toastTitle ?? 'Erreur',
message message
}) })
} }
@@ -1,113 +0,0 @@
/**
* Composable d'erreurs de formulaire convention de mapping erreurchamp pour
* tous les forms du projet (ERP-101).
*
* Le back renvoie TOUTES les violations d'une 422 d'un coup (un `propertyPath`
* + `message` par champ fautif). Ce composable centralise leur affichage
* inline : il tient un `Record<propertyPath, message>` reactif que le template
* branche directement sur la prop `:error` des composants `Malio*` (le nom du
* champ cote front = le `propertyPath` cote back, donc aucun mapping manuel).
*
* Chaque appel cree son propre etat (refs internes a la fonction) un form =
* une instance, pas de singleton partage.
*
* Convention d'usage : les appels API qui veulent un retour inline doivent
* passer `{ toast: false }` a `useApi` (sinon le toast natif masque le mapping
* fin), puis router l'erreur via `handleApiError`. Pour les collections (1
* appel par ligne), utiliser directement `mapViolationsToRecord` par ligne.
*/
import { computed, reactive } from 'vue'
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
/**
* Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status
* + payload) pour eviter de typer toute la lib.
*/
interface ApiFetchError {
response?: {
status?: number
_data?: unknown
}
}
/** Options de `handleApiError`. */
interface HandleApiErrorOptions {
/** Message de toast si l'erreur n'est pas une 422 exploitable. */
fallbackMessage?: string
}
export function useFormErrors() {
const toast = useToast()
const { t } = useI18n()
// Etat d'erreurs indexe par propertyPath. Reactif : muter une cle suffit a
// rafraichir la prop `:error` du champ correspondant.
const errors = reactive<Record<string, string>>({})
const hasErrors = computed(() => Object.keys(errors).length > 0)
/** Pose une erreur sur un champ. */
function setError(field: string, message: string): void {
errors[field] = message
}
/** Retire l'erreur d'un champ (no-op si absente). */
function clearError(field: string): void {
delete errors[field]
}
/** Vide toutes les erreurs (a appeler en debut de submit). */
function clearErrors(): void {
for (const key of Object.keys(errors)) {
delete errors[key]
}
}
/**
* Mappe les violations 422 d'un payload sur les champs. Retourne true des
* qu'au moins une violation a ete posee, false sinon (payload sans
* violation exploitable).
*/
function setServerErrors(data: unknown): boolean {
const mapped = mapViolationsToRecord(data)
const keys = Object.keys(mapped)
if (keys.length === 0) return false
for (const key of keys) {
errors[key] = mapped[key]
}
return true
}
/**
* Route une erreur API : 422 avec violations exploitables mapping inline
* (pas de toast, l'erreur s'affiche sous le champ) ; sinon toast de
* fallback (message serveur extrait, ou `fallbackMessage`).
*
* Retourne true si l'erreur a ete mappee inline, false si fallback toast.
*/
function handleApiError(e: unknown, opts: HandleApiErrorOptions = {}): boolean {
const status = (e as ApiFetchError)?.response?.status
const data = (e as ApiFetchError)?.response?._data
if (status === 422 && setServerErrors(data)) {
return true
}
const message
= extractApiErrorMessage(data)
|| opts.fallbackMessage
|| t('errors.generic')
toast.error({ title: t('errors.title'), message })
return false
}
return {
errors,
hasErrors,
setError,
clearError,
clearErrors,
setServerErrors,
handleApiError,
}
}
@@ -1,58 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mapViolationsToRecord } from '../api'
/**
* Tests de `mapViolationsToRecord` fondation du mapping erreurchamp des
* formulaires (ERP-101). Transforme un payload 422 API Platform en
* `Record<propertyPath, message>` directement consommable par la prop `:error`
* des composants `Malio*`.
*/
describe('mapViolationsToRecord', () => {
it('mappe chaque violation par son propertyPath (format `violations`)', () => {
const data = {
violations: [
{ propertyPath: 'companyName', message: 'Obligatoire.' },
{ propertyPath: 'siren', message: 'SIREN deja utilise.' },
],
}
expect(mapViolationsToRecord(data)).toEqual({
companyName: 'Obligatoire.',
siren: 'SIREN deja utilise.',
})
})
it('supporte le format negocie `hydra:violations`', () => {
const data = {
'hydra:violations': [
{ propertyPath: 'email', message: 'Adresse invalide.' },
],
}
expect(mapViolationsToRecord(data)).toEqual({ email: 'Adresse invalide.' })
})
it('renvoie un objet vide quand il n\'y a pas de violation exploitable', () => {
expect(mapViolationsToRecord({})).toEqual({})
expect(mapViolationsToRecord(null)).toEqual({})
expect(mapViolationsToRecord({ violations: [] })).toEqual({})
})
it('ignore les violations sans propertyPath', () => {
const data = {
violations: [
{ propertyPath: '', message: 'Erreur globale.' },
{ propertyPath: 'iban', message: 'IBAN invalide.' },
],
}
expect(mapViolationsToRecord(data)).toEqual({ iban: 'IBAN invalide.' })
})
it('en cas de doublon de propertyPath, la derniere violation gagne', () => {
const data = {
violations: [
{ propertyPath: 'name', message: 'Premier message.' },
{ propertyPath: 'name', message: 'Second message.' },
],
}
expect(mapViolationsToRecord(data)).toEqual({ name: 'Second message.' })
})
})
@@ -1,56 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { httpExternal } from '../httpExternal'
// On mocke ofetch : httpExternal s'appuie sur $fetch sans jamais toucher le
// reseau pendant les tests. vi.mock est hoiste par Vitest au-dessus des imports.
const mockFetch = vi.hoisted(() => vi.fn())
vi.mock('ofetch', () => ({ $fetch: mockFetch }))
describe('httpExternal', () => {
beforeEach(() => {
mockFetch.mockReset()
})
it('retourne le JSON parse renvoye par $fetch', async () => {
mockFetch.mockResolvedValueOnce({ ok: true })
const res = await httpExternal<{ ok: boolean }>('https://example.test/api')
expect(res).toEqual({ ok: true })
})
it('transmet la query, coupe le cookie (credentials omit) et pose un timeout par defaut', async () => {
mockFetch.mockResolvedValueOnce([])
await httpExternal('https://example.test/search', {
query: { q: '80000', type: 'municipality' },
})
expect(mockFetch).toHaveBeenCalledWith(
'https://example.test/search',
expect.objectContaining({
query: { q: '80000', type: 'municipality' },
credentials: 'omit',
retry: 0,
timeout: 5000,
}),
)
})
it('permet de surcharger le timeout', async () => {
mockFetch.mockResolvedValueOnce(null)
await httpExternal('https://example.test', { timeoutMs: 1000 })
expect(mockFetch).toHaveBeenCalledWith(
'https://example.test',
expect.objectContaining({ timeout: 1000 }),
)
})
it('propage l\'erreur reseau / timeout (throw)', async () => {
mockFetch.mockRejectedValueOnce(new Error('network down'))
await expect(httpExternal('https://example.test')).rejects.toThrow('network down')
})
})
@@ -20,27 +20,4 @@ describe('formatPhoneFR', () => {
it('groupe par 2 meme un nombre impair de chiffres (dernier groupe seul)', () => { it('groupe par 2 meme un nombre impair de chiffres (dernier groupe seul)', () => {
expect(formatPhoneFR('123')).toBe('12 3') expect(formatPhoneFR('123')).toBe('12 3')
}) })
it('formate une saisie courte (<= 4 chiffres) sans planter', () => {
expect(formatPhoneFR('1')).toBe('1')
expect(formatPhoneFR('12')).toBe('12')
expect(formatPhoneFR('1234')).toBe('12 34')
})
it('strip les caracteres non numeriques (lettres, espaces, ponctuation)', () => {
expect(formatPhoneFR('abc')).toBe('')
expect(formatPhoneFR('Tel : 06.12')).toBe('06 12')
expect(formatPhoneFR(' 06 12 ')).toBe('06 12')
})
it('conserve l\'indicatif international (+33) sans le transformer', () => {
// Comportement fige : on retire seulement le `+`, on ne deduit pas le
// prefixe pays. Le `+33...` est donc groupe brut par paquets de 2.
expect(formatPhoneFR('+33612345678')).toBe('33 61 23 45 67 8')
})
it('groupe sans tronquer une saisie plus longue que 10 chiffres', () => {
// Aucune troncature silencieuse : on figure tous les chiffres groupes par 2.
expect(formatPhoneFR('061234567899')).toBe('06 12 34 56 78 99')
})
}) })
-19
View File
@@ -66,25 +66,6 @@ export function extractApiViolations(data: unknown): ApiViolation[] {
return out return out
} }
/**
* Transforme un payload d'erreur 422 d'API Platform en dictionnaire
* `{ propertyPath: message }`, directement consommable par la prop `:error`
* des composants `Malio*` (le nom du champ cote front = le `propertyPath`
* renvoye par le back). Fondation du mapping erreurchamp des formulaires :
* utilise par `useFormErrors` (champs scalaires) et par les boucles de submit
* de collections (erreur par ligne).
*
* Les violations sans `propertyPath` (erreur globale) sont ignorees ; en cas
* de doublon de `propertyPath`, la derniere violation l'emporte.
*/
export function mapViolationsToRecord(data: unknown): Record<string, string> {
const out: Record<string, string> = {}
for (const v of extractApiViolations(data)) {
if (v.propertyPath) out[v.propertyPath] = v.message
}
return out
}
/** /**
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON * Extrait un message d'erreur lisible depuis un payload Hydra / JSON
* d'erreur API Platform. Essaie les champs courants dans l'ordre : * d'erreur API Platform. Essaie les champs courants dans l'ordre :
-40
View File
@@ -1,40 +0,0 @@
import { $fetch } from 'ofetch'
/**
* Options d'un appel HTTP externe.
*/
export interface HttpExternalOptions {
/** Parametres de query string (encodes par ofetch). */
query?: Record<string, string | number | undefined>
/** Timeout en millisecondes avant abandon (defaut 5000). */
timeoutMs?: number
}
/**
* Petit client HTTP pour les APIs PUBLIQUES EXTERNES (domaine tiers, hors `/api`).
*
* Pourquoi un helper dedie plutot que `useApi()` : `useApi()` est le client de
* l'API interne Starseed (baseURL `/api`, cookie JWT `credentials: 'include'`,
* parsing/erreurs Hydra, redirection `/login` sur 401, toasts i18n). Tout cela
* est inadapte voire indesirable pour un endpoint public externe comme la
* Base Adresse Nationale (`api-adresse.data.gouv.fr`).
*
* Ce helper est donc le SEUL point d'entree autorise pour un `$fetch` brut vers
* l'externe (cf. regle frontend n°4 : pas de `$fetch` eparpille dans les
* composants). Il :
* - cible une URL absolue (pas de baseURL `/api`) ;
* - n'envoie PAS le cookie de session (`credentials: 'omit'`) ;
* - ne retente pas (`retry: 0`) et applique un timeout ;
* - laisse remonter l'erreur (throw) au consommateur de gerer le mode degrade.
*/
export async function httpExternal<T>(
url: string,
opts: HttpExternalOptions = {},
): Promise<T> {
return $fetch<T>(url, {
query: opts.query,
credentials: 'omit',
retry: 0,
timeout: opts.timeoutMs ?? 5000,
})
}
@@ -112,7 +112,7 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
// persiste, sans contradiction entre l'ordre Validate / Process. // persiste, sans contradiction entre l'ordre Validate / Process.
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'Le nom est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'Le nom est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 120, minMessage: 'Le nom doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(min: 2, max: 120, normalizer: 'trim')]
#[Groups(['category:read', 'category:write'])] #[Groups(['category:read', 'category:write'])]
private ?string $name = null; private ?string $name = null;
@@ -1,77 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Domain\Entity\Client;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier (spec-front § Onglet Comptabilite) : a la soumission complete
* de l'onglet Comptabilite, les six champs scalaires obligatoires doivent etre
* renseignes (SIREN, Numero de compte, Mode de TVA, N de TVA, Delai de reglement,
* Type de reglement). La banque reste conditionnelle (RG-1.12) et les RIB aussi
* (RG-1.13) : ils ne sont pas couverts ici.
*
* Calque sur ClientInformationCompletenessValidator (RG-1.04) : colonnes nullable
* en base + validateur contextuel, plutot qu'un Assert\NotBlank sur l'entite (qui
* casserait le POST de l'onglet principal, lequel n'envoie aucun champ comptable).
*
* Invoque par le ClientProcessor uniquement quand le payload porte les six champs
* (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ comptable.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
* coherence avec les violations Symfony rendues par API Platform (mapping inline
* front via useFormErrors, ERP-101).
*/
final class ClientAccountingCompletenessValidator
{
public function validate(Client $client): void
{
// Map champ -> valeur courante des champs obligatoires de l'onglet.
$fields = [
'siren' => $client->getSiren(),
'accountNumber' => $client->getAccountNumber(),
'tvaMode' => $client->getTvaMode(),
'nTva' => $client->getNTva(),
'paymentDelay' => $client->getPaymentDelay(),
'paymentType' => $client->getPaymentType(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
'Ce champ est obligatoire.',
null,
[],
$client,
$property,
$value,
));
}
}
if (count($violations) > 0) {
throw new ValidationException($violations);
}
}
/**
* Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
* references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que
* lorsqu'elles valent null.
*/
private function isMissing(mixed $value): bool
{
if (null === $value) {
return true;
}
return is_string($value) && '' === trim($value);
}
}
@@ -147,7 +147,7 @@ class Client implements TimestampableInterface, BlamableInterface
// === Formulaire principal === // === Formulaire principal ===
#[ORM\Column(length: 180)] #[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom de l\'entreprise doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom de l\'entreprise ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(min: 2, max: 180, normalizer: 'trim')]
#[Groups(['client:read', 'client:write:main'])] #[Groups(['client:read', 'client:write:main'])]
private ?string $companyName = null; private ?string $companyName = null;
@@ -188,7 +188,6 @@ class Client implements TimestampableInterface, BlamableInterface
private ?string $description = null; private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
private ?string $competitors = null; private ?string $competitors = null;
@@ -197,7 +196,7 @@ class Client implements TimestampableInterface, BlamableInterface
private ?DateTimeImmutable $foundedAt = null; private ?DateTimeImmutable $foundedAt = null;
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
#[Assert\PositiveOrZero(message: 'L\'effectif doit être un nombre positif ou nul.')] #[Assert\PositiveOrZero]
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
private ?int $employeesCount = null; private ?int $employeesCount = null;
@@ -206,7 +205,6 @@ class Client implements TimestampableInterface, BlamableInterface
private ?string $revenueAmount = null; private ?string $revenueAmount = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
private ?string $directorName = null; private ?string $directorName = null;
@@ -219,12 +217,10 @@ class Client implements TimestampableInterface, BlamableInterface
// futur Provider si l'user a la permission accounting.view). Ecriture via // futur Provider si l'user a la permission accounting.view). Ecriture via
// `client:write:accounting` (le futur Processor exige accounting.manage). // `client:write:accounting` (le futur Processor exige accounting.manage).
#[ORM\Column(length: 20, nullable: true)] #[ORM\Column(length: 20, nullable: true)]
#[Assert\Length(max: 20, maxMessage: 'Le SIREN ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client:read:accounting', 'client:write:accounting'])] #[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $siren = null; private ?string $siren = null;
#[ORM\Column(length: 40, nullable: true)] #[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client:read:accounting', 'client:write:accounting'])] #[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $accountNumber = null; private ?string $accountNumber = null;
@@ -234,7 +230,6 @@ class Client implements TimestampableInterface, BlamableInterface
private ?TvaMode $tvaMode = null; private ?TvaMode $tvaMode = null;
#[ORM\Column(length: 40, nullable: true)] #[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client:read:accounting', 'client:write:accounting'])] #[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $nTva = null; private ?string $nTva = null;
@@ -63,11 +63,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
uriVariables: [ uriVariables: [
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'), 'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
], ],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT ClientAddress ... WHERE client = :id) et
// casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par ClientAddressProcessor::linkParent (404 si absent).
read: false,
security: "is_granted('commercial.clients.manage')", security: "is_granted('commercial.clients.manage')",
normalizationContext: ['groups' => ['client_address:read']], normalizationContext: ['groups' => ['client_address:read']],
denormalizationContext: ['groups' => ['client_address:write']], denormalizationContext: ['groups' => ['client_address:write']],
@@ -130,39 +125,33 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
private bool $isBilling = false; private bool $isBilling = false;
#[ORM\Column(length: 80, options: ['default' => 'France'])] #[ORM\Column(length: 80, options: ['default' => 'France'])]
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private string $country = 'France'; private string $country = 'France';
// RG-1.09 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur). // RG-1.09 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
// Le Regex borne deja la longueur (≤ 5) : pas de Length redondant.
#[ORM\Column(length: 20)] #[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le code postal est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank]
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')] #[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private ?string $postalCode = null; private ?string $postalCode = null;
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private ?string $city = null; private ?string $city = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private ?string $street = null; private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private ?string $streetComplement = null; private ?string $streetComplement = null;
// RG-1.11 : obligatoire ssi isBilling (validateBillingEmailPresence + CHECK BDD). // RG-1.11 : obligatoire ssi isBilling (validateBillingEmailPresence + CHECK BDD).
#[ORM\Column(length: 180, nullable: true)] #[ORM\Column(length: 180, nullable: true)]
#[Assert\Email(message: 'L\'email de facturation n\'est pas valide.')] #[Assert\Email]
#[Assert\Length(max: 180, maxMessage: 'L\'email de facturation ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private ?string $billingEmail = null; private ?string $billingEmail = null;
@@ -188,14 +177,12 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private Collection $contacts; private Collection $contacts;
// Au moins une categorie est obligatoire sur une adresse (spec-front § Adresse).
// RG-1.29 : categories de code DISTRIBUTEUR/COURTIER interdites (validateCategoryCodes). // RG-1.29 : categories de code DISTRIBUTEUR/COURTIER interdites (validateCategoryCodes).
/** @var Collection<int, CategoryInterface> */ /** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'client_address_category')] #[ORM\JoinTable(name: 'client_address_category')]
#[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private Collection $categories; private Collection $categories;
@@ -50,11 +50,6 @@ use Symfony\Component\Validator\Constraints as Assert;
uriVariables: [ uriVariables: [
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'), 'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
], ],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT ClientContact ... WHERE client = :id) et
// casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par ClientContactProcessor::linkParent (404 si absent).
read: false,
security: "is_granted('commercial.clients.manage')", security: "is_granted('commercial.clients.manage')",
normalizationContext: ['groups' => ['client_contact:read']], normalizationContext: ['groups' => ['client_contact:read']],
denormalizationContext: ['groups' => ['client_contact:write']], denormalizationContext: ['groups' => ['client_contact:write']],
@@ -93,36 +88,30 @@ class ClientContact implements TimestampableInterface, BlamableInterface
// RG-1.05 : firstName OU lastName obligatoire (CHECK BDD + Processor). Les // RG-1.05 : firstName OU lastName obligatoire (CHECK BDD + Processor). Les
// deux restent nullable au niveau ORM. // deux restent nullable au niveau ORM.
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])] #[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $firstName = null; private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])] #[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $lastName = null; private ?string $lastName = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])] #[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $jobTitle = null; private ?string $jobTitle = null;
// RG : pas de validation de format telephone (saisie libre), mais une
// Assert\Length calee sur la colonne VARCHAR(20) evite l'erreur Postgres
// (500 non rattachee au champ) au profit d'une 422 propre (ERP-107).
#[ORM\Column(length: 20, nullable: true)] #[ORM\Column(length: 20, nullable: true)]
#[Assert\Length(max: 20, maxMessage: 'Le téléphone ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])] #[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $phonePrimary = null; private ?string $phonePrimary = null;
#[ORM\Column(length: 20, nullable: true)] #[ORM\Column(length: 20, nullable: true)]
#[Assert\Length(max: 20, maxMessage: 'Le téléphone secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])] #[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $phoneSecondary = null; private ?string $phoneSecondary = null;
#[ORM\Column(length: 180, nullable: true)] #[ORM\Column(length: 180, nullable: true)]
#[Assert\Email(message: 'L\'adresse email n\'est pas valide.')] #[Assert\Email]
#[Assert\Length(max: 180, maxMessage: 'L\'email ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])] #[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $email = null; private ?string $email = null;
@@ -54,11 +54,6 @@ use Symfony\Component\Validator\Constraints as Assert;
uriVariables: [ uriVariables: [
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'), 'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
], ],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT ClientRib ... WHERE client = :id) et
// casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par ClientRibProcessor::linkParent (404 si absent).
read: false,
security: "is_granted('commercial.clients.accounting.manage')", security: "is_granted('commercial.clients.accounting.manage')",
normalizationContext: ['groups' => ['client_rib:read']], normalizationContext: ['groups' => ['client_rib:read']],
denormalizationContext: ['groups' => ['client_rib:write']], denormalizationContext: ['groups' => ['client_rib:write']],
@@ -102,22 +97,20 @@ class ClientRib implements TimestampableInterface, BlamableInterface
private ?Client $client = null; private ?Client $client = null;
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'Le libellé du RIB est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank]
#[Assert\Length(max: 120, maxMessage: 'Le libellé ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])] #[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
private ?string $label = null; private ?string $label = null;
// Bic/Iban bornent deja le format (et donc la longueur) : pas de Length
// redondant calee sur la colonne (whitelist du garde-fou ERP-107).
#[ORM\Column(length: 20)] #[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank]
#[Assert\Bic(message: 'Le BIC n\'est pas valide.')] #[Assert\Bic]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])] #[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
private ?string $bic = null; private ?string $bic = null;
#[ORM\Column(length: 34)] #[ORM\Column(length: 34)]
#[Assert\NotBlank(message: 'L\'IBAN est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank]
#[Assert\Iban(message: 'L\'IBAN n\'est pas valide.')] #[Assert\Iban]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])] #[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
private ?string $iban = null; private ?string $iban = null;
@@ -12,7 +12,6 @@ use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\ClientAddress; use App\Module\Commercial\Domain\Entity\ClientAddress;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/** /**
* Processor d'ecriture de la sous-ressource Adresse d'un client (M1, § 4.5). * Processor d'ecriture de la sous-ressource Adresse d'un client (M1, § 4.5).
@@ -76,14 +75,9 @@ final class ClientAddressProcessor implements ProcessorInterface
? $clientId ? $clientId
: $this->em->getRepository(Client::class)->find($clientId); : $this->em->getRepository(Client::class)->find($clientId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est if ($client instanceof Client) {
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la $address->setClient($client);
// contrainte client_id NOT NULL).
if (!$client instanceof Client) {
throw new NotFoundHttpException('Client introuvable.');
} }
$address->setClient($client);
} }
/** /**
@@ -14,7 +14,6 @@ use App\Module\Commercial\Domain\Entity\ClientContact;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Component\Validator\ConstraintViolationList;
@@ -89,14 +88,9 @@ final class ClientContactProcessor implements ProcessorInterface
? $clientId ? $clientId
: $this->em->getRepository(Client::class)->find($clientId); : $this->em->getRepository(Client::class)->find($clientId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est if ($client instanceof Client) {
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la $contact->setClient($client);
// contrainte client_id NOT NULL).
if (!$client instanceof Client) {
throw new NotFoundHttpException('Client introuvable.');
} }
$contact->setClient($client);
} }
/** /**
@@ -8,7 +8,6 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException; use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer; use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use App\Module\Commercial\Application\Validator\ClientAccountingCompletenessValidator;
use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator; use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Client; use App\Module\Commercial\Domain\Entity\Client;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface; use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
@@ -76,14 +75,6 @@ final class ClientProcessor implements ProcessorInterface
'paymentType', 'bank', 'paymentType', 'bank',
]; ];
/**
* Champs comptables obligatoires a la validation complete de l'onglet
* (spec-front § Onglet Comptabilite). bank est exclu : conditionnel (RG-1.12).
*/
private const array ACCOUNTING_REQUIRED_FIELDS = [
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType',
];
/** Champ d'archivage (groupe client:write:archive). */ /** Champ d'archivage (groupe client:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived'; private const string ARCHIVE_FIELD = 'isArchived';
@@ -109,7 +100,6 @@ final class ClientProcessor implements ProcessorInterface
private readonly ProcessorInterface $persistProcessor, private readonly ProcessorInterface $persistProcessor,
private readonly ClientFieldNormalizer $normalizer, private readonly ClientFieldNormalizer $normalizer,
private readonly ClientInformationCompletenessValidator $informationValidator, private readonly ClientInformationCompletenessValidator $informationValidator,
private readonly ClientAccountingCompletenessValidator $accountingValidator,
private readonly Security $security, private readonly Security $security,
private readonly RequestStack $requestStack, private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
@@ -135,7 +125,6 @@ final class ClientProcessor implements ProcessorInterface
$this->validateDistributorBroker($data); $this->validateDistributorBroker($data);
$this->validateAccountingConsistency($data); $this->validateAccountingConsistency($data);
$this->validateAccountingCompleteness($data);
$this->validateInformationCompleteness($data); $this->validateInformationCompleteness($data);
try { try {
@@ -497,29 +486,6 @@ final class ClientProcessor implements ProcessorInterface
} }
} }
/**
* spec-front § Onglet Comptabilite : a la validation COMPLETE de l'onglet
* (les six champs obligatoires presents dans le payload le front les envoie
* toujours ensemble), chacun doit etre renseigne, sinon 422 par champ. On ne
* declenche pas sur un PATCH ciblant un sous-ensemble de champs comptables :
* ce n'est pas une validation d'onglet (edition ponctuelle preservee). bank /
* RIB restent geres par validateAccountingConsistency (RG-1.12 / RG-1.13).
*
* Colonnes nullable en base + validateur contextuel (meme parti que RG-1.04) :
* un Assert\NotBlank sur l'entite casserait le POST de l'onglet principal, qui
* n'envoie aucun champ comptable.
*/
private function validateAccountingCompleteness(Client $data): void
{
// Declenche uniquement si TOUS les champs requis sont presents dans le
// payload (= soumission d'onglet, pas un PATCH partiel cible).
if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) {
return;
}
$this->accountingValidator->validate($data);
}
/** /**
* RG-1.04 (durcie ERP-74) : si l'utilisateur porte le role metier * RG-1.04 (durcie ERP-74) : si l'utilisateur porte le role metier
* Commerciale, TOUS les champs de l'onglet Information sont obligatoires sur * Commerciale, TOUS les champs de l'onglet Information sont obligatoires sur
@@ -12,7 +12,6 @@ use App\Module\Commercial\Domain\Entity\ClientRib;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/** /**
* Processor d'ecriture de la sous-ressource RIB d'un client (M1, § 4.5). * Processor d'ecriture de la sous-ressource RIB d'un client (M1, § 4.5).
@@ -78,14 +77,9 @@ final class ClientRibProcessor implements ProcessorInterface
? $clientId ? $clientId
: $this->em->getRepository(Client::class)->find($clientId); : $this->em->getRepository(Client::class)->find($clientId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est if ($client instanceof Client) {
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la $rib->setClient($client);
// contrainte client_id NOT NULL).
if (!$client instanceof Client) {
throw new NotFoundHttpException('Client introuvable.');
} }
$rib->setClient($client);
} }
/** /**
+3 -5
View File
@@ -79,15 +79,13 @@ class Role
#[ORM\Column(length: 100)] #[ORM\Column(length: 100)]
#[Groups(['role:read', 'role:write'])] #[Groups(['role:read', 'role:write'])]
#[Assert\NotBlank(message: 'Le code du rôle est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank]
#[Assert\Regex(pattern: '/^[a-z][a-z0-9_]*$/', message: 'Le code doit être en snake_case et commencer par une lettre minuscule.')] #[Assert\Regex(pattern: '/^[a-z][a-z0-9_]*$/', message: 'Le code doit etre en snake_case et commencer par une lettre minuscule.')]
#[Assert\Length(max: 100, maxMessage: 'Le code ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
private string $code; private string $code;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Groups(['role:read', 'role:write'])] #[Groups(['role:read', 'role:write'])]
#[Assert\NotBlank(message: 'Le libellé du rôle est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank]
#[Assert\Length(max: 255, maxMessage: 'Le libellé ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
private string $label; private string $label;
#[ORM\Column(type: Types::TEXT, nullable: true)] #[ORM\Column(type: Types::TEXT, nullable: true)]
-3
View File
@@ -33,7 +33,6 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource( #[ApiResource(
operations: [ operations: [
@@ -86,8 +85,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Busines
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 180, unique: true)] #[ORM\Column(length: 180, unique: true)]
#[Assert\NotBlank(message: 'Le nom d\'utilisateur est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 180, maxMessage: 'Le nom d\'utilisateur ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['me:read', 'user:list', 'user:write'])] #[Groups(['me:read', 'user:list', 'user:write'])]
private ?string $username = null; private ?string $username = null;
-13
View File
@@ -219,19 +219,6 @@
"config/routes/security.yaml" "config/routes/security.yaml"
] ]
}, },
"symfony/translation": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843"
},
"files": [
"config/packages/translation.yaml",
"translations/.gitignore"
]
},
"symfony/twig-bundle": { "symfony/twig-bundle": {
"version": "8.0", "version": "8.0",
"recipe": { "recipe": {
@@ -1,363 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Architecture;
use Doctrine\ORM\Mapping\Column;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ReflectionProperty;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints as Assert;
use function in_array;
use function is_string;
use function sprintf;
use function str_contains;
/**
* Garde-fou architecture ERP-107 : toute contrainte `#[Assert\*]` portee par une
* entite metier doit avoir un message FR EXPLICITE (et non le defaut anglais de
* Symfony), et toute colonne string bornee writable doit avoir une `Assert\Length`
* calee sur le `length` de la colonne ORM.
*
* Pourquoi (lien ERP-101) : le front (useFormErrors / mapViolationsToRecord)
* affiche sous chaque champ le `message` renvoye par le back. Un message absent
* = defaut anglais ; une colonne bornee sans Assert\Length = erreur Postgres
* (500) au lieu d'une 422 propre rattachee au champ.
*
* Deux verifications, sur le modele de AuditableEntitiesHaveI18nLabelTest :
* 1. MESSAGE EXPLICITE : pour chaque contrainte connue, la (ou les) propriete(s)
* de message pertinente(s) doivent differer du defaut Symfony. La comparaison
* au defaut (instance « nue » de la meme contrainte) evite de valider un
* message anglais natif laisse tel quel.
* 2. LENGTH == ORM length : toute propriete string writable avec `ORM\Column(length:)`
* doit porter `Assert\Length(max:)` egal a ce length sauf si le format est
* deja borne par Bic/Iban, ou whitelistee dans EXCLUDED_LENGTH_MIRROR.
*
* @internal
*/
final class EntityConstraintsHaveFrenchMessageTest extends TestCase
{
/**
* Proprietes writable exemptees du miroir Assert\Length == ORM length, avec
* justification. Toute entree doit citer la raison (format deja borne par une
* autre contrainte). Cle : "<ClasseCourte>::<propriete>".
*
* @var array<string, string>
*/
private const array EXCLUDED_LENGTH_MIRROR = [
// Le Regex /^[0-9]{4,5}$/ borne deja la longueur a 5 caracteres (< 20).
'ClientAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
// Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres.
'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.',
];
/**
* Mapping contrainte -> proprietes de message a verifier. Une contrainte
* absente de ce mapping (hors Callback) fait ECHOUER le test : il faut
* l'ajouter explicitement (anti faux positif vert sur une contrainte inconnue).
*
* Pour Length / Count, la liste est calculee dynamiquement (minMessage si
* `min` est pose, maxMessage si `max` est pose).
*
* @var list<class-string<Constraint>>
*/
private const array SIMPLE_MESSAGE_CONSTRAINTS = [
Assert\NotBlank::class,
Assert\NotNull::class,
Assert\Email::class,
Assert\Regex::class,
Assert\Bic::class,
Assert\Iban::class,
Assert\PositiveOrZero::class,
Assert\Positive::class,
Assert\NegativeOrZero::class,
Assert\Negative::class,
];
public function testEveryConstraintHasAnExplicitFrenchMessage(): void
{
$checked = 0;
foreach ($this->entityProperties() as [$shortClass, $property]) {
foreach ($property->getAttributes() as $attribute) {
$name = $attribute->getName();
if (!is_subclass_of($name, Constraint::class)) {
continue;
}
// Les Callback portent leur message dans la closure : hors scope.
if (Assert\Callback::class === $name) {
continue;
}
/** @var Constraint $constraint */
$constraint = $attribute->newInstance();
$messageProps = $this->messagePropertiesFor($constraint);
self::assertNotNull(
$messageProps,
sprintf(
'Contrainte non geree par le garde-fou : %s sur %s::$%s. '
.'Ajouter sa classe au mapping de EntityConstraintsHaveFrenchMessageTest.',
$name,
$shortClass,
$property->getName(),
),
);
foreach ($messageProps as $prop) {
$actual = $constraint->{$prop} ?? null;
$default = $this->defaultMessageFor($name, $prop);
self::assertTrue(
is_string($actual) && '' !== $actual && $actual !== $default,
sprintf(
'La contrainte %s sur %s::$%s n\'a pas de %s FR explicite '
.'(message absent ou laisse au defaut anglais). Cf. ERP-107.',
$name,
$shortClass,
$property->getName(),
$prop,
),
);
++$checked;
}
}
}
self::assertGreaterThan(0, $checked, 'Aucune contrainte verifiee : detection d\'attributs cassee ?');
}
public function testBoundedStringColumnsHaveMatchingLength(): void
{
$checked = 0;
foreach ($this->entityProperties() as [$shortClass, $property]) {
$column = $this->ormColumn($property);
if (null === $column || null === $column->length) {
continue;
}
// Colonnes non-string (text, decimal, date...) : pas de length scalaire a calquer.
if (null !== $column->type && 'string' !== $column->type) {
continue;
}
// Le miroir ne protege que la saisie utilisateur (champs writable).
if (!$this->isPropertyWritable($property)) {
continue;
}
$constraints = $this->constraintsOf($property);
// Format deja borne par Bic/Iban : longueur garantie cote contrainte.
if ($this->hasAnyConstraint($constraints, [Assert\Bic::class, Assert\Iban::class])) {
continue;
}
$excludeKey = $shortClass.'::'.$property->getName();
if (isset(self::EXCLUDED_LENGTH_MIRROR[$excludeKey])) {
continue;
}
$length = null;
foreach ($constraints as $c) {
if ($c instanceof Assert\Length) {
$length = $c->max;
break;
}
}
self::assertNotNull(
$length,
sprintf(
'%s::$%s est une colonne string bornee (length=%d) writable sans Assert\Length : '
.'risque d\'erreur Postgres 500. Ajouter Assert\Length(max: %d) ou whitelister. Cf. ERP-107.',
$shortClass,
$property->getName(),
$column->length,
$column->length,
),
);
self::assertSame(
$column->length,
$length,
sprintf(
'Derive Assert\Length.max (%s) != ORM length (%d) sur %s::$%s. '
.'Le max doit refleter le length de la colonne (anti-derive ERP-107).',
(string) $length,
$column->length,
$shortClass,
$property->getName(),
),
);
++$checked;
}
self::assertGreaterThan(0, $checked, 'Aucune colonne string bornee verifiee : scan casse ?');
}
/**
* Itere (classe courte, ReflectionProperty) sur toutes les entites metier
* sous src/Module/<m>/Domain/Entity/.
*
* @return iterable<array{0: string, 1: ReflectionProperty}>
*/
private function entityProperties(): iterable
{
$finder = new Finder()
->files()
->in(__DIR__.'/../../src/Module')
->path('Domain/Entity')
->name('*.php')
;
self::assertNotEmpty(iterator_to_array($finder), 'Aucune entite scannee : chemin src/Module invalide ?');
foreach ($finder as $file) {
$fqcn = $this->extractFqcn($file->getRealPath());
if (null === $fqcn) {
continue;
}
$reflection = new ReflectionClass($fqcn);
if ($reflection->isAbstract()) {
continue;
}
foreach ($reflection->getProperties() as $property) {
yield [$reflection->getShortName(), $property];
}
}
}
/**
* Liste des proprietes de message a verifier pour une contrainte donnee, ou
* null si la contrainte n'est pas geree (le test echoue alors explicitement).
*
* @return list<string>|null
*/
private function messagePropertiesFor(Constraint $constraint): ?array
{
if ($constraint instanceof Assert\Length) {
$props = [];
if (null !== $constraint->min) {
$props[] = 'minMessage';
}
if (null !== $constraint->max) {
$props[] = 'maxMessage';
}
return $props;
}
if ($constraint instanceof Assert\Count) {
$props = [];
if (null !== $constraint->min) {
$props[] = 'minMessage';
}
if (null !== $constraint->max) {
$props[] = 'maxMessage';
}
return $props;
}
if (in_array($constraint::class, self::SIMPLE_MESSAGE_CONSTRAINTS, true)) {
return ['message'];
}
return null;
}
/**
* Message par defaut d'une contrainte (instance « nue ») pour la propriete
* demandee. Sert de reference pour detecter un message laisse au defaut.
*/
private function defaultMessageFor(string $class, string $prop): ?string
{
$bare = match ($class) {
Assert\Length::class => new Assert\Length(max: 1),
Assert\Count::class => new Assert\Count(min: 1),
Assert\Regex::class => new Assert\Regex(pattern: '/^x$/'),
default => new $class(),
};
$value = $bare->{$prop} ?? null;
return is_string($value) ? $value : null;
}
private function ormColumn(ReflectionProperty $property): ?Column
{
$attrs = $property->getAttributes(Column::class);
return [] === $attrs ? null : $attrs[0]->newInstance();
}
/** @return list<Constraint> */
private function constraintsOf(ReflectionProperty $property): array
{
$out = [];
foreach ($property->getAttributes() as $attribute) {
if (is_subclass_of($attribute->getName(), Constraint::class)) {
$out[] = $attribute->newInstance();
}
}
return $out;
}
/**
* @param list<Constraint> $constraints
* @param list<class-string<Constraint>> $classes
*/
private function hasAnyConstraint(array $constraints, array $classes): bool
{
foreach ($constraints as $c) {
if (in_array($c::class, $classes, true)) {
return true;
}
}
return false;
}
private function isPropertyWritable(ReflectionProperty $property): bool
{
$attrs = $property->getAttributes(Groups::class);
if ([] === $attrs) {
return false;
}
/** @var Groups $groups */
$groups = $attrs[0]->newInstance();
foreach ($groups->groups as $group) {
if (is_string($group) && str_contains($group, 'write')) {
return true;
}
}
return false;
}
private function extractFqcn(string $path): ?string
{
$source = file_get_contents($path);
if (false === $source) {
return null;
}
if (
1 !== preg_match('/^namespace\s+([^;]+);/m', $source, $nsMatch)
|| 1 !== preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $source, $classMatch)
) {
return null;
}
return trim($nsMatch[1]).'\\'.$classMatch[1];
}
}
@@ -167,9 +167,8 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
public function testNonBillingAddressAcceptsEmptyBillingEmail(): void public function testNonBillingAddressAcceptsEmptyBillingEmail(): void
{ {
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Non Billing Empty Email'); $seed = $this->seedClient('Non Billing Empty Email');
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -180,7 +179,6 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
'city' => 'Châtellerault', 'city' => 'Châtellerault',
'street' => '1 rue du Test', 'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()], 'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
], ],
]); ]);
@@ -288,29 +286,6 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
} }
/**
* Spec-front § Adresse : au moins une categorie est obligatoire sur une
* adresse. POST sans categorie (mais avec site) -> 422.
*/
public function testAddressRequiresAtLeastOneCategory(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address No Cat');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
],
]);
self::assertResponseStatusCodeSame(422);
}
/** /**
* Retourne l'IRI du premier site seede (fixtures Sites). * Retourne l'IRI du premier site seede (fixtures Sites).
*/ */
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api; namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Client as ClientEntity; use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Module\Commercial\Domain\Entity\ClientContact; use App\Module\Commercial\Domain\Entity\ClientContact;
use App\Module\Commercial\Domain\Entity\ClientRib; use App\Module\Commercial\Domain\Entity\ClientRib;
use App\Module\Commercial\Domain\Entity\PaymentType; use App\Module\Commercial\Domain\Entity\PaymentType;
@@ -67,98 +66,6 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
} }
/**
* ERP-107 : une violation de contrainte sort avec un message FR explicite ET
* un `propertyPath` rattache au champ (consommable par useFormErrors /
* mapViolationsToRecord cote front, ERP-101). On verifie le JSON 422 reel.
*/
public function testPostContactInvalidEmailReturns422WithFrenchMessageOnField(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Contact Bad Email');
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'firstName' => 'Jean',
'email' => 'pas-un-email',
],
]);
self::assertResponseStatusCodeSame(422);
$byPath = [];
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
self::assertArrayHasKey('email', $byPath, 'La violation email doit porter propertyPath=email (mapping front).');
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
}
/**
* Regression ERP-110 (bug subresource Link toProperty) : POST d'un contact sur
* un client qui en a DEJA >= 2 ne doit pas exploser en 500
* (NonUniqueResultException sur la resolution du parent), mais creer (201).
*/
public function testPostContactOnClientWithTwoExistingContactsReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Contact Multi');
$this->seedContact($seed, 'Alpha');
$this->seedContact($seed, 'Beta');
$client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Gamma'],
]);
self::assertResponseStatusCodeSame(201);
}
/**
* Meme contexte (>= 2 contacts existants) : un email invalide doit produire
* une 422 par champ (la validation est bien atteinte), pas une 500.
*/
public function testPostInvalidContactOnPopulatedClientReturns422OnField(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Contact Multi Bad');
$this->seedContact($seed, 'Alpha');
$this->seedContact($seed, 'Beta');
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Gamma', 'email' => 'pas-un-email'],
]);
self::assertResponseStatusCodeSame(422);
$byPath = [];
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
self::assertArrayHasKey('email', $byPath);
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
}
/**
* ERP-110 : avec read:false sur le POST, un parent introuvable n'est plus
* intercepte au stade lecture. Le 404 est desormais porte par
* ClientContactProcessor::linkParent (sinon 500 au persist sur client_id
* NOT NULL). Le payload est valide pour atteindre le processor (apres la
* validation).
*/
public function testPostContactOnMissingClientReturns404(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/clients/999999/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Orphan'],
]);
self::assertResponseStatusCodeSame(404);
}
public function testPatchContactNormalizes(): void public function testPatchContactNormalizes(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
@@ -203,10 +110,9 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
public function testPostAddressNormalizesBillingEmail(): void public function testPostAddressNormalizesBillingEmail(): void
{ {
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address Host'); $seed = $this->seedClient('Address Host');
$siteIri = $this->firstSiteIri(); $siteIri = $this->firstSiteIri();
$category = $this->createCategory('SECTEUR');
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -217,7 +123,6 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
'city' => 'Châtellerault', 'city' => 'Châtellerault',
'street' => '1 rue du Test', 'street' => '1 rue du Test',
'sites' => [$siteIri], 'sites' => [$siteIri],
'categories' => ['/api/categories/'.$category->getId()],
], ],
])->toArray(); ])->toArray();
@@ -266,61 +171,6 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
} }
/**
* Regression ERP-110 : POST d'une adresse sur un client qui en a DEJA >= 2 ne
* doit pas exploser en 500 (NonUniqueResult sur la resolution du parent). Le
* POST porte un site + une categorie (RG-1.10 / RG-1.29) pour etre valide.
*/
public function testPostAddressOnClientWithTwoExistingAddressesReturns201(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Addr Multi');
$siteIri = $this->firstSiteIri();
$category = $this->createCategory('SECTEUR');
$this->seedAddress($seed, 'Bordeaux');
$this->seedAddress($seed, 'Lyon');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'postalCode' => '75001',
'city' => 'Paris',
'street' => '2 rue Neuve',
'sites' => [$siteIri],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
}
/**
* ERP-110 : POST adresse sur un client inexistant -> 404 porte par
* ClientAddressProcessor::linkParent (read:false). Payload valide (site +
* categorie, RG-1.10 / RG-1.29) pour atteindre le processor.
*/
public function testPostAddressOnMissingClientReturns404(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$siteIri = $this->firstSiteIri();
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/999999/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'postalCode' => '75001',
'city' => 'Paris',
'street' => '2 rue Neuve',
'sites' => [$siteIri],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(404);
}
// === RIBs === // === RIBs ===
public function testPostRibByAdminReturns201(): void public function testPostRibByAdminReturns201(): void
@@ -359,43 +209,6 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
} }
/**
* Regression ERP-110 : POST d'un RIB sur un client qui en a DEJA >= 2 ne doit
* pas exploser en 500 (NonUniqueResult sur la resolution du parent). L'admin
* porte commercial.clients.accounting.manage requis par le POST.
*/
public function testPostRibOnClientWithTwoExistingRibsReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Rib Multi');
$this->seedRib($seed, 'Compte 1');
$this->seedRib($seed, 'Compte 2');
$client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['label' => 'Compte 3', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
]);
self::assertResponseStatusCodeSame(201);
}
/**
* ERP-110 : POST RIB sur un client inexistant -> 404 porte par
* ClientRibProcessor::linkParent (read:false). L'admin porte
* commercial.clients.accounting.manage ; payload valide (BIC / IBAN).
*/
public function testPostRibOnMissingClientReturns404(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/clients/999999/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['label' => 'Orphan', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
]);
self::assertResponseStatusCodeSame(404);
}
public function testDeleteRibNonLcrReturns204(): void public function testDeleteRibNonLcrReturns204(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
@@ -463,34 +276,13 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
} }
/** /**
* Seede une adresse minimale valide en base (sans passer par l'API) : seules * Seede un ClientRib valide rattache a un client (sans passer par l'API).
* les colonnes NOT NULL sont posees (CP / ville / rue). Les M2M sites /
* categories restent vides non contraints en base, suffisant pour peupler
* un client de plusieurs adresses.
*/ */
private function seedAddress(ClientEntity $client, string $city): ClientAddress private function seedRib(ClientEntity $client): ClientRib
{
$em = $this->getEm();
$address = new ClientAddress();
$address->setClient($client);
$address->setPostalCode('33000');
$address->setCity($city);
$address->setStreet('1 rue du Test');
$em->persist($address);
$em->flush();
return $address;
}
/**
* Seede un ClientRib valide rattache a un client (sans passer par l'API). Le
* libelle est parametrable pour seeder plusieurs RIB distincts.
*/
private function seedRib(ClientEntity $client, string $label = 'Seed RIB'): ClientRib
{ {
$em = $this->getEm(); $em = $this->getEm();
$rib = new ClientRib(); $rib = new ClientRib();
$rib->setLabel($label); $rib->setLabel('Seed RIB');
$rib->setBic(self::VALID_BIC); $rib->setBic(self::VALID_BIC);
$rib->setIban(self::VALID_IBAN); $rib->setIban(self::VALID_IBAN);
$rib->setClient($client); $rib->setClient($client);
@@ -8,14 +8,11 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException; use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer; use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use App\Module\Commercial\Application\Validator\ClientAccountingCompletenessValidator;
use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator; use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Bank; use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\Client; use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\ClientRib; use App\Module\Commercial\Domain\Entity\ClientRib;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType; use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor; use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface; use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles; use App\Shared\Domain\Security\BusinessRoles;
@@ -283,65 +280,6 @@ final class ClientProcessorTest extends TestCase
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation())); self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
} }
public function testFullAccountingSubmitWithEmptyFieldsIsUnprocessable(): void
{
// spec-front § Onglet Comptabilite : une validation complete de l'onglet
// (les 6 champs presents dans le payload) avec des valeurs vides -> 422.
// C'est le bug corrige : avant, le back acceptait un onglet tout vide.
$client = $this->minimalClient(); // aucun champ comptable renseigne
$processor = $this->makeProcessor(
granted: ['commercial.clients.accounting.manage'],
payload: $this->emptyAccountingPayload(),
);
$this->expectException(ValidationException::class);
$processor->process($client, $this->operation());
}
public function testFullAccountingSubmitWithAllFieldsPasses(): void
{
// Les 6 champs obligatoires renseignes + type de reglement neutre
// (ni VIREMENT ni LCR -> ni banque ni RIB requis) -> 200.
$client = $this->minimalClient();
$client->setSiren('123456789');
$client->setAccountNumber('00012345678');
$client->setTvaMode(new TvaMode());
$client->setNTva('FR12345678901');
$client->setPaymentDelay(new PaymentDelay());
$client->setPaymentType($this->paymentType('CHEQUE'));
$processor = $this->makeProcessor(
granted: ['commercial.clients.accounting.manage'],
payload: $this->emptyAccountingPayload(),
);
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
}
public function testPartialAccountingPatchSkipsCompleteness(): void
{
// Un PATCH ciblant un seul champ comptable n'est pas une validation
// d'onglet : la completude n'est pas exigee (les autres champs restent
// vides) -> 200. Preserve l'edition ponctuelle (ex. Compta corrige le SIREN).
$client = $this->minimalClient();
$client->setSiren('999999999');
$processor = $this->makeProcessor(
granted: ['commercial.clients.accounting.manage'],
payload: ['siren' => '999999999'],
managed: true,
originalData: [
'siren' => '111111111',
'companyName' => 'TEST CO',
'triageService' => false,
'isArchived' => false,
],
);
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
}
public function testCommercialeIncompleteInformationIsUnprocessable(): void public function testCommercialeIncompleteInformationIsUnprocessable(): void
{ {
// RG-1.04 : role Commerciale + onglet Information incomplet -> 422. // RG-1.04 : role Commerciale + onglet Information incomplet -> 422.
@@ -441,7 +379,6 @@ final class ClientProcessorTest extends TestCase
$persist, $persist,
new ClientFieldNormalizer(), new ClientFieldNormalizer(),
new ClientInformationCompletenessValidator(), new ClientInformationCompletenessValidator(),
new ClientAccountingCompletenessValidator(),
$security, $security,
$requestStack, $requestStack,
$em, $em,
@@ -461,25 +398,6 @@ final class ClientProcessorTest extends TestCase
return $client; return $client;
} }
/**
* Payload simulant une validation complete de l'onglet Comptabilite : les 6
* champs obligatoires presents (le front les envoie toujours ensemble). Les
* valeurs importent peu la completude est evaluee sur l'etat de l'entite.
*
* @return array<string, mixed>
*/
private function emptyAccountingPayload(): array
{
return [
'siren' => null,
'accountNumber' => null,
'tvaMode' => null,
'nTva' => null,
'paymentDelay' => null,
'paymentType' => null,
];
}
private function paymentType(string $code): PaymentType private function paymentType(string $code): PaymentType
{ {
$type = new PaymentType(); $type = new PaymentType();
-2
View File
@@ -1,2 +0,0 @@
# Les traductions natives FR viennent du vendor (validators.fr.xlf).
# Ce dossier accueille les overrides applicatifs eventuels.