Compare commits

...

4 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
17 changed files with 207 additions and 148 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.98' 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) {
@@ -12,8 +12,7 @@
:label="filterButtonLabel" :label="filterButtonLabel"
icon-name="mdi:tune" icon-name="mdi:tune"
icon-position="left" icon-position="left"
icon-size="20" icon-size="24"
button-class="w-[180px] justify-start gap-4 text-black"
@click="openFilters" @click="openFilters"
/> />
<MalioButton <MalioButton
@@ -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"
@@ -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();