feat : bloque les caractères spéciaux dans les champs texte des 4 répertoires (ERP-193)

This commit is contained in:
2026-06-19 09:46:23 +02:00
parent 403dc4a870
commit 07f5a95a6b
32 changed files with 537 additions and 58 deletions
@@ -191,6 +191,7 @@ import {
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
import { sanitizeAddress, sanitizeEmail } from '~/shared/utils/textSanitize'
// Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####'
@@ -284,9 +285,23 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
// Filtres de saisie par champ (ERP-193) : voie / complement / ville = profil
// adresse, emails de facturation = profil email.
const FIELD_SANITIZERS: Partial<Record<keyof AddressFormDraft, (v: string) => string>> = {
street: sanitizeAddress,
streetComplement: sanitizeAddress,
city: sanitizeAddress,
billingEmail: sanitizeEmail,
billingEmailSecondary: sanitizeEmail,
}
/** Emet un nouveau brouillon avec le champ modifie (immutabilite), sanitise si besoin. */
function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
const sanitizer = FIELD_SANITIZERS[field]
const next = (sanitizer && typeof value === 'string')
? (sanitizer(value) as AddressFormDraft[K])
: value
emit('update:modelValue', { ...props.modelValue, [field]: next })
}
/** Revele le 2e champ email de facturation (clic sur le « + »). */
@@ -71,6 +71,7 @@
<script setup lang="ts">
import type { ContactFormDraft } from '~/modules/commercial/types/clientForm'
import { sanitizeEmail, sanitizeFreeText, sanitizePersonName } from '~/shared/utils/textSanitize'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste
// serveur, cf. formatPhoneFR re-applique a la valeur renvoyee).
@@ -99,9 +100,22 @@ const { t } = useI18n()
// Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue)
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
// Filtres de saisie par champ (ERP-193) : on retire les caracteres parasites a la
// frappe. Noms = profil personne, fonction = texte libre, email = profil email.
const FIELD_SANITIZERS: Partial<Record<keyof ContactFormDraft, (v: string) => string>> = {
lastName: sanitizePersonName,
firstName: sanitizePersonName,
jobTitle: sanitizeFreeText,
email: sanitizeEmail,
}
/** Emet un nouveau brouillon avec le champ modifie (immutabilite), sanitise si besoin. */
function update<K extends keyof ContactFormDraft>(field: K, value: ContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
const sanitizer = FIELD_SANITIZERS[field]
const next = (sanitizer && typeof value === 'string')
? (sanitizer(value) as ContactFormDraft[K])
: value
emit('update:modelValue', { ...props.modelValue, [field]: next })
}
/** Revele le 2e numero (RG-1.02/1.20 : max 1 secondaire, le « + » disparait). */
@@ -169,6 +169,7 @@
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import type { SupplierAddressFormDraft, SupplierAddressType } from '~/modules/commercial/types/supplierForm'
import { sanitizeAddress } from '~/shared/utils/textSanitize'
// Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####'
@@ -238,9 +239,21 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
// Filtres de saisie par champ (ERP-193) : voie / complement / ville = profil
// adresse. Les autres champs (CP, bennes, selects) ne sont pas filtres ici.
const FIELD_SANITIZERS: Partial<Record<keyof SupplierAddressFormDraft, (v: string) => string>> = {
street: sanitizeAddress,
streetComplement: sanitizeAddress,
city: sanitizeAddress,
}
/** Emet un nouveau brouillon avec le champ modifie (immutabilite), sanitise si besoin. */
function update<K extends keyof SupplierAddressFormDraft>(field: K, value: SupplierAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
const sanitizer = FIELD_SANITIZERS[field]
const next = (sanitizer && typeof value === 'string')
? (sanitizer(value) as SupplierAddressFormDraft[K])
: value
emit('update:modelValue', { ...props.modelValue, [field]: next })
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
@@ -70,6 +70,7 @@
<script setup lang="ts">
import type { SupplierContactFormDraft } from '~/modules/commercial/types/supplierForm'
import { sanitizeEmail, sanitizeFreeText, sanitizePersonName } from '~/shared/utils/textSanitize'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
@@ -97,9 +98,22 @@ const { t } = useI18n()
// Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue)
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
// Filtres de saisie par champ (ERP-193) : on retire les caracteres parasites a la
// frappe. Noms = profil personne, fonction = texte libre, email = profil email.
const FIELD_SANITIZERS: Partial<Record<keyof SupplierContactFormDraft, (v: string) => string>> = {
lastName: sanitizePersonName,
firstName: sanitizePersonName,
jobTitle: sanitizeFreeText,
email: sanitizeEmail,
}
/** Emet un nouveau brouillon avec le champ modifie (immutabilite), sanitise si besoin. */
function update<K extends keyof SupplierContactFormDraft>(field: K, value: SupplierContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
const sanitizer = FIELD_SANITIZERS[field]
const next = (sanitizer && typeof value === 'string')
? (sanitizer(value) as SupplierContactFormDraft[K])
: value
emit('update:modelValue', { ...props.modelValue, [field]: next })
}
/** Revele le 2e numero (max 1 secondaire, le « + » disparait). */
@@ -24,7 +24,8 @@
`manage` (ex. Compta). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:model-value="main.companyName"
@update:model-value="(v: string) => main.companyName = sanitizeFreeText(v)"
:label="t('commercial.clients.form.main.companyName')"
:required="true"
:readonly="businessReadonly"
@@ -105,7 +106,8 @@
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:model-value="information.competitors"
@update:model-value="(v: string) => information.competitors = sanitizeFreeText(v)"
:label="t('commercial.clients.form.information.competitors')"
:readonly="businessReadonly"
:error="informationErrors.errors.competitors"
@@ -139,7 +141,8 @@
@update:model-value="onRevenueAmountInput"
/>
<MalioInputText
v-model="information.directorName"
:model-value="information.directorName"
@update:model-value="(v: string) => information.directorName = sanitizePersonName(v)"
:label="t('commercial.clients.form.information.directorName')"
:readonly="businessReadonly"
:error="informationErrors.errors.directorName"
@@ -251,7 +254,8 @@
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:model-value="accounting.accountNumber"
@update:model-value="(v: string) => accounting.accountNumber = sanitizeCodeAlnum(v)"
:label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
@@ -268,7 +272,8 @@
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:model-value="accounting.nTva"
@update:model-value="(v: string) => accounting.nTva = sanitizeCodeAlnum(v)"
:label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
@@ -331,14 +336,16 @@
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:model-value="rib.bic"
@update:model-value="(v: string) => rib.bic = sanitizeCodeAlnum(v)"
:label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:model-value="rib.iban"
@update:model-value="(v: string) => rib.iban = sanitizeCodeAlnum(v)"
:label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="isRibRequired"
@@ -432,6 +439,7 @@ import {
} from '~/modules/commercial/utils/forms/clientEdit'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { sanitizeCodeAlnum, sanitizeFreeText, sanitizePersonName } from '~/shared/utils/textSanitize'
import {
buildClientFormTabKeys,
isAddressValid,
@@ -18,7 +18,8 @@
automatiquement sur l'onglet Information. -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:model-value="main.companyName"
@update:model-value="(v: string) => main.companyName = sanitizeFreeText(v)"
:label="t('commercial.clients.form.main.companyName')"
:required="true"
:readonly="mainLocked"
@@ -100,7 +101,8 @@
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:model-value="information.competitors"
@update:model-value="(v: string) => information.competitors = sanitizeFreeText(v)"
:label="t('commercial.clients.form.information.competitors')"
:readonly="isValidated('information')"
:error="informationErrors.errors.competitors"
@@ -134,7 +136,8 @@
@update:model-value="onRevenueAmountInput"
/>
<MalioInputText
v-model="information.directorName"
:model-value="information.directorName"
@update:model-value="(v: string) => information.directorName = sanitizePersonName(v)"
:label="t('commercial.clients.form.information.directorName')"
:readonly="isValidated('information')"
:error="informationErrors.errors.directorName"
@@ -249,7 +252,8 @@
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:model-value="accounting.accountNumber"
@update:model-value="(v: string) => accounting.accountNumber = sanitizeCodeAlnum(v)"
:label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
@@ -266,7 +270,8 @@
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:model-value="accounting.nTva"
@update:model-value="(v: string) => accounting.nTva = sanitizeCodeAlnum(v)"
:label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
@@ -330,14 +335,16 @@
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:model-value="rib.bic"
@update:model-value="(v: string) => rib.bic = sanitizeCodeAlnum(v)"
:label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:model-value="rib.iban"
@update:model-value="(v: string) => rib.iban = sanitizeCodeAlnum(v)"
:label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="isRibRequired"
@@ -416,6 +423,7 @@ import {
} from '~/modules/commercial/utils/forms/clientFormRules'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { sanitizeCodeAlnum, sanitizeFreeText, sanitizePersonName } from '~/shared/utils/textSanitize'
import {
buildAddressPayload,
buildMainPayload,
@@ -23,11 +23,12 @@
roles sans `manage` (ex. Compta). Pas de contact inline (ERP-106). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:model-value="main.companyName"
:label="t('commercial.suppliers.form.main.companyName')"
:required="true"
:readonly="businessReadonly"
:error="mainErrors.errors.companyName"
@update:model-value="(v: string) => main.companyName = sanitizeFreeText(v)"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
@@ -66,10 +67,11 @@
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:model-value="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')"
:readonly="businessReadonly"
:error="informationErrors.errors.competitors"
@update:model-value="(v: string) => information.competitors = sanitizeFreeText(v)"
/>
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
@@ -100,10 +102,11 @@
@update:model-value="onRevenueAmountInput"
/>
<MalioInputText
v-model="information.directorName"
:model-value="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')"
:readonly="businessReadonly"
:error="informationErrors.errors.directorName"
@update:model-value="(v: string) => information.directorName = sanitizePersonName(v)"
/>
<MalioInputAmount
v-model="information.profitAmount"
@@ -220,11 +223,12 @@
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:model-value="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
@update:model-value="(v: string) => accounting.accountNumber = sanitizeCodeAlnum(v)"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
@@ -237,11 +241,12 @@
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:model-value="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
@update:model-value="(v: string) => accounting.nTva = sanitizeCodeAlnum(v)"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
@@ -300,18 +305,20 @@
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:model-value="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
@update:model-value="(v: string) => rib.bic = sanitizeCodeAlnum(v)"
/>
<MalioInputText
v-model="rib.iban"
:model-value="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
@update:model-value="(v: string) => rib.iban = sanitizeCodeAlnum(v)"
/>
</div>
</div>
@@ -421,6 +428,7 @@ import {
} from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
import { sanitizeCodeAlnum, sanitizeFreeText, sanitizePersonName } from '~/shared/utils/textSanitize'
import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur).
@@ -18,11 +18,12 @@
automatiquement sur l'onglet Information. Pas de contact inline (ERP-106). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:model-value="main.companyName"
:label="t('commercial.suppliers.form.main.companyName')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.companyName"
@update:model-value="(v: string) => main.companyName = sanitizeFreeText(v)"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
@@ -60,10 +61,11 @@
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:model-value="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')"
:readonly="isValidated('information')"
:error="informationErrors.errors.competitors"
@update:model-value="(v: string) => information.competitors = sanitizeFreeText(v)"
/>
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
@@ -94,10 +96,11 @@
@update:model-value="onRevenueAmountInput"
/>
<MalioInputText
v-model="information.directorName"
:model-value="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')"
:readonly="isValidated('information')"
:error="informationErrors.errors.directorName"
@update:model-value="(v: string) => information.directorName = sanitizePersonName(v)"
/>
<MalioInputAmount
v-model="information.profitAmount"
@@ -214,11 +217,12 @@
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:model-value="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
@update:model-value="(v: string) => accounting.accountNumber = sanitizeCodeAlnum(v)"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
@@ -231,11 +235,12 @@
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:model-value="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
@update:model-value="(v: string) => accounting.nTva = sanitizeCodeAlnum(v)"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
@@ -294,18 +299,20 @@
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:model-value="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
@update:model-value="(v: string) => rib.bic = sanitizeCodeAlnum(v)"
/>
<MalioInputText
v-model="rib.iban"
:model-value="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
@update:model-value="(v: string) => rib.iban = sanitizeCodeAlnum(v)"
/>
</div>
</div>
@@ -394,6 +401,7 @@ import {
} from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
import { sanitizeCodeAlnum, sanitizeFreeText, sanitizePersonName } from '~/shared/utils/textSanitize'
// Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -131,6 +131,7 @@
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
import { sanitizeAddress } from '~/shared/utils/textSanitize'
// Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####'
@@ -193,9 +194,20 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
// Filtres de saisie par champ (ERP-193) : voie / complement / ville = profil adresse.
const FIELD_SANITIZERS: Partial<Record<keyof ProviderAddressFormDraft, (v: string) => string>> = {
street: sanitizeAddress,
streetComplement: sanitizeAddress,
city: sanitizeAddress,
}
/** Emet un nouveau brouillon avec le champ modifie (immutabilite), sanitise si besoin. */
function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
const sanitizer = FIELD_SANITIZERS[field]
const next = (sanitizer && typeof value === 'string')
? (sanitizer(value) as ProviderAddressFormDraft[K])
: value
emit('update:modelValue', { ...props.modelValue, [field]: next })
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
@@ -71,6 +71,7 @@
<script setup lang="ts">
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
import { sanitizeEmail, sanitizeFreeText, sanitizePersonName } from '~/shared/utils/textSanitize'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
@@ -96,9 +97,22 @@ const { t } = useI18n()
// Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue)
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
// Filtres de saisie par champ (ERP-193) : on retire les caracteres parasites a la
// frappe. Noms = profil personne, fonction = texte libre, email = profil email.
const FIELD_SANITIZERS: Partial<Record<keyof ProviderContactFormDraft, (v: string) => string>> = {
lastName: sanitizePersonName,
firstName: sanitizePersonName,
jobTitle: sanitizeFreeText,
email: sanitizeEmail,
}
/** Emet un nouveau brouillon avec le champ modifie (immutabilite), sanitise si besoin. */
function update<K extends keyof ProviderContactFormDraft>(field: K, value: ProviderContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
const sanitizer = FIELD_SANITIZERS[field]
const next = (sanitizer && typeof value === 'string')
? (sanitizer(value) as ProviderContactFormDraft[K])
: value
emit('update:modelValue', { ...props.modelValue, [field]: next })
}
/** Revele le 2e numero (max 1 secondaire, le « + » disparait). */
@@ -20,11 +20,12 @@
<!-- Bloc principal (pre-rempli, editable si `manage`) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:model-value="main.companyName"
:label="t('technique.providers.form.main.companyName')"
:required="true"
:readonly="businessReadonly"
:error="mainErrors.errors.companyName"
@update:model-value="(v: string) => main.companyName = sanitizeFreeText(v)"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
@@ -146,11 +147,12 @@
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:model-value="accounting.accountNumber"
:label="t('technique.providers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
@update:model-value="(v: string) => accounting.accountNumber = sanitizeCodeAlnum(v)"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
@@ -163,11 +165,12 @@
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:model-value="accounting.nTva"
:label="t('technique.providers.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
@update:model-value="(v: string) => accounting.nTva = sanitizeCodeAlnum(v)"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
@@ -226,18 +229,20 @@
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:model-value="rib.bic"
:label="t('technique.providers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.bic"
@update:model-value="(v: string) => rib.bic = sanitizeCodeAlnum(v)"
/>
<MalioInputText
v-model="rib.iban"
:model-value="rib.iban"
:label="t('technique.providers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.iban"
@update:model-value="(v: string) => rib.iban = sanitizeCodeAlnum(v)"
/>
</div>
</div>
@@ -313,6 +318,7 @@ import {
} from '~/modules/technique/types/providerForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
import { sanitizeCodeAlnum, sanitizeFreeText } from '~/shared/utils/textSanitize'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -19,11 +19,12 @@
Selecteur de site present ici (RG-3.03, relation directe). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:model-value="main.companyName"
:label="t('technique.providers.form.main.companyName')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.companyName"
@update:model-value="(v: string) => main.companyName = sanitizeFreeText(v)"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
@@ -145,11 +146,12 @@
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:model-value="accounting.accountNumber"
:label="t('technique.providers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
@update:model-value="(v: string) => accounting.accountNumber = sanitizeCodeAlnum(v)"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
@@ -162,11 +164,12 @@
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:model-value="accounting.nTva"
:label="t('technique.providers.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
@update:model-value="(v: string) => accounting.nTva = sanitizeCodeAlnum(v)"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
@@ -226,18 +229,20 @@
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:model-value="rib.bic"
:label="t('technique.providers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.bic"
@update:model-value="(v: string) => rib.bic = sanitizeCodeAlnum(v)"
/>
<MalioInputText
v-model="rib.iban"
:model-value="rib.iban"
:label="t('technique.providers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.iban"
@update:model-value="(v: string) => rib.iban = sanitizeCodeAlnum(v)"
/>
</div>
</div>
@@ -297,6 +302,7 @@ import {
} from '~/modules/technique/utils/forms/providerAccounting'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
import { sanitizeCodeAlnum, sanitizeFreeText } from '~/shared/utils/textSanitize'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -91,6 +91,7 @@
<script setup lang="ts">
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
import { sanitizeAddress } from '~/shared/utils/textSanitize'
interface RefOption {
value: string
@@ -150,9 +151,21 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
// Filtres de saisie par champ (ERP-193) : voie / complement / ville = profil
// adresse. Le code postal (masque numerique) n'est pas filtre ici.
const FIELD_SANITIZERS: Partial<Record<keyof CarrierAddressFormDraft, (v: string) => string>> = {
street: sanitizeAddress,
streetComplement: sanitizeAddress,
city: sanitizeAddress,
}
/** Emet un nouveau brouillon avec le champ modifie (immutabilite), sanitise si besoin. */
function update<K extends keyof CarrierAddressFormDraft>(field: K, value: CarrierAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
const sanitizer = FIELD_SANITIZERS[field]
const next = (sanitizer && typeof value === 'string')
? (sanitizer(value) as CarrierAddressFormDraft[K])
: value
emit('update:modelValue', { ...props.modelValue, [field]: next })
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
@@ -71,6 +71,7 @@
<script setup lang="ts">
import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm'
import { sanitizeEmail, sanitizeFreeText, sanitizePersonName } from '~/shared/utils/textSanitize'
// Masque téléphone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
@@ -96,9 +97,22 @@ const { t } = useI18n()
// Alias local pour la lisibilité du template.
const model = computed(() => props.modelValue)
/** Émet un nouveau brouillon avec le champ modifié (immutabilité). */
// Filtres de saisie par champ (ERP-193) : on retire les caractères parasites à la
// frappe. Noms = profil personne, fonction = texte libre, email = profil email.
const FIELD_SANITIZERS: Partial<Record<keyof CarrierContactFormDraft, (v: string) => string>> = {
lastName: sanitizePersonName,
firstName: sanitizePersonName,
jobTitle: sanitizeFreeText,
email: sanitizeEmail,
}
/** Émet un nouveau brouillon avec le champ modifié (immutabilité), sanitisé si besoin. */
function update<K extends keyof CarrierContactFormDraft>(field: K, value: CarrierContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
const sanitizer = FIELD_SANITIZERS[field]
const next = (sanitizer && typeof value === 'string')
? (sanitizer(value) as CarrierContactFormDraft[K])
: value
emit('update:modelValue', { ...props.modelValue, [field]: next })
}
/** Révèle le 2e numéro (max 1 secondaire, le « + » disparaît). */
@@ -19,7 +19,8 @@
<!-- Formulaire principal (éditable, PATCH partiel) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.name"
:model-value="main.name"
@update:model-value="(v: string) => main.name = sanitizeFreeText(v)"
:label="t('transport.carriers.form.main.name')"
:required="true"
:error="mainErrors.errors.name"
@@ -214,6 +215,7 @@ import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
import { useCarrier } from '~/modules/transport/composables/useCarrier'
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
import { sanitizeFreeText } from '~/shared/utils/textSanitize'
interface SelectOption {
value: string
@@ -19,7 +19,8 @@
seule pour un transporteur QUALIMAT (saisie assistee, onglet Qualimat). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.name"
:model-value="main.name"
@update:model-value="(v: string) => main.name = sanitizeFreeText(v)"
:label="t('transport.carriers.form.main.name')"
:required="true"
:readonly="mainLocked"
@@ -112,7 +113,7 @@
name="carrier-main-container"
value="BENNE"
:label="t('transport.carriers.containerType.BENNE')"
:disabled="mainLocked"
:readonly="mainLocked"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
/>
@@ -121,7 +122,7 @@
name="carrier-main-container"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
:disabled="mainLocked"
:readonly="mainLocked"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
/>
@@ -308,6 +309,7 @@ import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTa
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
import { sanitizeFreeText } from '~/shared/utils/textSanitize'
interface SelectOption {
value: string
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import {
sanitizeAddress,
sanitizeCodeAlnum,
sanitizeEmail,
sanitizeFreeText,
sanitizePersonName,
} from '../textSanitize'
describe('sanitizePersonName', () => {
it('garde lettres accentuees, espace, apostrophe, tiret, point', () => {
expect(sanitizePersonName('Jean-Pierre')).toBe('Jean-Pierre')
expect(sanitizePersonName('OBrien')).toBe('OBrien')
expect(sanitizePersonName("D'Angelo")).toBe("D'Angelo")
expect(sanitizePersonName('Saint-Étienne J.')).toBe('Saint-Étienne J.')
})
it('retire chiffres et caracteres parasites', () => {
expect(sanitizePersonName('Dupont²³')).toBe('Dupont')
expect(sanitizePersonName('Jean§&#~|')).toBe('Jean')
expect(sanitizePersonName('Marie123')).toBe('Marie')
})
})
describe('sanitizeFreeText', () => {
it('garde &, /, parentheses, degre, chiffres (raison sociale / fonction)', () => {
expect(sanitizeFreeText('Dupont & Fils')).toBe('Dupont & Fils')
expect(sanitizeFreeText('Resp. Achats/Ventes')).toBe('Resp. Achats/Ventes')
expect(sanitizeFreeText('SARL Léon (Pôle n°2)')).toBe('SARL Léon (Pôle n°2)')
})
it('retire les parasites ²³§~#|', () => {
expect(sanitizeFreeText('ACME²³§')).toBe('ACME')
expect(sanitizeFreeText('Test~#|<>{}')).toBe('Test')
})
})
describe('sanitizeAddress', () => {
it('garde chiffres, virgule, point, apostrophe, slash, degre, tiret', () => {
expect(sanitizeAddress('12 bis, rue de l’Église')).toBe('12 bis, rue de l’Église')
expect(sanitizeAddress('Bât. n°3 - Zone A/B')).toBe('Bât. n°3 - Zone A/B')
})
it('retire les parasites', () => {
expect(sanitizeAddress('5 rue X²³§&')).toBe('5 rue X')
})
})
describe('sanitizeEmail', () => {
it('garde les caracteres email valides', () => {
expect(sanitizeEmail('jean.dupont+pro@acme-corp.fr')).toBe('jean.dupont+pro@acme-corp.fr')
})
it('retire espaces et parasites', () => {
expect(sanitizeEmail('jean §² dupont@acme.fr')).toBe('jeandupont@acme.fr')
expect(sanitizeEmail('a&b#c@x.fr')).toBe('abc@x.fr')
})
})
describe('sanitizeCodeAlnum', () => {
it('force la majuscule et ne garde que A-Z 0-9', () => {
expect(sanitizeCodeAlnum('411dupont')).toBe('411DUPONT')
expect(sanitizeCodeAlnum('FR 12 345')).toBe('FR12345')
expect(sanitizeCodeAlnum('4-11.000§')).toBe('411000')
})
it('chaine vide reste vide', () => {
expect(sanitizeCodeAlnum('')).toBe('')
})
})
+58
View File
@@ -0,0 +1,58 @@
/**
* Filtres de saisie texte (retour metier ERP-193) : on retire a la frappe / au
* collage les caracteres parasites (« ²³§~#| … ») des champs texte libres.
*
* Miroir FRONT des patterns back `App\Shared\Domain\Validation\TextInputPattern`
* (allow-list par famille de champ). Le back reste l'autorite (Assert\Regex →
* 422 inline via useFormErrors) ; ces fonctions ne font que le confort de saisie.
* Purs / testables.
*
* IMPORTANT : garder les classes de caracteres STRICTEMENT alignees sur le back
* (toute divergence = soit un caractere bloque au front mais accepte au back, soit
* l'inverse → 422 surprise).
*/
/**
* Noms de personnes (Nom, Prenom, Dirigeant) : lettres (accents), espace,
* apostrophe droite/courbe, tiret, point.
*/
export function sanitizePersonName(value: string): string {
return value.replace(/[^\p{L}\p{M} '.-]/gu, '')
}
/**
* Texte societe / libre (Raison sociale, Concurrents, Fonction) : nom + chiffres,
* virgule, esperluette, slash, parentheses, degre.
*/
export function sanitizeFreeText(value: string): string {
// 0-9 (et pas \p{N}) : \p{N} engloberait les exposants ² ³ — justement parasites.
return value.replace(/[^\p{L}\p{M}0-9 '.,&/()°-]/gu, '')
}
/**
* Adresse (voie, complement, ville) : lettres, chiffres, espace, apostrophe,
* point, virgule, slash, degre, tiret.
*/
export function sanitizeAddress(value: string): string {
// 0-9 (et pas \p{N}) : evite de laisser passer les exposants ² ³.
return value.replace(/[^\p{L}\p{M}0-9 '.,/°-]/gu, '')
}
/**
* Codes alphanumeriques majuscules (N° de compte comptable, N° de TVA, IBAN, BIC) :
* uniquement A-Z et 0-9, majuscule forcee.
*/
export function sanitizeCodeAlnum(value: string): string {
return value.toUpperCase().replace(/[^A-Z0-9]/g, '')
}
/**
* Email : retire espaces et caracteres impossibles dans une adresse, en gardant
* le jeu de caracteres email valides (lettres, chiffres, @ . _ % + - '). La
* validation de FORMAT reste au back (Assert\Email) ; ici on bloque juste les
* parasites (« ²³§~#| … ») a la frappe. La normalisation lowercase est portee par
* MalioInputEmail (prop `lowercase`), on ne la duplique pas.
*/
export function sanitizeEmail(value: string): string {
return value.replace(/[^A-Za-z0-9@._%+'-]/g, '')
}
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -162,6 +163,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
#[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom de l\'entreprise doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom de l\'entreprise ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['client:read', 'client:write:main'])]
private ?string $companyName = null;
@@ -214,6 +216,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['client:read', 'client:write:information'])]
private ?string $competitors = null;
@@ -244,6 +247,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['client:read', 'client:write:information'])]
private ?string $directorName = null;
@@ -262,6 +266,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
#[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $accountNumber = null;
@@ -272,6 +277,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
#[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $nTva = null;
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\ClientAddressInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -158,17 +159,20 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
#[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $city = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $streetComplement = null;
@@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
@@ -94,16 +95,19 @@ class ClientContact implements TimestampableInterface, BlamableInterface
// deux restent nullable au niveau ORM.
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $lastName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $jobTitle = null;
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SupplierInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -171,6 +172,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
#[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom du fournisseur est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du fournisseur doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du fournisseur ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['supplier:read', 'supplier:write:main'])]
private ?string $companyName = null;
@@ -195,6 +197,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['supplier:read', 'supplier:write:information'])]
private ?string $competitors = null;
@@ -223,6 +226,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['supplier:read', 'supplier:write:information'])]
private ?string $directorName = null;
@@ -248,6 +252,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
#[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
private ?string $accountNumber = null;
@@ -258,6 +263,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
#[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
private ?string $nTva = null;
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SupplierAddressInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -154,17 +155,20 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
#[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $city = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $streetComplement = null;
@@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
@@ -99,16 +100,19 @@ class SupplierContact implements TimestampableInterface, BlamableInterface
// deux restent nullable au niveau ORM.
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:contacts'])]
private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:contacts'])]
private ?string $lastName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:contacts'])]
private ?string $jobTitle = null;
@@ -22,6 +22,7 @@ use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -156,6 +157,7 @@ class Provider implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom du prestataire est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du prestataire doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du prestataire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['provider:read', 'provider:write:main'])]
private ?string $companyName = null;
@@ -200,6 +202,7 @@ class Provider implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $accountNumber = null;
@@ -210,6 +213,7 @@ class Provider implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $nTva = null;
@@ -18,6 +18,7 @@ use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -135,17 +136,20 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
#[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:addresses'])]
private ?string $city = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:addresses'])]
private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:addresses'])]
private ?string $streetComplement = null;
@@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
@@ -102,16 +103,19 @@ class ProviderContact implements TimestampableInterface, BlamableInterface, Prov
// champs restent nullable au niveau ORM.
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $lastName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $jobTitle = null;
@@ -17,6 +17,7 @@ use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Entity\UploadedDocument;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -145,6 +146,7 @@ class Carrier implements TimestampableInterface, BlamableInterface
maxMessage: 'Le nom du transporteur ne peut dépasser {{ limit }} caractères.',
normalizer: 'trim',
)]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $name = null;
@@ -15,6 +15,7 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
@@ -123,16 +124,19 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $city = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'L\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $street = null;
#[ORM\Column(name: 'street_complement', length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $streetComplement = null;
@@ -15,6 +15,7 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
@@ -102,16 +103,19 @@ class CarrierContact implements TimestampableInterface, BlamableInterface
// Processor + CHECK BDD). Les colonnes restent nullable au niveau ORM.
#[ORM\Column(name: 'first_name', length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $firstName = null;
#[ORM\Column(name: 'last_name', length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $lastName = null;
#[ORM\Column(name: 'job_title', length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $jobTitle = null;
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Validation;
/**
* Profils de caracteres autorises pour les champs texte libres (retour metier
* ERP-193 : bloquer les caracteres parasites « ²³§~#| … » sans casser les saisies
* legitimes — accents, apostrophe, tiret, &, etc.).
*
* Approche allow-list (pas blacklist) : on definit ce qui est AUTORISE par famille
* de champ, le reste est rejete. Couche AUTORITAIRE (back) : `#[Assert\Regex]` avec
* ces patterns et messages FR ; le front (shared/utils/textSanitize.ts) miroite ces
* memes ensembles en filtrant la saisie a la frappe.
*
* Note : `Assert\Regex` laisse passer null et la chaine vide (champs nullable OK) ;
* seules les valeurs non vides sont controlees.
*/
final class TextInputPattern
{
/**
* Noms de personnes (Nom, Prenom, Dirigeant) : lettres (accents inclus),
* espace, apostrophe droite/courbe, tiret, point. Ni chiffres ni symboles.
*/
public const string PERSON_NAME = '/^[\p{L}\p{M} \'.\-]+$/u';
public const string PERSON_NAME_MESSAGE = 'Ce champ ne peut contenir que des lettres, espaces, apostrophes, tirets et points.';
/**
* Texte societe / libre (Raison sociale, Concurrents, Fonction) : comme un nom
* + chiffres, virgule, esperluette, slash, parentheses, degre (n°). Couvre
* « Dupont & Fils », « Achats/Ventes », « Pole 2 ».
*/
// 0-9 (et pas \p{N}) : \p{N} engloberait les exposants ² ³ — justement parasites.
public const string FREE_TEXT = '/^[\p{L}\p{M}0-9 \'.,&\/()°\-]+$/u';
public const string FREE_TEXT_MESSAGE = 'Ce champ contient des caractères non autorisés.';
/**
* Adresse (voie, complement, ville) : lettres, chiffres, espace, apostrophe,
* point, virgule, slash, degre, tiret. Couvre « 12 bis, rue de l’Église ».
*/
public const string ADDRESS = '/^[\p{L}\p{M}0-9 \'.,\/°\-]+$/u';
public const string ADDRESS_MESSAGE = 'Cette adresse contient des caractères non autorisés.';
/**
* Codes alphanumeriques majuscules (N° de compte comptable, N° de TVA) :
* uniquement A-Z et 0-9. Le front force la majuscule a la frappe.
*/
public const string CODE_ALNUM = '/^[A-Z0-9]+$/';
public const string CODE_ALNUM_MESSAGE = 'Ce champ ne doit contenir que des lettres majuscules et des chiffres.';
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Validation back-autoritative des caracteres autorises dans les champs texte
* (retour metier ERP-193) : on rejette les caracteres parasites « ²³§~#| … » via
* une allow-list par profil (App\Shared\Domain\Validation\TextInputPattern). Le
* front filtre deja a la frappe, mais le back reste l'autorite : une 422 portee
* sur le champ fautif (mappable inline par useFormErrors).
*
* On couvre les clients (M1) et les fournisseurs (M2) — meme socle de profils.
*
* @internal
*/
final class TextInputSanitizationTest extends AbstractSupplierApiTestCase
{
/** Raison sociale avec exposants ²³ et § -> 422 sur companyName. */
public function testClientCompanyNameAvecParasitesEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Parasite Client SARL');
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'ACME²³§'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('companyName', $this->violationsByPath($body));
}
/** Raison sociale legitime « Dupont & Fils » (esperluette) -> acceptee (200). */
public function testClientCompanyNameLegitimeEst200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Legit Client SARL');
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Dupont & Fils (Pôle n°2)'],
]);
self::assertResponseStatusCodeSame(200);
}
/** Dirigeant avec chiffres -> 422 (profil nom de personne, pas de chiffres). */
public function testClientDirectorNameAvecChiffresEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Director Parasite SARL');
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['directorName' => 'Jean123'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('directorName', $this->violationsByPath($body));
}
/** N° de compte avec caractere special -> 422 (profil code alphanumerique). */
public function testClientAccountNumberAvecParasiteEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Account Parasite SARL');
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['accountNumber' => '411#DUP'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('accountNumber', $this->violationsByPath($body));
}
/** Fournisseur : raison sociale avec parasites -> 422 sur companyName. */
public function testSupplierCompanyNameAvecParasitesEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Parasite Fournisseur SARL');
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'NEGOCE~#|²'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('companyName', $this->violationsByPath($body));
}
}