Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b36520d3b1 | |||
| a340d8139a | |||
| 7d8a633eee | |||
| df9451a5f4 |
@@ -79,6 +79,7 @@ Regles :
|
|||||||
- **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin).
|
- **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin).
|
||||||
- **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07).
|
- **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07).
|
||||||
- **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref<Record<string, string>[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record<string, string>`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`.
|
- **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref<Record<string, string>[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record<string, string>`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`.
|
||||||
|
- **Message back technique → surcharge i18n par code** : la plupart des contraintes back portent un message FR explicite (affiche tel quel). Mais une 422 peut porter un message TECHNIQUE non montrable (ex. erreur de type API Platform sur une date non parsable : « Cette valeur doit être de type DateTimeImmutable|null. », voire en anglais selon la negociation). On le surcharge **cote front** via le `code` de violation (UUID Symfony fige, robuste — pas un match sur le texte) : table `VIOLATION_MESSAGE_I18N` + `resolveViolationMessage` dans `shared/utils/api.ts`, appliquee par `useFormErrors`. Ajouter un cas = une entree `code -> cle i18n`. Cas reference : date invalide (MalioDate forwarde la saisie brute via `@update:rawValue`, le back renvoie 422 sur `foundedAt` grace a `collectDenormalizationErrors`, le front affiche `errors.validation.invalidDate`).
|
||||||
|
|
||||||
**Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`.
|
**Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.108'
|
app.version: '0.1.110'
|
||||||
|
|||||||
@@ -386,7 +386,10 @@
|
|||||||
},
|
},
|
||||||
"title": "Erreur",
|
"title": "Erreur",
|
||||||
"generic": "Une erreur est survenue.",
|
"generic": "Une erreur est survenue.",
|
||||||
"unknown": "Erreur inconnue."
|
"unknown": "Erreur inconnue.",
|
||||||
|
"validation": {
|
||||||
|
"invalidDate": "Date invalide"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"sites": {
|
"sites": {
|
||||||
"selector": {
|
"selector": {
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ import {
|
|||||||
addressTypeFromFlags,
|
addressTypeFromFlags,
|
||||||
isBillingEmailRequired,
|
isBillingEmailRequired,
|
||||||
type AddressType,
|
type AddressType,
|
||||||
} from '~/modules/commercial/utils/clientFormRules'
|
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||||
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||||
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
|
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
|||||||
@@ -26,13 +26,18 @@
|
|||||||
:error="errors?.firstName"
|
:error="errors?.firstName"
|
||||||
@update:model-value="(v: string) => update('firstName', v)"
|
@update:model-value="(v: string) => update('firstName', v)"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||||
:model-value="model.jobTitle"
|
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||||
:label="t('commercial.clients.form.contact.jobTitle')"
|
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||||
:readonly="readonly"
|
<div class="col-span-2">
|
||||||
:error="errors?.jobTitle"
|
<MalioInputText
|
||||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
:model-value="model.jobTitle"
|
||||||
/>
|
:label="t('commercial.clients.form.contact.jobTitle')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.jobTitle"
|
||||||
|
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<MalioInputEmail
|
<MalioInputEmail
|
||||||
:model-value="model.email"
|
:model-value="model.email"
|
||||||
:label="t('commercial.clients.form.contact.email')"
|
:label="t('commercial.clients.form.contact.email')"
|
||||||
|
|||||||
@@ -25,13 +25,18 @@
|
|||||||
:error="errors?.firstName"
|
:error="errors?.firstName"
|
||||||
@update:model-value="(v: string) => update('firstName', v)"
|
@update:model-value="(v: string) => update('firstName', v)"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||||
:model-value="model.jobTitle"
|
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||||
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||||
:readonly="readonly"
|
<div class="col-span-2">
|
||||||
:error="errors?.jobTitle"
|
<MalioInputText
|
||||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
:model-value="model.jobTitle"
|
||||||
/>
|
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.jobTitle"
|
||||||
|
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<MalioInputEmail
|
<MalioInputEmail
|
||||||
:model-value="model.email"
|
:model-value="model.email"
|
||||||
:label="t('commercial.suppliers.form.contact.email')"
|
:label="t('commercial.suppliers.form.contact.email')"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation'
|
import type { ClientDetail } from '~/modules/commercial/utils/forms/clientConsultation'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
|
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
|
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
|
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
@@ -401,7 +402,7 @@ import {
|
|||||||
mapAddressToDraft,
|
mapAddressToDraft,
|
||||||
mapRibToDraft,
|
mapRibToDraft,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
} from '~/modules/commercial/utils/clientConsultation'
|
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||||
import {
|
import {
|
||||||
buildAccountingPayload,
|
buildAccountingPayload,
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
@@ -417,7 +418,7 @@ import {
|
|||||||
type ClientEditAbilities,
|
type ClientEditAbilities,
|
||||||
type InformationFormDraft,
|
type InformationFormDraft,
|
||||||
type MainFormDraft,
|
type MainFormDraft,
|
||||||
} from '~/modules/commercial/utils/clientEdit'
|
} from '~/modules/commercial/utils/forms/clientEdit'
|
||||||
import {
|
import {
|
||||||
buildClientFormTabKeys,
|
buildClientFormTabKeys,
|
||||||
isAddressValid,
|
isAddressValid,
|
||||||
@@ -429,7 +430,7 @@ import {
|
|||||||
isRibComplete,
|
isRibComplete,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
showsRelationAndTriageFields,
|
showsRelationAndTriageFields,
|
||||||
} from '~/modules/commercial/utils/clientFormRules'
|
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||||
import {
|
import {
|
||||||
emptyAddress,
|
emptyAddress,
|
||||||
emptyContact,
|
emptyContact,
|
||||||
|
|||||||
@@ -280,7 +280,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useClient } from '~/modules/commercial/composables/useClient'
|
import { useClient } from '~/modules/commercial/composables/useClient'
|
||||||
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/clientFormRules'
|
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
import {
|
import {
|
||||||
canEditClient,
|
canEditClient,
|
||||||
@@ -297,7 +297,7 @@ import {
|
|||||||
showRestoreAction,
|
showRestoreAction,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
type SelectOption,
|
type SelectOption,
|
||||||
} from '~/modules/commercial/utils/clientConsultation'
|
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||||
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
|
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||||
|
|||||||
@@ -111,6 +111,7 @@
|
|||||||
:readonly="isValidated('information')"
|
:readonly="isValidated('information')"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
|
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
@@ -401,12 +402,12 @@ import {
|
|||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
lastFillableTabKey,
|
lastFillableTabKey,
|
||||||
showsRelationAndTriageFields,
|
showsRelationAndTriageFields,
|
||||||
} from '~/modules/commercial/utils/clientFormRules'
|
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||||
import {
|
import {
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
buildRibPayload,
|
buildRibPayload,
|
||||||
} from '~/modules/commercial/utils/clientEdit'
|
} from '~/modules/commercial/utils/forms/clientEdit'
|
||||||
import {
|
import {
|
||||||
emptyAddress,
|
emptyAddress,
|
||||||
emptyContact,
|
emptyContact,
|
||||||
@@ -651,6 +652,8 @@ const information = reactive({
|
|||||||
description: null as string | null,
|
description: null as string | null,
|
||||||
competitors: null as string | null,
|
competitors: null as string | null,
|
||||||
foundedAt: null as string | null,
|
foundedAt: null as string | null,
|
||||||
|
// Saisie brute invalide remontee par MalioDate (cf. foundedAtRaw, MUI-44).
|
||||||
|
foundedAtRaw: '',
|
||||||
employeesCount: null as string | null,
|
employeesCount: null as string | null,
|
||||||
revenueAmount: null as string | null,
|
revenueAmount: null as string | null,
|
||||||
profitAmount: null as string | null,
|
profitAmount: null as string | null,
|
||||||
@@ -666,7 +669,8 @@ async function submitInformation(): Promise<void> {
|
|||||||
await api.patch(`/clients/${clientId.value}`, {
|
await api.patch(`/clients/${clientId.value}`, {
|
||||||
description: information.description || null,
|
description: information.description || null,
|
||||||
competitors: information.competitors || null,
|
competitors: information.competitors || null,
|
||||||
foundedAt: information.foundedAt || null,
|
// Saisie invalide prioritaire -> 422 back sur foundedAt (cf. foundedAtRaw).
|
||||||
|
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
||||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||||
revenueAmount: information.revenueAmount || null,
|
revenueAmount: information.revenueAmount || null,
|
||||||
profitAmount: information.profitAmount || null,
|
profitAmount: information.profitAmount || null,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
|
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
@@ -370,7 +371,7 @@ import {
|
|||||||
mapAddressToDraft,
|
mapAddressToDraft,
|
||||||
mapRibToDraft,
|
mapRibToDraft,
|
||||||
type SupplierDetail,
|
type SupplierDetail,
|
||||||
} from '~/modules/commercial/utils/supplierConsultation'
|
} from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||||
import {
|
import {
|
||||||
buildAccountingPayload,
|
buildAccountingPayload,
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
@@ -386,7 +387,7 @@ import {
|
|||||||
type InformationFormDraft,
|
type InformationFormDraft,
|
||||||
type MainFormDraft,
|
type MainFormDraft,
|
||||||
type SupplierEditAbilities,
|
type SupplierEditAbilities,
|
||||||
} from '~/modules/commercial/utils/supplierEdit'
|
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||||
import {
|
import {
|
||||||
buildSupplierFormTabKeys,
|
buildSupplierFormTabKeys,
|
||||||
isAddressValid,
|
isAddressValid,
|
||||||
@@ -396,7 +397,7 @@ import {
|
|||||||
isRibBlank,
|
isRibBlank,
|
||||||
isRibComplete,
|
isRibComplete,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
} from '~/modules/commercial/utils/supplierFormRules'
|
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||||
import {
|
import {
|
||||||
emptyAddress,
|
emptyAddress,
|
||||||
emptyContact,
|
emptyContact,
|
||||||
|
|||||||
@@ -263,7 +263,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
|
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
|
||||||
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/supplierFormRules'
|
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
import {
|
import {
|
||||||
canEditSupplier,
|
canEditSupplier,
|
||||||
@@ -280,7 +280,7 @@ import {
|
|||||||
showRestoreAction,
|
showRestoreAction,
|
||||||
type SelectOption,
|
type SelectOption,
|
||||||
type SupplierDetail,
|
type SupplierDetail,
|
||||||
} from '~/modules/commercial/utils/supplierConsultation'
|
} from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||||
import { emptyContact } from '~/modules/commercial/types/supplierForm'
|
import { emptyContact } from '~/modules/commercial/types/supplierForm'
|
||||||
|
|
||||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
:readonly="isValidated('information')"
|
:readonly="isValidated('information')"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
|
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
@@ -361,7 +362,7 @@ import {
|
|||||||
isRibComplete,
|
isRibComplete,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
lastFillableTabKey,
|
lastFillableTabKey,
|
||||||
} from '~/modules/commercial/utils/supplierFormRules'
|
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||||
import {
|
import {
|
||||||
buildAccountingPayload,
|
buildAccountingPayload,
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
@@ -369,7 +370,7 @@ import {
|
|||||||
buildInformationPayload,
|
buildInformationPayload,
|
||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
buildRibPayload,
|
buildRibPayload,
|
||||||
} from '~/modules/commercial/utils/supplierEdit'
|
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||||
import {
|
import {
|
||||||
emptyAddress,
|
emptyAddress,
|
||||||
emptyContact,
|
emptyContact,
|
||||||
@@ -549,6 +550,8 @@ const information = reactive({
|
|||||||
description: null as string | null,
|
description: null as string | null,
|
||||||
competitors: null as string | null,
|
competitors: null as string | null,
|
||||||
foundedAt: null as string | null,
|
foundedAt: null as string | null,
|
||||||
|
// Saisie brute invalide remontee par MalioDate (cf. foundedAtRaw, MUI-44).
|
||||||
|
foundedAtRaw: '',
|
||||||
employeesCount: null as string | null,
|
employeesCount: null as string | null,
|
||||||
revenueAmount: null as string | null,
|
revenueAmount: null as string | null,
|
||||||
profitAmount: null as string | null,
|
profitAmount: null as string | null,
|
||||||
|
|||||||
+11
@@ -36,6 +36,7 @@ function informationDraft(overrides: Partial<InformationFormDraft> = {}): Inform
|
|||||||
description: 'desc',
|
description: 'desc',
|
||||||
competitors: 'concurrents',
|
competitors: 'concurrents',
|
||||||
foundedAt: '2010-05-01',
|
foundedAt: '2010-05-01',
|
||||||
|
foundedAtRaw: '',
|
||||||
employeesCount: '42',
|
employeesCount: '42',
|
||||||
revenueAmount: '1000000',
|
revenueAmount: '1000000',
|
||||||
profitAmount: '50000',
|
profitAmount: '50000',
|
||||||
@@ -140,6 +141,16 @@ describe('buildInformationPayload — scoping strict groupe client:write:informa
|
|||||||
expect(payload.description).toBeNull()
|
expect(payload.description).toBeNull()
|
||||||
expect(payload.directorName).toBeNull()
|
expect(payload.directorName).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
|
||||||
|
// Saisie malformee : on transmet le texte brut tel quel pour declencher la
|
||||||
|
// 422 back sur foundedAt (validation autoritaire du format, MUI-44).
|
||||||
|
expect(buildInformationPayload(informationDraft({ foundedAt: null, foundedAtRaw: '32/13/2026' })).foundedAt)
|
||||||
|
.toBe('32/13/2026')
|
||||||
|
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
|
||||||
|
expect(buildInformationPayload(informationDraft({ foundedAt: '2010-05-01', foundedAtRaw: '' })).foundedAt)
|
||||||
|
.toBe('2010-05-01')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
|
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
|
||||||
+11
-2
@@ -11,7 +11,7 @@ import {
|
|||||||
mapMainDraft,
|
mapMainDraft,
|
||||||
resolveTabEditability,
|
resolveTabEditability,
|
||||||
} from '../supplierEdit'
|
} from '../supplierEdit'
|
||||||
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||||
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm'
|
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm'
|
||||||
|
|
||||||
describe('buildMainPayload (groupe supplier:write:main)', () => {
|
describe('buildMainPayload (groupe supplier:write:main)', () => {
|
||||||
@@ -37,7 +37,7 @@ describe('buildMainPayload (groupe supplier:write:main)', () => {
|
|||||||
|
|
||||||
describe('buildInformationPayload (groupe supplier:write:information)', () => {
|
describe('buildInformationPayload (groupe supplier:write:information)', () => {
|
||||||
const base = {
|
const base = {
|
||||||
description: null, competitors: null, foundedAt: null, employeesCount: null,
|
description: null, competitors: null, foundedAt: null, foundedAtRaw: '', employeesCount: null,
|
||||||
revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null,
|
revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +48,15 @@ describe('buildInformationPayload (groupe supplier:write:information)', () => {
|
|||||||
})
|
})
|
||||||
expect(buildInformationPayload(base)).toMatchObject({ employeesCount: null, volumeForecast: null })
|
expect(buildInformationPayload(base)).toMatchObject({ employeesCount: null, volumeForecast: null })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
|
||||||
|
// Saisie malformee transmise telle quelle pour declencher la 422 back (MUI-44).
|
||||||
|
expect(buildInformationPayload({ ...base, foundedAt: null, foundedAtRaw: '32/13/2026' }).foundedAt)
|
||||||
|
.toBe('32/13/2026')
|
||||||
|
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
|
||||||
|
expect(buildInformationPayload({ ...base, foundedAt: '2008-04-01', foundedAtRaw: '' }).foundedAt)
|
||||||
|
.toBe('2008-04-01')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('buildContactPayload (sous-ressource supplier_contact)', () => {
|
describe('buildContactPayload (sous-ressource supplier_contact)', () => {
|
||||||
+14
-3
@@ -20,14 +20,14 @@ import {
|
|||||||
iriOf,
|
iriOf,
|
||||||
relationOf,
|
relationOf,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
} from '~/modules/commercial/utils/clientConsultation'
|
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||||
import {
|
import {
|
||||||
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
|
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
blankEmptyRequired,
|
blankEmptyRequired,
|
||||||
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
omitEmptyRequired,
|
omitEmptyRequired,
|
||||||
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
} from '~/modules/commercial/utils/clientFormRules'
|
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||||
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,6 +53,13 @@ export interface InformationFormDraft {
|
|||||||
competitors: string | null
|
competitors: string | null
|
||||||
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
||||||
foundedAt: string | null
|
foundedAt: string | null
|
||||||
|
/**
|
||||||
|
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
|
||||||
|
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
|
||||||
|
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
|
||||||
|
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
|
||||||
|
*/
|
||||||
|
foundedAtRaw: string
|
||||||
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
||||||
employeesCount: string | null
|
employeesCount: string | null
|
||||||
revenueAmount: string | null
|
revenueAmount: string | null
|
||||||
@@ -118,6 +125,8 @@ export function mapInformationDraft(client: ClientDetail): InformationFormDraft
|
|||||||
competitors: client.competitors ?? null,
|
competitors: client.competitors ?? null,
|
||||||
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
||||||
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
|
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
|
||||||
|
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
|
||||||
|
foundedAtRaw: '',
|
||||||
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
|
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
|
||||||
revenueAmount: client.revenueAmount ?? null,
|
revenueAmount: client.revenueAmount ?? null,
|
||||||
profitAmount: client.profitAmount ?? null,
|
profitAmount: client.profitAmount ?? null,
|
||||||
@@ -191,7 +200,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
|
|||||||
return {
|
return {
|
||||||
description: information.description || null,
|
description: information.description || null,
|
||||||
competitors: information.competitors || null,
|
competitors: information.competitors || null,
|
||||||
foundedAt: information.foundedAt || null,
|
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
|
||||||
|
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
|
||||||
|
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
||||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||||
revenueAmount: information.revenueAmount || null,
|
revenueAmount: information.revenueAmount || null,
|
||||||
profitAmount: information.profitAmount || null,
|
profitAmount: information.profitAmount || null,
|
||||||
+14
-3
@@ -17,8 +17,8 @@ import {
|
|||||||
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
omitEmptyRequired,
|
omitEmptyRequired,
|
||||||
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
} from '~/modules/commercial/utils/supplierFormRules'
|
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||||
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||||
import type {
|
import type {
|
||||||
SupplierAddressFormDraft,
|
SupplierAddressFormDraft,
|
||||||
SupplierContactFormDraft,
|
SupplierContactFormDraft,
|
||||||
@@ -38,6 +38,13 @@ export interface InformationFormDraft {
|
|||||||
competitors: string | null
|
competitors: string | null
|
||||||
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
||||||
foundedAt: string | null
|
foundedAt: string | null
|
||||||
|
/**
|
||||||
|
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
|
||||||
|
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
|
||||||
|
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
|
||||||
|
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
|
||||||
|
*/
|
||||||
|
foundedAtRaw: string
|
||||||
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
||||||
employeesCount: string | null
|
employeesCount: string | null
|
||||||
revenueAmount: string | null
|
revenueAmount: string | null
|
||||||
@@ -95,6 +102,8 @@ export function mapInformationDraft(supplier: SupplierDetail): InformationFormDr
|
|||||||
competitors: supplier.competitors ?? null,
|
competitors: supplier.competitors ?? null,
|
||||||
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
||||||
foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null,
|
foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null,
|
||||||
|
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
|
||||||
|
foundedAtRaw: '',
|
||||||
employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null,
|
employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null,
|
||||||
revenueAmount: supplier.revenueAmount ?? null,
|
revenueAmount: supplier.revenueAmount ?? null,
|
||||||
profitAmount: supplier.profitAmount ?? null,
|
profitAmount: supplier.profitAmount ?? null,
|
||||||
@@ -177,7 +186,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
|
|||||||
return {
|
return {
|
||||||
description: information.description || null,
|
description: information.description || null,
|
||||||
competitors: information.competitors || null,
|
competitors: information.competitors || null,
|
||||||
foundedAt: information.foundedAt || null,
|
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
|
||||||
|
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
|
||||||
|
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
||||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||||
revenueAmount: information.revenueAmount || null,
|
revenueAmount: information.revenueAmount || null,
|
||||||
profitAmount: information.profitAmount || null,
|
profitAmount: information.profitAmount || null,
|
||||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
|||||||
"name": "starseed-frontend",
|
"name": "starseed-frontend",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.7.8",
|
"@malio/layer-ui": "^1.7.10",
|
||||||
"@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.8",
|
"version": "1.7.10",
|
||||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz",
|
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.10/layer-ui-1.7.10.tgz",
|
||||||
"integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==",
|
"integrity": "sha512-ZWYaKvl+VpGAqeTE+4xdyKOmuRd4zwjlUYVppeIBZwGeNAK16kZnrztR+4eQmnzUqPZVybBhEBdKP9weqWHSUg==",
|
||||||
"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.8",
|
"@malio/layer-ui": "^1.7.10",
|
||||||
"@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",
|
||||||
|
|||||||
@@ -41,6 +41,22 @@ describe('useFormErrors', () => {
|
|||||||
expect(hasErrors.value).toBe(true)
|
expect(hasErrors.value).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('setServerErrors surcharge un message technique (erreur de type) par la cle i18n', () => {
|
||||||
|
const { errors, setServerErrors } = useFormErrors()
|
||||||
|
const mapped = setServerErrors({
|
||||||
|
violations: [
|
||||||
|
// Code Symfony Type::INVALID_TYPE_ERROR (date non parsable) : surcharge.
|
||||||
|
{ propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: 'ba785a8c-82cb-4283-967c-3cf342181b40' },
|
||||||
|
// Violation metier classique : message back conserve.
|
||||||
|
{ propertyPath: 'companyName', message: 'Obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(mapped).toBe(true)
|
||||||
|
// Stub i18n -> renvoie la cle telle quelle.
|
||||||
|
expect(errors.foundedAt).toBe('errors.validation.invalidDate')
|
||||||
|
expect(errors.companyName).toBe('Obligatoire.')
|
||||||
|
})
|
||||||
|
|
||||||
it('setServerErrors retourne false et ne touche rien sans violation', () => {
|
it('setServerErrors retourne false et ne touche rien sans violation', () => {
|
||||||
const { errors, setServerErrors } = useFormErrors()
|
const { errors, setServerErrors } = useFormErrors()
|
||||||
expect(setServerErrors({})).toBe(false)
|
expect(setServerErrors({})).toBe(false)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
* appel par ligne), utiliser directement `mapViolationsToRecord` par ligne.
|
* appel par ligne), utiliser directement `mapViolationsToRecord` par ligne.
|
||||||
*/
|
*/
|
||||||
import { computed, reactive } from 'vue'
|
import { computed, reactive } from 'vue'
|
||||||
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
import { extractApiErrorMessage, extractApiViolations, resolveViolationMessage } from '~/shared/utils/api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status
|
* Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status
|
||||||
@@ -69,13 +69,16 @@ export function useFormErrors() {
|
|||||||
* violation exploitable).
|
* violation exploitable).
|
||||||
*/
|
*/
|
||||||
function setServerErrors(data: unknown): boolean {
|
function setServerErrors(data: unknown): boolean {
|
||||||
const mapped = mapViolationsToRecord(data)
|
const violations = extractApiViolations(data)
|
||||||
const keys = Object.keys(mapped)
|
let mapped = false
|
||||||
if (keys.length === 0) return false
|
for (const v of violations) {
|
||||||
for (const key of keys) {
|
if (!v.propertyPath) continue
|
||||||
errors[key] = mapped[key]
|
// Message back tel quel, sauf code surcharge par une cle i18n (ex.
|
||||||
|
// erreur de type sur une date non parsable -> « Date invalide »).
|
||||||
|
errors[v.propertyPath] = resolveViolationMessage(v, t)
|
||||||
|
mapped = true
|
||||||
}
|
}
|
||||||
return true
|
return mapped
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { mapViolationsToRecord } from '../api'
|
import { mapViolationsToRecord, resolveViolationMessage } from '../api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests de `mapViolationsToRecord` — fondation du mapping erreur→champ des
|
* Tests de `mapViolationsToRecord` — fondation du mapping erreur→champ des
|
||||||
@@ -56,3 +56,30 @@ describe('mapViolationsToRecord', () => {
|
|||||||
expect(mapViolationsToRecord(data)).toEqual({ name: 'Second message.' })
|
expect(mapViolationsToRecord(data)).toEqual({ name: 'Second message.' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests de `resolveViolationMessage` — surcharge i18n d'un message back par code
|
||||||
|
* de violation. Le back peut renvoyer un message technique (erreur de type sur
|
||||||
|
* une date non parsable) : on le remplace via le `code` Symfony (stable) par une
|
||||||
|
* cle i18n, sans toucher au back. Le `t` ici renvoie la cle telle quelle.
|
||||||
|
*/
|
||||||
|
describe('resolveViolationMessage', () => {
|
||||||
|
const t = (key: string) => key
|
||||||
|
// Code Symfony Constraints\Type::INVALID_TYPE_ERROR (fige).
|
||||||
|
const TYPE_ERROR = 'ba785a8c-82cb-4283-967c-3cf342181b40'
|
||||||
|
|
||||||
|
it('surcharge le message technique d\'une erreur de type par la cle i18n', () => {
|
||||||
|
const v = { propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: TYPE_ERROR }
|
||||||
|
expect(resolveViolationMessage(v, t)).toBe('errors.validation.invalidDate')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renvoie le message back tel quel quand le code n\'est pas surcharge', () => {
|
||||||
|
const v = { propertyPath: 'companyName', message: 'Le nom est obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' }
|
||||||
|
expect(resolveViolationMessage(v, t)).toBe('Le nom est obligatoire.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renvoie le message back tel quel quand il n\'y a pas de code', () => {
|
||||||
|
const v = { propertyPath: 'siren', message: 'SIREN deja utilise.', code: '' }
|
||||||
|
expect(resolveViolationMessage(v, t)).toBe('SIREN deja utilise.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -34,11 +34,15 @@ export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
|
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
|
||||||
* pointe le champ concerne, `message` est le libelle a afficher.
|
* pointe le champ concerne, `message` est le libelle a afficher, `code` est le
|
||||||
|
* code de contrainte Symfony (UUID stable, independant de la langue) — il sert
|
||||||
|
* a surcharger un message back technique par une cle i18n (cf.
|
||||||
|
* `VIOLATION_MESSAGE_I18N` / `resolveViolationMessage`).
|
||||||
*/
|
*/
|
||||||
export interface ApiViolation {
|
export interface ApiViolation {
|
||||||
propertyPath: string
|
propertyPath: string
|
||||||
message: string
|
message: string
|
||||||
|
code: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,6 +65,7 @@ export function extractApiViolations(data: unknown): ApiViolation[] {
|
|||||||
out.push({
|
out.push({
|
||||||
propertyPath: String(obj.propertyPath ?? ''),
|
propertyPath: String(obj.propertyPath ?? ''),
|
||||||
message: String(obj.message ?? ''),
|
message: String(obj.message ?? ''),
|
||||||
|
code: String(obj.code ?? ''),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -85,6 +90,45 @@ export function mapViolationsToRecord(data: unknown): Record<string, string> {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surcharge i18n d'un message back par CODE de violation.
|
||||||
|
*
|
||||||
|
* La plupart des contraintes back portent deja un message FR explicite (ex.
|
||||||
|
* `#[Assert\NotBlank(message: '...')]`) : on l'affiche tel quel. Mais certaines
|
||||||
|
* 422 portent un message TECHNIQUE non montrable a l'utilisateur — typiquement
|
||||||
|
* l'erreur de TYPE renvoyee par API Platform quand le back ne peut pas
|
||||||
|
* denormaliser la valeur (date non parsable envoyee sur un champ
|
||||||
|
* `DateTimeImmutable` : « Cette valeur doit être de type DateTimeImmutable|null. »,
|
||||||
|
* voire en anglais selon la negociation de langue).
|
||||||
|
*
|
||||||
|
* Plutot que de traduire/maquiller cote back, on surcharge ces messages cote
|
||||||
|
* front via leur `code` de violation. Ce code est un UUID Symfony FIGE (contrat
|
||||||
|
* de compatibilite : il ne change pas entre versions), donc bien plus robuste
|
||||||
|
* qu'un match sur le texte du message (qui depend de la langue). La table
|
||||||
|
* associe un code -> une cle i18n ; `resolveViolationMessage` l'applique.
|
||||||
|
*
|
||||||
|
* Limite a connaitre : le code de type-error est GENERIQUE (toute valeur de
|
||||||
|
* mauvais type). Dans nos formulaires, seul un champ date saisi en texte libre
|
||||||
|
* (MalioDate, qui forwarde la saisie brute invalide) le declenche, d'ou le
|
||||||
|
* libelle « Date invalide ». Si un autre champ typé en saisie libre apparait,
|
||||||
|
* affiner la resolution via `propertyPath` plutot que par code seul.
|
||||||
|
*/
|
||||||
|
export const VIOLATION_MESSAGE_I18N: Record<string, string> = {
|
||||||
|
// Symfony `Constraints\Type::INVALID_TYPE_ERROR` — valeur de mauvais type.
|
||||||
|
'ba785a8c-82cb-4283-967c-3cf342181b40': 'errors.validation.invalidDate',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resout le message a afficher pour une violation : si son `code` est surcharge
|
||||||
|
* par `VIOLATION_MESSAGE_I18N`, renvoie la traduction de la cle associee ;
|
||||||
|
* sinon, le message back tel quel (cas nominal). `t` est passe par l'appelant
|
||||||
|
* (les utils sont purs, sans acces a useI18n).
|
||||||
|
*/
|
||||||
|
export function resolveViolationMessage(v: ApiViolation, t: (key: string) => string): string {
|
||||||
|
const i18nKey = VIOLATION_MESSAGE_I18N[v.code]
|
||||||
|
return i18nKey ? t(i18nKey) : v.message
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
|
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
|
||||||
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
|
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ use DateTimeImmutable;
|
|||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Context;
|
||||||
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\Serializer\Normalizer\DateTimeNormalizer;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
@@ -94,6 +96,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
security: "is_granted('commercial.clients.manage')",
|
security: "is_granted('commercial.clients.manage')",
|
||||||
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
|
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
|
||||||
denormalizationContext: ['groups' => ['client:write:main']],
|
denormalizationContext: ['groups' => ['client:write:main']],
|
||||||
|
// Une valeur de mauvais type (ex. date non parsable sur foundedAt)
|
||||||
|
// doit produire un 422 porte sur le champ (violations[].propertyPath,
|
||||||
|
// mappable inline par useFormErrors) plutot qu'un 400 generique non
|
||||||
|
// exploitable. Le front (MalioDate, MUI-44) forwarde la saisie brute
|
||||||
|
// invalide : le back reste la couche autoritaire du format (ERP-101).
|
||||||
|
collectDenormalizationErrors: true,
|
||||||
processor: ClientProcessor::class,
|
processor: ClientProcessor::class,
|
||||||
),
|
),
|
||||||
new Patch(
|
new Patch(
|
||||||
@@ -117,6 +125,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
'client:write:accounting',
|
'client:write:accounting',
|
||||||
'client:write:archive',
|
'client:write:archive',
|
||||||
]],
|
]],
|
||||||
|
// Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ
|
||||||
|
// au lieu d'un 400 generique. Indispensable au mapping inline du
|
||||||
|
// front (MalioDate MUI-44 forwarde la saisie brute invalide).
|
||||||
|
collectDenormalizationErrors: true,
|
||||||
provider: ClientProvider::class,
|
provider: ClientProvider::class,
|
||||||
processor: ClientProcessor::class,
|
processor: ClientProcessor::class,
|
||||||
),
|
),
|
||||||
@@ -206,6 +218,13 @@ class Client implements TimestampableInterface, BlamableInterface
|
|||||||
|
|
||||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
#[Groups(['client:read', 'client:write:information'])]
|
#[Groups(['client:read', 'client:write:information'])]
|
||||||
|
// Format d'ENTREE strict ISO `Y-m-d` (le `!` remet l'heure a 00:00:00). Sans
|
||||||
|
// ce format, PHP DateTime accepte des formes ambigues : « 12/25/2026 » (que
|
||||||
|
// le front MalioDate juge invalide en JJ/MM/AAAA) serait sinon interprete en
|
||||||
|
// M/J/AAAA -> 25 decembre 2026, et accepte a tort. Avec le format, toute
|
||||||
|
// saisie brute non-ISO (forwardee par MalioDate sur date invalide) echoue la
|
||||||
|
// denormalisation -> 422 sur foundedAt (cf. collectDenormalizationErrors).
|
||||||
|
#[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
|
||||||
private ?DateTimeImmutable $foundedAt = null;
|
private ?DateTimeImmutable $foundedAt = null;
|
||||||
|
|
||||||
#[ORM\Column(nullable: true)]
|
#[ORM\Column(nullable: true)]
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ use DateTimeImmutable;
|
|||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Context;
|
||||||
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\Serializer\Normalizer\DateTimeNormalizer;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
@@ -94,6 +96,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
security: "is_granted('commercial.suppliers.manage')",
|
security: "is_granted('commercial.suppliers.manage')",
|
||||||
normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']],
|
normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']],
|
||||||
denormalizationContext: ['groups' => ['supplier:write:main']],
|
denormalizationContext: ['groups' => ['supplier:write:main']],
|
||||||
|
// Une valeur de mauvais type (ex. date non parsable sur foundedAt)
|
||||||
|
// doit produire un 422 porte sur le champ (violations[].propertyPath,
|
||||||
|
// mappable inline par useFormErrors) plutot qu'un 400 generique. Le
|
||||||
|
// front (MalioDate, MUI-44) forwarde la saisie brute invalide : le
|
||||||
|
// back reste la couche autoritaire du format (ERP-101). Cf. Client.
|
||||||
|
collectDenormalizationErrors: true,
|
||||||
processor: SupplierProcessor::class,
|
processor: SupplierProcessor::class,
|
||||||
),
|
),
|
||||||
new Patch(
|
new Patch(
|
||||||
@@ -113,6 +121,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
'supplier:write:accounting',
|
'supplier:write:accounting',
|
||||||
'supplier:write:archive',
|
'supplier:write:archive',
|
||||||
]],
|
]],
|
||||||
|
// Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ
|
||||||
|
// au lieu d'un 400 generique. Indispensable au mapping inline du
|
||||||
|
// front (MalioDate MUI-44 forwarde la saisie brute invalide).
|
||||||
|
collectDenormalizationErrors: true,
|
||||||
provider: SupplierProvider::class,
|
provider: SupplierProvider::class,
|
||||||
processor: SupplierProcessor::class,
|
processor: SupplierProcessor::class,
|
||||||
),
|
),
|
||||||
@@ -187,6 +199,11 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
|||||||
|
|
||||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||||
|
// Format d'ENTREE strict ISO `Y-m-d` : sans lui, PHP DateTime accepte des
|
||||||
|
// formes ambigues (« 12/25/2026 », jugee invalide par MalioDate en JJ/MM/AAAA,
|
||||||
|
// serait lue en M/J -> 25 decembre et acceptee a tort). Avec le format, toute
|
||||||
|
// saisie brute non-ISO echoue -> 422 sur foundedAt. Cf. Client.
|
||||||
|
#[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
|
||||||
private ?DateTimeImmutable $foundedAt = null;
|
private ?DateTimeImmutable $foundedAt = null;
|
||||||
|
|
||||||
#[ORM\Column(nullable: true)]
|
#[ORM\Column(nullable: true)]
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Commercial\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation back-autoritative du FORMAT de la date de creation (foundedAt,
|
||||||
|
* onglet Information).
|
||||||
|
*
|
||||||
|
* Le front (MalioDate, cf. MUI-44) forwarde desormais la saisie brute invalide
|
||||||
|
* au serveur plutot que de l'avaler. Cote back, une date non parsable doit
|
||||||
|
* produire un 422 porte sur `foundedAt` (mappable inline par useFormErrors),
|
||||||
|
* et non un 400 generique. Repose sur `collectDenormalizationErrors` actif sur
|
||||||
|
* l'operation Patch du Client.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ClientFoundedAtFormatTest extends AbstractCommercialApiTestCase
|
||||||
|
{
|
||||||
|
private const string MERGE = 'application/merge-patch+json';
|
||||||
|
|
||||||
|
/** Date non parsable -> 422 porte sur foundedAt (et pas un 400 generique). */
|
||||||
|
public function testFoundedAtNonParsableEst422(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Founded Format SARL');
|
||||||
|
|
||||||
|
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||||
|
'headers' => ['Content-Type' => self::MERGE],
|
||||||
|
'json' => ['foundedAt' => '32/13/2026'],
|
||||||
|
])->toArray(false);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cas piege : « 12/25/2026 » est invalide cote front (JJ/MM/AAAA -> mois 25)
|
||||||
|
* mais PHP DateTime l'accepterait en M/J/AAAA (25 decembre). Le format d'entree
|
||||||
|
* strict ISO `Y-m-d` (Context sur foundedAt) doit le rejeter -> 422.
|
||||||
|
*/
|
||||||
|
public function testFoundedAtFormatAmbiguUsEst422(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Founded Ambigu SARL');
|
||||||
|
|
||||||
|
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||||
|
'headers' => ['Content-Type' => self::MERGE],
|
||||||
|
'json' => ['foundedAt' => '12/25/2026'],
|
||||||
|
])->toArray(false);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-regression : une date ISO valide reste acceptee (200). */
|
||||||
|
public function testFoundedAtIsoValideEst200(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedClient('Founded Ok SARL');
|
||||||
|
|
||||||
|
$data = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||||
|
'headers' => ['Content-Type' => self::MERGE],
|
||||||
|
'json' => ['foundedAt' => '2010-05-01'],
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
self::assertStringStartsWith('2010-05-01', $data['foundedAt']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Commercial\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation back-autoritative du FORMAT de la date de creation (foundedAt,
|
||||||
|
* onglet Information) du fournisseur. Miroir de {@see ClientFoundedAtFormatTest}.
|
||||||
|
*
|
||||||
|
* Une date non parsable (saisie brute forwardee par MalioDate, MUI-44) doit
|
||||||
|
* produire un 422 porte sur `foundedAt` (mappable inline par useFormErrors), et
|
||||||
|
* non un 400 generique. Repose sur `collectDenormalizationErrors` sur les
|
||||||
|
* operations write du Supplier.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class SupplierFoundedAtFormatTest extends AbstractSupplierApiTestCase
|
||||||
|
{
|
||||||
|
/** Date non parsable -> 422 porte sur foundedAt (et pas un 400 generique). */
|
||||||
|
public function testFoundedAtNonParsableEst422(): void
|
||||||
|
{
|
||||||
|
$seed = $this->seedSupplier('Founded Format Negoce');
|
||||||
|
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
|
||||||
|
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||||
|
|
||||||
|
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||||
|
'headers' => ['Content-Type' => self::MERGE],
|
||||||
|
'json' => ['foundedAt' => '32/13/2026'],
|
||||||
|
])->toArray(false);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cas piege : « 12/25/2026 » est invalide cote front (JJ/MM/AAAA -> mois 25)
|
||||||
|
* mais PHP DateTime l'accepterait en M/J/AAAA. Le format d'entree strict ISO
|
||||||
|
* `Y-m-d` (Context sur foundedAt) doit le rejeter -> 422.
|
||||||
|
*/
|
||||||
|
public function testFoundedAtFormatAmbiguUsEst422(): void
|
||||||
|
{
|
||||||
|
$seed = $this->seedSupplier('Founded Ambigu Negoce');
|
||||||
|
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
|
||||||
|
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||||
|
|
||||||
|
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||||
|
'headers' => ['Content-Type' => self::MERGE],
|
||||||
|
'json' => ['foundedAt' => '12/25/2026'],
|
||||||
|
])->toArray(false);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-regression : une date ISO valide reste acceptee (200). */
|
||||||
|
public function testFoundedAtIsoValideEst200(): void
|
||||||
|
{
|
||||||
|
$seed = $this->seedSupplier('Founded Ok Negoce');
|
||||||
|
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
|
||||||
|
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||||
|
|
||||||
|
$data = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||||
|
'headers' => ['Content-Type' => self::MERGE],
|
||||||
|
'json' => ['foundedAt' => '2010-05-01'],
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
self::assertStringStartsWith('2010-05-01', $data['foundedAt']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user