Compare commits

...

6 Commits

Author SHA1 Message Date
gitea-actions b3ab23ee8f chore: bump version to v0.1.100
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 36s
2026-06-09 08:44:19 +00:00
tristan 222338e5a4 fix(commercial) : validation onglet compta LCR + controle croise BIC/IBAN (ERP-118) (#78)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-118 — Validation onglet Comptabilité (LCR / RIB)

### 1. Fix — 422 « Au moins un RIB est obligatoire pour le type de règlement LCR »

L'onglet Comptabilité envoyait le `PATCH /clients/{id}` des scalaires (`paymentType=LCR`) **avant** le `POST /clients/{id}/ribs`. Or le back valide RG-1.13 (LCR ⟹ ≥1 RIB persisté) sur ce PATCH, en lisant les RIB en base — vides à ce stade. Résultat : 422, et le `return` empêchait la création des RIB. Premier passage en LCR impossible (deadlock).

**Correctif :** inverser l'ordre — RIB d'abord, puis PATCH des scalaires.
- `new.vue` : `POST/PATCH RIB` → `PATCH scalaires`.
- `[id]/edit.vue` : ordre universel `CREATE/UPDATE RIB` → `PATCH scalaires` → `DELETE RIB retirés` (suppressions après le PATCH : le guard back n'autorise la suppression du dernier RIB qu'une fois quitté LCR). Corrige au passage un 409 latent sur le swap du dernier RIB en LCR.

### 2. Feat — contrôle croisé pays BIC/IBAN

`Assert\Bic(ibanPropertyPath: 'iban')` sur `ClientRib` et `SupplierRib` : le pays du BIC (positions 5-6) doit correspondre au pays de l'IBAN (positions 1-2). Un BIC et un IBAN valides isolément mais de pays différents → 422, violation portée par le champ `bic` avec message FR (`ibanMessage`), mappée inline côté front. Aucune modif front nécessaire.

### Tests

- Tests fonctionnels du mismatch (BIC DE + IBAN FR → 422 sur `propertyPath=bic`, message FR) côté client et fournisseur.
- Suite back complète au vert (garde-fou `EntityConstraintsHaveFrenchMessageTest` inclus), suite front Vitest au vert.

### Points d'attention

- **Durcissement de RG** (cross-check BIC/IBAN) hors spec initiale : des RIB existants avec BIC/IBAN de pays différents deviendraient non modifiables sans correction.
- L'orchestration de submit n'est pas couverte par un test unitaire (pas d'infra de test composant sur ces écrans) — vérification golden path recommandée.

Reviewed-on: #78
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 08:44:12 +00:00
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
18 changed files with 221 additions and 163 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.97' app.version: '0.1.100'
+1 -3
View File
@@ -416,7 +416,6 @@
"newCategory": "Ajouter", "newCategory": "Ajouter",
"editCategory": "Modifier la catégorie", "editCategory": "Modifier la catégorie",
"createCategory": "Créer une catégorie", "createCategory": "Créer une catégorie",
"viewCategory": "Détail de la catégorie",
"noCategories": "Aucune catégorie pour l'instant.", "noCategories": "Aucune catégorie pour l'instant.",
"table": { "table": {
"name": "Nom", "name": "Nom",
@@ -431,8 +430,7 @@
}, },
"form": { "form": {
"name": "Nom", "name": "Nom",
"types": "Types de catégorie", "types": "Types de catégorie"
"typesPlaceholder": "Sélectionner un ou plusieurs types"
}, },
"validation": { "validation": {
"nameRequired": "Le nom est obligatoire.", "nameRequired": "Le nom est obligatoire.",
@@ -31,7 +31,6 @@
v-model="form.categoryTypeIds.value" v-model="form.categoryTypeIds.value"
:options="typeOptions" :options="typeOptions"
:label="t('admin.categories.form.types')" :label="t('admin.categories.form.types')"
:empty-option-label="t('admin.categories.form.typesPlaceholder')"
:error="form.errors.categoryTypes" :error="form.errors.categoryTypes"
:display-tag="true" :display-tag="true"
:disabled="loadingTypes" :disabled="loadingTypes"
@@ -91,28 +90,17 @@ const emit = defineEmits<{
delete: [] delete: []
}>() }>()
/** // Mode du drawer : creation (pas de category prop, POST au save) ou
* Mode du drawer (dérivé du composable `useCategoryForm`) : // modification d'une categorie existante (PATCH au save). Pas de distinction
* - 'create' : pas de category prop, formulaire vide, POST au save. // view/edit : comme les autres drawers, le titre et le bouton Enregistrer sont
* - 'view' : category prop set, formulaire pre-rempli, save MASQUE // stables quel que soit l'etat « dirty » du formulaire.
* jusqu'a ce que l'utilisateur modifie un champ.
* - 'edit' : category prop set et formulaire « dirty » (au moins un
* champ different de l'original), PATCH au save.
*/
type DrawerMode = 'create' | 'view' | 'edit'
const isCreateMode = computed(() => props.category === null) const isCreateMode = computed(() => props.category === null)
const mode = computed<DrawerMode>(() => { const headerLabel = computed(() =>
if (isCreateMode.value) return 'create' isCreateMode.value
return form.isDirty.value ? 'edit' : 'view' ? t('admin.categories.createCategory')
}) : t('admin.categories.editCategory'),
)
const headerLabel = computed(() => {
if (mode.value === 'create') return t('admin.categories.createCategory')
if (mode.value === 'edit') return t('admin.categories.editCategory')
return t('admin.categories.viewCategory')
})
// Le bouton Supprimer n'est visible qu'en consultation/edition d'une categorie // 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 // 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'), () => !isCreateMode.value && can('catalog.categories.manage'),
) )
// Save : visible en creation, ou en edition (apres modification d'un champ). // Save : visible en creation, et en consultation/edition d'une categorie
// Masque en view tant que rien n'a change. // 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( const canShowSave = computed(
() => mode.value === 'create' || mode.value === 'edit', () => isCreateMode.value || can('catalog.categories.manage'),
) )
const typeOptions = computed(() => const typeOptions = computed(() =>
@@ -154,18 +144,18 @@ watch(
) )
/** /**
* Sauvegarde : delegue au composable (POST en mode create, PATCH en mode * Sauvegarde : delegue au composable (POST en creation, PATCH en modification).
* edit). Le toast succes + mapping erreur 409/422 est gere par le composable. * Le toast succes + mapping erreur 409/422 est gere par le composable. Le PATCH
* En cas de succes, on ferme le drawer et on previent le parent pour qu'il * envoie le payload complet, donc le bouton Enregistrer sauvegarde a tout
* refresh la liste. * 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> { async function handleSave(): Promise<void> {
let result: Category | null = null const result = isCreateMode.value
if (mode.value === 'create') { ? await form.submitCreate()
result = await form.submitCreate() : props.category
} else if (mode.value === 'edit' && props.category) { ? await form.submitUpdate(props.category.id)
result = await form.submitUpdate(props.category.id) : null
}
if (result) { if (result) {
emit('saved') emit('saved')
emit('update:modelValue', false) emit('update:modelValue', false)
@@ -346,7 +346,7 @@ describe('useCategoryForm', () => {
}) })
describe('submitUpdate', () => { 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' }) mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
const form = useCategoryForm() const form = useCategoryForm()
form.loadFrom(CAT) form.loadFrom(CAT)
@@ -354,9 +354,11 @@ describe('useCategoryForm', () => {
await form.submitUpdate(42) 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( expect(mockPatch).toHaveBeenCalledWith(
'/categories/42', '/categories/42',
{ name: 'Vis V2' }, // pas de categoryTypes car non modifies { name: 'Vis V2', categoryTypes: ['/api/category_types/1'] },
{ toast: false }, { toast: false },
) )
}) })
@@ -371,20 +373,25 @@ describe('useCategoryForm', () => {
expect(mockPatch).toHaveBeenCalledWith( expect(mockPatch).toHaveBeenCalledWith(
'/categories/42', '/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 }, { 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() const form = useCategoryForm()
form.loadFrom(CAT) 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) const result = await form.submitUpdate(42)
expect(mockPatch).not.toHaveBeenCalled() expect(mockPatch).toHaveBeenCalledWith(
expect(result).toBeNull() '/categories/42',
{ name: CAT.name, categoryTypes: ['/api/category_types/1'] },
{ toast: false },
)
expect(result).toEqual(CAT)
expect(form.submitting.value).toBe(false) expect(form.submitting.value).toBe(false)
}) })
@@ -174,26 +174,18 @@ export function useCategoryForm() {
} }
/** /**
* PATCH /api/categories/{id}. Envoie uniquement les champs modifies pour * PATCH /api/categories/{id}. Envoie le payload complet (name +
* coller a la semantique merge-patch (Content-Type pose par useApi). * categoryTypes), comme les autres drawers du projet : le bouton
* Renvoie la categorie mise a jour, ou `null` en cas d'echec. * 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> { async function submitUpdate(id: number): Promise<Category | null> {
if (!validate()) return null if (!validate()) return null
submitting.value = true submitting.value = true
const payload: Record<string, unknown> = {} const payload: Record<string, unknown> = {
if (name.value !== initialName.value) { name: name.value.trim(),
payload.name = name.value.trim() categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`),
}
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
} }
try { try {
const updated = await api.patch<Category>(`/categories/${id}`, payload, { const updated = await api.patch<Category>(`/categories/${id}`, payload, {
@@ -3,17 +3,10 @@
<PageHeader> <PageHeader>
{{ t('admin.categories.title') }} {{ t('admin.categories.title') }}
<template #actions> <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). --> design que le Repertoire Clients). -->
<div class="flex items-center gap-12"> <div class="flex items-center gap-8">
<MalioButton <!-- Bouton Filtres a GAUCHE d'Ajouter. Le compteur reflete
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
les filtres actifs. --> les filtres actifs. -->
<MalioButton <MalioButton
variant="tertiary" variant="tertiary"
@@ -21,9 +14,16 @@
icon-name="mdi:tune" icon-name="mdi:tune"
icon-position="left" icon-position="left"
icon-size="24" icon-size="24"
button-class="w-[184px] justify-start gap-4 text-black"
@click="openFilters" @click="openFilters"
/> />
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('admin.categories.newCategory')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
</div> </div>
</template> </template>
</PageHeader> </PageHeader>
@@ -956,35 +956,21 @@ function askRemoveRib(index: number): void {
} }
/** /**
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting, * Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
* exige accounting.manage cote back) PUIS DELETE/POST/PATCH des RIB sur la * PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
* sous-ressource. Aucun champ main/information dans le payload (mode strict * back) PUIS DELETE des RIB retires. Les RIB crees d'abord : le back valide RG-1.13
* RG-1.28 : sinon 403 sur tout le payload). * (LCR => au moins un RIB persiste) sur le PATCH scalaires ; les suppressions en
* dernier (le guard back n'autorise la suppression du dernier RIB qu'une fois quitte
* LCR). Aucun champ main/information dans le payload (mode strict RG-1.28 : sinon
* 403 sur tout le payload).
*/ */
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors() accountingErrors.clearErrors()
// Reset des erreurs RIB des le debut : l'etape 1 (PATCH scalaires) peut
// echouer et `return` avant submitRows (qui porte sinon le reset), laissant
// des erreurs de RIB obsoletes affichees sous les blocs.
ribErrors.value = []
try { try {
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs). // 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
try { // tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
// 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes).
// Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex. // Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex.
// IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline. // IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline.
const ribHasError = await submitRows( const ribHasError = await submitRows(
@@ -1011,6 +997,23 @@ async function submitAccounting(): Promise<void> {
rib => rib.id === null && isRibBlank(rib), rib => rib.id === null && isRibBlank(rib),
) )
if (ribHasError) return if (ribHasError) return
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change).
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { catch (e) {
@@ -3,8 +3,18 @@
<PageHeader> <PageHeader>
{{ t('commercial.clients.title') }} {{ t('commercial.clients.title') }}
<template #actions> <template #actions>
<!-- gap-12 = 48px d'espacement entre Ajouter et Filtres. --> <!-- gap-8 = 32px d'espacement entre Filtres et Ajouter. -->
<div class="flex items-center gap-8"> <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"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
@click="openFilters"
/>
<MalioButton <MalioButton
v-if="canManage" v-if="canManage"
variant="secondary" variant="secondary"
@@ -13,18 +23,6 @@
icon-position="left" icon-position="left"
@click="goToCreate" @click="goToCreate"
/> />
<!-- Bouton Filtres a DROITE d'Ajouter : meme design que
l'audit-log. Le compteur reflete les filtres actifs. -->
<MalioButton
v-if="canView"
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="20"
button-class="w-[180px] justify-start gap-4 text-black"
@click="openFilters"
/>
</div> </div>
</template> </template>
</PageHeader> </PageHeader>
@@ -939,37 +939,20 @@ function askRemoveRib(index: number): void {
} }
/** /**
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting) * Valide l'onglet Comptabilite : POST/PATCH des RIB sur /clients/{id}/ribs PUIS
* PUIS POST des RIB sur /clients/{id}/ribs. Deux appels distincts (mode strict * PATCH des scalaires (groupe client:write:accounting). Les RIB d'abord : le back
* RG-1.28 : il n'existe pas d'endpoint /accounting, cf. recon back). * valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires, les RIB
* doivent donc exister en base AVANT (sinon 422 « Au moins un RIB est obligatoire
* pour le type de reglement LCR »). Deux appels distincts (mode strict RG-1.28 :
* il n'existe pas d'endpoint /accounting, cf. recon back).
*/ */
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors() accountingErrors.clearErrors()
// Reset des erreurs RIB des le debut : l'etape 1 (PATCH scalaires) peut
// echouer et `return` avant submitRows (qui porte sinon le reset), laissant
// des erreurs de RIB obsoletes affichees sous les blocs.
ribErrors.value = []
try { try {
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs). // 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
try { // tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
await api.patch(`/clients/${clientId.value}`, {
siren: accounting.siren || null,
accountNumber: accounting.accountNumber || null,
tvaMode: accounting.tvaModeIri,
nTva: accounting.nTva || null,
paymentDelay: accounting.paymentDelayIri,
paymentType: accounting.paymentTypeIri,
bank: isBankRequired.value ? accounting.bankIri : null,
}, { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
// 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes).
// Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex. // Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex.
// IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline. // IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline.
const ribHasError = await submitRows( const ribHasError = await submitRows(
@@ -997,6 +980,23 @@ async function submitAccounting(): Promise<void> {
) )
if (ribHasError) return if (ribHasError) return
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(`/clients/${clientId.value}`, {
siren: accounting.siren || null,
accountNumber: accounting.accountNumber || null,
tvaMode: accounting.tvaModeIri,
nTva: accounting.nTva || null,
paymentDelay: accounting.paymentDelayIri,
paymentType: accounting.paymentTypeIri,
bank: isBankRequired.value ? accounting.bankIri : null,
}, { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
completeTab('accounting') completeTab('accounting')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
@@ -9,7 +9,6 @@
icon-name="mdi:tune" icon-name="mdi:tune"
icon-position="left" icon-position="left"
icon-size="24" icon-size="24"
button-class="w-[184px] justify-start gap-4 text-black"
@click="openFilters" @click="openFilters"
/> />
</template> </template>
@@ -5,6 +5,7 @@
<template #actions> <template #actions>
<MalioButton <MalioButton
v-if="can('core.roles.manage')" v-if="can('core.roles.manage')"
variant="secondary"
:label="t('admin.roles.newRole')" :label="t('admin.roles.newRole')"
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
@@ -5,6 +5,7 @@
<template #actions> <template #actions>
<MalioButton <MalioButton
v-if="can('sites.manage')" v-if="can('sites.manage')"
variant="secondary"
:label="t('admin.sites.newSite')" :label="t('admin.sites.newSite')"
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
+4 -4
View File
@@ -2,11 +2,11 @@
<!-- Entete de page standard : source unique du style des titres. <!-- Entete de page standard : source unique du style des titres.
Slot par defaut = texte du titre, slot #actions = boutons a droite. Slot par defaut = texte du titre, slot #actions = boutons a droite.
Sticky en haut du <main> scrollable : reste visible au scroll. Fond blanc Sticky en haut du <main> scrollable : reste visible au scroll. Fond blanc
+ pt-11/pb-[34px] (au lieu de marges) pour que le contenu defilant soit + 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 masque sous l'entete (espaces haut ET bas compris) et que l'entete soit
collee sous le SiteSelector sans trou. pt-11 = 44px, la marge haute collee sous le SiteSelector sans trou. pt = marge haute (38px),
d'origine. z-20 < drawers/modales. --> 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-11 pb-[34px]"> <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"> <h1 class="text-[30px] font-semibold text-primary-500">
<slot/> <slot/>
</h1> </h1>
@@ -31,8 +31,8 @@ use Symfony\Component\Validator\Constraints as Assert;
* comptable et la conformite, cf. spec § 2.5 / § 6.1). * comptable et la conformite, cf. spec § 2.5 / § 6.1).
* *
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1 * Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1
* (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable * (HP-M2-14 : pas de controle externe banque reelle), avec controle croise pays
* standard. * BIC/IBAN (ibanPropertyPath). Timestampable/Blamable standard.
* *
* Sous-ressource API (ERP-57, spec § 4.5) — gating comptable renforce : * Sous-ressource API (ERP-57, spec § 4.5) — gating comptable renforce :
* - POST /api/clients/{clientId}/ribs : creation rattachee au client parent * - POST /api/clients/{clientId}/ribs : creation rattachee au client parent
@@ -109,9 +109,15 @@ class ClientRib implements TimestampableInterface, BlamableInterface
// Bic/Iban bornent deja le format (et donc la longueur) : pas de Length // Bic/Iban bornent deja le format (et donc la longueur) : pas de Length
// redondant calee sur la colonne (whitelist du garde-fou ERP-107). // redondant calee sur la colonne (whitelist du garde-fou ERP-107).
// ibanPropertyPath : controle croise — le pays du BIC (positions 5-6) doit
// correspondre au pays de l'IBAN (positions 1-2). Violation portee sur `bic`.
#[ORM\Column(length: 20)] #[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
#[Assert\Bic(message: 'Le BIC n\'est pas valide.')] #[Assert\Bic(
message: 'Le BIC n\'est pas valide.',
ibanPropertyPath: 'iban',
ibanMessage: 'Le BIC ne correspond pas au pays de l\'IBAN.',
)]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])] #[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
private ?string $bic = null; private ?string $bic = null;
@@ -44,7 +44,8 @@ use Symfony\Component\Validator\Constraints as Assert;
* Tout passe par le SupplierRibProcessor (RG-2.08 sur DELETE). * Tout passe par le SupplierRibProcessor (RG-2.08 sur DELETE).
* *
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle * Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle
* banque reelle). Audite (#[Auditable]) + Timestampable / Blamable. * banque reelle), avec controle croise pays BIC/IBAN (ibanPropertyPath). Audite
* (#[Auditable]) + Timestampable / Blamable.
*/ */
#[ApiResource( #[ApiResource(
operations: [ operations: [
@@ -105,9 +106,15 @@ class SupplierRib implements TimestampableInterface, BlamableInterface
// Bic/Iban bornent deja le format (et donc la longueur) : pas de Length // Bic/Iban bornent deja le format (et donc la longueur) : pas de Length
// redondant calee sur la colonne (auto-exempte du miroir ERP-107). // redondant calee sur la colonne (auto-exempte du miroir ERP-107).
// ibanPropertyPath : controle croise — le pays du BIC (positions 5-6) doit
// correspondre au pays de l'IBAN (positions 1-2). Violation portee sur `bic`.
#[ORM\Column(length: 20)] #[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
#[Assert\Bic(message: 'Le BIC n\'est pas valide.')] #[Assert\Bic(
message: 'Le BIC n\'est pas valide.',
ibanPropertyPath: 'iban',
ibanMessage: 'Le BIC ne correspond pas au pays de l\'IBAN.',
)]
#[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
private ?string $bic = null; private ?string $bic = null;
@@ -51,6 +51,9 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
/** IBAN/BIC valides (Assert\Iban / Assert\Bic) reutilises par les seeds. */ /** IBAN/BIC valides (Assert\Iban / Assert\Bic) reutilises par les seeds. */
protected const string VALID_IBAN = 'FR1420041010050500013M02606'; protected const string VALID_IBAN = 'FR1420041010050500013M02606';
protected const string VALID_BIC = 'BNPAFRPPXXX'; protected const string VALID_BIC = 'BNPAFRPPXXX';
// BIC allemand valide isolement (pays DE en positions 5-6) : sert au controle
// croise pays BIC/IBAN (DE vs IBAN FR -> mismatch, cf. Assert\Bic ibanPropertyPath).
protected const string FOREIGN_BIC = 'DEUTDEFFXXX';
protected function tearDown(): void protected function tearDown(): void
{ {
@@ -295,6 +298,26 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
return $this->referential(Bank::class, $code); return $this->referential(Bank::class, $code);
} }
/**
* Indexe les violations d'un corps de reponse 422 par propertyPath. Permet
* d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422
* orthogonal) : un test qui se contente du code 422 passerait meme si la RG
* visee etait cassee pour une autre raison.
*
* @param array<string, mixed> $body corps decode de la reponse (toArray(false))
*
* @return array<string, string> propertyPath => message
*/
protected function violationsByPath(array $body): array
{
$byPath = [];
foreach ($body['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
return $byPath;
}
/** /**
* Recupere un referentiel comptable seede (CommercialReferentialFixtures) par * Recupere un referentiel comptable seede (CommercialReferentialFixtures) par
* code. Echoue explicitement si absent (fixtures non chargees). * code. Echoue explicitement si absent (fixtures non chargees).
@@ -316,24 +339,4 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
return $entity; return $entity;
} }
/**
* Indexe les violations d'un corps de reponse 422 par propertyPath. Permet
* d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422
* orthogonal) : un test qui se contente du code 422 passerait meme si la RG
* visee etait cassee pour une autre raison.
*
* @param array<string, mixed> $body corps decode de la reponse (toArray(false))
*
* @return array<string, string> propertyPath => message
*/
protected function violationsByPath(array $body): array
{
$byPath = [];
foreach ($body['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
return $byPath;
}
} }
@@ -27,6 +27,9 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
private const string MERGE = 'application/merge-patch+json'; private const string MERGE = 'application/merge-patch+json';
private const string VALID_IBAN = 'FR1420041010050500013M02606'; private const string VALID_IBAN = 'FR1420041010050500013M02606';
private const string VALID_BIC = 'BNPAFRPPXXX'; private const string VALID_BIC = 'BNPAFRPPXXX';
// BIC allemand valide isolement (pays DE en positions 5-6) : sert au controle
// croise pays BIC/IBAN (DE vs IBAN FR -> mismatch, cf. Assert\Bic ibanPropertyPath).
private const string FOREIGN_BIC = 'DEUTDEFFXXX';
// === Contacts === // === Contacts ===
@@ -359,6 +362,35 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
} }
/**
* Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et
* un IBAN (FR) valides isolement mais de pays differents -> 422. La violation
* porte propertyPath=bic et le message FR `ibanMessage` (mapping inline front).
*/
public function testPostRibWithBicIbanCountryMismatchReturns422WithFrenchMessageOnBic(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Rib Pays Mismatch');
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'label' => 'Compte incoherent',
'bic' => self::FOREIGN_BIC,
'iban' => self::VALID_IBAN,
],
]);
self::assertResponseStatusCodeSame(422);
$byPath = [];
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
self::assertArrayHasKey('bic', $byPath, 'Le mismatch pays BIC/IBAN doit porter propertyPath=bic (mapping front).');
self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);
}
/** /**
* Regression ERP-110 : POST d'un RIB sur un client qui en a DEJA >= 2 ne doit * Regression ERP-110 : POST d'un RIB sur un client qui en a DEJA >= 2 ne doit
* pas exploser en 500 (NonUniqueResult sur la resolution du parent). L'admin * pas exploser en 500 (NonUniqueResult sur la resolution du parent). L'admin
@@ -294,6 +294,27 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
} }
/**
* Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et
* un IBAN (FR) valides isolement mais de pays differents -> 422. La violation
* porte propertyPath=bic et le message FR `ibanMessage` (mapping inline front).
*/
public function testPostRibWithBicIbanCountryMismatchReturns422WithFrenchMessageOnBic(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Rib Pays Mismatch');
$response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['label' => 'Compte incoherent', 'bic' => self::FOREIGN_BIC, 'iban' => self::VALID_IBAN],
]);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('bic', $byPath, 'Le mismatch pays BIC/IBAN doit porter propertyPath=bic (mapping front).');
self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);
}
public function testDeleteRibNonLcrReturns204(): void public function testDeleteRibNonLcrReturns204(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();