Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26b1f2c39b | |||
| 8490de99da | |||
| b3ab23ee8f | |||
| 222338e5a4 | |||
| d4a5df50a7 | |||
| 191fd42406 |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.98'
|
app.version: '0.1.101'
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
"toast": {
|
"toast": {
|
||||||
"createSuccess": "Client créé avec succès",
|
"createSuccess": "Client créé avec succès",
|
||||||
"updateSuccess": "Client mis à jour avec succès",
|
"updateSuccess": "Client mis à jour avec succès",
|
||||||
|
"addComplete": "Client ajouté",
|
||||||
"archiveSuccess": "Client archivé avec succès",
|
"archiveSuccess": "Client archivé avec succès",
|
||||||
"restoreSuccess": "Client restauré avec succès",
|
"restoreSuccess": "Client restauré avec succès",
|
||||||
"error": "Une erreur est survenue. Réessayez.",
|
"error": "Une erreur est survenue. Réessayez.",
|
||||||
@@ -173,15 +174,20 @@
|
|||||||
"addressTypeDelivery": "Livraison",
|
"addressTypeDelivery": "Livraison",
|
||||||
"addressTypeBilling": "Facturation",
|
"addressTypeBilling": "Facturation",
|
||||||
"addressTypeDeliveryBilling": "Adresse + Facturation",
|
"addressTypeDeliveryBilling": "Adresse + Facturation",
|
||||||
|
"addressTypeBroker": "Adresse Courtier",
|
||||||
|
"addressTypeDistributor": "Adresse Distributeur",
|
||||||
"categories": "Catégorie",
|
"categories": "Catégorie",
|
||||||
"country": "Pays",
|
"country": "Pays",
|
||||||
"postalCode": "Code postal",
|
"postalCode": "Code postal",
|
||||||
"city": "Ville",
|
"city": "Ville",
|
||||||
"street": "Adresse",
|
"street": "Adresse",
|
||||||
|
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
|
||||||
"streetComplement": "Adresse complémentaire",
|
"streetComplement": "Adresse complémentaire",
|
||||||
"sites": "Sites",
|
"sites": "Sites",
|
||||||
"contacts": "Contact(s) rattaché(s)",
|
"contacts": "Contact(s) rattaché(s)",
|
||||||
"billingEmail": "Email de facturation",
|
"billingEmail": "Email de facturation",
|
||||||
|
"billingEmailSecondary": "Email de facturation secondaire",
|
||||||
|
"addBillingEmail": "Ajouter un email",
|
||||||
"remove": "Supprimer l'adresse",
|
"remove": "Supprimer l'adresse",
|
||||||
"add": "Nouvelle adresse",
|
"add": "Nouvelle adresse",
|
||||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||||
@@ -416,7 +422,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 +436,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>
|
||||||
|
|||||||
@@ -14,12 +14,15 @@
|
|||||||
remplacant les 3 cases. Les options encodent les combinaisons valides
|
remplacant les 3 cases. Les options encodent les combinaisons valides
|
||||||
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
|
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
|
||||||
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
|
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
|
||||||
|
<!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire +
|
||||||
|
exclusivite prospect) -> affichee sous le select Type d'adresse. -->
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="addressType"
|
:model-value="addressType"
|
||||||
:options="addressTypeOptions"
|
:options="addressTypeOptions"
|
||||||
:label="t('commercial.clients.form.address.addressType')"
|
:label="t('commercial.clients.form.address.addressType')"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
|
:error="errors?.isProspect"
|
||||||
@update:model-value="onAddressTypeChange"
|
@update:model-value="onAddressTypeChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@
|
|||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
|
:error="errors?.sites"
|
||||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -43,9 +47,10 @@
|
|||||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Email de facturation : ligne 1 colonne 4, visible/obligatoire
|
<!-- Email(s) de facturation : visible/obligatoire seulement si Facturation
|
||||||
seulement si Facturation (RG-1.11). Sinon un filler comble la
|
(RG-1.11). Le « + » revele un 2e email optionnel (max 2, pendant du
|
||||||
colonne pour que Categorie reparte au debut de la ligne 2. -->
|
telephone secondaire) qui coule dans la grille. Sinon un filler comble
|
||||||
|
la colonne pour que Categorie reparte au debut de la ligne suivante. -->
|
||||||
<MalioInputEmail
|
<MalioInputEmail
|
||||||
v-if="isBillingEmailRequired(model)"
|
v-if="isBillingEmailRequired(model)"
|
||||||
:model-value="model.billingEmail"
|
:model-value="model.billingEmail"
|
||||||
@@ -54,10 +59,23 @@
|
|||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:lowercase="true"
|
:lowercase="true"
|
||||||
:error="errors?.billingEmail"
|
:error="errors?.billingEmail"
|
||||||
|
:addable="!model.hasSecondaryBillingEmail && !readonly"
|
||||||
|
:add-button-label="t('commercial.clients.form.address.addBillingEmail')"
|
||||||
@update:model-value="(v: string) => update('billingEmail', v)"
|
@update:model-value="(v: string) => update('billingEmail', v)"
|
||||||
|
@add="revealSecondaryBillingEmail"
|
||||||
/>
|
/>
|
||||||
<div v-else aria-hidden="true" />
|
<div v-else aria-hidden="true" />
|
||||||
|
|
||||||
|
<MalioInputEmail
|
||||||
|
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
|
||||||
|
:model-value="model.billingEmailSecondary"
|
||||||
|
:label="t('commercial.clients.form.address.billingEmailSecondary')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:lowercase="true"
|
||||||
|
:error="errors?.billingEmailSecondary"
|
||||||
|
@update:model-value="(v: string) => update('billingEmailSecondary', v)"
|
||||||
|
/>
|
||||||
|
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
:model-value="model.categoryIris"
|
:model-value="model.categoryIris"
|
||||||
:options="categoryOptions"
|
:options="categoryOptions"
|
||||||
@@ -65,6 +83,7 @@
|
|||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
|
:error="errors?.categories"
|
||||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -118,10 +137,10 @@
|
|||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple
|
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple
|
||||||
seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas
|
seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas
|
||||||
sa valeur liee, il n'afficherait rien en readonly). Une erreur BAN
|
sa valeur liee, il n'afficherait rien en readonly). allow-create :
|
||||||
ne bascule PAS en saisie libre : l'autocompletion reste montee et
|
si la BAN ne propose rien (ou erreur), le texte saisi est CONSERVE au
|
||||||
chaque frappe relance la recherche (l'utilisateur peut aussi taper
|
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
|
||||||
une rue librement). -->
|
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
|
||||||
<MalioInputAutocomplete
|
<MalioInputAutocomplete
|
||||||
v-if="!readonly"
|
v-if="!readonly"
|
||||||
:model-value="model.street"
|
:model-value="model.street"
|
||||||
@@ -132,6 +151,8 @@
|
|||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:required="true"
|
:required="true"
|
||||||
:error="errors?.street"
|
:error="errors?.street"
|
||||||
|
:allow-create="true"
|
||||||
|
:no-results-text="t('commercial.clients.form.address.streetNotFound')"
|
||||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||||
@search="onAddressSearch"
|
@search="onAddressSearch"
|
||||||
@select="onAddressSelect"
|
@select="onAddressSelect"
|
||||||
@@ -147,7 +168,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2">
|
<div class="col-span-1">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
:model-value="model.streetComplement"
|
:model-value="model.streetComplement"
|
||||||
:label="t('commercial.clients.form.address.streetComplement')"
|
:label="t('commercial.clients.form.address.streetComplement')"
|
||||||
@@ -213,6 +234,8 @@ const addressTypeOptions = computed<RefOption[]>(() => [
|
|||||||
{ value: 'delivery', label: t('commercial.clients.form.address.addressTypeDelivery') },
|
{ value: 'delivery', label: t('commercial.clients.form.address.addressTypeDelivery') },
|
||||||
{ value: 'billing', label: t('commercial.clients.form.address.addressTypeBilling') },
|
{ value: 'billing', label: t('commercial.clients.form.address.addressTypeBilling') },
|
||||||
{ value: 'delivery_billing', label: t('commercial.clients.form.address.addressTypeDeliveryBilling') },
|
{ value: 'delivery_billing', label: t('commercial.clients.form.address.addressTypeDeliveryBilling') },
|
||||||
|
{ value: 'broker', label: t('commercial.clients.form.address.addressTypeBroker') },
|
||||||
|
{ value: 'distributor', label: t('commercial.clients.form.address.addressTypeDistributor') },
|
||||||
])
|
])
|
||||||
|
|
||||||
/** Applique le type choisi en repercutant les 3 drapeaux back (immutabilite). */
|
/** Applique le type choisi en repercutant les 3 drapeaux back (immutabilite). */
|
||||||
@@ -266,6 +289,11 @@ function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDr
|
|||||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Revele le 2e champ email de facturation (clic sur le « + »). */
|
||||||
|
function revealSecondaryBillingEmail(): void {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true })
|
||||||
|
}
|
||||||
|
|
||||||
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||||
function notifyUnavailable(): void {
|
function notifyUnavailable(): void {
|
||||||
if (!unavailableNotified) {
|
if (!unavailableNotified) {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const MalioInputAutocompleteStub = defineComponent({
|
|||||||
minSearchLength: { type: Number, default: 0 },
|
minSearchLength: { type: Number, default: 0 },
|
||||||
label: { type: String, default: '' },
|
label: { type: String, default: '' },
|
||||||
readonly: { type: Boolean, default: false },
|
readonly: { type: Boolean, default: false },
|
||||||
|
allowCreate: { type: Boolean, default: false },
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue', 'search', 'select'],
|
emits: ['update:modelValue', 'search', 'select'],
|
||||||
setup(props) {
|
setup(props) {
|
||||||
@@ -78,6 +79,14 @@ describe('ClientAddressBlock — affichage de l\'adresse persistee', () => {
|
|||||||
|
|
||||||
expect(values).toContain('8 Boulevard du Port')
|
expect(values).toContain('8 Boulevard du Port')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ERP-119 : saisie manuelle possible quand la BAN ne trouve rien -> allow-create
|
||||||
|
// (sans cette prop, MalioInputAutocomplete efface le texte non selectionne au blur).
|
||||||
|
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
|
||||||
|
const wrapper = mountBlock(null)
|
||||||
|
|
||||||
|
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,6 +143,32 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
|||||||
)
|
)
|
||||||
expect(field?.attributes('data-error')).toBe('Code postal invalide.')
|
expect(field?.attributes('data-error')).toBe('Code postal invalide.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ERP-119 : type d'adresse (propertyPath back `isProspect`), sites et
|
||||||
|
// categories sont obligatoires ; leurs violations 422 doivent s'afficher sous
|
||||||
|
// le champ correspondant (bindings :error de ClientAddressBlock).
|
||||||
|
it('affiche l\'erreur serveur sur type d\'adresse (propertyPath isProspect)', () => {
|
||||||
|
const wrapper = mountWithErrors({ isProspect: 'Le type d\'adresse est obligatoire.' })
|
||||||
|
|
||||||
|
const field = wrapper.findAll('malio-select-stub').find(
|
||||||
|
el => el.attributes('label') === 'commercial.clients.form.address.addressType',
|
||||||
|
)
|
||||||
|
expect(field?.attributes('error')).toBe('Le type d\'adresse est obligatoire.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche les erreurs serveur sur sites et categories', () => {
|
||||||
|
const wrapper = mountWithErrors({
|
||||||
|
sites: 'Au moins un site est obligatoire.',
|
||||||
|
categories: 'Au moins une catégorie est obligatoire.',
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
|
||||||
|
const sitesField = checkboxes.find(el => el.attributes('label') === 'commercial.clients.form.address.sites')
|
||||||
|
const categoriesField = checkboxes.find(el => el.attributes('label') === 'commercial.clients.form.address.categories')
|
||||||
|
|
||||||
|
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
|
||||||
|
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => {
|
describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('commercial.clients.edit.save')"
|
:label="t('commercial.clients.edit.save')"
|
||||||
:disabled="!isMainValid || mainSubmitting"
|
:disabled="mainSubmitting"
|
||||||
@click="submitMain"
|
@click="submitMain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,6 +114,7 @@
|
|||||||
v-model="information.foundedAt"
|
v-model="information.foundedAt"
|
||||||
:label="t('commercial.clients.form.information.foundedAt')"
|
:label="t('commercial.clients.form.information.foundedAt')"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
|
:editable="true"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
@@ -178,7 +179,7 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('commercial.clients.edit.save')"
|
:label="t('commercial.clients.edit.save')"
|
||||||
:disabled="!canValidateContacts || tabSubmitting"
|
:disabled="tabSubmitting"
|
||||||
@click="submitContacts"
|
@click="submitContacts"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,7 +217,7 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('commercial.clients.edit.save')"
|
:label="t('commercial.clients.edit.save')"
|
||||||
:disabled="!canValidateAddresses || tabSubmitting"
|
:disabled="tabSubmitting"
|
||||||
@click="submitAddresses"
|
@click="submitAddresses"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,7 +348,7 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('commercial.clients.edit.save')"
|
:label="t('commercial.clients.edit.save')"
|
||||||
:disabled="!canValidateAccounting || tabSubmitting"
|
:disabled="tabSubmitting"
|
||||||
@click="submitAccounting"
|
@click="submitAccounting"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -419,8 +420,6 @@ import {
|
|||||||
} from '~/modules/commercial/utils/clientEdit'
|
} from '~/modules/commercial/utils/clientEdit'
|
||||||
import {
|
import {
|
||||||
buildClientFormTabKeys,
|
buildClientFormTabKeys,
|
||||||
hasAllRequiredAccountingFields,
|
|
||||||
hasAtLeastOneValidContact,
|
|
||||||
isAddressValid,
|
isAddressValid,
|
||||||
isBankRequiredForPaymentType,
|
isBankRequiredForPaymentType,
|
||||||
isBillingEmailRequired,
|
isBillingEmailRequired,
|
||||||
@@ -673,17 +672,6 @@ const {
|
|||||||
} = useClientFormErrors()
|
} = useClientFormErrors()
|
||||||
|
|
||||||
// ── Bloc principal ───────────────────────────────────────────────────────────
|
// ── Bloc principal ───────────────────────────────────────────────────────────
|
||||||
const isMainValid = computed(() => {
|
|
||||||
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
|
|
||||||
const relationValid
|
|
||||||
= main.relationType === null
|
|
||||||
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|
|
||||||
|| (main.relationType === 'courtier' && filled(main.brokerIri))
|
|
||||||
return filled(main.companyName)
|
|
||||||
&& main.categoryIris.length >= 1
|
|
||||||
&& relationValid
|
|
||||||
})
|
|
||||||
|
|
||||||
async function onRelationChange(value: string | number | null): Promise<void> {
|
async function onRelationChange(value: string | number | null): Promise<void> {
|
||||||
const relation = (value === null || value === '') ? null : (String(value) as 'distributeur' | 'courtier')
|
const relation = (value === null || value === '') ? null : (String(value) as 'distributeur' | 'courtier')
|
||||||
main.relationType = relation
|
main.relationType = relation
|
||||||
@@ -697,7 +685,7 @@ async function onRelationChange(value: string | number | null): Promise<void> {
|
|||||||
|
|
||||||
/** PATCH /clients/{id} — groupe client:write:main UNIQUEMENT (mode strict). */
|
/** PATCH /clients/{id} — groupe client:write:main UNIQUEMENT (mode strict). */
|
||||||
async function submitMain(): Promise<void> {
|
async function submitMain(): Promise<void> {
|
||||||
if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return
|
if (businessReadonly.value || mainSubmitting.value) return
|
||||||
mainSubmitting.value = true
|
mainSubmitting.value = true
|
||||||
mainErrors.clearErrors()
|
mainErrors.clearErrors()
|
||||||
try {
|
try {
|
||||||
@@ -750,9 +738,6 @@ const canAddContact = computed(() => {
|
|||||||
const last = contacts.value[contacts.value.length - 1]
|
const last = contacts.value[contacts.value.length - 1]
|
||||||
return last === undefined || isContactNamed(last)
|
return last === undefined || isContactNamed(last)
|
||||||
})
|
})
|
||||||
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
|
|
||||||
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
|
|
||||||
|
|
||||||
function addContact(): void {
|
function addContact(): void {
|
||||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||||
}
|
}
|
||||||
@@ -774,7 +759,7 @@ function askRemoveContact(index: number): void {
|
|||||||
* collection contacts (endpoints client_contact dedies).
|
* collection contacts (endpoints client_contact dedies).
|
||||||
*/
|
*/
|
||||||
async function submitContacts(): Promise<void> {
|
async function submitContacts(): Promise<void> {
|
||||||
if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
contactErrors.value = []
|
contactErrors.value = []
|
||||||
try {
|
try {
|
||||||
@@ -783,6 +768,11 @@ async function submitContacts(): Promise<void> {
|
|||||||
}
|
}
|
||||||
removedContactIds.value = []
|
removedContactIds.value = []
|
||||||
|
|
||||||
|
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
|
||||||
|
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
|
||||||
|
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
|
||||||
|
// obligatoire » inline (la RG-1.14 n'a pas d'equivalent back au POST).
|
||||||
|
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
|
||||||
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
|
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
|
||||||
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
|
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
|
||||||
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
|
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
|
||||||
@@ -805,10 +795,10 @@ async function submitContacts(): Promise<void> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
error => showError(error),
|
error => showError(error),
|
||||||
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
|
// On ne saute une amorce neuve (id null) totalement vide QUE si un autre
|
||||||
// bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif
|
// bloc sera soumis : sinon on la soumet pour declencher la 422 RG-1.05
|
||||||
// serait perdue en silence avec un faux toast de succes).
|
// (un onglet Contact vide ne doit pas passer en faux succes).
|
||||||
contact => contact.id === null && isContactBlank(contact),
|
contact => hasSubmittableContact && contact.id === null && isContactBlank(contact),
|
||||||
)
|
)
|
||||||
// Tant qu'un bloc reste en erreur : pas de toast succes.
|
// Tant qu'un bloc reste en erreur : pas de toast succes.
|
||||||
if (hasError) return
|
if (hasError) return
|
||||||
@@ -823,10 +813,6 @@ async function submitContacts(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Onglet Adresse ───────────────────────────────────────────────────────────
|
// ── Onglet Adresse ───────────────────────────────────────────────────────────
|
||||||
const canValidateAddresses = computed(() =>
|
|
||||||
addresses.value.length > 0 && addresses.value.every(isAddressValid),
|
|
||||||
)
|
|
||||||
|
|
||||||
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
|
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
|
||||||
const canAddAddress = computed(() => {
|
const canAddAddress = computed(() => {
|
||||||
const last = addresses.value[addresses.value.length - 1]
|
const last = addresses.value[addresses.value.length - 1]
|
||||||
@@ -859,7 +845,7 @@ function onAddressDegraded(): void {
|
|||||||
|
|
||||||
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
|
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
|
||||||
async function submitAddresses(): Promise<void> {
|
async function submitAddresses(): Promise<void> {
|
||||||
if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
addressErrors.value = []
|
addressErrors.value = []
|
||||||
try {
|
try {
|
||||||
@@ -927,13 +913,6 @@ function onPaymentTypeChange(value: string | number | null): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const canValidateAccounting = computed(() => {
|
|
||||||
if (!hasAllRequiredAccountingFields(accounting)) return false
|
|
||||||
if (isBankRequired.value && accounting.bankIri === null) return false
|
|
||||||
if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
|
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
|
||||||
const canAddRib = computed(() => {
|
const canAddRib = computed(() => {
|
||||||
const last = ribs.value[ribs.value.length - 1]
|
const last = ribs.value[ribs.value.length - 1]
|
||||||
@@ -956,35 +935,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 || 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 +976,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
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('commercial.clients.form.submit')"
|
:label="t('commercial.clients.form.submit')"
|
||||||
:disabled="!isMainValid || mainSubmitting"
|
:disabled="mainSubmitting"
|
||||||
@click="submitMain"
|
@click="submitMain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,6 +109,7 @@
|
|||||||
v-model="information.foundedAt"
|
v-model="information.foundedAt"
|
||||||
:label="t('commercial.clients.form.information.foundedAt')"
|
:label="t('commercial.clients.form.information.foundedAt')"
|
||||||
:readonly="isValidated('information')"
|
:readonly="isValidated('information')"
|
||||||
|
:editable="true"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
@@ -140,13 +141,12 @@
|
|||||||
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
|
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
|
||||||
<!-- Desactive tant que le client n'est pas cree (evite un PATCH
|
<!-- Desactive tant que le client n'est pas cree (evite un PATCH
|
||||||
avant le POST si clic trop tot, Information etant l'onglet
|
avant le POST si clic trop tot, Information etant l'onglet
|
||||||
actif par defaut) OU si aucun champ n'est rempli : onglet
|
actif par defaut). Onglet facultatif : un enregistrement a
|
||||||
facultatif, mais pas de validation a vide (on passe alors
|
vide reste possible, c'est le back qui valide. -->
|
||||||
directement a Contact). -->
|
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('commercial.clients.form.submit')"
|
:label="t('commercial.clients.form.submit')"
|
||||||
:disabled="tabSubmitting || clientId === null || !canValidateInformation"
|
:disabled="tabSubmitting || clientId === null"
|
||||||
@click="submitInformation"
|
@click="submitInformation"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('commercial.clients.form.submit')"
|
:label="t('commercial.clients.form.submit')"
|
||||||
:disabled="!canValidateContacts || tabSubmitting"
|
:disabled="tabSubmitting"
|
||||||
@click="submitContacts"
|
@click="submitContacts"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,7 +216,7 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('commercial.clients.form.submit')"
|
:label="t('commercial.clients.form.submit')"
|
||||||
:disabled="!canValidateAddresses || tabSubmitting"
|
:disabled="tabSubmitting"
|
||||||
@click="submitAddresses"
|
@click="submitAddresses"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,7 +347,7 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('commercial.clients.form.submit')"
|
:label="t('commercial.clients.form.submit')"
|
||||||
:disabled="!canValidateAccounting || tabSubmitting"
|
:disabled="tabSubmitting"
|
||||||
@click="submitAccounting"
|
@click="submitAccounting"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,9 +391,6 @@ import { useClientFormErrors } from '~/modules/commercial/composables/useClientF
|
|||||||
import {
|
import {
|
||||||
buildClientFormTabKeys,
|
buildClientFormTabKeys,
|
||||||
CLIENT_FORM_PLACEHOLDER_TABS,
|
CLIENT_FORM_PLACEHOLDER_TABS,
|
||||||
hasAllRequiredAccountingFields,
|
|
||||||
hasAtLeastOneInformationField,
|
|
||||||
hasAtLeastOneValidContact,
|
|
||||||
isAddressValid,
|
isAddressValid,
|
||||||
isBankRequiredForPaymentType,
|
isBankRequiredForPaymentType,
|
||||||
isBillingEmailRequired,
|
isBillingEmailRequired,
|
||||||
@@ -402,8 +399,14 @@ import {
|
|||||||
isRibBlank,
|
isRibBlank,
|
||||||
isRibComplete,
|
isRibComplete,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
|
lastFillableTabKey,
|
||||||
showsRelationAndTriageFields,
|
showsRelationAndTriageFields,
|
||||||
} from '~/modules/commercial/utils/clientFormRules'
|
} from '~/modules/commercial/utils/clientFormRules'
|
||||||
|
import {
|
||||||
|
buildAddressPayload,
|
||||||
|
buildMainPayload,
|
||||||
|
buildRibPayload,
|
||||||
|
} from '~/modules/commercial/utils/clientEdit'
|
||||||
import {
|
import {
|
||||||
emptyAddress,
|
emptyAddress,
|
||||||
emptyContact,
|
emptyContact,
|
||||||
@@ -517,25 +520,6 @@ watch(showRelationAndTriage, (visible) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Validation du formulaire principal (gate le bouton « Valider ») :
|
|
||||||
// - companyName / >= 1 categorie obligatoires ;
|
|
||||||
// - relation Distributeur/Courtier optionnelle, mais le nom correspondant
|
|
||||||
// devient requis si l'un des deux est choisi (spec fonctionnelle).
|
|
||||||
// Les coordonnees de contact ne sont plus saisies ici : elles vivent dans
|
|
||||||
// l'onglet Contacts (RG-1.05/1.14 garantissent >= 1 contact valide).
|
|
||||||
const isMainValid = computed(() => {
|
|
||||||
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
|
|
||||||
// Relation Distributeur/Courtier OPTIONNELLE ; mais si « Depend du
|
|
||||||
// distributeur/courtier » est choisi, le nom correspondant devient requis.
|
|
||||||
const relationValid
|
|
||||||
= main.relationType === null
|
|
||||||
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|
|
||||||
|| (main.relationType === 'courtier' && filled(main.brokerIri))
|
|
||||||
return filled(main.companyName)
|
|
||||||
&& main.categoryIris.length >= 1
|
|
||||||
&& relationValid
|
|
||||||
})
|
|
||||||
|
|
||||||
async function onRelationChange(value: string | number | null): Promise<void> {
|
async function onRelationChange(value: string | number | null): Promise<void> {
|
||||||
const relation = (value === null || value === '')
|
const relation = (value === null || value === '')
|
||||||
? null
|
? null
|
||||||
@@ -551,18 +535,13 @@ async function onRelationChange(value: string | number | null): Promise<void> {
|
|||||||
|
|
||||||
/** POST /clients (groupe client:write:main). Au succes : verrouille + bascule Information. */
|
/** POST /clients (groupe client:write:main). Au succes : verrouille + bascule Information. */
|
||||||
async function submitMain(): Promise<void> {
|
async function submitMain(): Promise<void> {
|
||||||
if (!isMainValid.value || mainSubmitting.value) return
|
if (mainSubmitting.value) return
|
||||||
mainSubmitting.value = true
|
mainSubmitting.value = true
|
||||||
mainErrors.clearErrors()
|
mainErrors.clearErrors()
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, unknown> = {
|
// Payload partage avec l'edition (buildMainPayload) : meme logique
|
||||||
companyName: main.companyName,
|
// d'omission des requis vides et meme envoi de relationType (ERP-119).
|
||||||
categories: main.categoryIris,
|
const created = await api.post<ClientResponse>('/clients', buildMainPayload(main), {
|
||||||
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
|
|
||||||
broker: main.relationType === 'courtier' ? main.brokerIri : null,
|
|
||||||
triageService: main.triageService,
|
|
||||||
}
|
|
||||||
const created = await api.post<ClientResponse>('/clients', payload, {
|
|
||||||
headers: { Accept: 'application/ld+json' },
|
headers: { Accept: 'application/ld+json' },
|
||||||
toast: false,
|
toast: false,
|
||||||
})
|
})
|
||||||
@@ -606,6 +585,12 @@ const validated = reactive<Record<string, boolean>>({})
|
|||||||
|
|
||||||
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value))
|
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value))
|
||||||
|
|
||||||
|
// Dernier onglet REMPLISSABLE par le role (cf. lastFillableTabKey) : deja role-aware
|
||||||
|
// via tabKeys (accounting present ssi accounting.view, et a la creation « present » =
|
||||||
|
// « editable » : aucun role createur n'a la Compta en lecture seule). Sa validation
|
||||||
|
// cloture l'ajout -> redirection vers la liste.
|
||||||
|
const lastFillableTab = computed(() => lastFillableTabKey(tabKeys.value))
|
||||||
|
|
||||||
// Icone (Iconify) affichee dans l'onglet, par cle. A ajuster librement.
|
// Icone (Iconify) affichee dans l'onglet, par cle. A ajuster librement.
|
||||||
const TAB_ICONS: Record<string, string> = {
|
const TAB_ICONS: Record<string, string> = {
|
||||||
information: 'mdi:account-outline',
|
information: 'mdi:account-outline',
|
||||||
@@ -633,12 +618,23 @@ function tabIndex(key: string): number {
|
|||||||
return tabKeys.value.indexOf(key)
|
return tabKeys.value.indexOf(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Marque l'onglet valide, deverrouille et avance automatiquement au suivant. */
|
/**
|
||||||
function completeTab(key: string): void {
|
* Marque l'onglet valide. Si c'est le dernier onglet remplissable, l'ajout est
|
||||||
|
* termine : toast final + redirection vers la liste, et on retourne true pour que
|
||||||
|
* l'appelant n'affiche pas son toast « mis a jour ». Sinon, deverrouille et avance
|
||||||
|
* a l'onglet suivant, et retourne false.
|
||||||
|
*/
|
||||||
|
function completeTab(key: string): boolean {
|
||||||
validated[key] = true
|
validated[key] = true
|
||||||
|
if (key === lastFillableTab.value) {
|
||||||
|
toast.success({ title: t('commercial.clients.toast.addComplete') })
|
||||||
|
router.push('/clients')
|
||||||
|
return true
|
||||||
|
}
|
||||||
const next = tabKeys.value[tabIndex(key) + 1]
|
const next = tabKeys.value[tabIndex(key) + 1]
|
||||||
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
|
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
|
||||||
if (next) activeTab.value = next
|
if (next) activeTab.value = next
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passage automatique sur les onglets coquille (Transport, Stats, Rapports, Echanges).
|
// Passage automatique sur les onglets coquille (Transport, Stats, Rapports, Echanges).
|
||||||
@@ -661,12 +657,9 @@ const information = reactive({
|
|||||||
directorName: null as string | null,
|
directorName: null as string | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Onglet facultatif, mais pas de validation « a vide » : au moins un champ rempli.
|
|
||||||
const canValidateInformation = computed(() => hasAtLeastOneInformationField(information))
|
|
||||||
|
|
||||||
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
|
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
|
||||||
async function submitInformation(): Promise<void> {
|
async function submitInformation(): Promise<void> {
|
||||||
if (clientId.value === null || tabSubmitting.value || !canValidateInformation.value) return
|
if (clientId.value === null || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
informationErrors.clearErrors()
|
informationErrors.clearErrors()
|
||||||
try {
|
try {
|
||||||
@@ -679,7 +672,7 @@ async function submitInformation(): Promise<void> {
|
|||||||
profitAmount: information.profitAmount || null,
|
profitAmount: information.profitAmount || null,
|
||||||
directorName: information.directorName || null,
|
directorName: information.directorName || null,
|
||||||
}, { toast: false })
|
}, { toast: false })
|
||||||
completeTab('information')
|
if (completeTab('information')) return
|
||||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -701,9 +694,6 @@ const canAddContact = computed(() => {
|
|||||||
return last !== undefined && isContactNamed(last)
|
return last !== undefined && isContactNamed(last)
|
||||||
})
|
})
|
||||||
|
|
||||||
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
|
|
||||||
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
|
|
||||||
|
|
||||||
function addContact(): void {
|
function addContact(): void {
|
||||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||||
}
|
}
|
||||||
@@ -717,9 +707,14 @@ function askRemoveContact(index: number): void {
|
|||||||
|
|
||||||
/** POST/PATCH des contacts sur la sous-ressource /clients/{id}/contacts. */
|
/** POST/PATCH des contacts sur la sous-ressource /clients/{id}/contacts. */
|
||||||
async function submitContacts(): Promise<void> {
|
async function submitContacts(): Promise<void> {
|
||||||
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
|
if (clientId.value === null || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
|
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
|
||||||
|
// amorces neuves vides, on ne les skippe pas -> le bloc vide est POSTe et
|
||||||
|
// le back renvoie la 422 RG-1.05 « prénom ou nom obligatoire » inline (la
|
||||||
|
// RG-1.14 n'a pas d'equivalent back au POST, on la materialise via RG-1.05).
|
||||||
|
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
|
||||||
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
|
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
|
||||||
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
|
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
|
||||||
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
|
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
|
||||||
@@ -749,14 +744,14 @@ async function submitContacts(): Promise<void> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
||||||
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
|
// On ne saute une amorce neuve (id null) totalement vide QUE si un autre
|
||||||
// bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif
|
// bloc sera soumis : sinon on la soumet pour declencher la 422 RG-1.05
|
||||||
// serait perdue en silence avec un faux toast de succes).
|
// (un onglet Contact vide ne doit pas passer en faux succes).
|
||||||
contact => contact.id === null && isContactBlank(contact),
|
contact => hasSubmittableContact && contact.id === null && isContactBlank(contact),
|
||||||
)
|
)
|
||||||
// Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes.
|
// Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes.
|
||||||
if (hasError) return
|
if (hasError) return
|
||||||
completeTab('contact')
|
if (completeTab('contact')) return
|
||||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
@@ -789,12 +784,6 @@ const countryOptions: RefOption[] = [
|
|||||||
{ value: 'Espagne', label: 'Espagne' },
|
{ value: 'Espagne', label: 'Espagne' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email
|
|
||||||
// facturation si Facturation) sur chaque adresse.
|
|
||||||
const canValidateAddresses = computed(() =>
|
|
||||||
addresses.value.length > 0 && addresses.value.every(isAddressValid),
|
|
||||||
)
|
|
||||||
|
|
||||||
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
|
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
|
||||||
const canAddAddress = computed(() => {
|
const canAddAddress = computed(() => {
|
||||||
const last = addresses.value[addresses.value.length - 1]
|
const last = addresses.value[addresses.value.length - 1]
|
||||||
@@ -824,7 +813,7 @@ function onAddressDegraded(): void {
|
|||||||
|
|
||||||
/** POST des adresses sur la sous-ressource /clients/{id}/addresses. */
|
/** POST des adresses sur la sous-ressource /clients/{id}/addresses. */
|
||||||
async function submitAddresses(): Promise<void> {
|
async function submitAddresses(): Promise<void> {
|
||||||
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
|
if (clientId.value === null || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
|
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
|
||||||
@@ -832,20 +821,8 @@ async function submitAddresses(): Promise<void> {
|
|||||||
addresses.value,
|
addresses.value,
|
||||||
addressErrors,
|
addressErrors,
|
||||||
async (address) => {
|
async (address) => {
|
||||||
const body = {
|
// Payload partage avec l'edition (buildAddressPayload, ERP-119).
|
||||||
isProspect: address.isProspect,
|
const body = buildAddressPayload(address, isBillingEmailRequired(address))
|
||||||
isDelivery: address.isDelivery,
|
|
||||||
isBilling: address.isBilling,
|
|
||||||
country: address.country,
|
|
||||||
postalCode: address.postalCode || null,
|
|
||||||
city: address.city || null,
|
|
||||||
street: address.street || null,
|
|
||||||
streetComplement: address.streetComplement || null,
|
|
||||||
categories: address.categoryIris,
|
|
||||||
sites: address.siteIris,
|
|
||||||
contacts: address.contactIris,
|
|
||||||
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
|
|
||||||
}
|
|
||||||
if (address.id === null) {
|
if (address.id === null) {
|
||||||
const created = await api.post<{ id: number }>(
|
const created = await api.post<{ id: number }>(
|
||||||
`/clients/${clientId.value}/addresses`,
|
`/clients/${clientId.value}/addresses`,
|
||||||
@@ -861,7 +838,7 @@ async function submitAddresses(): Promise<void> {
|
|||||||
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
||||||
)
|
)
|
||||||
if (hasError) return
|
if (hasError) return
|
||||||
completeTab('address')
|
if (completeTab('address')) return
|
||||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
@@ -909,16 +886,6 @@ function onPaymentTypeChange(value: string | number | null): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact /
|
|
||||||
// Adresse, le bouton reste desactive tant que l'onglet n'est pas complet).
|
|
||||||
// RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR.
|
|
||||||
const canValidateAccounting = computed(() => {
|
|
||||||
if (!hasAllRequiredAccountingFields(accounting)) return false
|
|
||||||
if (isBankRequired.value && (accounting.bankIri === null)) return false
|
|
||||||
if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
|
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
|
||||||
const canAddRib = computed(() => {
|
const canAddRib = computed(() => {
|
||||||
const last = ribs.value[ribs.value.length - 1]
|
const last = ribs.value[ribs.value.length - 1]
|
||||||
@@ -939,44 +906,28 @@ 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 || 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(
|
||||||
ribs.value,
|
ribs.value,
|
||||||
ribErrors,
|
ribErrors,
|
||||||
async (rib) => {
|
async (rib) => {
|
||||||
const body = { label: rib.label, bic: rib.bic, iban: rib.iban }
|
// Payload partage avec l'edition (buildRibPayload, ERP-119).
|
||||||
|
const body = buildRibPayload(rib)
|
||||||
if (rib.id === null) {
|
if (rib.id === null) {
|
||||||
const created = await api.post<{ id: number }>(
|
const created = await api.post<{ id: number }>(
|
||||||
`/clients/${clientId.value}/ribs`,
|
`/clients/${clientId.value}/ribs`,
|
||||||
@@ -997,7 +948,24 @@ async function submitAccounting(): Promise<void> {
|
|||||||
)
|
)
|
||||||
if (ribHasError) return
|
if (ribHasError) return
|
||||||
|
|
||||||
completeTab('accounting')
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completeTab('accounting')) return
|
||||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export interface AddressFormDraft {
|
|||||||
isProspect: boolean
|
isProspect: boolean
|
||||||
isDelivery: boolean
|
isDelivery: boolean
|
||||||
isBilling: boolean
|
isBilling: boolean
|
||||||
|
/** Adresse Courtier — type autonome exclusif. */
|
||||||
|
isBroker: boolean
|
||||||
|
/** Adresse Distributeur — type autonome exclusif. */
|
||||||
|
isDistributor: boolean
|
||||||
country: string
|
country: string
|
||||||
postalCode: string | null
|
postalCode: string | null
|
||||||
city: string | null
|
city: string | null
|
||||||
@@ -43,6 +47,10 @@ export interface AddressFormDraft {
|
|||||||
contactIris: string[]
|
contactIris: string[]
|
||||||
/** Email de facturation (obligatoire si isBilling — RG-1.11). */
|
/** Email de facturation (obligatoire si isBilling — RG-1.11). */
|
||||||
billingEmail: string | null
|
billingEmail: string | null
|
||||||
|
/** 2e email de facturation, optionnel (max 2 — pendant du telephone secondaire). */
|
||||||
|
billingEmailSecondary: string | null
|
||||||
|
/** Drapeau UI : 2e champ email revele (comme hasSecondaryPhone). */
|
||||||
|
hasSecondaryBillingEmail: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Un RIB du client (onglet Comptabilite). */
|
/** Un RIB du client (onglet Comptabilite). */
|
||||||
@@ -75,6 +83,8 @@ export function emptyAddress(): AddressFormDraft {
|
|||||||
isProspect: false,
|
isProspect: false,
|
||||||
isDelivery: false,
|
isDelivery: false,
|
||||||
isBilling: false,
|
isBilling: false,
|
||||||
|
isBroker: false,
|
||||||
|
isDistributor: false,
|
||||||
country: 'France',
|
country: 'France',
|
||||||
postalCode: null,
|
postalCode: null,
|
||||||
city: null,
|
city: null,
|
||||||
@@ -84,6 +94,8 @@ export function emptyAddress(): AddressFormDraft {
|
|||||||
siteIris: [],
|
siteIris: [],
|
||||||
contactIris: [],
|
contactIris: [],
|
||||||
billingEmail: null,
|
billingEmail: null,
|
||||||
|
billingEmailSecondary: null,
|
||||||
|
hasSecondaryBillingEmail: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ function accountingDraft(overrides: Partial<AccountingFormDraft> = {}): Accounti
|
|||||||
// Le contact inline (nom/prenom/telephones/email) ne fait plus partie du groupe
|
// Le contact inline (nom/prenom/telephones/email) ne fait plus partie du groupe
|
||||||
// main : les coordonnees vivent desormais sur la sous-ressource ClientContact.
|
// main : les coordonnees vivent desormais sur la sous-ressource ClientContact.
|
||||||
const MAIN_KEYS = [
|
const MAIN_KEYS = [
|
||||||
'companyName', 'categories', 'distributor', 'broker', 'triageService',
|
// relationType : champ transitoire envoye au back pour la validation croisee
|
||||||
|
// « relation choisie => FK obligatoire » (RG-1.03 bis, ERP-119).
|
||||||
|
'companyName', 'categories', 'relationType', 'distributor', 'broker', 'triageService',
|
||||||
]
|
]
|
||||||
const INFORMATION_KEYS = [
|
const INFORMATION_KEYS = [
|
||||||
'description', 'competitors', 'foundedAt', 'employeesCount',
|
'description', 'competitors', 'foundedAt', 'employeesCount',
|
||||||
@@ -99,6 +101,27 @@ describe('buildMainPayload — scoping strict groupe client:write:main', () => {
|
|||||||
expect(payload.distributor).toBeNull()
|
expect(payload.distributor).toBeNull()
|
||||||
expect(payload.broker).toBeNull()
|
expect(payload.broker).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('transmet relationType au back pour la validation croisee (RG-1.03 bis)', () => {
|
||||||
|
expect(buildMainPayload(mainDraft({ relationType: 'distributeur' })).relationType).toBe('distributeur')
|
||||||
|
expect(buildMainPayload(mainDraft({ relationType: 'courtier' })).relationType).toBe('courtier')
|
||||||
|
expect(buildMainPayload(mainDraft({ relationType: null })).relationType).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ERP-119 : companyName est requis ET adosse a une colonne NON-nullable. Si le
|
||||||
|
// champ est vide, on OMET la cle (au lieu d'envoyer null) pour que le back
|
||||||
|
// renvoie une 422 NotBlank (propertyPath companyName) et non un 400 de type.
|
||||||
|
it('omet companyName quand il est vide (null) -> 422 NotBlank cote back', () => {
|
||||||
|
expect('companyName' in buildMainPayload(mainDraft({ companyName: null }))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omet companyName quand il est une chaine vide', () => {
|
||||||
|
expect('companyName' in buildMainPayload(mainDraft({ companyName: '' }))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('conserve companyName quand il est renseigne', () => {
|
||||||
|
expect(buildMainPayload(mainDraft({ companyName: 'ACME' })).companyName).toBe('ACME')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('buildInformationPayload — scoping strict groupe client:write:information', () => {
|
describe('buildInformationPayload — scoping strict groupe client:write:information', () => {
|
||||||
@@ -142,19 +165,50 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
|
|||||||
|
|
||||||
it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => {
|
it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => {
|
||||||
const address: AddressFormDraft = {
|
const address: AddressFormDraft = {
|
||||||
id: 3, isProspect: false, isDelivery: false, isBilling: true, country: 'France',
|
id: 3, isProspect: false, isDelivery: false, isBilling: true, isBroker: false, isDistributor: false, country: 'France',
|
||||||
postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null,
|
postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null,
|
||||||
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
|
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
|
||||||
billingEmail: 'facturation@acme.fr',
|
billingEmail: 'facturation@acme.fr', billingEmailSecondary: 'compta@acme.fr', hasSecondaryBillingEmail: true,
|
||||||
}
|
}
|
||||||
expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr')
|
expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr')
|
||||||
expect(buildAddressPayload(address, false).billingEmail).toBeNull()
|
expect(buildAddressPayload(address, false).billingEmail).toBeNull()
|
||||||
|
// 2e email : transmis si facturation + revele, sinon null (ERP-119).
|
||||||
|
expect(buildAddressPayload(address, true).billingEmailSecondary).toBe('compta@acme.fr')
|
||||||
|
expect(buildAddressPayload(address, false).billingEmailSecondary).toBeNull()
|
||||||
|
expect(buildAddressPayload({ ...address, hasSecondaryBillingEmail: false }, true).billingEmailSecondary).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rib : label / bic / iban transmis tels quels', () => {
|
it('rib : label / bic / iban transmis tels quels', () => {
|
||||||
const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }
|
const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }
|
||||||
expect(buildRibPayload(rib)).toEqual({ label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' })
|
expect(buildRibPayload(rib)).toEqual({ label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ERP-119 : un RIB partiel (IBAN seul) doit omettre label/bic vides pour
|
||||||
|
// declencher la 422 NotBlank par champ, pas un 400 de type a la deserialisation.
|
||||||
|
it('rib partiel : omet label / bic vides, conserve iban', () => {
|
||||||
|
const rib: RibFormDraft = { id: null, label: null, bic: null, iban: 'FR7612345' }
|
||||||
|
const payload = buildRibPayload(rib)
|
||||||
|
expect('label' in payload).toBe(false)
|
||||||
|
expect('bic' in payload).toBe(false)
|
||||||
|
expect(payload.iban).toBe('FR7612345')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ERP-119 : une adresse partielle omet postalCode/city/street vides (NotBlank).
|
||||||
|
it('adresse partielle : omet postalCode / city / street vides', () => {
|
||||||
|
const address: AddressFormDraft = {
|
||||||
|
id: null, isProspect: false, isDelivery: true, isBilling: false, isBroker: false, isDistributor: false, country: 'France',
|
||||||
|
postalCode: null, city: '', street: null, streetComplement: null,
|
||||||
|
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
|
||||||
|
billingEmail: null, billingEmailSecondary: null, hasSecondaryBillingEmail: false,
|
||||||
|
}
|
||||||
|
const payload = buildAddressPayload(address, false)
|
||||||
|
expect('postalCode' in payload).toBe(false)
|
||||||
|
expect('city' in payload).toBe(false)
|
||||||
|
expect('street' in payload).toBe(false)
|
||||||
|
// Les champs non requis / booleens restent presents.
|
||||||
|
expect(payload.isDelivery).toBe(true)
|
||||||
|
expect(payload.sites).toEqual(['/api/sites/1'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('mapMainDraft — pre-remplissage bloc principal', () => {
|
describe('mapMainDraft — pre-remplissage bloc principal', () => {
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import {
|
|||||||
isRibBlank,
|
isRibBlank,
|
||||||
isRibComplete,
|
isRibComplete,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
|
lastFillableTabKey,
|
||||||
|
omitEmptyRequired,
|
||||||
showsRelationAndTriageFields,
|
showsRelationAndTriageFields,
|
||||||
|
type AddressFlagsDraft,
|
||||||
type AddressValidityDraft,
|
type AddressValidityDraft,
|
||||||
type ContactDraft,
|
type ContactDraft,
|
||||||
type ContactFillableDraft,
|
type ContactFillableDraft,
|
||||||
@@ -68,6 +71,24 @@ describe('buildClientFormTabKeys (gating onglet Comptabilite + onglets edit-only
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('lastFillableTabKey (redirection fin d\'ajout, role-aware)', () => {
|
||||||
|
it('Adresse pour un role sans Comptabilite (Bureau / Commerciale)', () => {
|
||||||
|
expect(lastFillableTabKey(buildClientFormTabKeys(false))).toBe('address')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Comptabilite pour un role avec accounting.view (Admin)', () => {
|
||||||
|
expect(lastFillableTabKey(buildClientFormTabKeys(true))).toBe('accounting')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignore les onglets placeholder (Transport en dernier ne compte pas)', () => {
|
||||||
|
expect(lastFillableTabKey(['information', 'contact', 'address', 'transport'])).toBe('address')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('undefined si aucun onglet remplissable (que des placeholders)', () => {
|
||||||
|
expect(lastFillableTabKey(['transport', 'statistics'])).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('isContactNamed (RG-1.05)', () => {
|
describe('isContactNamed (RG-1.05)', () => {
|
||||||
it('vrai si le prenom est renseigne', () => {
|
it('vrai si le prenom est renseigne', () => {
|
||||||
expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true)
|
expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true)
|
||||||
@@ -148,83 +169,79 @@ describe('hasAtLeastOneValidContact (RG-1.14)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Drapeaux d'adresse complets (5 types) avec surcharge partielle. */
|
||||||
|
function flags(overrides: Partial<AddressFlagsDraft> = {}): AddressFlagsDraft {
|
||||||
|
return {
|
||||||
|
isProspect: false, isDelivery: false, isBilling: false, isBroker: false, isDistributor: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('exclusivite Prospect / Livraison / Facturation (RG-1.06/07/08)', () => {
|
describe('exclusivite Prospect / Livraison / Facturation (RG-1.06/07/08)', () => {
|
||||||
it('Prospect est selectionnable tant que ni Livraison ni Facturation', () => {
|
it('Prospect est selectionnable tant que ni Livraison ni Facturation', () => {
|
||||||
expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true)
|
expect(canSelectProspect(flags())).toBe(true)
|
||||||
expect(canSelectProspect({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false)
|
expect(canSelectProspect(flags({ isDelivery: true }))).toBe(false)
|
||||||
expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: true })).toBe(false)
|
expect(canSelectProspect(flags({ isBilling: true }))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Livraison / Facturation selectionnables tant que pas Prospect', () => {
|
it('Livraison / Facturation selectionnables tant que pas Prospect', () => {
|
||||||
expect(canSelectDeliveryOrBilling({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true)
|
expect(canSelectDeliveryOrBilling(flags())).toBe(true)
|
||||||
expect(canSelectDeliveryOrBilling({ isProspect: true, isDelivery: false, isBilling: false })).toBe(false)
|
expect(canSelectDeliveryOrBilling(flags({ isProspect: true }))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('cocher Prospect efface Livraison et Facturation', () => {
|
it('cocher Prospect efface Livraison et Facturation', () => {
|
||||||
const next = applyProspectExclusivity(
|
const next = applyProspectExclusivity(flags({ isDelivery: true, isBilling: true }), 'isProspect', true)
|
||||||
{ isProspect: false, isDelivery: true, isBilling: true },
|
expect(next).toEqual(flags({ isProspect: true }))
|
||||||
'isProspect',
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
expect(next).toEqual({ isProspect: true, isDelivery: false, isBilling: false })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('cocher Livraison efface Prospect', () => {
|
it('cocher Livraison efface Prospect', () => {
|
||||||
const next = applyProspectExclusivity(
|
const next = applyProspectExclusivity(flags({ isProspect: true }), 'isDelivery', true)
|
||||||
{ isProspect: true, isDelivery: false, isBilling: false },
|
expect(next).toEqual(flags({ isDelivery: true }))
|
||||||
'isDelivery',
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('cocher Facturation efface Prospect mais conserve Livraison', () => {
|
it('cocher Facturation efface Prospect mais conserve Livraison', () => {
|
||||||
const next = applyProspectExclusivity(
|
const next = applyProspectExclusivity(flags({ isProspect: true, isDelivery: true }), 'isBilling', true)
|
||||||
{ isProspect: true, isDelivery: true, isBilling: false },
|
expect(next).toEqual(flags({ isDelivery: true, isBilling: true }))
|
||||||
'isBilling',
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: true })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('decocher un drapeau ne reactive rien d autre', () => {
|
it('decocher un drapeau ne reactive rien d autre', () => {
|
||||||
const next = applyProspectExclusivity(
|
const next = applyProspectExclusivity(flags({ isDelivery: true, isBilling: true }), 'isBilling', false)
|
||||||
{ isProspect: false, isDelivery: true, isBilling: true },
|
expect(next).toEqual(flags({ isDelivery: true }))
|
||||||
'isBilling',
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('isBillingEmailRequired (RG-1.11)', () => {
|
describe('isBillingEmailRequired (RG-1.11)', () => {
|
||||||
it('obligatoire uniquement si Facturation est coche', () => {
|
it('obligatoire uniquement si Facturation est coche', () => {
|
||||||
expect(isBillingEmailRequired({ isProspect: false, isDelivery: false, isBilling: true })).toBe(true)
|
expect(isBillingEmailRequired(flags({ isBilling: true }))).toBe(true)
|
||||||
expect(isBillingEmailRequired({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false)
|
expect(isBillingEmailRequired(flags({ isDelivery: true }))).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('type d\'adresse (Select front) <-> drapeaux back', () => {
|
describe('type d\'adresse (Select front) <-> drapeaux back', () => {
|
||||||
it('addressFlagsFromType mappe chaque type vers les bons drapeaux', () => {
|
it('addressFlagsFromType mappe chaque type vers les bons drapeaux', () => {
|
||||||
expect(addressFlagsFromType('prospect')).toEqual({ isProspect: true, isDelivery: false, isBilling: false })
|
expect(addressFlagsFromType('prospect')).toEqual(flags({ isProspect: true }))
|
||||||
expect(addressFlagsFromType('delivery')).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
|
expect(addressFlagsFromType('delivery')).toEqual(flags({ isDelivery: true }))
|
||||||
expect(addressFlagsFromType('billing')).toEqual({ isProspect: false, isDelivery: false, isBilling: true })
|
expect(addressFlagsFromType('billing')).toEqual(flags({ isBilling: true }))
|
||||||
expect(addressFlagsFromType('delivery_billing')).toEqual({ isProspect: false, isDelivery: true, isBilling: true })
|
expect(addressFlagsFromType('delivery_billing')).toEqual(flags({ isDelivery: true, isBilling: true }))
|
||||||
|
expect(addressFlagsFromType('broker')).toEqual(flags({ isBroker: true }))
|
||||||
|
expect(addressFlagsFromType('distributor')).toEqual(flags({ isDistributor: true }))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('addressTypeFromFlags reconstruit le type (Prospect prioritaire, livraison+facturation groupes)', () => {
|
it('addressTypeFromFlags reconstruit le type (Prospect/Courtier/Distributeur autonomes, livraison+facturation groupes)', () => {
|
||||||
expect(addressTypeFromFlags({ isProspect: true, isDelivery: false, isBilling: false })).toBe('prospect')
|
expect(addressTypeFromFlags(flags({ isProspect: true }))).toBe('prospect')
|
||||||
expect(addressTypeFromFlags({ isProspect: false, isDelivery: true, isBilling: false })).toBe('delivery')
|
expect(addressTypeFromFlags(flags({ isDelivery: true }))).toBe('delivery')
|
||||||
expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: true })).toBe('billing')
|
expect(addressTypeFromFlags(flags({ isBilling: true }))).toBe('billing')
|
||||||
expect(addressTypeFromFlags({ isProspect: false, isDelivery: true, isBilling: true })).toBe('delivery_billing')
|
expect(addressTypeFromFlags(flags({ isDelivery: true, isBilling: true }))).toBe('delivery_billing')
|
||||||
|
expect(addressTypeFromFlags(flags({ isBroker: true }))).toBe('broker')
|
||||||
|
expect(addressTypeFromFlags(flags({ isDistributor: true }))).toBe('distributor')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('addressTypeFromFlags retourne null quand aucun drapeau (amorce vierge -> bouton bloque)', () => {
|
it('addressTypeFromFlags retourne null quand aucun drapeau (amorce vierge -> bouton bloque)', () => {
|
||||||
expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: false })).toBeNull()
|
expect(addressTypeFromFlags(flags())).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('aller-retour type -> drapeaux -> type stable pour les 4 types', () => {
|
it('aller-retour type -> drapeaux -> type stable pour les 6 types', () => {
|
||||||
for (const type of ['prospect', 'delivery', 'billing', 'delivery_billing'] as const) {
|
for (const type of ['prospect', 'delivery', 'billing', 'delivery_billing', 'broker', 'distributor'] as const) {
|
||||||
expect(addressTypeFromFlags(addressFlagsFromType(type))).toBe(type)
|
expect(addressTypeFromFlags(addressFlagsFromType(type))).toBe(type)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -324,6 +341,8 @@ describe('isAddressValid (gating « + Adresse » + validation onglet)', () => {
|
|||||||
isProspect: false,
|
isProspect: false,
|
||||||
isDelivery: true,
|
isDelivery: true,
|
||||||
isBilling: false,
|
isBilling: false,
|
||||||
|
isBroker: false,
|
||||||
|
isDistributor: false,
|
||||||
categoryIris: ['/api/client_categories/1'],
|
categoryIris: ['/api/client_categories/1'],
|
||||||
siteIris: ['/api/sites/1'],
|
siteIris: ['/api/sites/1'],
|
||||||
billingEmail: null,
|
billingEmail: null,
|
||||||
@@ -369,3 +388,33 @@ describe('isRibComplete (gating « + RIB » + RG-1.13)', () => {
|
|||||||
expect(isRibComplete({ label: null, bic: null, iban: null })).toBe(false)
|
expect(isRibComplete({ label: null, bic: null, iban: null })).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('omitEmptyRequired (ERP-119 : 422 NotBlank au lieu de 400 de type)', () => {
|
||||||
|
it('retire les cles requises vides (null / vide / undefined)', () => {
|
||||||
|
const payload = omitEmptyRequired(
|
||||||
|
{ companyName: null, label: '', iban: undefined, categories: ['/api/categories/1'] },
|
||||||
|
['companyName', 'label', 'iban'],
|
||||||
|
)
|
||||||
|
expect('companyName' in payload).toBe(false)
|
||||||
|
expect('label' in payload).toBe(false)
|
||||||
|
expect('iban' in payload).toBe(false)
|
||||||
|
// Les cles hors liste ne sont jamais touchees.
|
||||||
|
expect(payload.categories).toEqual(['/api/categories/1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('conserve les cles requises renseignees', () => {
|
||||||
|
const payload = omitEmptyRequired({ companyName: 'ACME', bic: 'BNPAFRPP' }, ['companyName', 'bic'])
|
||||||
|
expect(payload).toEqual({ companyName: 'ACME', bic: 'BNPAFRPP' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne retire jamais une cle hors de la liste requise, meme vide', () => {
|
||||||
|
const payload = omitEmptyRequired({ streetComplement: null }, ['street'])
|
||||||
|
expect('streetComplement' in payload).toBe(true)
|
||||||
|
expect(payload.streetComplement).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('false / 0 ne sont pas consideres vides (booleens / nombres preserves)', () => {
|
||||||
|
const payload = omitEmptyRequired({ isDelivery: false, position: 0 }, ['isDelivery', 'position'])
|
||||||
|
expect(payload).toEqual({ isDelivery: false, position: 0 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -63,9 +63,12 @@ export interface AddressRead extends HydraRef {
|
|||||||
street?: string | null
|
street?: string | null
|
||||||
streetComplement?: string | null
|
streetComplement?: string | null
|
||||||
billingEmail?: string | null
|
billingEmail?: string | null
|
||||||
|
billingEmailSecondary?: string | null
|
||||||
isProspect?: boolean
|
isProspect?: boolean
|
||||||
isDelivery?: boolean
|
isDelivery?: boolean
|
||||||
isBilling?: boolean
|
isBilling?: boolean
|
||||||
|
isBroker?: boolean
|
||||||
|
isDistributor?: boolean
|
||||||
sites?: SiteRead[]
|
sites?: SiteRead[]
|
||||||
categories?: CategoryRead[]
|
categories?: CategoryRead[]
|
||||||
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
|
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
|
||||||
@@ -209,6 +212,8 @@ export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
|
|||||||
isProspect: address.isProspect ?? false,
|
isProspect: address.isProspect ?? false,
|
||||||
isDelivery: address.isDelivery ?? false,
|
isDelivery: address.isDelivery ?? false,
|
||||||
isBilling: address.isBilling ?? false,
|
isBilling: address.isBilling ?? false,
|
||||||
|
isBroker: address.isBroker ?? false,
|
||||||
|
isDistributor: address.isDistributor ?? false,
|
||||||
country: address.country ?? 'France',
|
country: address.country ?? 'France',
|
||||||
postalCode: address.postalCode ?? null,
|
postalCode: address.postalCode ?? null,
|
||||||
city: address.city ?? null,
|
city: address.city ?? null,
|
||||||
@@ -218,6 +223,8 @@ export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
|
|||||||
siteIris: (address.sites ?? []).map(s => s['@id']),
|
siteIris: (address.sites ?? []).map(s => s['@id']),
|
||||||
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
|
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
|
||||||
billingEmail: address.billingEmail ?? null,
|
billingEmail: address.billingEmail ?? null,
|
||||||
|
billingEmailSecondary: address.billingEmailSecondary ?? null,
|
||||||
|
hasSecondaryBillingEmail: (address.billingEmailSecondary ?? null) !== null && address.billingEmailSecondary !== '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ import {
|
|||||||
relationOf,
|
relationOf,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
} from '~/modules/commercial/utils/clientConsultation'
|
} from '~/modules/commercial/utils/clientConsultation'
|
||||||
|
import {
|
||||||
|
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
|
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
|
omitEmptyRequired,
|
||||||
|
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
|
} from '~/modules/commercial/utils/clientFormRules'
|
||||||
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,13 +145,21 @@ export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraf
|
|||||||
* que la FK correspondant au type choisi, l'autre est forcee a null.
|
* que la FK correspondant au type choisi, l'autre est forcee a null.
|
||||||
*/
|
*/
|
||||||
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
|
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
|
||||||
return {
|
// companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119).
|
||||||
|
// relationType : champ transitoire (non persiste cote back) qui porte
|
||||||
|
// l'intention UI « ce client depend d'un distributeur / courtier ». Il sert
|
||||||
|
// a la validation croisee serveur (RG-1.03 bis) : si une relation est choisie,
|
||||||
|
// la FK correspondante devient obligatoire -> 422 sur distributor / broker.
|
||||||
|
// Sans equivalent derivable cote back (FK nullable), c'est la seule facon de
|
||||||
|
// rester sur « on soumet, le back tranche » plutot qu'une garde front-only.
|
||||||
|
return omitEmptyRequired({
|
||||||
companyName: main.companyName,
|
companyName: main.companyName,
|
||||||
categories: main.categoryIris,
|
categories: main.categoryIris,
|
||||||
|
relationType: main.relationType,
|
||||||
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
|
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
|
||||||
broker: main.relationType === 'courtier' ? main.brokerIri : null,
|
broker: main.relationType === 'courtier' ? main.brokerIri : null,
|
||||||
triageService: main.triageService,
|
triageService: main.triageService,
|
||||||
}
|
}, MAIN_REQUIRED_NON_NULLABLE_KEYS)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */
|
/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */
|
||||||
@@ -198,10 +212,13 @@ export function buildAddressPayload(
|
|||||||
address: AddressFormDraft,
|
address: AddressFormDraft,
|
||||||
isBillingEmailRequired: boolean,
|
isBillingEmailRequired: boolean,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
// postalCode / city / street omis si vides -> 422 NotBlank (ERP-119).
|
||||||
|
return omitEmptyRequired({
|
||||||
isProspect: address.isProspect,
|
isProspect: address.isProspect,
|
||||||
isDelivery: address.isDelivery,
|
isDelivery: address.isDelivery,
|
||||||
isBilling: address.isBilling,
|
isBilling: address.isBilling,
|
||||||
|
isBroker: address.isBroker,
|
||||||
|
isDistributor: address.isDistributor,
|
||||||
country: address.country,
|
country: address.country,
|
||||||
postalCode: address.postalCode || null,
|
postalCode: address.postalCode || null,
|
||||||
city: address.city || null,
|
city: address.city || null,
|
||||||
@@ -211,16 +228,19 @@ export function buildAddressPayload(
|
|||||||
sites: address.siteIris,
|
sites: address.siteIris,
|
||||||
contacts: address.contactIris,
|
contacts: address.contactIris,
|
||||||
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
|
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
|
||||||
}
|
billingEmailSecondary: isBillingEmailRequired && address.hasSecondaryBillingEmail ? (address.billingEmailSecondary || null) : null,
|
||||||
|
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload d'un RIB (sous-ressource client_rib). */
|
/** Payload d'un RIB (sous-ressource client_rib). */
|
||||||
export function buildRibPayload(rib: RibFormDraft): Record<string, unknown> {
|
export function buildRibPayload(rib: RibFormDraft): Record<string, unknown> {
|
||||||
return {
|
// label / bic / iban omis si vides -> 422 NotBlank au lieu d'un 400 de type
|
||||||
|
// sur un RIB partiel (ex. IBAN seul). ERP-119.
|
||||||
|
return omitEmptyRequired({
|
||||||
label: rib.label,
|
label: rib.label,
|
||||||
bic: rib.bic,
|
bic: rib.bic,
|
||||||
iban: rib.iban,
|
iban: rib.iban,
|
||||||
}
|
}, RIB_REQUIRED_NON_NULLABLE_KEYS)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Gating par permission ────────────────────────────────────────────────────
|
// ── Gating par permission ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ export function buildClientFormTabKeys(
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dernier onglet REMPLISSABLE d'un jeu d'onglets : le dernier qui n'est pas un
|
||||||
|
* placeholder (coquille). Role-aware sans regle ad hoc — il suffit de lui passer
|
||||||
|
* les `tabKeys` deja filtres par permission (l'onglet Comptabilite n'y figure que
|
||||||
|
* si accounting.view). Sa validation marque la fin de l'ajout (redirection liste).
|
||||||
|
*/
|
||||||
|
export function lastFillableTabKey(tabKeys: string[]): string | undefined {
|
||||||
|
return [...tabKeys].reverse().find(
|
||||||
|
key => !(CLIENT_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Codes de categorie « intermediaire » : un client dont la categorie est
|
* Codes de categorie « intermediaire » : un client dont la categorie est
|
||||||
* Distributeur ou Courtier n'a ni relation amont (il EST le distributeur /
|
* Distributeur ou Courtier n'a ni relation amont (il EST le distributeur /
|
||||||
@@ -81,6 +93,10 @@ export interface AddressFlagsDraft {
|
|||||||
isProspect: boolean
|
isProspect: boolean
|
||||||
isDelivery: boolean
|
isDelivery: boolean
|
||||||
isBilling: boolean
|
isBilling: boolean
|
||||||
|
/** Adresse Courtier — type autonome exclusif (comme isProspect). */
|
||||||
|
isBroker: boolean
|
||||||
|
/** Adresse Distributeur — type autonome exclusif (comme isProspect). */
|
||||||
|
isDistributor: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vrai si une chaine porte au moins un caractere non-espace. */
|
/** Vrai si une chaine porte au moins un caractere non-espace. */
|
||||||
@@ -220,22 +236,30 @@ export function isBillingEmailRequired(flags: AddressFlagsDraft): boolean {
|
|||||||
* drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). Les seules
|
* drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). Les seules
|
||||||
* combinaisons proposees respectent l'exclusivite Prospect (RG-1.06/07/08).
|
* combinaisons proposees respectent l'exclusivite Prospect (RG-1.06/07/08).
|
||||||
*/
|
*/
|
||||||
export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing'
|
export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing' | 'broker' | 'distributor'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mappe le type d'adresse choisi vers les trois drapeaux back.
|
* Mappe le type d'adresse choisi vers les cinq drapeaux back.
|
||||||
* « Adresse + Facturation » = livraison ET facturation sur la meme adresse.
|
* « Adresse + Facturation » = livraison ET facturation sur la meme adresse.
|
||||||
|
* Courtier / Distributeur sont autonomes (un seul drapeau, exclusif du reste).
|
||||||
*/
|
*/
|
||||||
export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
|
export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
|
||||||
|
const none: AddressFlagsDraft = {
|
||||||
|
isProspect: false, isDelivery: false, isBilling: false, isBroker: false, isDistributor: false,
|
||||||
|
}
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'prospect':
|
case 'prospect':
|
||||||
return { isProspect: true, isDelivery: false, isBilling: false }
|
return { ...none, isProspect: true }
|
||||||
case 'delivery':
|
case 'delivery':
|
||||||
return { isProspect: false, isDelivery: true, isBilling: false }
|
return { ...none, isDelivery: true }
|
||||||
case 'billing':
|
case 'billing':
|
||||||
return { isProspect: false, isDelivery: false, isBilling: true }
|
return { ...none, isBilling: true }
|
||||||
case 'delivery_billing':
|
case 'delivery_billing':
|
||||||
return { isProspect: false, isDelivery: true, isBilling: true }
|
return { ...none, isDelivery: true, isBilling: true }
|
||||||
|
case 'broker':
|
||||||
|
return { ...none, isBroker: true }
|
||||||
|
case 'distributor':
|
||||||
|
return { ...none, isDistributor: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +270,8 @@ export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
|
|||||||
*/
|
*/
|
||||||
export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | null {
|
export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | null {
|
||||||
if (flags.isProspect) return 'prospect'
|
if (flags.isProspect) return 'prospect'
|
||||||
|
if (flags.isBroker) return 'broker'
|
||||||
|
if (flags.isDistributor) return 'distributor'
|
||||||
if (flags.isDelivery && flags.isBilling) return 'delivery_billing'
|
if (flags.isDelivery && flags.isBilling) return 'delivery_billing'
|
||||||
if (flags.isDelivery) return 'delivery'
|
if (flags.isDelivery) return 'delivery'
|
||||||
if (flags.isBilling) return 'billing'
|
if (flags.isBilling) return 'billing'
|
||||||
@@ -358,3 +384,38 @@ export function hasAllRequiredAccountingFields(accounting: AccountingRequiredDra
|
|||||||
&& filled(accounting.paymentDelayIri)
|
&& filled(accounting.paymentDelayIri)
|
||||||
&& filled(accounting.paymentTypeIri)
|
&& filled(accounting.paymentTypeIri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Champs requis adosses a une colonne NON-nullable (ERP-119) ───────────────
|
||||||
|
// Ces champs requis (NotBlank back) sont portes par une colonne Doctrine NON
|
||||||
|
// nullable. Si le front envoie `null` (champ vide, desormais possible : le bouton
|
||||||
|
// « Valider » n'est plus desactive), API Platform rejette la valeur en 400 de TYPE
|
||||||
|
// a la deserialisation (« The type of the X attribute must be string, NULL given »)
|
||||||
|
// AVANT le Validator -> pas de violation, donc pas d'erreur rouge cote champ.
|
||||||
|
// La parade : OMETTRE la cle du payload quand elle est vide. Sans la cle, la
|
||||||
|
// propriete garde son defaut null cote entite et #[Assert\NotBlank] se declenche
|
||||||
|
// normalement -> 422 avec propertyPath, mappee en rouge sous le champ.
|
||||||
|
// (Les champs requis a colonne NULLABLE — contacts, scalaires compta — acceptent
|
||||||
|
// deja `null` et renvoient une 422 : inutile de les omettre.)
|
||||||
|
export const MAIN_REQUIRED_NON_NULLABLE_KEYS = ['companyName'] as const
|
||||||
|
export const ADDRESS_REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
|
||||||
|
export const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire d'un payload d'ecriture les cles requises laissees vides (null / ''
|
||||||
|
* / undefined), pour laisser le back produire une 422 NotBlank par champ plutot
|
||||||
|
* qu'un 400 de type sur une colonne non-nullable. Mute et retourne le payload.
|
||||||
|
* A n'appliquer QU'aux cles ci-dessus (champs requis a colonne non-nullable).
|
||||||
|
*/
|
||||||
|
export function omitEmptyRequired<T extends Record<string, unknown>>(
|
||||||
|
payload: T,
|
||||||
|
requiredKeys: readonly string[],
|
||||||
|
): T {
|
||||||
|
for (const key of requiredKeys) {
|
||||||
|
const value = payload[key]
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
delete payload[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
|||||||
"name": "starseed-frontend",
|
"name": "starseed-frontend",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.7.7",
|
"@malio/layer-ui": "^1.7.8",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
@@ -1866,9 +1866,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@malio/layer-ui": {
|
"node_modules/@malio/layer-ui": {
|
||||||
"version": "1.7.7",
|
"version": "1.7.8",
|
||||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.7/layer-ui-1.7.7.tgz",
|
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz",
|
||||||
"integrity": "sha512-MLHDtOzUxcCwIBGWj4FcUMLQTExtGD29uLvpU+IA6qr7gCj9kZ9fGZDu76LXxuJJdfBwzZmenuZioE7Z1qQUUw==",
|
"integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.7.7",
|
"@malio/layer-ui": "^1.7.8",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commercial — deux nouveaux types d'adresse client : Courtier et Distributeur.
|
||||||
|
*
|
||||||
|
* Ajoute les drapeaux `is_broker` / `is_distributor` sur `client_address`, au
|
||||||
|
* meme titre que `is_prospect` / `is_delivery` / `is_billing`. Ce sont des types
|
||||||
|
* AUTONOMES (comme la Prospection) : exclusifs de tout autre usage. Deux CHECK
|
||||||
|
* Postgres miroitent l'exclusivite applicative (validateExclusiveAddressTypes),
|
||||||
|
* en filet de securite (comme chk_client_address_prospect_exclusive).
|
||||||
|
*
|
||||||
|
* NB Postgres : `ADD COLUMN` ajoute en derniere position physique (pas de clause
|
||||||
|
* AFTER) — l'ordre physique est cosmetique, on adresse par nom. Les colonnes sont
|
||||||
|
* declarees juste apres isBilling dans l'entite (ERP-119).
|
||||||
|
*
|
||||||
|
* Migration au namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) : le
|
||||||
|
* tri par version garantit son passage apres l'init des tables.
|
||||||
|
*/
|
||||||
|
final class Version20260609120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Commercial : types d\'adresse Courtier / Distributeur (is_broker / is_distributor) sur client_address, exclusifs (CHECK).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE client_address ADD COLUMN is_broker BOOLEAN DEFAULT FALSE NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE client_address ADD COLUMN is_distributor BOOLEAN DEFAULT FALSE NOT NULL');
|
||||||
|
|
||||||
|
// Exclusivite miroir (filet de securite DBAL) : un type autonome interdit
|
||||||
|
// tout autre drapeau. Livraison + Facturation restent cumulables entre eux.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE client_address
|
||||||
|
ADD CONSTRAINT chk_client_address_broker_exclusive
|
||||||
|
CHECK (NOT (is_broker = TRUE AND (is_prospect = TRUE OR is_delivery = TRUE OR is_billing = TRUE OR is_distributor = TRUE)))
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE client_address
|
||||||
|
ADD CONSTRAINT chk_client_address_distributor_exclusive
|
||||||
|
CHECK (NOT (is_distributor = TRUE AND (is_prospect = TRUE OR is_delivery = TRUE OR is_billing = TRUE OR is_broker = TRUE)))
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->comment('client_address', 'is_broker', 'Adresse Courtier — type autonome exclusif de tout autre usage (chk_client_address_broker_exclusive). Faux par defaut.');
|
||||||
|
$this->comment('client_address', 'is_distributor', 'Adresse Distributeur — type autonome exclusif de tout autre usage (chk_client_address_distributor_exclusive). Faux par defaut.');
|
||||||
|
|
||||||
|
// Le commentaire de table mentionnait seulement prospect/livraison/facturation :
|
||||||
|
// on y ajoute les types autonomes Courtier / Distributeur (cf. ColumnCommentsCatalog).
|
||||||
|
$this->addSql('COMMENT ON TABLE client_address IS $_$Adresses d un client (1:n) — types prospect / livraison / facturation (exclusivites RG-1.06/07/08) + Courtier / Distributeur autonomes (exclusifs de tout autre usage), >= 1 site rattache (RG-1.10).$_$');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('COMMENT ON TABLE client_address IS $_$Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).$_$');
|
||||||
|
$this->addSql('ALTER TABLE client_address DROP CONSTRAINT IF EXISTS chk_client_address_broker_exclusive');
|
||||||
|
$this->addSql('ALTER TABLE client_address DROP CONSTRAINT IF EXISTS chk_client_address_distributor_exclusive');
|
||||||
|
$this->addSql('ALTER TABLE client_address DROP COLUMN is_distributor');
|
||||||
|
$this->addSql('ALTER TABLE client_address DROP COLUMN is_broker');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emet un `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour
|
||||||
|
* eviter tout echappement.
|
||||||
|
*/
|
||||||
|
private function comment(string $table, string $column, string $description): void
|
||||||
|
{
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||||
|
'"'.str_replace('"', '""', $table).'"',
|
||||||
|
'"'.str_replace('"', '""', $column).'"',
|
||||||
|
$description,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commercial — second email de facturation (optionnel) sur une adresse client.
|
||||||
|
*
|
||||||
|
* Ajoute `billing_email_secondary` sur `client_address`, pendant du telephone
|
||||||
|
* secondaire du contact (max 2 emails). Optionnel ; comme l'email principal, il
|
||||||
|
* n'a de sens que sur une adresse de facturation (validateBillingEmailPresence).
|
||||||
|
*
|
||||||
|
* Migration au namespace racine `DoctrineMigrations` (regle ABSOLUE n°11).
|
||||||
|
*/
|
||||||
|
final class Version20260609140000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Commercial : 2e email de facturation optionnel (billing_email_secondary) sur client_address.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE client_address ADD COLUMN billing_email_secondary VARCHAR(180) DEFAULT NULL');
|
||||||
|
|
||||||
|
$this->comment('client_address', 'billing_email_secondary', '2e email de facturation, optionnel (max 2). Interdit hors facturation (validateBillingEmailPresence), normalise en minuscules (RG-1.21).');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE client_address DROP COLUMN billing_email_secondary');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emet un `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour
|
||||||
|
* eviter tout echappement.
|
||||||
|
*/
|
||||||
|
private function comment(string $table, string $column, string $description): void
|
||||||
|
{
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||||
|
'"'.str_replace('"', '""', $table).'"',
|
||||||
|
'"'.str_replace('"', '""', $column).'"',
|
||||||
|
$description,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ use Doctrine\ORM\Mapping as ORM;
|
|||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client (M1 Commercial) — entite racine du repertoire clients. Porte le
|
* Client (M1 Commercial) — entite racine du repertoire clients. Porte le
|
||||||
@@ -171,6 +172,17 @@ class Client implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['client:read', 'client:write:main'])]
|
#[Groups(['client:read', 'client:write:main'])]
|
||||||
private bool $triageService = false;
|
private bool $triageService = false;
|
||||||
|
|
||||||
|
// Champ transitoire (NON persiste : aucune colonne ORM) portant l'intention UI
|
||||||
|
// « ce client depend d'un distributeur / courtier ». Write-only (groupe
|
||||||
|
// d'ecriture main uniquement, pas de groupe de lecture -> jamais serialise en
|
||||||
|
// sortie). Sert exclusivement a la validation croisee validateRelationName :
|
||||||
|
// si une relation est choisie, la FK correspondante (distributor / broker)
|
||||||
|
// devient obligatoire. Non mappe ORM -> non audite, et toujours null une fois
|
||||||
|
// l'entite rechargee depuis la base (ne sert qu'au cycle d'une ecriture).
|
||||||
|
#[Assert\Choice(choices: ['distributeur', 'courtier'], message: 'Le type de relation est invalide.')]
|
||||||
|
#[Groups(['client:write:main'])]
|
||||||
|
private ?string $relationType = null;
|
||||||
|
|
||||||
// RG : au moins une categorie (Count min 1). M2M vers Category via le contrat
|
// RG : au moins une categorie (Count min 1). M2M vers Category via le contrat
|
||||||
// CategoryInterface (resolve_target_entities -> Category).
|
// CategoryInterface (resolve_target_entities -> Category).
|
||||||
/** @var Collection<int, CategoryInterface> */
|
/** @var Collection<int, CategoryInterface> */
|
||||||
@@ -333,6 +345,45 @@ class Client implements TimestampableInterface, BlamableInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRelationType(): ?string
|
||||||
|
{
|
||||||
|
return $this->relationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRelationType(?string $relationType): static
|
||||||
|
{
|
||||||
|
$this->relationType = $relationType;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-1.03 bis : si l'utilisateur declare une relation (« depend d'un
|
||||||
|
* distributeur / courtier » via le champ transitoire relationType), la FK
|
||||||
|
* correspondante est obligatoire. Le back ne peut pas deviner cette intention
|
||||||
|
* a partir des seules FK nullable (distributor=null ne distingue pas « pas de
|
||||||
|
* relation » de « relation choisie sans nom »), d'ou relationType qui la porte.
|
||||||
|
* Violation portee sur distributor / broker (champ fautif cote formulaire), de
|
||||||
|
* sorte que useFormErrors la mappe inline sous le bon select (ERP-101).
|
||||||
|
*/
|
||||||
|
#[Assert\Callback]
|
||||||
|
public function validateRelationName(ExecutionContextInterface $context): void
|
||||||
|
{
|
||||||
|
if ('distributeur' === $this->relationType && null === $this->distributor) {
|
||||||
|
$context->buildViolation('Le nom du distributeur est obligatoire.')
|
||||||
|
->atPath('distributor')
|
||||||
|
->addViolation()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('courtier' === $this->relationType && null === $this->broker) {
|
||||||
|
$context->buildViolation('Le nom du courtier est obligatoire.')
|
||||||
|
->atPath('broker')
|
||||||
|
->addViolation()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function isTriageService(): bool
|
public function isTriageService(): bool
|
||||||
{
|
{
|
||||||
return $this->triageService;
|
return $this->triageService;
|
||||||
|
|||||||
@@ -129,6 +129,18 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['client_address:write'])]
|
#[Groups(['client_address:write'])]
|
||||||
private bool $isBilling = false;
|
private bool $isBilling = false;
|
||||||
|
|
||||||
|
// Adresse Courtier / Distributeur : types autonomes (comme Prospection),
|
||||||
|
// exclusifs de tout autre usage (validateExclusiveAddressTypes + CHECK BDD
|
||||||
|
// chk_client_address_broker_exclusive / chk_client_address_distributor_exclusive).
|
||||||
|
// Lecture portee par le getter + SerializedName (meme pattern que isProspect).
|
||||||
|
#[ORM\Column(name: 'is_broker', options: ['default' => false])]
|
||||||
|
#[Groups(['client_address:write'])]
|
||||||
|
private bool $isBroker = false;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'is_distributor', options: ['default' => false])]
|
||||||
|
#[Groups(['client_address:write'])]
|
||||||
|
private bool $isDistributor = false;
|
||||||
|
|
||||||
#[ORM\Column(length: 80, options: ['default' => 'France'])]
|
#[ORM\Column(length: 80, options: ['default' => 'France'])]
|
||||||
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||||
#[Groups(['client_address:read', 'client_address:write'])]
|
#[Groups(['client_address:read', 'client_address:write'])]
|
||||||
@@ -166,6 +178,15 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['client_address:read', 'client_address:write'])]
|
#[Groups(['client_address:read', 'client_address:write'])]
|
||||||
private ?string $billingEmail = null;
|
private ?string $billingEmail = null;
|
||||||
|
|
||||||
|
// 2e email de facturation, optionnel (max 2 — pendant du telephone secondaire).
|
||||||
|
// Comme le principal : interdit hors facturation (validateBillingEmailPresence),
|
||||||
|
// mais jamais obligatoire. Normalise en lowercase par le ClientAddressProcessor.
|
||||||
|
#[ORM\Column(length: 180, nullable: true)]
|
||||||
|
#[Assert\Email(message: 'L\'email de facturation secondaire n\'est pas valide.')]
|
||||||
|
#[Assert\Length(max: 180, maxMessage: 'L\'email de facturation secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||||
|
#[Groups(['client_address:read', 'client_address:write'])]
|
||||||
|
private ?string $billingEmailSecondary = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
#[Groups(['client_address:read', 'client_address:write'])]
|
#[Groups(['client_address:read', 'client_address:write'])]
|
||||||
private int $position = 0;
|
private int $position = 0;
|
||||||
@@ -223,6 +244,48 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Au moins un type d'adresse est obligatoire (Prospection, Livraison ou
|
||||||
|
* Facturation) : une adresse sans aucun drapeau pose n'a pas de sens metier.
|
||||||
|
* La violation est portee sur `isProspect` (meme champ que l'exclusivite) pour
|
||||||
|
* un mapping inline sous le select « Type d'adresse » cote front (ERP-119).
|
||||||
|
*/
|
||||||
|
#[Assert\Callback]
|
||||||
|
public function validateAddressTypeRequired(ExecutionContextInterface $context): void
|
||||||
|
{
|
||||||
|
if (!$this->isProspect && !$this->isDelivery && !$this->isBilling && !$this->isBroker && !$this->isDistributor) {
|
||||||
|
$context->buildViolation('Le type d\'adresse est obligatoire.')
|
||||||
|
->atPath('isProspect')
|
||||||
|
->addViolation()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Courtier et Distributeur sont des types d'adresse AUTONOMES (comme la
|
||||||
|
* Prospection) : exclusifs de tout autre usage (Livraison / Facturation /
|
||||||
|
* Prospection / l'autre type autonome). Mirror applicatif (422) des CHECK
|
||||||
|
* chk_client_address_broker_exclusive / chk_client_address_distributor_exclusive.
|
||||||
|
* Violation portee sur `isProspect` (mappee sous le select « Type d'adresse »).
|
||||||
|
*/
|
||||||
|
#[Assert\Callback]
|
||||||
|
public function validateExclusiveAddressTypes(ExecutionContextInterface $context): void
|
||||||
|
{
|
||||||
|
if ($this->isBroker && ($this->isProspect || $this->isDelivery || $this->isBilling || $this->isDistributor)) {
|
||||||
|
$context->buildViolation('Une adresse Courtier ne peut pas avoir d\'autre type.')
|
||||||
|
->atPath('isProspect')
|
||||||
|
->addViolation()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isDistributor && ($this->isProspect || $this->isDelivery || $this->isBilling || $this->isBroker)) {
|
||||||
|
$context->buildViolation('Une adresse Distributeur ne peut pas avoir d\'autre type.')
|
||||||
|
->atPath('isProspect')
|
||||||
|
->addViolation()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-1.11 : l'email de facturation est obligatoire si l'adresse est de
|
* RG-1.11 : l'email de facturation est obligatoire si l'adresse est de
|
||||||
* facturation, et interdit sinon. Mirror applicatif (422) du CHECK
|
* facturation, et interdit sinon. Mirror applicatif (422) du CHECK
|
||||||
@@ -254,6 +317,16 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
|||||||
->addViolation()
|
->addViolation()
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Le 2e email est OPTIONNEL (jamais requis), mais comme le principal il
|
||||||
|
// n'a de sens que sur une adresse de facturation.
|
||||||
|
$hasSecondaryEmail = null !== $this->billingEmailSecondary && '' !== trim($this->billingEmailSecondary);
|
||||||
|
if (!$this->isBilling && $hasSecondaryEmail) {
|
||||||
|
$context->buildViolation('L\'email de facturation n\'est autorisé que sur une adresse de facturation.')
|
||||||
|
->atPath('billingEmailSecondary')
|
||||||
|
->addViolation()
|
||||||
|
;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -343,6 +416,34 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Groups(['client_address:read'])]
|
||||||
|
#[SerializedName('isBroker')]
|
||||||
|
public function isBroker(): bool
|
||||||
|
{
|
||||||
|
return $this->isBroker;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsBroker(bool $isBroker): static
|
||||||
|
{
|
||||||
|
$this->isBroker = $isBroker;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Groups(['client_address:read'])]
|
||||||
|
#[SerializedName('isDistributor')]
|
||||||
|
public function isDistributor(): bool
|
||||||
|
{
|
||||||
|
return $this->isDistributor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsDistributor(bool $isDistributor): static
|
||||||
|
{
|
||||||
|
$this->isDistributor = $isDistributor;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getCountry(): string
|
public function getCountry(): string
|
||||||
{
|
{
|
||||||
return $this->country;
|
return $this->country;
|
||||||
@@ -415,6 +516,18 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getBillingEmailSecondary(): ?string
|
||||||
|
{
|
||||||
|
return $this->billingEmailSecondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBillingEmailSecondary(?string $billingEmailSecondary): static
|
||||||
|
{
|
||||||
|
$this->billingEmailSecondary = $billingEmailSecondary;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getPosition(): int
|
public function getPosition(): int
|
||||||
{
|
{
|
||||||
return $this->position;
|
return $this->position;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
+1
@@ -94,5 +94,6 @@ final class ClientAddressProcessor implements ProcessorInterface
|
|||||||
private function normalize(ClientAddress $address): void
|
private function normalize(ClientAddress $address): void
|
||||||
{
|
{
|
||||||
$address->setBillingEmail($this->normalizer->normalizeEmail($address->getBillingEmail()));
|
$address->setBillingEmail($this->normalizer->normalizeEmail($address->getBillingEmail()));
|
||||||
|
$address->setBillingEmailSecondary($this->normalizer->normalizeEmail($address->getBillingEmailSecondary()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,19 +219,22 @@ final class ColumnCommentsCatalog
|
|||||||
] + self::timestampableBlamableComments(),
|
] + self::timestampableBlamableComments(),
|
||||||
|
|
||||||
'client_address' => [
|
'client_address' => [
|
||||||
'_table' => 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).',
|
'_table' => 'Adresses d un client (1:n) — types prospect / livraison / facturation (exclusivites RG-1.06/07/08) + Courtier / Distributeur autonomes (exclusifs de tout autre usage), >= 1 site rattache (RG-1.10).',
|
||||||
'id' => 'Identifiant interne auto-incremente.',
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.',
|
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.',
|
||||||
'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.',
|
'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.',
|
||||||
'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.',
|
'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.',
|
||||||
'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.',
|
'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.',
|
||||||
'country' => 'Pays de l adresse — defaut France.',
|
'is_broker' => 'Adresse Courtier — type autonome exclusif de tout autre usage (chk_client_address_broker_exclusive). Faux par defaut.',
|
||||||
'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).',
|
'is_distributor' => 'Adresse Distributeur — type autonome exclusif de tout autre usage (chk_client_address_distributor_exclusive). Faux par defaut.',
|
||||||
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).',
|
'country' => 'Pays de l adresse — defaut France.',
|
||||||
'street' => 'Numero et voie de l adresse.',
|
'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).',
|
||||||
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
|
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).',
|
||||||
'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).',
|
'street' => 'Numero et voie de l adresse.',
|
||||||
'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).',
|
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
|
||||||
|
'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).',
|
||||||
|
'billing_email_secondary' => '2e email de facturation, optionnel (max 2). Interdit hors facturation, normalise en minuscules (RG-1.21).',
|
||||||
|
'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).',
|
||||||
] + self::timestampableBlamableComments(),
|
] + self::timestampableBlamableComments(),
|
||||||
|
|
||||||
'client_address_site' => [
|
'client_address_site' => [
|
||||||
|
|||||||
@@ -137,6 +137,27 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
|
|||||||
return $client;
|
return $client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. Mutualise ici (et non dans la
|
||||||
|
* sous-classe Supplier) pour etre accessible a tous les tests Commercial.
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
|
||||||
private function cleanupCommercialTestData(): void
|
private function cleanupCommercialTestData(): void
|
||||||
{
|
{
|
||||||
$em = $this->getEm();
|
$em = $this->getEm();
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -316,24 +319,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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
|||||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
|
'isDelivery' => true,
|
||||||
'isBilling' => false,
|
'isBilling' => false,
|
||||||
'billingEmail' => 'parasite@test.fr',
|
'billingEmail' => 'parasite@test.fr',
|
||||||
'postalCode' => '86100',
|
'postalCode' => '86100',
|
||||||
@@ -174,6 +175,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
|||||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
|
'isDelivery' => true,
|
||||||
'isBilling' => false,
|
'isBilling' => false,
|
||||||
'billingEmail' => '',
|
'billingEmail' => '',
|
||||||
'postalCode' => '86100',
|
'postalCode' => '86100',
|
||||||
@@ -187,6 +189,62 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
|||||||
self::assertResponseStatusCodeSame(201);
|
self::assertResponseStatusCodeSame(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-119 : une adresse de facturation accepte un 2e email (optionnel, max 2).
|
||||||
|
*/
|
||||||
|
public function testBillingAddressAcceptsTwoEmails(): void
|
||||||
|
{
|
||||||
|
$this->skipIfSitesModuleDisabled();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Billing Two Emails');
|
||||||
|
$category = $this->createCategory('SECTEUR');
|
||||||
|
|
||||||
|
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
|
'headers' => ['Content-Type' => self::LD],
|
||||||
|
'json' => [
|
||||||
|
'isBilling' => true,
|
||||||
|
'billingEmail' => 'facturation@test.fr',
|
||||||
|
'billingEmailSecondary' => 'compta@test.fr',
|
||||||
|
'postalCode' => '86100',
|
||||||
|
'city' => 'Châtellerault',
|
||||||
|
'street' => '1 rue du Test',
|
||||||
|
'sites' => [$this->firstSiteIri()],
|
||||||
|
'categories' => ['/api/categories/'.$category->getId()],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-119 : le 2e email de facturation, comme le principal, n'est autorise que
|
||||||
|
* sur une adresse de facturation -> 422 avec violation sur billingEmailSecondary.
|
||||||
|
*/
|
||||||
|
public function testSecondaryBillingEmailRejectedOnNonBillingAddress(): void
|
||||||
|
{
|
||||||
|
$this->skipIfSitesModuleDisabled();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Secondary Email Non Billing');
|
||||||
|
$category = $this->createCategory('SECTEUR');
|
||||||
|
|
||||||
|
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
|
'json' => [
|
||||||
|
'isDelivery' => true,
|
||||||
|
'billingEmailSecondary' => 'compta@test.fr',
|
||||||
|
'postalCode' => '86100',
|
||||||
|
'city' => 'Châtellerault',
|
||||||
|
'street' => '1 rue du Test',
|
||||||
|
'sites' => [$this->firstSiteIri()],
|
||||||
|
'categories' => ['/api/categories/'.$category->getId()],
|
||||||
|
],
|
||||||
|
])->toArray(false);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
$byPath = $this->violationsByPath($body);
|
||||||
|
self::assertArrayHasKey('billingEmailSecondary', $byPath);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-1.29 : poster une categorie de type DISTRIBUTEUR sur une adresse -> 422
|
* RG-1.29 : poster une categorie de type DISTRIBUTEUR sur une adresse -> 422
|
||||||
* avec violation sur le champ `categories`.
|
* avec violation sur le champ `categories`.
|
||||||
@@ -201,6 +259,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
|||||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
|
'isDelivery' => true,
|
||||||
'postalCode' => '86100',
|
'postalCode' => '86100',
|
||||||
'city' => 'Châtellerault',
|
'city' => 'Châtellerault',
|
||||||
'street' => '1 rue du Test',
|
'street' => '1 rue du Test',
|
||||||
@@ -229,6 +288,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
|||||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
|
'isDelivery' => true,
|
||||||
'postalCode' => '86100',
|
'postalCode' => '86100',
|
||||||
'city' => 'Châtellerault',
|
'city' => 'Châtellerault',
|
||||||
'street' => '1 rue du Test',
|
'street' => '1 rue du Test',
|
||||||
@@ -253,6 +313,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
|||||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
|
'isDelivery' => true,
|
||||||
'postalCode' => '86100',
|
'postalCode' => '86100',
|
||||||
'city' => 'Châtellerault',
|
'city' => 'Châtellerault',
|
||||||
'street' => '1 rue du Test',
|
'street' => '1 rue du Test',
|
||||||
@@ -277,6 +338,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
|||||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
|
'isDelivery' => true,
|
||||||
'postalCode' => '86100',
|
'postalCode' => '86100',
|
||||||
'city' => 'Châtellerault',
|
'city' => 'Châtellerault',
|
||||||
'street' => '1 rue du Test',
|
'street' => '1 rue du Test',
|
||||||
@@ -301,6 +363,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
|||||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
|
'isDelivery' => true,
|
||||||
'postalCode' => '86100',
|
'postalCode' => '86100',
|
||||||
'city' => 'Châtellerault',
|
'city' => 'Châtellerault',
|
||||||
'street' => '1 rue du Test',
|
'street' => '1 rue du Test',
|
||||||
@@ -311,6 +374,115 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
|||||||
self::assertResponseStatusCodeSame(422);
|
self::assertResponseStatusCodeSame(422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG (ERP-119) : au moins un type d'adresse (Prospection / Livraison /
|
||||||
|
* Facturation) est obligatoire. POST sans aucun drapeau de type -> 422, avec
|
||||||
|
* une violation portee sur `isProspect` (mappee sous le select « Type
|
||||||
|
* d'adresse » cote front via ClientAddressBlock).
|
||||||
|
*/
|
||||||
|
public function testAddressRequiresAtLeastOneType(): void
|
||||||
|
{
|
||||||
|
$this->skipIfSitesModuleDisabled();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Address No Type');
|
||||||
|
$category = $this->createCategory('SECTEUR');
|
||||||
|
|
||||||
|
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
|
'json' => [
|
||||||
|
'postalCode' => '86100',
|
||||||
|
'city' => 'Châtellerault',
|
||||||
|
'street' => '1 rue du Test',
|
||||||
|
'sites' => [$this->firstSiteIri()],
|
||||||
|
'categories' => ['/api/categories/'.$category->getId()],
|
||||||
|
],
|
||||||
|
])->toArray(false);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
$byPath = $this->violationsByPath($body);
|
||||||
|
self::assertArrayHasKey('isProspect', $byPath);
|
||||||
|
self::assertSame('Le type d\'adresse est obligatoire.', $byPath['isProspect']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nouveaux types d'adresse (ERP-119) : Courtier et Distributeur sont acceptes
|
||||||
|
* comme types autonomes (avec site + categorie). is_broker / is_distributor.
|
||||||
|
*/
|
||||||
|
public function testBrokerAddressAccepted(): void
|
||||||
|
{
|
||||||
|
$this->skipIfSitesModuleDisabled();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Address Broker Type');
|
||||||
|
$category = $this->createCategory('SECTEUR');
|
||||||
|
|
||||||
|
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
|
'headers' => ['Content-Type' => self::LD],
|
||||||
|
'json' => [
|
||||||
|
'isBroker' => true,
|
||||||
|
'postalCode' => '86100',
|
||||||
|
'city' => 'Châtellerault',
|
||||||
|
'street' => '1 rue du Test',
|
||||||
|
'sites' => [$this->firstSiteIri()],
|
||||||
|
'categories' => ['/api/categories/'.$category->getId()],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDistributorAddressAccepted(): void
|
||||||
|
{
|
||||||
|
$this->skipIfSitesModuleDisabled();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Address Distributor Type');
|
||||||
|
$category = $this->createCategory('SECTEUR');
|
||||||
|
|
||||||
|
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
|
'headers' => ['Content-Type' => self::LD],
|
||||||
|
'json' => [
|
||||||
|
'isDistributor' => true,
|
||||||
|
'postalCode' => '86100',
|
||||||
|
'city' => 'Châtellerault',
|
||||||
|
'street' => '1 rue du Test',
|
||||||
|
'sites' => [$this->firstSiteIri()],
|
||||||
|
'categories' => ['/api/categories/'.$category->getId()],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Courtier / Distributeur sont des types AUTONOMES exclusifs : les combiner avec
|
||||||
|
* un autre usage (ici Livraison) -> 422, violation sur isProspect (mappee sous le
|
||||||
|
* select Type d'adresse). Miroir applicatif du CHECK chk_client_address_broker_exclusive.
|
||||||
|
*/
|
||||||
|
public function testExclusiveAddressTypeRejected(): void
|
||||||
|
{
|
||||||
|
$this->skipIfSitesModuleDisabled();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Address Broker Mix');
|
||||||
|
$category = $this->createCategory('SECTEUR');
|
||||||
|
|
||||||
|
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
|
'json' => [
|
||||||
|
'isBroker' => true,
|
||||||
|
'isDelivery' => true,
|
||||||
|
'postalCode' => '86100',
|
||||||
|
'city' => 'Châtellerault',
|
||||||
|
'street' => '1 rue du Test',
|
||||||
|
'sites' => [$this->firstSiteIri()],
|
||||||
|
'categories' => ['/api/categories/'.$category->getId()],
|
||||||
|
],
|
||||||
|
])->toArray(false);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
$byPath = $this->violationsByPath($body);
|
||||||
|
self::assertArrayHasKey('isProspect', $byPath);
|
||||||
|
self::assertSame('Une adresse Courtier ne peut pas avoir d\'autre type.', $byPath['isProspect']);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retourne l'IRI du premier site seede (fixtures Sites).
|
* Retourne l'IRI du premier site seede (fixtures Sites).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -85,4 +85,77 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
|
|||||||
self::assertNotNull($persisted);
|
self::assertNotNull($persisted);
|
||||||
self::assertSame('LEGACY FIELDS SARL', $persisted->getCompanyName());
|
self::assertSame('LEGACY FIELDS SARL', $persisted->getCompanyName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-1.03 bis : declarer une relation « depend d'un distributeur »
|
||||||
|
* (relationType, champ transitoire) sans renseigner la FK distributor doit
|
||||||
|
* produire une 422 portee sur `distributor`. Le back ne peut pas deviner
|
||||||
|
* l'intention depuis la seule FK nullable (distributor=null = client
|
||||||
|
* independant), d'ou relationType qui la transporte.
|
||||||
|
*/
|
||||||
|
public function testRelationDistributeurSansDistributeurEst422(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$cat = $this->createCategory('SECTEUR');
|
||||||
|
|
||||||
|
$body = $client->request('POST', '/api/clients', [
|
||||||
|
'headers' => ['Content-Type' => self::LD],
|
||||||
|
'json' => [
|
||||||
|
'companyName' => 'Relation Sans Distrib SARL',
|
||||||
|
'categories' => ['/api/categories/'.$cat->getId()],
|
||||||
|
'relationType' => 'distributeur',
|
||||||
|
],
|
||||||
|
])->toArray(false);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
$byPath = $this->violationsByPath($body);
|
||||||
|
self::assertArrayHasKey('distributor', $byPath);
|
||||||
|
self::assertSame('Le nom du distributeur est obligatoire.', $byPath['distributor']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idem courtier : relationType=courtier sans broker -> 422 portee sur `broker`. */
|
||||||
|
public function testRelationCourtierSansCourtierEst422(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$cat = $this->createCategory('SECTEUR');
|
||||||
|
|
||||||
|
$body = $client->request('POST', '/api/clients', [
|
||||||
|
'headers' => ['Content-Type' => self::LD],
|
||||||
|
'json' => [
|
||||||
|
'companyName' => 'Relation Sans Courtier SARL',
|
||||||
|
'categories' => ['/api/categories/'.$cat->getId()],
|
||||||
|
'relationType' => 'courtier',
|
||||||
|
],
|
||||||
|
])->toArray(false);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
$byPath = $this->violationsByPath($body);
|
||||||
|
self::assertArrayHasKey('broker', $byPath);
|
||||||
|
self::assertSame('Le nom du courtier est obligatoire.', $byPath['broker']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le champ transitoire relationType ne casse pas la creation nominale : avec
|
||||||
|
* la FK correspondante renseignee, le client se cree (201) et relationType
|
||||||
|
* n'est jamais serialise en sortie (write-only, aucun groupe de lecture).
|
||||||
|
*/
|
||||||
|
public function testRelationDistributeurAvecDistributeurEst201(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$cat = $this->createCategory('SECTEUR');
|
||||||
|
$distributor = $this->seedClient('Distrib Cible', false, 'DISTRIBUTEUR');
|
||||||
|
|
||||||
|
$data = $client->request('POST', '/api/clients', [
|
||||||
|
'headers' => ['Content-Type' => self::LD],
|
||||||
|
'json' => [
|
||||||
|
'companyName' => 'Relation Ok SARL',
|
||||||
|
'categories' => ['/api/categories/'.$cat->getId()],
|
||||||
|
'relationType' => 'distributeur',
|
||||||
|
'distributor' => '/api/clients/'.$distributor->getId(),
|
||||||
|
],
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
self::assertArrayNotHasKey('relationType', $data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,12 +59,18 @@ final class ClientSerializationContractTest extends AbstractCommercialApiTestCas
|
|||||||
self::assertArrayHasKey('isProspect', $address);
|
self::assertArrayHasKey('isProspect', $address);
|
||||||
self::assertArrayHasKey('isDelivery', $address);
|
self::assertArrayHasKey('isDelivery', $address);
|
||||||
self::assertArrayHasKey('isBilling', $address);
|
self::assertArrayHasKey('isBilling', $address);
|
||||||
|
// Memes garanties pour les types Courtier / Distributeur (ERP-119, meme
|
||||||
|
// pattern getter + SerializedName).
|
||||||
|
self::assertArrayHasKey('isBroker', $address);
|
||||||
|
self::assertArrayHasKey('isDistributor', $address);
|
||||||
|
|
||||||
// L'adresse seedee est livraison + facturation (prospect exclusif, RG-1.06).
|
// L'adresse seedee est livraison + facturation (prospect exclusif, RG-1.06).
|
||||||
// Prouve qu'un booleen `true` est bien serialise (le bug masquait meme les true).
|
// Prouve qu'un booleen `true` est bien serialise (le bug masquait meme les true).
|
||||||
self::assertFalse($address['isProspect']);
|
self::assertFalse($address['isProspect']);
|
||||||
self::assertTrue($address['isDelivery']);
|
self::assertTrue($address['isDelivery']);
|
||||||
self::assertTrue($address['isBilling']);
|
self::assertTrue($address['isBilling']);
|
||||||
|
self::assertFalse($address['isBroker']);
|
||||||
|
self::assertFalse($address['isDistributor']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === #80 — Gating des RIB par accounting.view ===
|
// === #80 — Gating des RIB par accounting.view ===
|
||||||
|
|||||||
@@ -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 ===
|
||||||
|
|
||||||
@@ -86,10 +89,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
self::assertResponseStatusCodeSame(422);
|
||||||
$byPath = [];
|
$byPath = $this->violationsByPath($response->toArray(false));
|
||||||
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
|
|
||||||
$byPath[$v['propertyPath']] = $v['message'];
|
|
||||||
}
|
|
||||||
|
|
||||||
self::assertArrayHasKey('email', $byPath, 'La violation email doit porter propertyPath=email (mapping front).');
|
self::assertArrayHasKey('email', $byPath, 'La violation email doit porter propertyPath=email (mapping front).');
|
||||||
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
|
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
|
||||||
@@ -132,10 +132,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
self::assertResponseStatusCodeSame(422);
|
||||||
$byPath = [];
|
$byPath = $this->violationsByPath($response->toArray(false));
|
||||||
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
|
|
||||||
$byPath[$v['propertyPath']] = $v['message'];
|
|
||||||
}
|
|
||||||
self::assertArrayHasKey('email', $byPath);
|
self::assertArrayHasKey('email', $byPath);
|
||||||
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
|
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
|
||||||
}
|
}
|
||||||
@@ -234,6 +231,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
|||||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
|
'isDelivery' => true,
|
||||||
'postalCode' => '86100',
|
'postalCode' => '86100',
|
||||||
'city' => 'Châtellerault',
|
'city' => 'Châtellerault',
|
||||||
'street' => '1 rue du Test',
|
'street' => '1 rue du Test',
|
||||||
@@ -255,6 +253,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
|||||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
|
'isDelivery' => true,
|
||||||
'postalCode' => '123',
|
'postalCode' => '123',
|
||||||
'city' => 'Châtellerault',
|
'city' => 'Châtellerault',
|
||||||
'street' => '1 rue du Test',
|
'street' => '1 rue du Test',
|
||||||
@@ -284,6 +283,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
|||||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
|
'isDelivery' => true,
|
||||||
'postalCode' => '75001',
|
'postalCode' => '75001',
|
||||||
'city' => 'Paris',
|
'city' => 'Paris',
|
||||||
'street' => '2 rue Neuve',
|
'street' => '2 rue Neuve',
|
||||||
@@ -310,6 +310,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
|||||||
$client->request('POST', '/api/clients/999999/addresses', [
|
$client->request('POST', '/api/clients/999999/addresses', [
|
||||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
|
'isDelivery' => true,
|
||||||
'postalCode' => '75001',
|
'postalCode' => '75001',
|
||||||
'city' => 'Paris',
|
'city' => 'Paris',
|
||||||
'street' => '2 rue Neuve',
|
'street' => '2 rue Neuve',
|
||||||
@@ -359,6 +360,32 @@ 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 = $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']);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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