Compare commits

...

6 Commits

Author SHA1 Message Date
gitea-actions d4a5df50a7 chore: bump version to v0.1.99
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 38s
2026-06-09 06:07:03 +00:00
tristan 191fd42406 Correctifs frontend ecran categories + alignement boutons admin (ERP-117) (#77)
Auto Tag Develop / tag (push) Successful in 9s
## Contexte
ERP-117 — correctifs frontend sur l'ecran de gestion des categories et alignement des boutons d'action des ecrans admin.

## Changements
### Drawer categories
- Titre stable « Modifier la categorie » (plus de bascule view → edit selon l'etat « dirty »), aligne sur les drawers simples du projet.
- Bouton Enregistrer toujours actif : il sauvegarde a tout moment, meme sans modification (PATCH du payload complet `name` + `categoryTypes`, comme `SiteDrawer`).
- Champ « Types de categorie » : suppression du label « Selectionner un ou plusieurs types ».

### Alignement des boutons admin
- Ecran Categories : ordre des boutons Filtres avant Ajouter + gap reduit (`gap-8`), comme le repertoire client.
- Boutons d'ajout admin (categories, roles, sites) passes en `variant=secondary`.
- Boutons Filtres (categories, audit-log, clients) en `tertiary` simple : suppression des surcharges de classe, icone a gauche 24px.

## Tests
- `useCategoryForm` mis a jour (PATCH payload complet).
- `make nuxt-test` : 256/256 OK.
- `make nuxt-lint` : OK.

Reviewed-on: #77
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 06:06:52 +00:00
gitea-actions edfb2b1619 chore: bump version to v0.1.98
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 37s
2026-06-08 14:53:01 +00:00
tristan c5c650c599 style(front) : marges PageHeader (38px haut / 30px bas) + ordre boutons Filtres avant Ajouter (repertoire client)
Auto Tag Develop / tag (push) Successful in 7s
2026-06-08 16:52:53 +02:00
gitea-actions e598a92f94 chore: bump version to v0.1.97
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 58s
2026-06-08 14:40:27 +00:00
tristan b8dc3cb696 Correctifs écran Client (ERP-115) (#76)
Auto Tag Develop / tag (push) Successful in 7s
Lot de correctifs sur l'écran Client (M1), + un retrait de règle métier et une petite fonctionnalité.

## Formulaire client (création / édition)
- Boutons « ajouter un bloc » (Adresse, RIB) désactivés tant que le dernier bloc n'est pas valide.
- Onglet Information : bouton Valider désactivé si aucun champ rempli (création) ; onglet Contact accessible dès la création (Information facultatif).
- Champs « Relation » (Distributeur/Courtier) et « Prestation de triage » masqués par défaut, révélés seulement si une catégorie ordinaire (≠ Distributeur/Courtier) est sélectionnée.
- Bloc RIB affiché uniquement si le type de règlement est LCR (création, édition, consultation) ; plus de RIB fantôme soumis.
- Alignement du bas du textarea « Description » sur les autres champs.

## Recherche d'adresse (BAN)
- Une erreur de l'API ne bloque plus définitivement la recherche : chaque frappe réessaie (le mode dégradé restait verrouillé).
- Garde minimum 3 caractères avant l'appel à l'API.

## Répertoire client
- Titres de colonne en noir 16px, corps + tags de site en 14px.

## Navigation
- L'onglet actif est conservé au passage consultation ↔ édition (via history.state, hors URL).

## Règle métier
- Retrait de RG-1.04 : l'onglet Information n'est plus obligatoire pour le rôle Commerciale — facultatif pour tous (back + tests + docs).

Tests : suites front (Vitest) et back (PHPUnit) vertes hormis flakes d'infra connus.
Reviewed-on: #76
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-08 14:40:18 +00:00
47 changed files with 721 additions and 514 deletions
+1 -1
View File
@@ -141,7 +141,7 @@ Disponibles uniquement après `make db-reset` / `make fixtures` (état « avec s
| `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) |
| `commerciale` | `demo` | ROLE_USER | clients : view + manage |
| `usine` | `demo` | ROLE_USER | aucun accès clients |
---
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.96'
app.version: '0.1.99'
+4 -5
View File
@@ -10,8 +10,8 @@ trous, zéro duplication »).
ERP-60 n'écrit QUE les tests des RG non déjà couvertes par la stack, et mappe ici
l'intégralité des RG (existantes + nouvelles + déléguées). Les tests dépendants
des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine + RG-1.04
fonctionnel) sont **délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le
des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine) sont
**délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le
merge de la stack.
## Mapping RG → test
@@ -21,7 +21,7 @@ merge de la stack.
| ~~RG-1.01~~ | _(supprimée V1 — refonte-contact)_ contact inline retiré du Client ; complétude couverte par RG-1.05 / RG-1.14 (`ClientContact`) | — | refonte-contact |
| ~~RG-1.02~~ | _(supprimée du Client V1)_ téléphones inline retirés du Client (testés sur `ClientContact`) | — | refonte-contact |
| RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 |
| RG-1.04 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** |
| ~~RG-1.04~~ | _(SUPPRIMÉE)_ Onglet Information désormais facultatif pour tous les rôles (validation de complétude retirée) | — | ERP-55 / ERP-74 |
| RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 |
| RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation → 422 (Assert\Callback + CHECK filet) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | ERP-60 / **ERP-76** |
| RG-1.09 | Code postal `^[0-9]{4,5}$` → 422 | `ClientSubResourceApiTest::testPostAddressWithInvalidPostalCodeReturns422` | ERP-57 |
@@ -60,8 +60,7 @@ merge de la stack.
- **Matrice RBAC différenciée** par rôle métier (Bureau / Compta / Commerciale /
Usine) : 200/403 par verbe et par onglet selon le rôle.
- **RG-1.04 fonctionnel** : PATCH onglet Information par une Commerciale avec
champs incomplets → 422 ; même PATCH par Admin → 200 (+ durcissement code/spec).
- ~~**RG-1.04 fonctionnel**~~ : _(SUPPRIMÉE)_ l'onglet Information est désormais facultatif pour tous les rôles.
- Raison : ces rôles métier ne sont seedés qu'après le merge de la stack M1.
## Gaps & suivi
+4 -5
View File
@@ -310,7 +310,7 @@ CREATE TABLE client (
distributor_id INT REFERENCES client(id) ON DELETE SET NULL,
broker_id INT REFERENCES client(id) ON DELETE SET NULL,
triage_service BOOLEAN NOT NULL DEFAULT FALSE,
-- Onglet Information (Commerciale obligatoire — RG-1.04 — null sinon)
-- Onglet Information (facultatif pour tous — RG-1.04 supprimée)
description TEXT,
competitors VARCHAR(255),
founded_at DATE,
@@ -864,8 +864,7 @@ Cf. § 2.6. Pattern Shared standard.
### Onglet Information
- **RG-1.04** _(durcie — ERP-74)_ : Pour un utilisateur portant le rôle métier **Commerciale**, **tous** les champs de l'onglet Information (`description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount`) sont obligatoires sur **POST et sur tout PATCH**, **indépendamment des champs réellement envoyés** (la condition d'intersection avec `client:write:information` a été retirée). Pour les autres rôles, ces champs restent optionnels. Implémenté via un validator custom `ClientInformationCompletenessValidator` invoqué systématiquement par le `ClientProcessor` quand le user porte le rôle Commerciale.
- **Conséquence** : le POST n'exposant que le groupe `client:write:main`, l'onglet Information n'y est pas renseignable → une Commerciale obtient **422** sur tout POST (cf. § 8.1). La complétude se fait donc via les PATCH `client:write:information` ultérieurs. Un Admin (non gaté) crée normalement (201).
- ~~**RG-1.04**~~ _(SUPPRIMÉE)_ : l'onglet Information est désormais **facultatif pour tous les rôles**, y compris Commerciale. Les champs `description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount` restent optionnels (colonnes nullable, aucune validation de complétude). Le validator `ClientInformationCompletenessValidator` et le gating par rôle métier dans le `ClientProcessor` ont été retirés.
### Onglet Contact
@@ -883,7 +882,7 @@ Cf. § 2.6. Pattern Shared standard.
### 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.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` (parti pris : nullable + validateur d'onglet plutôt qu'un `Assert\NotBlank` sur l'entité). 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.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 ».
@@ -938,7 +937,7 @@ Cf. § 2.6. Pattern Shared standard.
- [ ] ~~RG-1.02~~ _(supprimée du Client V1)_ : plus de téléphones inline sur le Client (téléphones testés sur `ClientContact`)
- [ ] **RG-1.03** : POST avec distributor ET broker → 422 ; POST distributor seul → 201
- [ ] **RG-1.03** : POST distributor référençant un client SANS catégorie de code DISTRIBUTEUR → 422 (validation custom `ClientProcessor::hasCategoryCode`)
- [ ] **RG-1.04** : PATCH onglet Information par un user Commerciale avec champs incomplets → 422 ; même PATCH par Admin → 200
- [x] ~~**RG-1.04**~~ _(SUPPRIMÉE)_ : onglet Information facultatif pour tous les rôles (plus de validation de complétude)
- [ ] **RG-1.05** : POST contact sans firstName ni lastName → 422 (BDD CHECK lève une exception)
- [ ] **RG-1.06/07/08** : POST adresse avec isProspect=true ET isDelivery=true → 422 / CHECK
- [ ] **RG-1.09** : POST adresse avec postalCode invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de validation stricte côté serveur)
+8 -8
View File
@@ -13,7 +13,7 @@ date_redaction: 2026-05-28
# === LIENS ===
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898"
regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.04, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13, RG-1.14, RG-1.15, RG-1.16, RG-1.17, RG-1.18, RG-1.19, RG-1.20, RG-1.21, RG-1.22, RG-1.23, RG-1.24, RG-1.25, RG-1.26, RG-1.27, RG-1.28, RG-1.29]
regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13, RG-1.14, RG-1.15, RG-1.16, RG-1.17, RG-1.18, RG-1.19, RG-1.20, RG-1.21, RG-1.22, RG-1.23, RG-1.24, RG-1.25, RG-1.26, RG-1.27, RG-1.28, RG-1.29]
roles: [Admin, Bureau, Compta, Commerciale, Usine]
lien_spec_back: ./spec-back.md
@@ -105,13 +105,13 @@ Saisir les informations de l'entreprise.
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Description** | `<MalioInputTextArea>` | Conditionnel | RG-1.04 (obligatoire pour rôle Commerciale) |
| **Concurrents** | `<MalioInputText>` | Conditionnel | RG-1.04 |
| **Date de création** (de l'entreprise) | `<input type="date">` (exception Malio — pas de composant date couvert) | Conditionnel | RG-1.04 |
| **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-1.04 |
| **CA €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
| **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-1.04 |
| **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
| **Description** | `<MalioInputTextArea>` | Non | — _(onglet facultatif, RG-1.04 supprimée)_ |
| **Concurrents** | `<MalioInputText>` | Non | — |
| **Date de création** (de l'entreprise) | `<input type="date">` (exception Malio — pas de composant date couvert) | Non | — |
| **Nombre de salariés** | `<MalioInputNumber>` | Non | — |
| **CA €** | `<MalioInputAmount>` | Non | — |
| **Dirigeant** | `<MalioInputText>` | Non | — |
| **Résultat €** | `<MalioInputAmount>` | Non | — |
**Action** : « Valider » → PATCH partiel `/api/clients/{id}` (groupe `client:write:information`).
+3 -4
View File
@@ -2,7 +2,9 @@
Valeurs en dur issues de la maquette Figma (design Starseed) :
- sidebar depliee : 232px (w-[232px], repli laisse par defaut 72px)
- marge horizontale du contenu sur desktop : 170px (xl:px-[170px])
- bande blanche sticky sous la navbar : 47px (h-[47px])
La marge haute du contenu (44px) vit desormais DANS l'entete (PageHeader,
pt-11) et non sur le <main> : sinon, l'entete etant sticky, ce padding
laissait un trou blanc entre le SiteSelector et l'entete.
A faire evoluer uniquement avec une mise a jour de maquette.
-->
<template>
@@ -25,9 +27,6 @@
<SiteSelector v-if="showSiteSelector"/>
<main
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-11">
<div
aria-hidden="true"
class="pointer-events-none sticky top-0 z-30 h-11 flex-shrink-0 bg-white"/>
<slot/>
</main>
</div>
+1 -3
View File
@@ -416,7 +416,6 @@
"newCategory": "Ajouter",
"editCategory": "Modifier la catégorie",
"createCategory": "Créer une catégorie",
"viewCategory": "Détail de la catégorie",
"noCategories": "Aucune catégorie pour l'instant.",
"table": {
"name": "Nom",
@@ -431,8 +430,7 @@
},
"form": {
"name": "Nom",
"types": "Types de catégorie",
"typesPlaceholder": "Sélectionner un ou plusieurs types"
"types": "Types de catégorie"
},
"validation": {
"nameRequired": "Le nom est obligatoire.",
@@ -31,7 +31,6 @@
v-model="form.categoryTypeIds.value"
:options="typeOptions"
:label="t('admin.categories.form.types')"
:empty-option-label="t('admin.categories.form.typesPlaceholder')"
:error="form.errors.categoryTypes"
:display-tag="true"
:disabled="loadingTypes"
@@ -91,28 +90,17 @@ const emit = defineEmits<{
delete: []
}>()
/**
* Mode du drawer (dérivé du composable `useCategoryForm`) :
* - 'create' : pas de category prop, formulaire vide, POST au save.
* - 'view' : category prop set, formulaire pre-rempli, save MASQUE
* jusqu'a ce que l'utilisateur modifie un champ.
* - 'edit' : category prop set et formulaire « dirty » (au moins un
* champ different de l'original), PATCH au save.
*/
type DrawerMode = 'create' | 'view' | 'edit'
// Mode du drawer : creation (pas de category prop, POST au save) ou
// modification d'une categorie existante (PATCH au save). Pas de distinction
// view/edit : comme les autres drawers, le titre et le bouton Enregistrer sont
// stables quel que soit l'etat « dirty » du formulaire.
const isCreateMode = computed(() => props.category === null)
const mode = computed<DrawerMode>(() => {
if (isCreateMode.value) return 'create'
return form.isDirty.value ? 'edit' : 'view'
})
const headerLabel = computed(() => {
if (mode.value === 'create') return t('admin.categories.createCategory')
if (mode.value === 'edit') return t('admin.categories.editCategory')
return t('admin.categories.viewCategory')
})
const headerLabel = computed(() =>
isCreateMode.value
? t('admin.categories.createCategory')
: t('admin.categories.editCategory'),
)
// Le bouton Supprimer n'est visible qu'en consultation/edition d'une categorie
// existante et seulement pour les users ayant la permission manage. En mode
@@ -121,10 +109,12 @@ const canShowDelete = computed(
() => !isCreateMode.value && can('catalog.categories.manage'),
)
// Save : visible en creation, ou en edition (apres modification d'un champ).
// Masque en view tant que rien n'a change.
// Save : visible en creation, et en consultation/edition d'une categorie
// existante (l'utilisateur doit pouvoir enregistrer sans qu'un champ ait
// d'abord ete modifie). Le bouton reste neanmoins protege par son `disabled`
// pendant la soumission / le chargement des types.
const canShowSave = computed(
() => mode.value === 'create' || mode.value === 'edit',
() => isCreateMode.value || can('catalog.categories.manage'),
)
const typeOptions = computed(() =>
@@ -154,18 +144,18 @@ watch(
)
/**
* Sauvegarde : delegue au composable (POST en mode create, PATCH en mode
* edit). Le toast succes + mapping erreur 409/422 est gere par le composable.
* En cas de succes, on ferme le drawer et on previent le parent pour qu'il
* refresh la liste.
* Sauvegarde : delegue au composable (POST en creation, PATCH en modification).
* Le toast succes + mapping erreur 409/422 est gere par le composable. Le PATCH
* envoie le payload complet, donc le bouton Enregistrer sauvegarde a tout
* moment (meme sans modification). En cas de succes, on ferme le drawer et on
* previent le parent pour qu'il refresh la liste.
*/
async function handleSave(): Promise<void> {
let result: Category | null = null
if (mode.value === 'create') {
result = await form.submitCreate()
} else if (mode.value === 'edit' && props.category) {
result = await form.submitUpdate(props.category.id)
}
const result = isCreateMode.value
? await form.submitCreate()
: props.category
? await form.submitUpdate(props.category.id)
: null
if (result) {
emit('saved')
emit('update:modelValue', false)
@@ -346,7 +346,7 @@ describe('useCategoryForm', () => {
})
describe('submitUpdate', () => {
it('appelle PATCH /categories/{id} uniquement avec les champs modifies', async () => {
it('appelle PATCH /categories/{id} avec le payload complet (name + categoryTypes)', async () => {
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
const form = useCategoryForm()
form.loadFrom(CAT)
@@ -354,9 +354,11 @@ describe('useCategoryForm', () => {
await form.submitUpdate(42)
// Payload complet : meme si seul le name change, on renvoie aussi
// les categoryTypes (PATCH full payload, cf. drawers simples).
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ name: 'Vis V2' }, // pas de categoryTypes car non modifies
{ name: 'Vis V2', categoryTypes: ['/api/category_types/1'] },
{ toast: false },
)
})
@@ -371,20 +373,25 @@ describe('useCategoryForm', () => {
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
{ name: CAT.name, categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
{ toast: false },
)
})
it('court-circuite l appel API si aucun champ n a change', async () => {
it('envoie un PATCH complet meme sans modification (save a tout moment)', async () => {
mockPatch.mockResolvedValueOnce(CAT)
const form = useCategoryForm()
form.loadFrom(CAT)
// Aucune modification — isDirty=false, patch payload vide.
// Aucune modification : le PATCH part quand meme avec le payload complet.
const result = await form.submitUpdate(42)
expect(mockPatch).not.toHaveBeenCalled()
expect(result).toBeNull()
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ name: CAT.name, categoryTypes: ['/api/category_types/1'] },
{ toast: false },
)
expect(result).toEqual(CAT)
expect(form.submitting.value).toBe(false)
})
@@ -174,26 +174,18 @@ export function useCategoryForm() {
}
/**
* PATCH /api/categories/{id}. Envoie uniquement les champs modifies pour
* coller a la semantique merge-patch (Content-Type pose par useApi).
* Renvoie la categorie mise a jour, ou `null` en cas d'echec.
* PATCH /api/categories/{id}. Envoie le payload complet (name +
* categoryTypes), comme les autres drawers du projet : le bouton
* Enregistrer sauvegarde a tout moment, meme sans modification, et renvoie
* toujours un retour (toast succes + refresh). Renvoie la categorie mise a
* jour, ou `null` en cas d'echec.
*/
async function submitUpdate(id: number): Promise<Category | null> {
if (!validate()) return null
submitting.value = true
const payload: Record<string, unknown> = {}
if (name.value !== initialName.value) {
payload.name = name.value.trim()
}
if (!sameIds(categoryTypeIds.value, initialCategoryTypeIds.value)) {
payload.categoryTypes = categoryTypeIds.value.map(id => `/api/category_types/${id}`)
}
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
// on protege le composable contre un appel direct mal utilise.
if (Object.keys(payload).length === 0) {
submitting.value = false
return null
const payload: Record<string, unknown> = {
name: name.value.trim(),
categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`),
}
try {
const updated = await api.patch<Category>(`/categories/${id}`, payload, {
@@ -3,17 +3,10 @@
<PageHeader>
{{ t('admin.categories.title') }}
<template #actions>
<!-- gap-12 = 48px d'espacement entre Ajouter et Filtres (meme
<!-- gap-8 = 32px d'espacement entre Filtres et Ajouter (meme
design que le Repertoire Clients). -->
<div class="flex items-center gap-12">
<MalioButton
v-if="canManage"
:label="t('admin.categories.newCategory')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
<!-- Bouton Filtres a DROITE d'Ajouter. Le compteur reflete
<div class="flex items-center gap-8">
<!-- Bouton Filtres a GAUCHE d'Ajouter. Le compteur reflete
les filtres actifs. -->
<MalioButton
variant="tertiary"
@@ -21,9 +14,16 @@
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
button-class="w-[184px] justify-start gap-4 text-black"
@click="openFilters"
/>
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('admin.categories.newCategory')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
</div>
</template>
</PageHeader>
@@ -87,8 +87,9 @@
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). En mode
degrade (service indisponible), bascule en saisie libre. -->
<!-- Ville : MalioSelect alimente par le code postal (BAN). Si la BAN est
indisponible, bascule en saisie libre — recuperable : re-saisir le
code postal relance la recherche et repasse en select au succes. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
@@ -115,11 +116,14 @@
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
le col-span-2, le champ le remplit (w-full). -->
<div class="col-span-2">
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple en
mode degrade OU en lecture seule (MalioInputAutocomplete ne reaffiche
pas sa valeur liee, il n'afficherait rien en readonly). -->
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple
seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas
sa valeur liee, il n'afficherait rien en readonly). Une erreur BAN
ne bascule PAS en saisie libre : l'autocompletion reste montee et
chaque frappe relance la recherche (l'utilisateur peut aussi taper
une rue librement). -->
<MalioInputAutocomplete
v-if="!degraded && !readonly"
v-if="!readonly"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
@@ -217,8 +221,12 @@ function onAddressTypeChange(value: string | number | null): void {
emit('update:modelValue', { ...props.modelValue, ...addressFlagsFromType(value as AddressType) })
}
// Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre.
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable :
// remis a false des qu'une recherche de ville aboutit). N'affecte plus le champ
// Adresse, qui reste en autocompletion et reessaie a chaque frappe.
const degraded = ref(false)
// Avertissement « service indisponible » envoye au parent une seule fois.
let unavailableNotified = false
// Villes proposees par la BAN (alimentees a la saisie du code postal).
const banCityOptions = ref<RefOption[]>([])
// Adresses proposees par la BAN (alimentees a la saisie d'adresse).
@@ -258,10 +266,10 @@ function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDr
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
/** Bascule définitivement en mode degrade et previent le parent (toast unique). */
function enterDegraded(): void {
if (!degraded.value) {
degraded.value = true
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void {
if (!unavailableNotified) {
unavailableNotified = true
emit('degraded')
}
}
@@ -270,9 +278,6 @@ function enterDegraded(): void {
async function onPostalCodeChange(value: string): Promise<void> {
update('postalCode', value)
if (degraded.value) {
return
}
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) {
return
@@ -280,15 +285,22 @@ async function onPostalCodeChange(value: string): Promise<void> {
try {
const suggestions = await autocomplete.searchCity(digits)
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
// Service repondu : on (re)passe la Ville en select assiste.
degraded.value = false
}
catch {
enterDegraded()
// BAN indispo : Ville en saisie libre (recuperable au prochain essai).
degraded.value = true
notifyUnavailable()
}
}
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> {
if (degraded.value) {
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400)
// et on vide les suggestions devenues obsoletes.
if (query.trim().length < 3) {
banAddressOptions.value = []
return
}
addressLoading.value = true
@@ -299,7 +311,10 @@ async function onAddressSearch(query: string): Promise<void> {
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
}
catch {
enterDegraded()
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie
// (pas de bascule definitive — c'etait le bug). Avertissement une seule fois.
banAddressOptions.value = []
notifyUnavailable()
}
finally {
addressLoading.value = false
@@ -1,16 +1,21 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } 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).
// Mocks controlables du composable BAN (hoisted) : chaque test configure le
// comportement de searchCity / searchAddress (succes, rejet, rejet-puis-succes).
// Par defaut ils renvoient undefined (aucune suggestion) — etat « adresse
// persistee mais liste vide » couvert par les tests d'affichage.
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
searchCityMock: vi.fn(),
searchAddressMock: vi.fn(),
}))
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
useAddressAutocomplete: () => ({
searchCity: vi.fn(),
searchAddress: vi.fn(),
searchCity: searchCityMock,
searchAddress: searchAddressMock,
}),
}))
@@ -130,3 +135,57 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
expect(field?.attributes('data-error')).toBe('Code postal invalide.')
})
})
describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => {
beforeEach(() => {
searchAddressMock.mockReset()
})
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
const wrapper = mountBlock(null)
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'ab')
await flushPromises()
expect(searchAddressMock).not.toHaveBeenCalled()
})
it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
searchAddressMock
.mockRejectedValueOnce(new Error('BAN indisponible'))
.mockResolvedValueOnce([
{ label: '8 Boulevard du Port, Paris', street: '8 Boulevard du Port', postalCode: '75001', city: 'Paris' },
])
const wrapper = mountBlock(null)
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
// 1er essai -> erreur BAN.
auto.vm.$emit('search', 'boulevard du port')
await flushPromises()
expect(searchAddressMock).toHaveBeenCalledTimes(1)
// 2e essai -> DOIT relancer l'appel (c'etait le bug : plus aucune recherche).
auto.vm.$emit('search', 'boulevard du porte')
await flushPromises()
expect(searchAddressMock).toHaveBeenCalledTimes(2)
// L'autocompletion reste montee (aucune bascule en saisie libre).
expect(wrapper.find('[data-testid="addr-autocomplete"]').exists()).toBe(true)
})
it('emet « degraded » une seule fois malgre plusieurs erreurs', async () => {
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
const wrapper = mountBlock(null)
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'rue de la paix')
await flushPromises()
auto.vm.$emit('search', 'rue de la paixx')
await flushPromises()
expect(wrapper.emitted('degraded')).toHaveLength(1)
})
})
@@ -99,7 +99,9 @@ export function useClientReferentials() {
*/
async function loadCommon(): Promise<void> {
await Promise.allSettled([
fetchAll<CategoryMember>('/categories')
// Taxonomie multi-types (ERP-84) : un client ne porte que des categories
// de type CLIENT (pas FOURNISSEUR) -> on filtre la collection cote API.
fetchAll<CategoryMember>('/categories', { typeCode: 'CLIENT' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code
@@ -1,7 +1,7 @@
<template>
<div>
<!-- En-tete : retour repertoire + nom du client. -->
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
@@ -9,7 +9,7 @@
v-bind="{ ariaLabel: t('commercial.clients.edit.back') }"
@click="goBack"
/>
<h1 class="text-[32px] font-bold text-m-primary">{{ headerTitle }}</h1>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
</div>
<!-- Etats de chargement / introuvable. -->
@@ -41,6 +41,7 @@
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
<MalioSelect
v-if="showRelationAndTriage"
:model-value="main.relationType"
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
@@ -49,7 +50,7 @@
@update:model-value="onRelationChange"
/>
<MalioSelect
v-if="main.relationType === 'courtier'"
v-if="showRelationAndTriage && main.relationType === 'courtier'"
:model-value="main.brokerIri"
:options="brokerOptions"
:label="t('commercial.clients.form.main.brokerName')"
@@ -59,7 +60,7 @@
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/>
<MalioSelect
v-if="main.relationType === 'distributeur'"
v-if="showRelationAndTriage && main.relationType === 'distributeur'"
:model-value="main.distributorIri"
:options="distributorOptions"
:label="t('commercial.clients.form.main.distributorName')"
@@ -69,6 +70,7 @@
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/>
<MalioCheckbox
v-if="showRelationAndTriage"
v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')"
group-class="self-center"
@@ -90,11 +92,14 @@
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
coussin de chaque cote). -->
<MalioInputTextArea
v-model="information.description"
:label="t('commercial.clients.form.information.description')"
resize="none"
group-class="row-span-2 pt-1"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
:readonly="businessReadonly"
:error="informationErrors.errors.description"
@@ -205,6 +210,7 @@
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.clients.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress"
/>
<MalioButton
@@ -289,9 +295,9 @@
</div>
</div>
<!-- Blocs RIB (0..n) — obligatoires si type de reglement = LCR (RG-1.13). -->
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). -->
<div
v-for="(rib, index) in ribs"
v-for="(rib, index) in visibleRibs"
:key="rib.id ?? `new-${index}`"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
@@ -330,10 +336,12 @@
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
<MalioButton
v-if="isRibRequired"
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.clients.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib"
/>
<MalioButton
@@ -379,7 +387,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient'
import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
@@ -410,16 +418,18 @@ import {
type MainFormDraft,
} from '~/modules/commercial/utils/clientEdit'
import {
addressTypeFromFlags,
buildClientFormTabKeys,
hasAllRequiredAccountingFields,
hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType,
isBillingEmailRequired,
isContactBlank,
isContactNamed,
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules'
import {
emptyAddress,
@@ -430,6 +440,7 @@ import {
type RibFormDraft,
} from '~/modules/commercial/types/clientForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -500,7 +511,9 @@ function hydrate(detail: ClientDetail): void {
// 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())
// RIB : amorce un bloc vide seulement si le type de reglement est une LCR
// (sinon la section reste masquee — RG-1.13).
if (isRibRequired.value && ribs.value.length === 0) ribs.value.push(emptyRib())
// Charge les listes distributeur / courtier si une relation est deja posee.
if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {})
if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {})
@@ -551,6 +564,28 @@ const relationOptions = computed<RefOption[]>(() => [
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
])
// Codes des categories selectionnees (resolus depuis l'union referentiel + embed).
const selectedCategoryCodes = computed(() =>
main.categoryIris
.map(iri => mainCategoryOptions.value.find(c => c.value === iri)?.code)
.filter((code): code is string => code !== undefined),
)
// « Relation » + « Prestation de triage » masques par defaut, reveles des qu'une
// categorie ordinaire (autre que Distributeur/Courtier) est selectionnee.
const showRelationAndTriage = computed(() => showsRelationAndTriageFields(selectedCategoryCodes.value))
// Masquer ces champs reinitialise leurs valeurs : pas de relation/triage fantome
// soumis pour un client Distributeur/Courtier.
watch(showRelationAndTriage, (visible) => {
if (!visible) {
main.relationType = null
main.distributorIri = null
main.brokerIri = null
main.triageService = false
}
})
// Distributeur / courtier : referentiel charge a la demande UNION valeur courante.
const currentDistributorOption = computed<RefOption[]>(() => {
const d = client.value?.distributor
@@ -592,11 +627,13 @@ const tabs = computed(() => tabKeys.value.map(key => ({
icon: TAB_ICONS[key],
})))
const activeTab = ref('information')
// Onglet initial : repris de la consultation (history.state), sinon Information.
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// ── Navigation ──────────────────────────────────────────────────────────────
/** Retour consultation en conservant l'onglet courant (via history.state). */
function goBack(): void {
router.push(`/clients/${clientId}`)
router.push({ path: `/clients/${clientId}`, state: { tab: activeTab.value } })
}
/**
@@ -787,18 +824,17 @@ async function submitContacts(): Promise<void> {
// ── Onglet Adresse ───────────────────────────────────────────────────────────
const canValidateAddresses = computed(() =>
addresses.value.length > 0
&& addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return addressTypeFromFlags(a) !== null
&& a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}),
addresses.value.length > 0 && addresses.value.every(isAddressValid),
)
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
return last !== undefined && isAddressValid(last)
})
function addAddress(): void {
addresses.value.push(emptyAddress())
if (canAddAddress.value) addresses.value.push(emptyAddress())
}
function askRemoveAddress(index: number): void {
@@ -870,25 +906,42 @@ const selectedPaymentTypeCode = computed(() =>
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
// Les blocs RIB ne sont affiches que pour une LCR (RG-1.13).
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value)
if (!isBankRequired.value) accounting.bankIri = null
}
function ribIsComplete(rib: { label: string | null, bic: string | null, iban: string | null }): boolean {
const filled = (v: string | null) => v !== null && v.trim() !== ''
return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide
// quand LCR est choisi, sinon on vide la liste — les RIB deja persistes sont
// marques pour suppression serveur au prochain enregistrement.
if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}
else {
for (const rib of ribs.value) {
if (rib.id != null) removedRibIds.value.push(rib.id)
}
ribs.value = []
ribErrors.value = []
}
}
const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) 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(isRibComplete)) return false
return true
})
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
return last !== undefined && isRibComplete(last)
})
function addRib(): void {
ribs.value.push(emptyRib())
if (canAddRib.value) ribs.value.push(emptyRib())
}
function askRemoveRib(index: number): void {
@@ -1,7 +1,7 @@
<template>
<div>
<!-- En-tete : retour repertoire + nom du client + actions (Modifier / Archiver|Restaurer). -->
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
@@ -9,7 +9,7 @@
v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }"
@click="goBack"
/>
<h1 class="text-[32px] font-bold text-m-primary">{{ headerTitle }}</h1>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
<!-- gap-12 = 48px : meme espacement que Ajouter / Filtres du repertoire. -->
<div class="ml-auto flex items-center gap-12">
@@ -88,11 +88,14 @@
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
coussin de chaque cote). -->
<MalioInputTextArea
:model-value="information.description"
:label="t('commercial.clients.form.information.description')"
resize="none"
group-class="row-span-2 pt-1"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
readonly
/>
@@ -278,6 +281,7 @@
import { computed, onMounted, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient'
import { buildClientFormTabKeys } from '~/modules/commercial/utils/clientFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditClient,
categoryOptionsOf,
@@ -293,7 +297,7 @@ import {
type ClientDetail,
type SelectOption,
} from '~/modules/commercial/utils/clientConsultation'
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/clientForm'
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
const SIREN_MASK = '#########'
@@ -350,10 +354,10 @@ const addressViews = computed(() => {
const views = (client.value?.addresses ?? []).map(mapAddressView)
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
})
const ribs = computed(() => {
const list = (client.value?.ribs ?? []).map(mapRibToDraft)
return list.length ? list : [emptyRib()]
})
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
// client n'en a pas (un RIB n'existe que pour un reglement LCR — RG-1.13). Pas
// de bloc vierge fantome en consultation.
const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft))
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
@@ -413,15 +417,17 @@ const tabs = computed(() => tabKeys.value.map(key => ({
icon: TAB_ICONS[key],
})))
const activeTab = ref('information')
// Onglet initial : repris de l'edition au retour (history.state), sinon Information.
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// ── Navigation ─────────────────────────────────────────────────────────────
function goBack(): void {
router.push('/clients')
}
/** Bascule en edition en conservant l'onglet courant (via history.state). */
function goEdit(): void {
router.push(`/clients/${clientId}/edit`)
router.push({ path: `/clients/${clientId}/edit`, state: { tab: activeTab.value } })
}
// ── Archivage / Restauration ────────────────────────────────────────────────
@@ -3,18 +3,9 @@
<PageHeader>
{{ t('commercial.clients.title') }}
<template #actions>
<!-- gap-12 = 48px d'espacement entre Ajouter et Filtres. -->
<div class="flex items-center gap-12">
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('commercial.clients.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
<!-- Bouton Filtres a DROITE d'Ajouter : meme design que
l'audit-log. Le compteur reflete les filtres actifs. -->
<!-- gap-8 = 32px d'espacement entre Filtres et Ajouter. -->
<div class="flex items-center gap-8">
<!-- Bouton Filtres a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
<MalioButton
v-if="canView"
variant="tertiary"
@@ -22,9 +13,16 @@
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
button-class="w-[184px] justify-start gap-4 text-black"
@click="openFilters"
/>
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('commercial.clients.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
</div>
</template>
</PageHeader>
@@ -39,7 +37,7 @@
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
row-clickable
table-class="table-fixed"
table-class="table-fixed clients-table"
:empty-message="t('commercial.clients.empty')"
@row-click="onRowClick"
@update:page="goToPage"
@@ -56,7 +54,7 @@
<span
v-for="site in (item.sites as ClientSite[])"
:key="site.id"
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-white"
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium text-white"
:style="{ backgroundColor: site.color }"
>
{{ site.name }}
@@ -70,7 +68,7 @@
</template>
</MalioDataTable>
<div class="flex justify-center mt-6">
<div class="flex justify-center mt-4">
<MalioButton
v-if="canView"
variant="primary"
@@ -350,7 +348,9 @@ async function loadFilterOptions(): Promise<void> {
const [cats, sites] = await Promise.all([
api.get<{ member?: Array<{ code: string, name: string }> }>(
'/categories',
{ pagination: 'false' },
// Taxonomie multi-types (ERP-84) : le filtre du repertoire client ne
// propose que les categories de type CLIENT (pas les FOURNISSEUR).
{ pagination: 'false', typeCode: 'CLIENT' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
),
api.get<{ member?: Array<{ id: number, name: string }> }>(
@@ -419,3 +419,16 @@ onMounted(() => {
})
})
</script>
<style scoped>
/*
* Colonne Sites uniquement (3e colonne : companyName, categories, SITES,
* lastActivity) : ses badges rendent la cellule trop haute. On reduit le padding
* vertical de SON td (16px Malio -> 8px) sans toucher les autres colonnes ni les
* couleurs/tailles (qui restent sur les defauts Malio).
*/
:deep(.clients-table tbody td:nth-child(3)) {
padding-top: 8px;
padding-bottom: 8px;
}
</style>
@@ -1,7 +1,7 @@
<template>
<div>
<!-- En-tete : retour vers le repertoire + titre. -->
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
@@ -9,7 +9,7 @@
v-bind="{ ariaLabel: t('commercial.clients.form.back') }"
@click="goBack"
/>
<h1 class="text-[32px] font-bold text-m-primary">{{ t('commercial.clients.form.title') }}</h1>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('commercial.clients.form.title') }}</h1>
</div>
<!-- Formulaire principal (pre-onglets)
@@ -35,6 +35,7 @@
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
<MalioSelect
v-if="showRelationAndTriage"
:model-value="main.relationType"
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
@@ -43,7 +44,7 @@
@update:model-value="onRelationChange"
/>
<MalioSelect
v-if="main.relationType === 'courtier'"
v-if="showRelationAndTriage && main.relationType === 'courtier'"
:model-value="main.brokerIri"
:options="referentials.brokers.value"
:label="t('commercial.clients.form.main.brokerName')"
@@ -53,7 +54,7 @@
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/>
<MalioSelect
v-if="main.relationType === 'distributeur'"
v-if="showRelationAndTriage && main.relationType === 'distributeur'"
:model-value="main.distributorIri"
:options="referentials.distributors.value"
:label="t('commercial.clients.form.main.distributorName')"
@@ -63,6 +64,7 @@
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/>
<MalioCheckbox
v-if="showRelationAndTriage"
v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')"
group-class="self-center"
@@ -84,13 +86,15 @@
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- pt-1 : aligne le bord superieur du textarea sur celui des
inputs (centres dans un conteneur h-12, soit ~4px de retrait haut). -->
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs, dont
le champ de 40px est centre dans un conteneur h-12 (~4px de
coussin en HAUT et en BAS). Sans pb-1, le textarea descend ~4px
plus bas que les champs voisins. -->
<MalioInputTextArea
v-model="information.description"
:label="t('commercial.clients.form.information.description')"
resize="none"
group-class="row-span-2 pt-1"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
:readonly="isValidated('information')"
:error="informationErrors.errors.description"
@@ -134,13 +138,15 @@
/>
</div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
<!-- Desactive tant que le client n'est pas cree : evite un PATCH
avant le POST si l'utilisateur clique trop tot (le panneau
Information est l'onglet actif par defaut). -->
<!-- Desactive tant que le client n'est pas cree (evite un PATCH
avant le POST si clic trop tot, Information etant l'onglet
actif par defaut) OU si aucun champ n'est rempli : onglet
facultatif, mais pas de validation a vide (on passe alors
directement a Contact). -->
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting || clientId === null"
:disabled="tabSubmitting || clientId === null || !canValidateInformation"
@click="submitInformation"
/>
</div>
@@ -204,6 +210,7 @@
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.clients.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress"
/>
<MalioButton
@@ -287,9 +294,9 @@
</div>
</div>
<!-- Blocs RIB (0..n) — obligatoires si type de reglement = LCR. -->
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). -->
<div
v-for="(rib, index) in ribs"
v-for="(rib, index) in visibleRibs"
:key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
@@ -329,10 +336,12 @@
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
<MalioButton
v-if="isRibRequired"
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.clients.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib"
/>
<MalioButton
@@ -380,17 +389,20 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import {
addressTypeFromFlags,
buildClientFormTabKeys,
CLIENT_FORM_PLACEHOLDER_TABS,
hasAllRequiredAccountingFields,
hasAtLeastOneInformationField,
hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType,
isBillingEmailRequired,
isContactBlank,
isContactNamed,
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules'
import {
emptyAddress,
@@ -483,6 +495,28 @@ const relationOptions = computed<RefOption[]>(() => [
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
])
// Codes des categories selectionnees (resolus depuis les IRI du brouillon).
const selectedCategoryCodes = computed(() =>
main.categoryIris
.map(iri => referentials.categories.value.find(c => c.value === iri)?.code)
.filter((code): code is string => code !== undefined),
)
// « Relation » + « Prestation de triage » masques par defaut, reveles des qu'une
// categorie ordinaire (autre que Distributeur/Courtier) est selectionnee.
const showRelationAndTriage = computed(() => showsRelationAndTriageFields(selectedCategoryCodes.value))
// Masquer ces champs reinitialise leurs valeurs : pas de relation/triage fantome
// soumis pour un client Distributeur/Courtier.
watch(showRelationAndTriage, (visible) => {
if (!visible) {
main.relationType = null
main.distributorIri = null
main.brokerIri = null
main.triageService = false
}
})
// Validation du formulaire principal (gate le bouton « Valider ») :
// - companyName / >= 1 categorie obligatoires ;
// - relation Distributeur/Courtier optionnelle, mais le nom correspondant
@@ -538,7 +572,9 @@ async function submitMain(): Promise<void> {
main.companyName = created.companyName ?? main.companyName
mainLocked.value = true
unlockedIndex.value = 0
// Information est facultatif : on deverrouille jusqu'a Contact (index 1)
// pour que l'utilisateur puisse y aller directement sans valider Information.
unlockedIndex.value = tabIndex('contact')
activeTab.value = 'information'
toast.success({ title: t('commercial.clients.toast.createSuccess') })
}
@@ -625,9 +661,12 @@ const information = reactive({
directorName: null as string | null,
})
// Onglet facultatif, mais pas de validation « a vide » : au moins un champ rempli.
const canValidateInformation = computed(() => hasAtLeastOneInformationField(information))
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return
if (clientId.value === null || tabSubmitting.value || !canValidateInformation.value) return
tabSubmitting.value = true
informationErrors.clearErrors()
try {
@@ -753,18 +792,17 @@ const countryOptions: RefOption[] = [
// Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email
// facturation si Facturation) sur chaque adresse.
const canValidateAddresses = computed(() =>
addresses.value.length > 0
&& addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return addressTypeFromFlags(a) !== null
&& a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}),
addresses.value.length > 0 && addresses.value.every(isAddressValid),
)
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
return last !== undefined && isAddressValid(last)
})
function addAddress(): void {
addresses.value.push(emptyAddress())
if (canAddAddress.value) addresses.value.push(emptyAddress())
}
function askRemoveAddress(index: number): void {
@@ -853,15 +891,22 @@ const selectedPaymentTypeCode = computed(() =>
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
// Les blocs RIB ne sont affiches que pour une LCR (RG-1.13).
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value)
// La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12).
if (!isBankRequired.value) accounting.bankIri = null
}
function ribIsComplete(rib: RibFormDraft): boolean {
const filled = (v: string | null) => v !== null && v.trim() !== ''
return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide
// quand LCR est choisi, on vide la liste sinon (pas de RIB fantome soumis).
if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}
else {
ribs.value = []
ribErrors.value = []
}
}
// RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact /
@@ -870,12 +915,18 @@ function ribIsComplete(rib: RibFormDraft): boolean {
const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) 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(isRibComplete)) return false
return true
})
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
return last !== undefined && isRibComplete(last)
})
function addRib(): void {
ribs.value.push(emptyRib())
if (canAddRib.value) ribs.value.push(emptyRib())
}
function askRemoveRib(index: number): void {
@@ -987,8 +1038,7 @@ interface ContactResponse {
onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides.
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())
// Pas d'amorce de RIB ici : un bloc vide n'apparait que si LCR est choisi
// (cf. onPaymentTypeChange).
})
</script>
@@ -7,14 +7,19 @@ import {
canSelectDeliveryOrBilling,
canSelectProspect,
hasAllRequiredAccountingFields,
hasAtLeastOneInformationField,
hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType,
isBillingEmailRequired,
isBlankRow,
isContactBlank,
isContactNamed,
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
showsRelationAndTriageFields,
type AddressValidityDraft,
type ContactDraft,
type ContactFillableDraft,
} from '../clientFormRules'
@@ -271,3 +276,96 @@ describe('hasAllRequiredAccountingFields (RG-1.30)', () => {
})).toBe(false)
})
})
describe('showsRelationAndTriageFields (affichage Relation + Triage selon categorie)', () => {
it('faux par defaut (aucune categorie selectionnee)', () => {
expect(showsRelationAndTriageFields([])).toBe(false)
})
it('faux si seules des categories Distributeur / Courtier sont selectionnees', () => {
expect(showsRelationAndTriageFields(['DISTRIBUTEUR'])).toBe(false)
expect(showsRelationAndTriageFields(['COURTIER'])).toBe(false)
expect(showsRelationAndTriageFields(['DISTRIBUTEUR', 'COURTIER'])).toBe(false)
})
it('vrai des qu\'une categorie ordinaire est selectionnee', () => {
expect(showsRelationAndTriageFields(['CLIENT'])).toBe(true)
expect(showsRelationAndTriageFields(['DISTRIBUTEUR', 'CLIENT'])).toBe(true)
})
})
describe('hasAtLeastOneInformationField (pas de validation a vide a la creation)', () => {
const blank = {
description: null,
competitors: null,
foundedAt: null,
employeesCount: null,
revenueAmount: null,
profitAmount: null,
directorName: null,
}
it('faux quand aucun champ n\'est rempli (onglet vierge)', () => {
expect(hasAtLeastOneInformationField(blank)).toBe(false)
expect(hasAtLeastOneInformationField({ ...blank, description: ' ' })).toBe(false)
})
it('vrai des qu\'un champ porte une valeur', () => {
expect(hasAtLeastOneInformationField({ ...blank, description: 'Acteur majeur' })).toBe(true)
expect(hasAtLeastOneInformationField({ ...blank, employeesCount: '42' })).toBe(true)
expect(hasAtLeastOneInformationField({ ...blank, foundedAt: '2020-01-01' })).toBe(true)
})
})
describe('isAddressValid (gating « + Adresse » + validation onglet)', () => {
/** Adresse de livraison valide (type + site + categorie ; pas de facturation). */
function validDelivery(): AddressValidityDraft {
return {
isProspect: false,
isDelivery: true,
isBilling: false,
categoryIris: ['/api/client_categories/1'],
siteIris: ['/api/sites/1'],
billingEmail: null,
}
}
it('vrai quand type + >= 1 site + >= 1 categorie (sans facturation)', () => {
expect(isAddressValid(validDelivery())).toBe(true)
})
it('faux si aucun drapeau (type d\'adresse non renseigne, amorce vierge)', () => {
expect(isAddressValid({ ...validDelivery(), isDelivery: false })).toBe(false)
})
it('faux si aucun site (RG-1.10)', () => {
expect(isAddressValid({ ...validDelivery(), siteIris: [] })).toBe(false)
})
it('faux si aucune categorie', () => {
expect(isAddressValid({ ...validDelivery(), categoryIris: [] })).toBe(false)
})
it('RG-1.11 : email de facturation obligatoire quand l\'adresse est de facturation', () => {
const billing: AddressValidityDraft = { ...validDelivery(), isBilling: true }
expect(isAddressValid({ ...billing, billingEmail: null })).toBe(false)
expect(isAddressValid({ ...billing, billingEmail: ' ' })).toBe(false)
expect(isAddressValid({ ...billing, billingEmail: 'facture@acme.fr' })).toBe(true)
})
})
describe('isRibComplete (gating « + RIB » + RG-1.13)', () => {
it('vrai quand label + BIC + IBAN sont remplis', () => {
expect(isRibComplete({ label: 'Compte courant', bic: 'BNPAFRPP', iban: 'FR1420041010050500013M02606' })).toBe(true)
})
it('faux si un champ manque (null ou vide apres trim)', () => {
expect(isRibComplete({ label: null, bic: 'BNPAFRPP', iban: 'FR14...' })).toBe(false)
expect(isRibComplete({ label: 'Compte', bic: ' ', iban: 'FR14...' })).toBe(false)
expect(isRibComplete({ label: 'Compte', bic: 'BNPAFRPP', iban: null })).toBe(false)
})
it('faux pour un bloc totalement vide (amorce)', () => {
expect(isRibComplete({ label: null, bic: null, iban: null })).toBe(false)
})
})
@@ -12,10 +12,8 @@
*
* Ces helpers ne touchent ni a l'API ni a l'etat reactif.
*
* NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement NON
* miroitee cote front (cf. clientFormRules.ts) — /api/me n'expose pas le code de
* role et Bureau partage les permissions de Commerciale. Le back l'applique de
* maniere fiable (422) ; on laisse remonter ce 422 en toast.
* NOTE : l'onglet Information est facultatif pour tous les roles (RG-1.04
* « Information obligatoire pour la Commerciale » retiree cote back).
*/
import {
@@ -9,12 +9,9 @@
* Le back reste la source de verite (les RG sont re-validees serveur) ; ces
* regles ne servent qu'au feedback UI immediat (gating de boutons, visibilite).
*
* NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement
* NON miroite cote front pour l'instant. Le payload /api/me ne porte pas le code
* de role (roles = IRIs opaques) et Bureau partage les memes permissions que
* Commerciale : aucun signal fiable pour distinguer le role cote front. Le back
* (ClientProcessor, via BusinessRoleAware) applique la regle de maniere fiable ;
* a rebrancher ici des qu'un code de role sera expose dans /api/me.
* NOTE : l'onglet Information est facultatif pour tous les roles. L'ancienne
* RG-1.04 (« Information obligatoire pour la Commerciale ») a ete retiree cote
* back — rien a miroiter ici.
*/
/**
@@ -53,6 +50,26 @@ export function buildClientFormTabKeys(
return keys
}
/**
* Codes de categorie « intermediaire » : un client dont la categorie est
* Distributeur ou Courtier n'a ni relation amont (il EST le distributeur /
* courtier) ni prestation de triage. Sert a conditionner l'affichage des champs
* « Relation » et « Prestation de triage » du formulaire principal.
*/
export const DISTRIBUTOR_BROKER_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER'] as const
/**
* Vrai des qu'au moins une categorie « ordinaire » (autre que Distributeur /
* Courtier) est selectionnee. Les champs « Relation » (depend du distributeur /
* courtier) et « Prestation de triage » du formulaire principal sont masques par
* defaut et reveles uniquement dans ce cas.
*/
export function showsRelationAndTriageFields(selectedCategoryCodes: string[]): boolean {
return selectedCategoryCodes.some(
code => !(DISTRIBUTOR_BROKER_CATEGORY_CODES as readonly string[]).includes(code),
)
}
/** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-1.05/1.14). */
export interface ContactDraft {
firstName: string | null
@@ -138,6 +155,16 @@ export function isRibBlank(rib: RibFillableDraft): boolean {
return isBlankRow([rib.label, rib.bic, rib.iban])
}
/**
* RG-1.13 : un RIB est complet quand ses trois champs sont remplis (label, BIC,
* IBAN). Predicat par-bloc partage entre le gating du bouton « + RIB » (le
* dernier bloc doit etre complet avant d'en ajouter un autre) et la validation
* de l'onglet (au moins un RIB complet si reglement LCR).
*/
export function isRibComplete(rib: RibFillableDraft): boolean {
return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban)
}
/**
* 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
@@ -226,6 +253,31 @@ export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | nu
return null
}
/**
* Sous-ensemble d'une adresse necessaire a sa validite par-bloc : drapeaux
* d'usage (pour le type + l'email de facturation conditionnel), sites et
* categories rattaches, email de facturation.
*/
export interface AddressValidityDraft extends AddressFlagsDraft {
categoryIris: string[]
siteIris: string[]
billingEmail: string | null
}
/**
* Validite par-bloc d'une adresse : type renseigne (RG-1.06/07/08), >= 1 site
* (RG-1.10), >= 1 categorie, et email de facturation rempli si l'adresse est de
* facturation (RG-1.11). Predicat partage entre le gating du bouton « + Adresse »
* (le dernier bloc doit etre valide avant d'en ajouter un autre) et la
* validation de l'onglet (toutes les adresses valides).
*/
export function isAddressValid(address: AddressValidityDraft): boolean {
return addressTypeFromFlags(address) !== null
&& address.siteIris.length >= 1
&& address.categoryIris.length >= 1
&& (!isBillingEmailRequired(address) || isFilled(address.billingEmail))
}
/** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */
const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
@@ -248,6 +300,36 @@ export function isRibRequiredForPaymentType(code: string | null | undefined): bo
return code === PAYMENT_TYPE_LCR
}
/** Champs saisissables de l'onglet Information (tous facultatifs). */
export interface InformationFieldsDraft {
description: string | null
competitors: string | null
foundedAt: string | null
employeesCount: string | null
revenueAmount: string | null
profitAmount: string | null
directorName: string | null
}
/**
* Vrai si au moins un champ de l'onglet Information est rempli. L'onglet est
* facultatif (aucun champ obligatoire), mais on n'autorise pas une validation
* « a vide » a la creation : sans donnee, rien a enregistrer, l'utilisateur
* passe directement a l'onglet Contact. (En edition, vider tous les champs reste
* une action legitime : ce gate n'y est pas applique.)
*/
export function hasAtLeastOneInformationField(information: InformationFieldsDraft): boolean {
return !isBlankRow([
information.description,
information.competitors,
information.foundedAt,
information.employeesCount,
information.revenueAmount,
information.profitAmount,
information.directorName,
])
}
/** Sous-ensemble du brouillon comptable portant les six champs obligatoires. */
export interface AccountingRequiredDraft {
siren: string | null
@@ -9,7 +9,6 @@
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
button-class="w-[184px] justify-start gap-4 text-black"
@click="openFilters"
/>
</template>
@@ -30,7 +29,7 @@
>
<template #cell-action="{ item }">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium"
:class="actionBadgeClass(item.action as string)"
>
{{ t(`audit.action.${item.action}`) }}
@@ -38,15 +37,14 @@
</template>
<template #cell-entityType="{ item }">
<span
class="text-xs"
:title="item.entityType as string"
>{{ formatEntityType(item.entityType as string) }}</span>
</template>
<template #cell-entityId="{ item }">
<span class="font-mono text-xs">{{ item.entityId }}</span>
<span>{{ item.entityId }}</span>
</template>
<template #cell-summary="{ item }">
<span class="text-xs text-gray-600">{{ item.summary }}</span>
<span class="text-gray-600">{{ item.summary }}</span>
</template>
</MalioDataTable>
+3 -2
View File
@@ -5,6 +5,7 @@
<template #actions>
<MalioButton
v-if="can('core.roles.manage')"
variant="secondary"
:label="t('admin.roles.newRole')"
icon-name="mdi:add-bold"
icon-position="left"
@@ -28,7 +29,7 @@
@update:per-page="setItemsPerPage"
>
<template #cell-code="{ item }">
<span class="font-mono text-xs">{{ item.code }}</span>
<span>{{ item.code }}</span>
</template>
<template #cell-permissions="{ item }">
{{ item.permissions }}
@@ -36,7 +37,7 @@
<template #cell-system="{ item }">
<span
v-if="item.isSystem"
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800"
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 font-medium text-blue-800"
>
{{ t('admin.roles.table.system') }}
</span>
+1 -1
View File
@@ -19,7 +19,7 @@
<template #cell-admin="{ item }">
<span
v-if="item.admin"
class="inline-flex items-center rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800"
class="inline-flex items-center rounded-full bg-purple-100 px-2.5 py-0.5 font-medium text-purple-800"
>
{{ t('admin.users.table.admin') }}
</span>
@@ -62,7 +62,7 @@
<MalioInputText
v-model="form.color"
placeholder="#RRGGBB"
input-class="w-full font-mono"
input-class="w-full"
required
/>
<!-- pb-4 sur le wrapper : simule le slot message du
+3 -2
View File
@@ -5,6 +5,7 @@
<template #actions>
<MalioButton
v-if="can('sites.manage')"
variant="secondary"
:label="t('admin.sites.newSite')"
icon-name="mdi:add-bold"
icon-position="left"
@@ -33,11 +34,11 @@
:style="{ backgroundColor: item.color }"
class="inline-block size-5 rounded-full border border-neutral-200"
/>
<span class="font-mono text-xs">{{ item.color }}</span>
<span>{{ item.color }}</span>
</span>
</template>
<template #cell-fullAddress="{ item }">
<span class="line-clamp-2 text-xs text-neutral-600">
<span class="line-clamp-2 text-neutral-600">
{{ item.fullAddress }}
</span>
</template>
+14 -14
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.7.4",
"@malio/layer-ui": "^1.7.7",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -583,20 +583,20 @@
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz",
"integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -604,9 +604,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1866,9 +1866,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.7.4",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.4/layer-ui-1.7.4.tgz",
"integrity": "sha512-JNXwBelj5UQ35Qv5VmnassXKt8niX9jDXjM1vUSukJQiyeUXRxAiZr16QumVgBN9P9YGDyjXVKrwCHltTXvPtQ==",
"version": "1.7.7",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.7/layer-ui-1.7.7.tgz",
"integrity": "sha512-MLHDtOzUxcCwIBGWj4FcUMLQTExtGD29uLvpU+IA6qr7gCj9kZ9fGZDu76LXxuJJdfBwzZmenuZioE7Z1qQUUw==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@malio/layer-ui": "^1.7.4",
"@malio/layer-ui": "^1.7.7",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -23,7 +23,7 @@
</thead>
<tbody>
<tr v-for="(diff, field) in updateDiff" :key="field" class="border-t border-gray-200">
<td class="px-2 py-1 font-mono">{{ field }}</td>
<td class="px-2 py-1">{{ field }}</td>
<td class="px-2 py-1 text-red-700">{{ formatValue(diff.old) }}</td>
<td class="px-2 py-1 text-green-700">{{ formatValue(diff.new) }}</td>
</tr>
@@ -31,7 +31,7 @@
{ added: [ids], removed: [ids] } affiche + et - sur
la meme ligne pour garder une colonne field unique. -->
<tr v-for="(diff, field) in collectionDiff" :key="`col-${field}`" class="border-t border-gray-200">
<td class="px-2 py-1 font-mono">{{ field }}</td>
<td class="px-2 py-1">{{ field }}</td>
<td class="px-2 py-1 text-red-700">
<span v-if="diff.removed.length"> {{ diff.removed.join(', ') }}</span>
<span v-else class="text-gray-400"></span>
@@ -47,7 +47,7 @@
<div v-else class="space-y-1">
<div v-for="(value, key) in entry.changes" :key="key" class="flex gap-2">
<span class="font-mono text-xs text-gray-600">{{ key }}:</span>
<span class="text-xs text-gray-600">{{ key }}:</span>
<span class="text-xs">{{ formatValue(value) }}</span>
</div>
</div>
+8 -3
View File
@@ -1,8 +1,13 @@
<template>
<!-- Entete de page standard : source unique du style des titres.
Slot par defaut = texte du titre, slot #actions = boutons a droite. -->
<div class="mb-[44px] flex items-center justify-between gap-4">
<h1 class="text-[32px] font-semibold text-primary-500">
Slot par defaut = texte du titre, slot #actions = boutons a droite.
Sticky en haut du <main> scrollable : reste visible au scroll. Fond blanc
+ pt-[38px]/pb-[30px] (au lieu de marges) pour que le contenu defilant soit
masque sous l'entete (espaces haut ET bas compris) et que l'entete soit
collee sous le SiteSelector sans trou. pt = marge haute (38px),
pb = espace titre -> contenu (30px). z-20 < drawers/modales. -->
<div class="sticky top-0 z-20 flex items-center justify-between gap-4 bg-white pt-[38px] pb-[30px]">
<h1 class="text-[30px] font-semibold text-primary-500">
<slot/>
</h1>
<div v-if="$slots.actions" class="shrink-0">
@@ -0,0 +1,33 @@
import { describe, it, expect, afterEach } from 'vitest'
import { readHistoryTab } from '../historyTab'
const KEYS = ['information', 'contact', 'address', 'accounting']
describe('readHistoryTab', () => {
afterEach(() => {
window.history.replaceState(null, '')
})
it('retourne la cle d\'onglet quand elle est presente et valide', () => {
window.history.replaceState({ tab: 'address' }, '')
expect(readHistoryTab(KEYS)).toBe('address')
})
it('retourne null quand l\'onglet n\'est pas dans les cles valides (ex: role sans Comptabilite)', () => {
window.history.replaceState({ tab: 'accounting' }, '')
expect(readHistoryTab(['information', 'contact', 'address'])).toBeNull()
})
it('retourne null sans onglet dans l\'etat d\'historique (navigation directe / refresh)', () => {
window.history.replaceState(null, '')
expect(readHistoryTab(KEYS)).toBeNull()
window.history.replaceState({ foo: 'bar' }, '')
expect(readHistoryTab(KEYS)).toBeNull()
})
it('retourne null quand la valeur n\'est pas une chaine', () => {
window.history.replaceState({ tab: 42 }, '')
expect(readHistoryTab(KEYS)).toBeNull()
})
})
+22
View File
@@ -0,0 +1,22 @@
/**
* Onglet actif transmis d'une page a l'autre via l'etat d'historique
* (`history.state`), SANS le mettre dans l'URL. Sert a preserver l'onglet courant
* au passage consultation <-> edition d'un client (dans les deux sens).
*
* On reste donc fidele a la regle « etat d'UI local, pas dans l'URL » : l'onglet
* voyage dans l'entree d'historique de la navigation, l'URL ne change pas.
*/
/**
* Lit la cle d'onglet posee par la page precedente (`history.state.tab`) si elle
* fait partie des onglets valides pour l'utilisateur. Retourne `null` sinon :
* navigation directe / deep link, rechargement de page, ou onglet inexistant
* pour ce role (ex: Comptabilite sans la permission).
*/
export function readHistoryTab(validKeys: string[]): string | null {
if (typeof window === 'undefined') {
return null
}
const tab = (window.history.state as Record<string, unknown> | null)?.tab
return typeof tab === 'string' && validKeys.includes(tab) ? tab : null
}
+7 -7
View File
@@ -256,13 +256,13 @@ final class Version20260601000000 extends AbstractMigration
$this->comment('client', 'distributor_id', 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.');
$this->comment('client', 'broker_id', 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.');
$this->comment('client', 'triage_service', 'Drapeau service triage active pour le client. Faux par defaut.');
$this->comment('client', 'description', 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.');
$this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
$this->comment('client', 'description', 'Onglet Information : description libre. Facultatif.');
$this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Facultatif.');
$this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Facultatif.');
$this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Facultatif.');
$this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Facultatif.');
$this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Facultatif.');
$this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Facultatif.');
$this->comment('client', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).');
$this->comment('client', 'account_number', 'Onglet Comptabilite : numero de compte comptable du client.');
$this->comment('client', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.');
@@ -16,9 +16,9 @@ use Symfony\Component\Validator\ConstraintViolationList;
* 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).
* Parti pris : 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.
@@ -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 RG-1.04 (durcie ERP-74) : pour un utilisateur portant le
* role metier Commerciale, TOUS les champs de l'onglet Information sont
* obligatoires sur POST comme sur tout PATCH, independamment des champs
* reellement envoyes.
*
* Invoque par le ClientProcessor des que l'utilisateur courant porte le role
* Commerciale (plus de condition d'intersection avec l'onglet Information).
* Pour les autres roles, ces champs restent optionnels — le validator n'est
* pas appele.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
* coherence avec les violations Symfony rendues par API Platform.
*/
final class ClientInformationCompletenessValidator
{
public function validate(Client $client): void
{
// Map champ -> valeur courante de l'onglet Information.
$fields = [
'description' => $client->getDescription(),
'competitors' => $client->getCompetitors(),
'foundedAt' => $client->getFoundedAt(),
'employeesCount' => $client->getEmployeesCount(),
'revenueAmount' => $client->getRevenueAmount(),
'directorName' => $client->getDirectorName(),
'profitAmount' => $client->getProfitAmount(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
// Pas de nom de champ technique dans le message : la violation est
// deja rattachee au bon champ via son propertyPath (mappe inline
// cote front par useFormErrors).
'Ce champ est obligatoire pour le rôle Commerciale.',
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 zeros numeriques (employeesCount = 0, profitAmount = "0.00") sont des
* valeurs valides : on ne les considere pas manquants.
*/
private function isMissing(mixed $value): bool
{
if (null === $value) {
return true;
}
return is_string($value) && '' === trim($value);
}
}
@@ -10,7 +10,7 @@ use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier RG-2.03 (jumeau du ClientInformationCompletenessValidator M1) :
* Validator metier RG-2.03 (completude Information cote fournisseur) :
* pour un utilisateur portant le role metier Commerciale, TOUS les champs de
* l'onglet Information sont obligatoires sur POST comme sur tout PATCH,
* independamment des champs reellement envoyes.
@@ -9,11 +9,8 @@ use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
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\Domain\Entity\Client;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
@@ -44,8 +41,8 @@ use Symfony\Component\Validator\ConstraintViolationList;
* 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer.
* 3. Regles metier : RG-1.03 (distributor/broker
* exclusifs + type de categorie), RG-1.12 (Virement -> banque),
* RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST
* et tout PATCH pour le role Commerciale).
* RG-1.13 (LCR -> >= 1 RIB). L'onglet Information est entierement facultatif
* (RG-1.04 retiree : plus d'obligation, y compris pour le role Commerciale).
* 4. Pose / retrait de archivedAt (RG-1.22 true=now, RG-1.23 false=null).
* 5. Persistance via le persist_processor Doctrine, avec traduction des
* collisions d'unicite en 409 (RG-1.16 doublon de nom ; RG-1.23 conflit de
@@ -108,7 +105,6 @@ final class ClientProcessor implements ProcessorInterface
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly ClientFieldNormalizer $normalizer,
private readonly ClientInformationCompletenessValidator $informationValidator,
private readonly ClientAccountingCompletenessValidator $accountingValidator,
private readonly Security $security,
private readonly RequestStack $requestStack,
@@ -142,7 +138,6 @@ final class ClientProcessor implements ProcessorInterface
$this->validateDistributorBroker($data);
$this->validateAccountingConsistency($data);
$this->validateAccountingCompleteness($data);
$this->validateInformationCompleteness($data);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
@@ -511,9 +506,9 @@ final class ClientProcessor implements ProcessorInterface
* 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.
* Colonnes nullable en base + validateur contextuel : 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
{
@@ -526,21 +521,6 @@ final class ClientProcessor implements ProcessorInterface
$this->accountingValidator->validate($data);
}
/**
* RG-1.04 (durcie ERP-74) : si l'utilisateur porte le role metier
* Commerciale, TOUS les champs de l'onglet Information sont obligatoires sur
* POST comme sur TOUT PATCH — independamment des champs reellement envoyes
* (plus de condition d'intersection avec INFORMATION_FIELDS). Garantit qu'un
* client cree/edite par une Commerciale ne reste jamais avec un onglet
* Information incomplet.
*/
private function validateInformationCompleteness(Client $data): void
{
if ($this->currentUserIsCommerciale()) {
$this->informationValidator->validate($data);
}
}
/**
* Vrai si au moins une categorie du client porte le code donne. S'appuie sur
* CategoryInterface::getCode() (pas d'import de Category — regle ABSOLUE n°1).
@@ -556,21 +536,12 @@ final class ClientProcessor implements ProcessorInterface
return false;
}
private function currentUserIsCommerciale(): bool
{
$user = $this->security->getUser();
return $user instanceof BusinessRoleAwareInterface
&& $user->hasBusinessRole(BusinessRoles::COMMERCIALE);
}
/**
* Cles ecrivables effectivement presentes dans le payload : on retire les
* cles JSON-LD (@id, @context, @var...) et tout champ non rattache a un
* groupe d'ecriture connu. C'est la base du 422 d'archivage (RG-1.22) et du
* declenchement conditionnel de RG-1.04 — sans elles, un PATCH
* « representation complete » porteur de @id ferait croire a une
* modification multi-onglets.
* groupe d'ecriture connu. C'est la base du 422 d'archivage (RG-1.22)
* sans elles, un PATCH « representation complete » porteur de @id ferait
* croire a une modification multi-onglets.
*
* @return list<string>
*/
@@ -234,7 +234,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
$this->addAddress($services, ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Conseil', isDelivery: true);
}
// === Onglet Information complet (RG-1.04) ===
// === Onglet Information complet (exemple de fiche renseignee) ===
[$holding, $isNew] = $this->ensureClient(
$manager,
companyName: 'Holding Premium Invest',
@@ -36,8 +36,8 @@ final class RbacSeeder
{
/**
* Codes des roles metier (snake_case, regex Role respectee). `commerciale`
* reference la constante Shared deja consommee par le ClientProcessor
* (RG-1.04) pour eviter tout drift : un seul litteral pour ce code.
* reference la constante Shared pour eviter tout drift : un seul litteral
* pour ce code.
*/
public const string ROLE_BUREAU = 'bureau';
public const string ROLE_COMPTA = 'compta';
+2 -2
View File
@@ -344,8 +344,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Busines
/**
* Implemente BusinessRoleAwareInterface : vrai si l'un des roles RBAC
* rattaches porte le code donne. Permet aux modules tiers de detecter un
* role metier (ex: `commerciale` pour RG-1.04 du M1 Clients) sans importer
* cette classe. Comparaison stricte sur Role::code.
* role metier (ex: `commerciale`) sans importer cette classe. Comparaison
* stricte sur Role::code.
*/
public function hasBusinessRole(string $roleCode): bool
{
@@ -10,7 +10,6 @@ namespace App\Shared\Domain\Contract;
* App\Shared\Domain\Security\BusinessRoles).
*
* Implementee par App\Module\Core\Domain\Entity\User. Permet a un module tiers
* (ex: Commercial — RG-1.04, completude Information pour le role Commerciale)
* de raisonner sur les roles metier via Security::getUser() sans importer User
* (regle ABSOLUE n°1 : pas d'import inter-modules).
*
+8 -6
View File
@@ -10,9 +10,11 @@ namespace App\Shared\Domain\Security;
* Distincts des roles SYSTEME (cf. App\Module\Core\Domain\Security\SystemRoles :
* `admin` / `user`). Un role metier porte une intention fonctionnelle (poste de
* travail) et conditionne certaines regles de gestion au-dela des permissions
* RBAC pures — ex : RG-1.04 du M1 Clients rend l'onglet Information obligatoire
* pour le seul role Commerciale, alors que Commerciale et Bureau partagent les
* memes permissions (commercial.clients.view + manage, cf. spec-back M1 § 5.2).
* RBAC pures (Commerciale et Bureau partagent par ex. les memes permissions
* commercial.clients.view + manage mais peuvent porter des regles de gestion
* propres au poste). NB : l'ancienne RG-1.04 du M1 (« Information obligatoire
* pour Commerciale ») a ete retiree — l'onglet Information est facultatif pour
* tous ; la machinerie de role metier reste disponible pour de futures regles.
*
* Ces constantes vivent dans Shared (et non dans un module) pour que :
* - le seed des roles cote Core (ERP-74) reference le meme code sans importer
@@ -24,14 +26,14 @@ namespace App\Shared\Domain\Security;
* Coordination stack M1 :
* - ERP-74 seede le role `commerciale` avec ce code exact.
* - ERP-59 / ERP-60 (declaration RBAC + tests personas) le reutilisent.
* - ERP-55 (ici) ne fait que le REFERENCER : tant qu'aucun user ne porte le
* role `commerciale`, la validation de completude Information reste dormante.
* - ERP-55 le referencait pour la completude Information (M1 RG-1.04), regle
* depuis retiree ; le code reste utilise par le seed RBAC et les personas.
*/
final class BusinessRoles
{
/**
* Role metier « Commerciale » — code de Role RBAC (champ Role::code,
* snake_case impose par la regex Role). Conditionne RG-1.04.
* snake_case impose par la regex Role).
*/
public const string COMMERCIALE = 'commerciale';
@@ -180,13 +180,13 @@ final class ColumnCommentsCatalog
'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.',
'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.',
'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.',
'description' => 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.',
'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).',
'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).',
'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).',
'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).',
'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).',
'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).',
'description' => 'Onglet Information : description libre. Facultatif.',
'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Facultatif.',
'founded_at' => 'Onglet Information : date de creation de l entreprise. Facultatif.',
'employees_count' => 'Onglet Information : effectif (entier >= 0). Facultatif.',
'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Facultatif.',
'director_name' => 'Onglet Information : nom du dirigeant. Facultatif.',
'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Facultatif.',
'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).',
'account_number' => 'Onglet Comptabilite : numero de compte comptable du client.',
'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.',
@@ -14,8 +14,8 @@ use App\Module\Sites\Domain\Entity\Site;
*
* Authentifies en ADMIN (bypass RBAC via isAdmin) : on valide ici les regles
* METIER (normalisation, unicite, distributor/broker, archivage, liste). Le
* gating par permission (accounting.manage / archive / RG-1.28 strict, RG-1.04
* Commerciale) est couvert par les tests unitaires du ClientProcessor : il
* gating par permission (accounting.manage / archive / RG-1.28 strict) est
* couvert par les tests unitaires du ClientProcessor : il
* exige des users non-admin portant des permissions `commercial.clients.*` qui
* ne sont declarees qu'en ERP-59 (tests RBAC complets en ERP-60).
*
@@ -14,8 +14,7 @@ use Symfony\Component\Console\Output\NullOutput;
/**
* Matrice RBAC complete du repertoire clients par role metier (spec-back M1
* § 2.7 + cahier ERP-74). Valide 200/403 par verbe et par onglet pour
* bureau / compta / commerciale / usine, plus le durcissement RG-1.04
* (Commerciale) au POST.
* bureau / compta / commerciale / usine.
*
* Les comptes demo et la matrice sont seedes via la commande reelle
* `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente.
@@ -174,14 +173,14 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// manage : la creation passe la security d'operation (pas un 403 comme
// Compta) mais bute sur RG-1.04 (onglet Information incomplet) -> 422.
// C'est la preuve que Commerciale porte `manage` (sinon 403).
// manage : la creation passe la security d'operation et aboutit -> 201
// (l'onglet Information est facultatif pour tous depuis le retrait de
// RG-1.04). C'est la preuve que Commerciale porte `manage` (sinon 403).
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Commerciale Post'),
]);
self::assertResponseStatusCodeSame(422);
self::assertResponseStatusCodeSame(201);
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
@@ -198,27 +197,6 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(403);
}
public function testRG104CommercialePostIncompleteIs422AdminIs201(): void
{
$cat = $this->createCategory('SECTEUR');
// RG-1.04 durcie : Commerciale POST sans onglet Information complet -> 422.
$commerciale = $this->authAs('commerciale');
$commerciale->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('RG104 Commerciale', $cat->getId()),
]);
self::assertResponseStatusCodeSame(422);
// Meme payload par un Admin (non gate par RG-1.04) -> 201.
$admin = $this->createAdminClient();
$admin->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('RG104 Admin', $cat->getId()),
]);
self::assertResponseStatusCodeSame(201);
}
public function testComptaFullRepresentationPatchWithUnchangedCategoriesIsNotForbidden(): void
{
// FIX review MR #40 : un Compta (accounting.manage, PAS manage) faisant un
@@ -12,8 +12,8 @@ namespace App\Tests\Module\Commercial\Api;
* - 403 si l'utilisateur authentifie ne porte pas `commercial.clients.view`.
*
* ⚠ La matrice RBAC differenciee par role metier (bureau / compta / commerciale
* / usine) et le test fonctionnel RG-1.04 sont DELEGUES a ERP-74 (#493) : ils
* exigent les roles seedes apres le merge de la stack. NE PAS les ajouter ici.
* / usine) est DELEGUEE a ERP-74 (#493) : elle exige les roles seedes apres le
* merge de la stack. NE PAS l'ajouter ici.
*
* @internal
*/
@@ -9,7 +9,6 @@ use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
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\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\ClientRib;
@@ -17,8 +16,6 @@ use App\Module\Commercial\Domain\Entity\PaymentDelay;
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\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\UnitOfWork;
use PHPUnit\Framework\TestCase;
@@ -27,13 +24,11 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Tests unitaires du ClientProcessor : gating par permission (accounting.manage
* / archive / RG-1.28 strict) et regles metier non testables en HTTP admin
* (RG-1.04 Commerciale, RG-1.12 Virement, RG-1.13 LCR), grace a un Security et
* un RequestStack stubbes.
* (RG-1.12 Virement, RG-1.13 LCR), grace a un Security et un RequestStack stubbes.
*
* @internal
*/
@@ -342,62 +337,6 @@ final class ClientProcessorTest extends TestCase
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
}
public function testCommercialeIncompleteInformationIsUnprocessable(): void
{
// RG-1.04 : role Commerciale + onglet Information incomplet -> 422.
$client = $this->minimalClient();
$client->setDescription('Une description'); // les autres champs Information restent null
$processor = $this->makeProcessor(
granted: [],
payload: ['description' => 'Une description'],
user: $this->commercialeUser(),
);
$this->expectException(ValidationException::class);
$processor->process($client, $this->operation());
}
public function testCommercialeIncompleteInformationOnNonInformationPatchIsUnprocessable(): void
{
// RG-1.04 durcie (ERP-74) : pour une Commerciale, la completude de
// l'onglet Information est exigee meme quand le payload ne touche PAS
// l'onglet Information (ici seulement companyName). L'ancienne condition
// d'intersection avec INFORMATION_FIELDS a ete retiree.
$client = $this->minimalClient();
$client->setCompanyName('Renamed Co'); // onglet principal uniquement, Information vide
$processor = $this->makeProcessor(
granted: ['commercial.clients.manage'],
payload: ['companyName' => 'Renamed Co'],
user: $this->commercialeUser(),
managed: true,
originalData: [
'companyName' => 'TEST CO',
'triageService' => false,
'isArchived' => false,
],
);
$this->expectException(ValidationException::class);
$processor->process($client, $this->operation());
}
public function testNonCommercialeSkipsInformationCompleteness(): void
{
// Meme payload incomplet, mais user non-Commerciale -> aucun blocage.
$client = $this->minimalClient();
$client->setDescription('Une description');
$processor = $this->makeProcessor(
granted: [],
payload: ['description' => 'Une description'],
user: null,
);
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
}
/**
* @param list<string> $granted Permissions accordees a l'utilisateur courant
* @param array<string, mixed> $payload Corps JSON simule de la requete
@@ -407,7 +346,6 @@ final class ClientProcessorTest extends TestCase
private function makeProcessor(
array $granted,
array $payload,
?UserInterface $user = null,
bool $managed = false,
array $originalData = [],
): ClientProcessor {
@@ -422,7 +360,6 @@ final class ClientProcessorTest extends TestCase
$security->method('isGranted')->willReturnCallback(
static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true),
);
$security->method('getUser')->willReturn($user);
$requestStack = new RequestStack();
$requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR)));
@@ -440,7 +377,6 @@ final class ClientProcessorTest extends TestCase
return new ClientProcessor(
$persist,
new ClientFieldNormalizer(),
new ClientInformationCompletenessValidator(),
new ClientAccountingCompletenessValidator(),
$security,
$requestStack,
@@ -493,26 +429,4 @@ final class ClientProcessorTest extends TestCase
{
return $this->createStub(Operation::class);
}
private function commercialeUser(): UserInterface
{
return new class implements UserInterface, BusinessRoleAwareInterface {
public function hasBusinessRole(string $roleCode): bool
{
return BusinessRoles::COMMERCIALE === $roleCode;
}
public function getRoles(): array
{
return ['ROLE_USER'];
}
public function eraseCredentials(): void {}
public function getUserIdentifier(): string
{
return 'commerciale-test';
}
};
}
}