Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3ab23ee8f | |||
| 222338e5a4 | |||
| d4a5df50a7 | |||
| 191fd42406 |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.98'
|
app.version: '0.1.100'
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user